2 Commits

Author SHA1 Message Date
openai-code-agent[bot] c6cd06c65f Initial plan 2026-02-28 01:25:23 +00:00
Burak Kaan Köse c942066878 Filter reminder snooze options by default reminder 2026-02-27 21:57:41 +01:00
852 changed files with 14524 additions and 80460 deletions
-1
View File
@@ -100,7 +100,6 @@ _dialogService.InfoBarMessage(Translator.Info_MissingFolderTitle, message);
- **NEVER** create IValueConverter classes or add them to Converters.xaml - **NEVER** create IValueConverter classes or add them to Converters.xaml
- **NEVER** use BoolToVisibilityConverter - WinUI 3 SDK automatically converts bool to Visibility - **NEVER** use BoolToVisibilityConverter - WinUI 3 SDK automatically converts bool to Visibility
- Direct binding: `Visibility="{x:Bind IsVisible, Mode=OneWay}"` - Direct binding: `Visibility="{x:Bind IsVisible, Mode=OneWay}"`
- Register control events (for example `Loaded`, `Unloaded`, `SizeChanged`, `PointerEntered`) in XAML markup, not with `+=` in `.xaml.cs`.
### XamlHelpers for Complex Conversions ### XamlHelpers for Complex Conversions
- **ALWAYS** use XamlHelpers static methods instead of converters - **ALWAYS** use XamlHelpers static methods instead of converters
-187
View File
@@ -1,187 +0,0 @@
name: Manual Beta Release
on:
workflow_dispatch:
inputs:
release_title:
description: Optional GitHub release title override
required: false
type: string
permissions:
contents: write
packages: read
jobs:
release-beta:
name: Build and publish beta release
runs-on: windows-latest
env:
PROJECT_PATH: Wino.Mail.WinUI/Wino.Mail.WinUI.csproj
MANIFEST_PATH: Wino.Mail.WinUI/Package.appxmanifest
CHANGELOG_PATH: CHANGELOG.md
NUGET_CONFIG_PATH: ${{ github.workspace }}\nuget.config
PACKAGE_OUTPUT_DIR: ${{ github.workspace }}\artifacts\package
RELEASE_OUTPUT_DIR: ${{ github.workspace }}\artifacts\release
CERTIFICATE_PFX_PATH: ${{ github.workspace }}\artifacts\signing\beta-signing-cert.pfx
CERTIFICATE_CER_PATH: ${{ github.workspace }}\artifacts\release\Wino-Mail-Beta.cer
steps:
- name: Checkout selected branch
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Fetch tags from origin
shell: pwsh
run: git fetch origin --force --tags
- name: Validate release secrets
shell: pwsh
env:
BETA_SIGNING_CERT_PFX_BASE64: ${{ secrets.BETA_SIGNING_CERT_PFX_BASE64 }}
run: |
if ([string]::IsNullOrWhiteSpace($env:BETA_SIGNING_CERT_PFX_BASE64)) {
throw "Missing required secret: BETA_SIGNING_CERT_PFX_BASE64"
}
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Compute beta version and release metadata
id: metadata
shell: pwsh
env:
RELEASE_TITLE_INPUT: ${{ github.event.inputs.release_title }}
run: |
$manifestPath = Join-Path $env:GITHUB_WORKSPACE $env:MANIFEST_PATH
if (-not (Test-Path $manifestPath)) {
throw "Package manifest not found: $manifestPath"
}
$changelogPath = Join-Path $env:GITHUB_WORKSPACE $env:CHANGELOG_PATH
if (-not (Test-Path $changelogPath)) {
throw "Release notes file not found: $changelogPath"
}
[xml]$manifest = Get-Content -LiteralPath $manifestPath
$identityNode = $manifest.Package.Identity
if (-not $identityNode) {
throw "Could not locate the Package/Identity node in $manifestPath"
}
$currentVersionText = [string]$identityNode.Version
if ($currentVersionText -notmatch '^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)\.(?<revision>\d+)$') {
throw "Manifest version '$currentVersionText' is not a four-part numeric version."
}
$packageVersion = $currentVersionText
$releaseTag = "v$packageVersion"
$releaseTitleInput = $env:RELEASE_TITLE_INPUT
$releaseTitle = if ([string]::IsNullOrWhiteSpace($releaseTitleInput)) { $releaseTag } else { $releaseTitleInput.Trim() }
$headSha = (git rev-parse HEAD).Trim()
if ([string]::IsNullOrWhiteSpace($headSha)) {
throw "Failed to resolve the checked out commit SHA."
}
$notesInput = Get-Content -LiteralPath $changelogPath -Raw
if ([string]::IsNullOrWhiteSpace($notesInput)) {
throw "Release notes file is empty: $changelogPath"
}
$notesInput = $notesInput.Trim()
New-Item -ItemType Directory -Path $env:RELEASE_OUTPUT_DIR -Force | Out-Null
$releaseNotesPath = Join-Path $env:RELEASE_OUTPUT_DIR 'beta-release-notes.md'
$notesInput | Set-Content -LiteralPath $releaseNotesPath -Encoding utf8
"package_version=$packageVersion" >> $env:GITHUB_OUTPUT
"release_tag=$releaseTag" >> $env:GITHUB_OUTPUT
"release_title=$releaseTitle" >> $env:GITHUB_OUTPUT
"release_notes_path=$releaseNotesPath" >> $env:GITHUB_OUTPUT
"head_sha=$headSha" >> $env:GITHUB_OUTPUT
- name: Materialize signing certificate
shell: pwsh
env:
BETA_SIGNING_CERT_PFX_BASE64: ${{ secrets.BETA_SIGNING_CERT_PFX_BASE64 }}
run: |
$signingDir = Split-Path -Parent $env:CERTIFICATE_PFX_PATH
New-Item -ItemType Directory -Path $signingDir -Force | Out-Null
[IO.File]::WriteAllBytes($env:CERTIFICATE_PFX_PATH, [Convert]::FromBase64String($env:BETA_SIGNING_CERT_PFX_BASE64))
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($env:CERTIFICATE_PFX_PATH, $null, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable)
New-Item -ItemType Directory -Path (Split-Path -Parent $env:CERTIFICATE_CER_PATH) -Force | Out-Null
[IO.File]::WriteAllBytes($env:CERTIFICATE_CER_PATH, $certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
- name: Restore WinUI project dependencies
shell: pwsh
run: |
if (-not (Test-Path $env:NUGET_CONFIG_PATH)) {
throw "NuGet config file not found: $env:NUGET_CONFIG_PATH"
}
dotnet restore $env:PROJECT_PATH `
--configfile $env:NUGET_CONFIG_PATH `
-p:Platform=x64 `
/p:RestoreConfigFile="$env:NUGET_CONFIG_PATH"
- name: Build MSIX bundle
shell: pwsh
run: |
New-Item -ItemType Directory -Path $env:PACKAGE_OUTPUT_DIR -Force | Out-Null
dotnet build $env:PROJECT_PATH `
--configuration Release `
--no-restore `
--configfile $env:NUGET_CONFIG_PATH `
/p:Platform=x64 `
/p:RestoreConfigFile="$env:NUGET_CONFIG_PATH" `
/p:GenerateAppxPackageOnBuild=true `
/p:UapAppxPackageBuildMode=SideloadOnly `
/p:AppxBundle=Always `
/p:AppxBundlePlatforms="x86|x64|arm64" `
/p:AppxPackageDir="$env:PACKAGE_OUTPUT_DIR\\" `
/p:AppxPackageVersion=${{ steps.metadata.outputs.package_version }} `
/p:PackageCertificateKeyFile="$env:CERTIFICATE_PFX_PATH" `
/p:PackageCertificatePassword= `
/p:PackageCertificateThumbprint= `
/p:AppxPackageSigningEnabled=true
- name: Collect packaged artifacts
id: package
shell: pwsh
run: |
$bundle = Get-ChildItem -Path $env:PACKAGE_OUTPUT_DIR -Recurse -Filter *.msixbundle | Select-Object -First 1
if (-not $bundle) {
throw "No .msixbundle file was generated under $env:PACKAGE_OUTPUT_DIR"
}
$releaseAssetPath = Join-Path $env:RELEASE_OUTPUT_DIR "Wino_${{ steps.metadata.outputs.package_version }}.zip"
if (Test-Path $releaseAssetPath) {
Remove-Item -LiteralPath $releaseAssetPath -Force
}
Compress-Archive -LiteralPath @($bundle.FullName, $env:CERTIFICATE_CER_PATH) -DestinationPath $releaseAssetPath -Force
"bundle_path=$($bundle.FullName)" >> $env:GITHUB_OUTPUT
"bundle_name=$($bundle.Name)" >> $env:GITHUB_OUTPUT
"release_asset_path=$releaseAssetPath" >> $env:GITHUB_OUTPUT
- name: Create GitHub prerelease
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.metadata.outputs.release_tag }}" `
"${{ steps.package.outputs.release_asset_path }}" `
--repo "${{ github.repository }}" `
--target "${{ steps.metadata.outputs.head_sha }}" `
--title "${{ steps.metadata.outputs.release_title }}" `
--notes-file "${{ steps.metadata.outputs.release_notes_path }}" `
--prerelease
+115
View File
@@ -0,0 +1,115 @@
name: PR WinUI Build
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
jobs:
build-winui:
name: Build project (${{ matrix.platform }})
if: github.event.pull_request.draft == false
runs-on: windows-latest
continue-on-error: ${{ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) }}
strategy:
fail-fast: false
matrix:
include:
- platform: x86
rid: win-x86
- platform: x64
rid: win-x64
- platform: ARM64
rid: win-arm64
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore WinUI project dependencies
run: dotnet restore Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configfile nuget.config -p:Platform=${{ matrix.platform }} -p:RuntimeIdentifier=${{ matrix.rid }}
- name: Build WinUI project
run: dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configuration Release --no-restore -p:Platform=${{ matrix.platform }} -p:RuntimeIdentifier=${{ matrix.rid }} -p:GenerateAppxPackageOnBuild=false -p:AppxPackageSigningEnabled=false
core-tests:
name: Run Core tests
if: github.event.pull_request.draft == false
runs-on: windows-latest
continue-on-error: ${{ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore Core test projects
shell: pwsh
run: |
$coreTests = Get-ChildItem -Path . -Recurse -Filter "*Core*.Tests.csproj" | ForEach-Object { $_.FullName }
if (-not $coreTests) {
throw "No Core test projects were found."
}
foreach ($project in $coreTests) {
dotnet restore $project --configfile nuget.config
}
- name: Run Core test projects
shell: pwsh
run: |
New-Item -ItemType Directory -Path TestResults -Force | Out-Null
$coreTests = Get-ChildItem -Path . -Recurse -Filter "*Core*.Tests.csproj"
if (-not $coreTests) {
throw "No Core test projects were found."
}
foreach ($project in $coreTests) {
$name = $project.BaseName
dotnet test $project.FullName --configuration Release --no-restore --verbosity normal --logger "trx;LogFileName=$name.trx" --results-directory TestResults
}
- name: Upload Core test result artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: core-test-results
path: TestResults/*.trx
if-no-files-found: warn
- name: Publish Core test report
if: always()
uses: EnricoMi/publish-unit-test-result-action/windows@v2
with:
trx_files: TestResults/*.trx
check_name: Core test results
enforce-for-non-maintainers:
name: Enforce required checks (non-maintainers)
if: github.event.pull_request.draft == false && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
runs-on: ubuntu-latest
needs:
- build-winui
- core-tests
steps:
- name: Fail when build or tests fail for non-maintainers
if: needs.build-winui.result != 'success' || needs.core-tests.result != 'success'
run: |
echo "WinUI build and Core tests must pass for non-maintainer pull requests."
exit 1
- name: Confirm build and test success for non-maintainers
run: echo "WinUI build and Core tests passed."
-1
View File
@@ -400,4 +400,3 @@ Wino/obj/x86/Debug/XamlSaveStateFile.xml
*.cache *.cache
.vs/Wino/v16/.suo .vs/Wino/v16/.suo
/.claude/settings.local.json /.claude/settings.local.json
scripts/translate_resources.local.bat
-163
View File
@@ -1,163 +0,0 @@
# AGENTS.md
This file provides guidance to AI agent when working with code in this repository.
## Project Overview
Wino Mail is a native Windows mail client (Windows 10 1809+ / Windows 11) replacing the deprecated Windows Mail & Calendar. It's **transitioning from UWP to WinUI 3** - always work with WinUI projects (Wino.Mail.WinUI), never edit the deprecated Wino.Mail UWP project.
## Build and Development Commands
```bash
# Open solution
# WinoMail.slnx is the main solution file (VS 2022+)
# Build WinUI project (Debug x64)
dotnet restore Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configfile nuget.config -p:Platform=x64 -p:RuntimeIdentifier=win-x64 && dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj -c Debug --no-restore /p:Platform=x64 /p:RuntimeIdentifier=win-x64 /p:GenerateAppxPackageOnBuild=false /p:AppxPackageSigningEnabled=false
# Build WinUI project with diagnostic XAML/compiler logging (use when plain build only shows "XamlCompiler.exe exited with code 1")
dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj -c Debug --no-restore /p:Platform=x64 /p:RuntimeIdentifier=win-x64 /p:GenerateAppxPackageOnBuild=false /p:AppxPackageSigningEnabled=false "/flp:logfile=winui-build.log;verbosity=diagnostic" /bl:winui-build.binlog
# Run tests (Debug x64)
dotnet test Wino.Core.Tests/Wino.Core.Tests.csproj -c Debug /p:Platform=x64
# Copilot CLI build command (Debug x64)
dotnet restore Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configfile nuget.config -p:Platform=x64 -p:RuntimeIdentifier=win-x64 && dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj -c Debug --no-restore /p:Platform=x64 /p:RuntimeIdentifier=win-x64 /p:GenerateAppxPackageOnBuild=false /p:AppxPackageSigningEnabled=false
```
**Prerequisites:** Visual Studio 2022+ with ".NET desktop development" workload, .NET SDK 10+
**Startup project:** Wino.Mail.WinUI
**Platforms:** x86, x64, ARM64
## Efficient Workflow
- Start with targeted symbol or file search before reading full files
- Prefer one focused task per thread; use a new thread for unrelated follow-up work
- Keep verification narrow: build only the affected project, not the full solution, unless cross-project changes require it
- After the first restore, prefer `--no-restore` builds unless package or project references changed
- Summarize long build logs and inspect only the files named in diagnostics instead of loading large logs into context
- When the prompt already names likely files, types, or symbols, start there instead of re-mapping the repository
- If a WinUI build only reports `XamlCompiler.exe exited with code 1`, rerun with the diagnostic logging command above and inspect the terminal output plus `winui-build.log` for real `WMC`/`WMC1121`/binding diagnostics before guessing
## Architecture
### Solution Structure
```
Wino.Core.Domain → Entities, interfaces, translations, enums (shared contracts)
Wino.Core → Synchronization engine, authenticators, request processing
Wino.Services → Database, mail, folder, account services
Wino.Authentication → OAuth2 authenticators (Outlook, Gmail)
Wino.Mail.ViewModels → Mail-specific ViewModels
Wino.Core.ViewModels → Shared ViewModels (settings, personalization)
Wino.Messaging → Pub-sub message definitions
Wino.Mail.WinUI → **Active WinUI 3 UI project** (use this)
Wino.Mail → **Deprecated UWP project** (DO NOT EDIT)
```
### Mail Synchronization Flow
1. **WinoRequestDelegator** → Validates and delegates user actions (mark read, delete, move)
2. **WinoRequestProcessor** → Batches requests using RequestComparer, queues to synchronizers
3. **Synchronizers** (OutlookSynchronizer, GmailSynchronizer, ImapSynchronizer) → Execute batched operations
4. **ChangeProcessors** → Apply changes to local SQLite database
5. Database updates trigger **Messenger** events (MailAddedMessage, MailUpdatedMessage, etc.)
### Synchronizer Types
- **OutlookSynchronizer** - Microsoft Graph SDK for Office 365
- **GmailSynchronizer** - Gmail API
- **ImapSynchronizer** - MimeKit/MailKit for IMAP/SMTP
### Queue-Based Sync Pattern
- Initial sync queues mail IDs first (MailItemQueue table), downloads metadata only
- MIME content downloaded on-demand when user opens mail
- Check `MailItemFolder.IsInitialSyncCompleted` for sync state
- See QUEUE_SYNC_IMPLEMENTATION.md for details
### Dependency Injection
- `RegisterCoreServices()` in Wino.Core/CoreContainerSetup.cs
- `RegisterSharedServices()` in Wino.Services/ServicesContainerSetup.cs
- ViewModels registered in App.xaml.cs
## Key Patterns
### MVVM with Source Generators
**CORRECT - use public partial properties:**
```csharp
[ObservableProperty]
public partial string SearchQuery { get; set; } = string.Empty;
```
**WRONG - will not work:**
```csharp
[ObservableProperty]
private string searchQuery = string.Empty;
```
### Messenger Pattern
- ViewModels inherit from CoreBaseViewModel or MailBaseViewModel
- Register handlers in `RegisterRecipients()`, unregister in `UnregisterRecipients()`
- Send via `WeakReferenceMessenger.Default.Send(new MessageType(...))`
### Data Binding - No Converters
- **NEVER** create IValueConverter classes
- WinUI 3 auto-converts bool to Visibility: `Visibility="{x:Bind IsVisible, Mode=OneWay}"`
- Use XamlHelpers for complex conversions: `{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(Prop)}`
- `x:Bind` does not implicitly convert `double` to `GridLength`; when binding `RowDefinition.Height` or `ColumnDefinition.Width`, use a `XamlHelpers` method such as `DoubleToGridLength(...)`
- For `ComboBox` controls in XAML, never use `DisplayMemberPath` or `SelectedValuePath`; use a typed `ItemTemplate` and bind `SelectedItem` explicitly, preferably with `x:Bind`
## Localization
1. Add English strings ONLY to Wino.Core.Domain/Translations/en_US/resources.json
2. Build project - source generators create Translator properties
3. Use Translator.{PropertyName} in code/XAML
4. NEVER edit any resources.json file outside Wino.Core.Domain/Translations/en_US/resources.json
5. Treat all non-en_US translation files as managed externally and leave them untouched, even when adding new localization keys
6. In XAML, translation bindings must use `Mode=OneTime` because `Wino.Core.Domain/Translator.cs` does not implement `INotifyPropertyChanged`
## Storage
- **SQLite database** in publisher cache folder (shared with future Wino Calendar)
- **EML files** in app local storage, referenced by `MailCopy.FileId`
- Paths resolved via `MimeFileService.GetMimeMessagePath()`
## WebView2 Mail Rendering
- `reader.html` for reading mails, `editor.html` for composing (Jodit editor)
- Virtual host mapping: `https://wino.mail/reader.html`
- JavaScript interop via `ExecuteScriptFunctionAsync()`
- MIME content downloaded on-demand, not during sync
## Common Pitfalls
- Forgetting to register ViewModels in App.xaml.cs `RegisterViewModels()`
- Not calling `RegisterRecipients()` for message handlers
- Using private fields with `[ObservableProperty]` instead of public partial
- Creating IValueConverter classes instead of using XamlHelpers
- Editing UWP project files instead of WinUI equivalents
- Hardcoding strings instead of using Translator
- Forgetting to unregister Messenger recipients (memory leaks)
- Putting authentication validation, token refresh, account API calls, settings serialization/deserialization, or preference-application logic into ViewModels instead of the corresponding service
## Code Style
- Avoid introducing new NuGet packages when possible
- Use existing libraries (MimeKit, MailKit, Microsoft Graph, Gmail API)
- Use `var` where type is obvious
- String interpolation over string.Format
- Wrap async operations in try-catch
- Log errors via IWinoLogger
- For dependency properties in WinUI code, always prefer `[GeneratedDependencyProperty]` from CommunityToolkit over manual `DependencyProperty.Register(...)` declarations.
- When a `[RelayCommand]` needs enable/disable logic, prefer the command's `CanExecute` over binding `Button.IsEnabled` in XAML; use `[NotifyCanExecuteChangedFor]` on dependent properties and call `NotifyCanExecuteChanged()` explicitly when non-generated state affects the command.
- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`).
- `ConfigureAwait(false)` continues execution on a background thread. Any UI-bound property change, `INotifyPropertyChanged` notification, collection mutation, or similar UI-facing state update after that point must be marshaled back with `ExecuteUIThread(...)` or the appropriate dispatcher call, otherwise the app can crash.
- Messenger messages are raised from a background thread by default, while UI control event handlers such as `Button.Click` start on the UI thread. Be deliberate when combining dispatcher usage with `ConfigureAwait(false)` so post-await UI updates always return to the UI thread.
- ViewModels should only handle UI interaction/state and delegate business logic to services; account-management work belongs in `WinoAccountProfileService`, and preferences import/export/apply logic belongs in `PreferencesService`.
- In `EventDetailsPageViewModel.LoadAttendeesAsync`, never mutate `CurrentEvent.Attendees` outside `ExecuteUIThread(...)`.
- Never create pure C# controls or controls that heavily manipulate UI structure from `.cs` files. Define controls in XAML and keep UI composition in XAML.
- Never add XAML-backed UI controls to `.xaml.cs`. If a view has XAML, all control declarations, flyouts, templates, and visual composition belong in the `.xaml` file; keep `.xaml.cs` limited to event handling and view glue.
- Never subscribe to framework events like `Loaded`, `Unloaded`, or input events from constructors in `.xaml.cs` for XAML-backed controls and pages; wire them directly in XAML instead.
- If you use `x:Load` in XAML, always give that `UIElement` an `x:Name`.
+132
View File
@@ -0,0 +1,132 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Wino Mail is a native Windows mail client (Windows 10 1809+ / Windows 11) replacing the deprecated Windows Mail & Calendar. It's **transitioning from UWP to WinUI 3** - always work with WinUI projects (Wino.Mail.WinUI), never edit the deprecated Wino.Mail UWP project.
## Build and Development Commands
```bash
# Open solution
# WinoMail.slnx is the main solution file (VS 2022+)
# Build from command line
dotnet build WinoMail.slnx -c Debug
# Run tests
dotnet test Wino.Core.Tests/Wino.Core.Tests.csproj
# Build specific platform
dotnet build WinoMail.slnx -c Debug /p:Platform=x64
```
**Prerequisites:** Visual Studio 2022+ with ".NET desktop development" workload, .NET SDK 10+
**Startup project:** Wino.Mail.WinUI
**Platforms:** x86, x64, ARM64
## Architecture
### Solution Structure
```
Wino.Core.Domain → Entities, interfaces, translations, enums (shared contracts)
Wino.Core → Synchronization engine, authenticators, request processing
Wino.Services → Database, mail, folder, account services
Wino.Authentication → OAuth2 authenticators (Outlook, Gmail)
Wino.Mail.ViewModels → Mail-specific ViewModels
Wino.Core.ViewModels → Shared ViewModels (settings, personalization)
Wino.Messaging → Pub-sub message definitions
Wino.Mail.WinUI → **Active WinUI 3 UI project** (use this)
Wino.Mail → **Deprecated UWP project** (DO NOT EDIT)
```
### Mail Synchronization Flow
1. **WinoRequestDelegator** → Validates and delegates user actions (mark read, delete, move)
2. **WinoRequestProcessor** → Batches requests using RequestComparer, queues to synchronizers
3. **Synchronizers** (OutlookSynchronizer, GmailSynchronizer, ImapSynchronizer) → Execute batched operations
4. **ChangeProcessors** → Apply changes to local SQLite database
5. Database updates trigger **Messenger** events (MailAddedMessage, MailUpdatedMessage, etc.)
### Synchronizer Types
- **OutlookSynchronizer** - Microsoft Graph SDK for Office 365
- **GmailSynchronizer** - Gmail API
- **ImapSynchronizer** - MimeKit/MailKit for IMAP/SMTP
### Queue-Based Sync Pattern
- Initial sync queues mail IDs first (MailItemQueue table), downloads metadata only
- MIME content downloaded on-demand when user opens mail
- Check `MailItemFolder.IsInitialSyncCompleted` for sync state
- See QUEUE_SYNC_IMPLEMENTATION.md for details
### Dependency Injection
- `RegisterCoreServices()` in Wino.Core/CoreContainerSetup.cs
- `RegisterSharedServices()` in Wino.Services/ServicesContainerSetup.cs
- ViewModels registered in App.xaml.cs
## Key Patterns
### MVVM with Source Generators
**CORRECT - use public partial properties:**
```csharp
[ObservableProperty]
public partial string SearchQuery { get; set; } = string.Empty;
```
**WRONG - will not work:**
```csharp
[ObservableProperty]
private string searchQuery = string.Empty;
```
### Messenger Pattern
- ViewModels inherit from CoreBaseViewModel or MailBaseViewModel
- Register handlers in `RegisterRecipients()`, unregister in `UnregisterRecipients()`
- Send via `WeakReferenceMessenger.Default.Send(new MessageType(...))`
### Data Binding - No Converters
- **NEVER** create IValueConverter classes
- WinUI 3 auto-converts bool to Visibility: `Visibility="{x:Bind IsVisible, Mode=OneWay}"`
- Use XamlHelpers for complex conversions: `{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(Prop)}`
## Localization
1. Add English strings ONLY to `Wino.Core.Domain/Translations/en_US/resources.json`
2. Build project - source generators create Translator properties
3. Use `Translator.{PropertyName}` in code/XAML
4. **NEVER** edit other language files - Crowdin manages translations
## Storage
- **SQLite database** in publisher cache folder (shared with future Wino Calendar)
- **EML files** in app local storage, referenced by `MailCopy.FileId`
- Paths resolved via `MimeFileService.GetMimeMessagePath()`
## WebView2 Mail Rendering
- `reader.html` for reading mails, `editor.html` for composing (Jodit editor)
- Virtual host mapping: `https://wino.mail/reader.html`
- JavaScript interop via `ExecuteScriptFunctionAsync()`
- MIME content downloaded on-demand, not during sync
## Common Pitfalls
- Forgetting to register ViewModels in App.xaml.cs `RegisterViewModels()`
- Not calling `RegisterRecipients()` for message handlers
- Using private fields with `[ObservableProperty]` instead of public partial
- Creating IValueConverter classes instead of using XamlHelpers
- Editing UWP project files instead of WinUI equivalents
- Hardcoding strings instead of using Translator
- Forgetting to unregister Messenger recipients (memory leaks)
## Code Style
- Avoid introducing new NuGet packages when possible
- Use existing libraries (MimeKit, MailKit, Microsoft Graph, Gmail API)
- Use `var` where type is obvious
- String interpolation over string.Format
- Wrap async operations in try-catch
- Log errors via IWinoLogger
- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`).
+34 -37
View File
@@ -4,9 +4,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="ColorHashSharp" Version="1.1.0" /> <PackageVersion Include="ColorHashSharp" Version="1.1.0" />
<PackageVersion Include="CommunityToolkit.Common" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Common" Version="8.4.0" />
<PackageVersion Include="CommunityToolkit.Diagnostics" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Diagnostics" Version="8.4.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.1-build.4" />
<PackageVersion Include="CommunityToolkit.WinUI.Animations" Version="8.2.251219" /> <PackageVersion Include="CommunityToolkit.WinUI.Animations" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Segmented" Version="8.2.251219" /> <PackageVersion Include="CommunityToolkit.WinUI.Controls.Segmented" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.251219" /> <PackageVersion Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.251219" />
@@ -15,66 +15,63 @@
<PackageVersion Include="CommunityToolkit.WinUI.Controls.TokenizingTextBox" Version="8.2.251219" /> <PackageVersion Include="CommunityToolkit.WinUI.Controls.TokenizingTextBox" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" /> <PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.250926-build.2293" /> <PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.250926-build.2293" />
<PackageVersion Include="CommunityToolkit.WinUI.Lottie" Version="8.2.250604" /> <PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageVersion Include="CommunityToolkit.Labs.WinUI.DependencyPropertyGenerator" Version="0.1.250926-build.2293" /> <PackageVersion Include="CommunityToolkit.Labs.WinUI.DependencyPropertyGenerator" Version="0.1.250926-build.2293" />
<PackageVersion Include="EmailValidation" Version="1.3.0" /> <PackageVersion Include="EmailValidation" Version="1.3.0" />
<PackageVersion Include="gravatar-dotnet" Version="0.1.3" /> <PackageVersion Include="gravatar-dotnet" Version="0.1.3" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" /> <PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="Ical.Net" Version="5.2.1" /> <PackageVersion Include="Ical.Net" Version="4.3.1" />
<PackageVersion Include="IsExternalInit" Version="1.0.3" /> <PackageVersion Include="IsExternalInit" Version="1.0.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Graph" Version="5.104.0" /> <PackageVersion Include="Microsoft.Graph" Version="5.99.0" />
<PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.4.0" /> <PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.3.2" />
<PackageVersion Include="Microsoft.Identity.Client" Version="4.83.3" /> <PackageVersion Include="Microsoft.Identity.Client" Version="4.79.2" />
<PackageVersion Include="Microsoft.Identity.Client.Broker" Version="4.83.3" /> <PackageVersion Include="Microsoft.Identity.Client.Broker" Version="4.79.2" />
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.3" /> <PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.79.2" />
<PackageVersion Include="Microsoft.NETCore.UniversalWindowsPlatform" Version="6.2.14" /> <PackageVersion Include="Microsoft.NETCore.UniversalWindowsPlatform" Version="6.2.14" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.260316102" /> <PackageVersion Include="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="3.0.0" />
<PackageVersion Include="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="3.0.1" /> <PackageVersion Include="MimeKit" Version="4.14.0" />
<PackageVersion Include="Wino.Mail.Contracts" Version="1.0.18" />
<PackageVersion Include="MimeKit" Version="4.16.0" />
<PackageVersion Include="morelinq" Version="4.4.0" /> <PackageVersion Include="morelinq" Version="4.4.0" />
<PackageVersion Include="Nito.AsyncEx" Version="5.1.2" /> <PackageVersion Include="Nito.AsyncEx" Version="5.1.2" />
<PackageVersion Include="Nito.AsyncEx.Tasks" Version="5.1.2" /> <PackageVersion Include="Nito.AsyncEx.Tasks" Version="5.1.2" />
<PackageVersion Include="NodaTime" Version="3.3.1" /> <PackageVersion Include="NodaTime" Version="3.2.3" />
<PackageVersion Include="Sentry.Serilog" Version="6.4.0" /> <PackageVersion Include="Sentry.Serilog" Version="6.0.0" />
<PackageVersion Include="Serilog" Version="4.3.1" /> <PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" /> <PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" /> <PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.ApplicationInsights" Version="4.0.0" /> <PackageVersion Include="Serilog.Sinks.ApplicationInsights" Version="4.0.0" />
<PackageVersion Include="SkiaSharp" Version="3.119.2" /> <PackageVersion Include="SkiaSharp" Version="3.119.1" />
<PackageVersion Include="SkiaSharp.Views.WinUI" Version="3.119.2" /> <PackageVersion Include="SkiaSharp.Views.WinUI" Version="3.119.1" />
<PackageVersion Include="sqlite-net-pcl" Version="1.10.196-beta" /> <PackageVersion Include="sqlite-net-pcl" Version="1.10.196-beta" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.5" /> <PackageVersion Include="System.Drawing.Common" Version="10.0.1" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" /> <PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" /> <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="10.0.6" /> <PackageVersion Include="System.Text.Json" Version="10.0.1" />
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.3.0" /> <PackageVersion Include="H.NotifyIcon.Wpf" Version="2.3.0" />
<PackageVersion Include="H.NotifyIcon.WinUI" Version="2.4.1" /> <PackageVersion Include="H.NotifyIcon.WinUI" Version="2.4.1" />
<PackageVersion Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" /> <PackageVersion Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" />
<PackageVersion Include="Google.Apis.Auth" Version="1.73.0" /> <PackageVersion Include="Google.Apis.Auth" Version="1.73.0" />
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.73.0.4073" /> <PackageVersion Include="Google.Apis.Calendar.v3" Version="1.73.0.3993" />
<PackageVersion Include="Google.Apis.Drive.v3" Version="1.73.0.4112" /> <PackageVersion Include="Google.Apis.Gmail.v1" Version="1.73.0.3987" />
<PackageVersion Include="Google.Apis.Gmail.v1" Version="1.73.0.4029" />
<PackageVersion Include="Google.Apis.PeopleService.v1" Version="1.72.0.3973" /> <PackageVersion Include="Google.Apis.PeopleService.v1" Version="1.72.0.3973" />
<PackageVersion Include="HtmlKit" Version="1.2.0" /> <PackageVersion Include="HtmlKit" Version="1.2.0" />
<PackageVersion Include="MailKit" Version="4.16.0" /> <PackageVersion Include="MailKit" Version="4.14.1" />
<PackageVersion Include="TimePeriodLibrary.NET" Version="2.1.6" /> <PackageVersion Include="TimePeriodLibrary.NET" Version="2.1.6" />
<PackageVersion Include="System.Reactive" Version="6.1.0" /> <PackageVersion Include="System.Reactive" Version="6.1.0" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" /> <PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.1" />
<PackageVersion Include="System.Text.Encodings.Web" Version="10.0.6" /> <PackageVersion Include="System.Text.Encodings.Web" Version="10.0.1" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" /> <PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7175" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.250930001-experimental1" /> <PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.250930001-experimental1" />
<PackageVersion Include="WinUIEx" Version="2.9.0" /> <PackageVersion Include="WinUIEx" Version="2.9.0" />
<!-- Testing packages --> <!-- Testing packages -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="FluentAssertions" Version="8.9.0" /> <PackageVersion Include="FluentAssertions" Version="7.0.0" />
<PackageVersion Include="Moq" Version="4.20.72" /> <PackageVersion Include="Moq" Version="4.20.72" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+21 -30
View File
@@ -1,54 +1,45 @@
<p align="center"> <p align="center">
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true&mode=full"> <a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true
<img src="https://www.winomail.app/images/v2/Logo.png" width="90" height="90" alt="Wino Mail logo"> &mode=full">
<img src="https://www.winomail.app/images/wino_logo.png" width=90 height=90>
</a> </a>
<h3 align="center">Wino Mail</h3> <h3 align="center">Wino Mail</h3>
<p align="center"> <p align="center">
Native mail and calendar client for Windows. Native mail client for Windows device families.
</p> </p>
</p> </p>
<br> <br>
![Wino Mail screenshot](https://user-images.githubusercontent.com/12009960/232114528-2d2c8e3c-dbe7-429a-94e0-6aecc73bdf70.png) ![pdark](https://user-images.githubusercontent.com/12009960/232114528-2d2c8e3c-dbe7-429a-94e0-6aecc73bdf70.png)
## Motivation ## Motivation
I'm a big fan of Windows Mail & Calendars due to its simplicity. Personally, I find it more intuitive for daily use cases compared to Outlook desktop and the new WebView2 powered Outlook version. Seeing [Microsoft deprecating it](https://support.microsoft.com/en-us/office/outlook-for-windows-the-future-of-mail-calendar-and-people-on-windows-11-715fc27c-e0f4-4652-9174-47faa751b199#:~:text=The%20Mail%20and%20Calendar%20applications,will%20no%20longer%20be%20supported.) dragged me into starting to work on Wino a couple of years ago. Wino's main motivation is to bring all the existing functionality from Mail & Calendars over time without changing the user experience that millions have loved since the Windows 8 days in Mail & Calendars. I'm a big fan of Windows Mail & Calendars due to its simplicity. Personally, I find it more intuitive for daily use cases compared to Outlook desktop and the new WebView2 powered Outlook version. Seeing [Microsoft deprecating it](https://support.microsoft.com/en-us/office/outlook-for-windows-the-future-of-mail-calendar-and-people-on-windows-11-715fc27c-e0f4-4652-9174-47faa751b199#:~:text=The%20Mail%20and%20Calendar%20applications,will%20no%20longer%20be%20supported.) dragged me into starting to work on Wino a couple of years ago. Wino's main motivation is to bring all the existing functionality from Mail & Calendars over time without changing the user experience that millions have loved since the Windows 8 days in Mail & Calendars
## vNext Release Highlights
Wino vNext focuses on making Mail, Calendar, and Contacts feel like one cohesive native Windows experience while improving sync reliability and startup responsiveness.
- 📅 **Calendar management:** Event compose/create flow, calendar-mail mapping, reminder snooze support, occurrence and detail-page improvements, and CalDAV correctness fixes.
- 👥 **Contact management:** Improved contact workflows, account/settings integration, and contact data-model cleanup.
- 🔄 **Synchronization reliability:** Refactored synchronizers, better state handling, 404 + 429 error handling, and duplicate-operation prevention.
- ✉️ **Compose and drafts:** Refined editor/toolbar architecture, better rendering pipeline, Gmail draft support, and large Outlook attachment upload sessions.
-**Performance and quality:** Faster mail fetching with batched DB queries and caching, SQLite indexing/foreign key enforcement, and broader test + CI coverage.
- 🎨 **WinUI polish:** Improved onboarding/startup, settings and dialogs refresh, notification routing fixes, and keyboard/navigation quality-of-life improvements.
## Features ## Features
- 📨 Outlook and Gmail API integration - API integration for Outlook and Gmail
- 🌐 IMAP/SMTP support for custom mail servers - IMAP/SMTP support for custom mail servers
- 📅 Calendar support with event creation/compose and reminders - Send, receive, mark as (read,important,spam etc), move mails.
- 👥 Contact management and people-centric account experience - Linked/Merged Accounts
- ✅ Core mail actions: send, receive, read/unread, move, spam, and more - Toast notifications with background sync.
- 🔗 Linked/Merged accounts - Instant startup performance
- 🔔 Toast notifications with background sync - Offline use / search.
- ⚡ Instant startup-oriented architecture - Modern and responsive UI
- 🔎 Offline-capable workflows and search improvements - Lots of personalization options
- 🎛️ Modern responsive WinUI interface with personalization options - Dark / Light mode for mail reader
- 🌗 Dark/Light mode for mail reader and app surfaces
## Download ## Download
Download latest version of Wino Mail from Microsoft Store for free. Download latest version of Wino Mail from Microsoft Store for free.
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true&mode=full"> <a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200" alt="Get Wino Mail from Microsoft Store"/> &mode=full">
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200"/>
</a> </a>
## Beta Releases ## Beta Releases
@@ -57,6 +48,7 @@ Stable releases will always be distributed on Microsoft Store. However, beta rel
These releases are distributed as side-loaded packages. To install them, download the **.msixbundle** file in GitHub releases and [follow the steps explained here.](https://learn.microsoft.com/en-us/windows/application-management/sideload-apps-in-windows) These releases are distributed as side-loaded packages. To install them, download the **.msixbundle** file in GitHub releases and [follow the steps explained here.](https://learn.microsoft.com/en-us/windows/application-management/sideload-apps-in-windows)
## Contributing ## Contributing
Check out the [contribution guidelines](/CONTRIBUTING.md) before diving into the source code or opening an issue. There are multiple ways to contribute and all of them are explained in detail there. Check out the [contribution guidelines](/CONTRIBUTING.md) before diving into the source code or opening an issue. There are multiple ways to contribute and all of them are explained in detail there.
@@ -67,4 +59,3 @@ Your donations will motivate me more to work on Wino in my spare time and cover
- You can [donate via Paypal by clicking here](https://www.paypal.com/donate/?hosted_button_id=LGPERGGXFMQ7U) - You can [donate via Paypal by clicking here](https://www.paypal.com/donate/?hosted_button_id=LGPERGGXFMQ7U)
- You can buy Unlimited Accounts add-on in the application. It's a one-time payment for lifetime, not a monthly recurring payment. - You can buy Unlimited Accounts add-on in the application. It's a one-time payment for lifetime, not a monthly recurring payment.
-158
View File
@@ -1,158 +0,0 @@
# Wino Mail vNext Improvements
This document summarizes the major improvements on `feature/vNext` compared to `main`, based on the commit history between the current branch and the merge-base with `main`.
## Wino Calendar
Calendar has grown from an early implementation into a much more complete product area on this branch.
### A full Wino Calendar experience
- Added a dedicated Wino Calendar app entry, making calendar a first-class experience instead of a secondary add-on.
- Built out the calendar rendering experience with multiple rounds of rendering improvements, updated calendar view styling, calendar buttons, and better event visuals.
- Added event creation and full event compose flows, including follow-up improvements for attachments, attendees, recurrence summaries, RSVP actions, reminders, and event details.
- Improved support for all-day events, better display dates, occurrence handling, and mail-to-calendar mapping so calendar actions connect more naturally with messages and invitations.
### Local calendar support
- Added local calendar operation coverage and supporting behavior for IMAP-backed/local calendar scenarios.
- Prevented duplicate operations by ignoring local calendar apply-changes in the wrong paths.
- Added busy-state support and metadata fetch flows so newly created accounts can initialize calendar data more reliably.
### CalDAV sync
- Introduced a dedicated CalDAV synchronizer and supporting service/client work.
- Fixed CalDAV delta sync issues.
- Fixed CalDAV timezone issues.
- Added manual live CalDAV workflow tests to validate real-world sync behavior.
This means local and self-hosted calendar scenarios are much better represented on this branch than on `main`.
### API calendar sync for Outlook and Gmail
- Expanded Outlook calendar sync behavior, including broader sync windows and fixes around date/time handling.
- Improved Gmail drafting and mail/calendar integration so event-related actions work better across providers.
- Added mail and calendar synchronizer state tracking to make sync progress and error handling more reliable.
- Added auto calendar sync on account creation and broader auto-sync trigger and cancellation support.
### Calendar polish and reliability
- Fixed calendar crashes and null-handling issues in calendar view date range updates.
- Fixed double initialization in calendar day views.
- Improved reaction to calendar changes and calendar item update-source handling.
- Added reminder snooze support across toast UI, services, and database storage.
Overall, Wino Calendar is one of the biggest themes of this branch: richer UI, more complete event workflows, and real sync support across local, CalDAV, Outlook, and Gmail-backed scenarios.
## Wino Accounts
Wino Accounts was significantly expanded and polished on this branch.
### Account flows and identity
- Added sign in, sign out, and registration flows.
- Redesigned login and registration dialogs.
- Added privacy policy presentation during registration.
- Added forgot password and email confirmation flows.
- Pointed the app to the real API and improved profile caching.
### Account management and settings
- Added Wino account settings and a dedicated management page.
- Added a special navigation item for Wino Accounts.
- Added import functionality for Wino Accounts.
- Added a preference to hide the title bar Wino account button.
- Improved the top-shell account icon and signed-out identity visuals.
### Purchases and add-ons
- Added handling for Paddle purchases and add-ons.
- Added purchase-success deep linking.
- Added support for AI pack handling through the Microsoft Store.
### User-facing polish
- Redesigned the Wino Account flyout and menu with a more polished Fluent-style presentation.
- Improved account cleanup behavior when an account is deleted.
- Added account attention handling and better account details/settings behavior.
Compared to `main`, this branch turns Wino Accounts into a much more complete platform feature rather than a minimal sign-in surface.
## Improved Stability and Reliability
A large part of this branch is about making the app more dependable in everyday use.
### Synchronization stability
- Refactored synchronizers to address long-standing reliability issues.
- Improved thread mapping across synchronizers.
- Added generic 404 handling for synchronizers.
- Added specific Outlook 429 handling for rate-limit scenarios.
- Improved Outlook authentication and Outlook sync reliability.
- Improved Gmail synchronizer behavior.
- Added explicit mail and calendar synchronizer state support.
### Mail and data reliability
- Optimized mail fetching with batched database queries and in-memory caching.
- Added SQLite indexes and enabled foreign key enforcement.
- Switched away from the old mail item queue approach and returned to a simpler initial sync strategy.
- Improved local draft resend behavior and added grace-period handling for local drafts.
- Added better handling for large Outlook attachments via upload sessions.
- Fixed issues with sent/draft placement, loading mails with infinite scroll, selection cleanup, and deleted-object scenarios.
### UI and lifecycle stability
- Fixed mail rendering page disposal issues.
- Fixed WebView2 runtime toast dispatching on the UI thread.
- Fixed startup mode issues, single-instancing problems, and shell/navigation regressions.
- Fixed multiple thread selection, container, flicker, and context-menu issues.
- Fixed crashes and null-reference style issues in several calendar and shell flows.
### Engineering quality
- Added more tests across calendar, CalDAV, IMAP, view-model, sanitization, and account sync scenarios.
- Added a GitHub Actions workflow to build WinUI and run Core tests on pull requests.
- Resolved warnings and moved the WinUI project toward warnings-as-errors discipline.
- Added AOT compatibility work and related cleanup across the app.
The branch is not just adding features; it is also clearly reducing failure points throughout sync, rendering, navigation, and storage.
## Contacts, Settings, and General UX
This branch also improves the everyday product experience outside mail and calendar core flows.
### Contacts
- Added contacts management.
- Improved contacts UI and related thread/image preview behavior.
- Removed legacy SQLite base64 contact storage from `AccountContact`.
- Added contact picture handling support and supporting contact service improvements.
### Settings
- Added a dedicated settings shell and refactored settings home/navigation.
- Expanded settings UI and introduced new setting options.
- Added calendar settings into the settings experience.
- Improved account details/settings pages and storage settings navigation.
- Refined settings visuals, shell integration, and menu behavior.
### Onboarding and app experience
- Added a new startup window and a more guided onboarding flow with wizard-like steps.
- Added a "What's New" implementation for feature communication.
- Improved dialogs, title bar behavior, shell content, navigation, and shell polish across multiple iterations.
- Added live store update notifications.
- Improved keyboard shortcuts and related dialogs.
- Added tray icon support and better toast routing between mail and calendar app entries.
## Summary
Compared to `main`, `feature/vNext` delivers four major leaps:
1. Wino Calendar becomes a substantially more complete feature set, including local calendar support, CalDAV sync, and stronger Outlook and Gmail calendar integration.
2. Wino Accounts becomes a real product surface with better authentication flows, management, imports, purchases, and polish.
3. The app is more stable thanks to synchronization refactors, storage improvements, test expansion, and many crash and lifecycle fixes.
4. Contacts, settings, onboarding, and shell/navigation experience all feel more mature and more consistent.
In short, this branch is a broad product maturation release rather than a narrow feature drop.
+1 -1
View File
@@ -45,6 +45,6 @@ public class GmailAuthenticator : BaseAuthenticator, IGmailAuthenticator
return GoogleWebAuthorizationBroker.AuthorizeAsync(new ClientSecrets() return GoogleWebAuthorizationBroker.AuthorizeAsync(new ClientSecrets()
{ {
ClientId = ClientId ClientId = ClientId
}, AuthenticatorConfig.GetGmailScope(account?.IsMailAccessGranted != false, account?.IsCalendarAccessGranted == true), account.Id.ToString(), CancellationToken.None, new FileDataStore(AuthenticatorConfig.GmailTokenStoreIdentifier)); }, AuthenticatorConfig.GmailScope, account.Id.ToString(), CancellationToken.None, new FileDataStore(AuthenticatorConfig.GmailTokenStoreIdentifier));
} }
} }
+15 -8
View File
@@ -65,10 +65,7 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
_publicClientApplication = outlookAppBuilder.Build(); _publicClientApplication = outlookAppBuilder.Build();
} }
private string[] GetScope(MailAccount account) public string[] Scope => AuthenticatorConfig.OutlookScope;
=> AuthenticatorConfig.GetOutlookScope(
account?.IsMailAccessGranted != false,
account?.IsCalendarAccessGranted == true);
private async Task EnsureTokenCacheAttachedAsync() private async Task EnsureTokenCacheAttachedAsync()
{ {
@@ -94,7 +91,7 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
try try
{ {
var authResult = await _publicClientApplication.AcquireTokenSilent(GetScope(account), storedAccount).ExecuteAsync(); var authResult = await _publicClientApplication.AcquireTokenSilent(Scope, storedAccount).ExecuteAsync();
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username); return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
} }
@@ -106,6 +103,10 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
return await GenerateTokenInformationAsync(account); return await GenerateTokenInformationAsync(account);
} }
catch (Exception)
{
throw;
}
} }
public async Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account) public async Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
@@ -121,11 +122,17 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
if (_nativeAppService.GetCoreWindowHwnd == null) throw new AuthenticationAttentionException(account); if (_nativeAppService.GetCoreWindowHwnd == null) throw new AuthenticationAttentionException(account);
AuthenticationResult authResult = await _publicClientApplication AuthenticationResult authResult = await _publicClientApplication
.AcquireTokenInteractive(GetScope(account)) .AcquireTokenInteractive(Scope)
.ExecuteAsync(); .ExecuteAsync();
// Microsoft 365 work/school tenants can use a sign-in UPN that differs from // If the account is null, it means it's the initial creation of it.
// the mailbox primary SMTP address, so interactive reauth must not reject them. // If not, make sure the authenticated user address matches the username.
// When people refresh their token, accounts must match.
if (account?.Address != null && !account.Address.Equals(authResult.Account.Username, StringComparison.OrdinalIgnoreCase))
{
throw new AuthenticationException("Authenticated address does not match with your account address. If you are signing with a Office365, it is not officially supported yet.");
}
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username); return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
} }
@@ -35,6 +35,9 @@ public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewMode
[ObservableProperty] [ObservableProperty]
public partial bool IsSyncEnabled { get; set; } public partial bool IsSyncEnabled { get; set; }
[ObservableProperty]
public partial bool IsPrimaryCalendar { get; set; }
public ObservableCollection<ShowAsOption> ShowAsOptions { get; } = new ObservableCollection<ShowAsOption>(); public ObservableCollection<ShowAsOption> ShowAsOptions { get; } = new ObservableCollection<ShowAsOption>();
[ObservableProperty] [ObservableProperty]
@@ -79,6 +82,7 @@ public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewMode
// Initialize properties from AccountCalendar // Initialize properties from AccountCalendar
AccountColorHex = AccountCalendar.BackgroundColorHex ?? "#0078D4"; AccountColorHex = AccountCalendar.BackgroundColorHex ?? "#0078D4";
IsSyncEnabled = AccountCalendar.IsSynchronizationEnabled; IsSyncEnabled = AccountCalendar.IsSynchronizationEnabled;
IsPrimaryCalendar = AccountCalendar.IsPrimary;
SelectedDefaultShowAsOption = ShowAsOptions.FirstOrDefault(o => o.ShowAs == AccountCalendar.DefaultShowAs) ?? ShowAsOptions[2]; SelectedDefaultShowAsOption = ShowAsOptions.FirstOrDefault(o => o.ShowAs == AccountCalendar.DefaultShowAs) ?? ShowAsOptions[2];
} }
@@ -87,7 +91,6 @@ public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewMode
if (AccountCalendar != null && !string.IsNullOrEmpty(value)) if (AccountCalendar != null && !string.IsNullOrEmpty(value))
{ {
AccountCalendar.BackgroundColorHex = value; AccountCalendar.BackgroundColorHex = value;
AccountCalendar.IsBackgroundColorUserOverridden = true;
SaveChangesAsync(); SaveChangesAsync();
} }
} }
@@ -101,6 +104,15 @@ public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewMode
} }
} }
partial void OnIsPrimaryCalendarChanged(bool value)
{
if (AccountCalendar != null)
{
AccountCalendar.IsPrimary = value;
SaveChangesAsync();
}
}
partial void OnSelectedDefaultShowAsOptionChanged(ShowAsOption value) partial void OnSelectedDefaultShowAsOptionChanged(ShowAsOption value)
{ {
if (AccountCalendar != null && value != null) if (AccountCalendar != null && value != null)
@@ -1,8 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@@ -11,25 +9,25 @@ using CommunityToolkit.Mvvm.Messaging;
using Serilog; using Serilog;
using Wino.Calendar.ViewModels.Data; using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Interfaces; using Wino.Calendar.ViewModels.Interfaces;
using Wino.Core.Domain;
using Wino.Core.Domain.Collections; using Wino.Core.Domain.Collections;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.MenuItems;
using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.ViewModels; using Wino.Core.ViewModels;
using Wino.Messaging.Client.Calendar; using Wino.Messaging.Client.Calendar;
using Wino.Messaging.Client.Navigation;
using Wino.Messaging.Server; using Wino.Messaging.Server;
using Wino.Messaging.UI; using Wino.Messaging.UI;
namespace Wino.Calendar.ViewModels; namespace Wino.Calendar.ViewModels;
public partial class CalendarAppShellViewModel : CalendarBaseViewModel, public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
ICalendarShellClient, IRecipient<VisibleDateRangeChangedMessage>,
IRecipient<CalendarEnableStatusChangedMessage>,
IRecipient<NavigateManageAccountsRequested>,
IRecipient<CalendarDisplayTypeChangedMessage>, IRecipient<CalendarDisplayTypeChangedMessage>,
IRecipient<AccountRemovedMessage> IRecipient<AccountRemovedMessage>
{ {
@@ -37,31 +35,27 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
public IStatePersistanceService StatePersistenceService { get; } public IStatePersistanceService StatePersistenceService { get; }
public IAccountCalendarStateService AccountCalendarStateService { get; } public IAccountCalendarStateService AccountCalendarStateService { get; }
public INavigationService NavigationService { get; } public INavigationService NavigationService { get; }
public WinoApplicationMode Mode => WinoApplicationMode.Calendar;
public bool HandlesNavigationSelection => false;
public VisibleDateRange CurrentVisibleRange => _calendarPageViewModel.CurrentVisibleRange;
public string VisibleDateRangeText => _calendarPageViewModel.VisibleDateRangeText;
System.Collections.IEnumerable ICalendarShellClient.GroupedAccountCalendars => AccountCalendarStateService.GroupedAccountCalendars;
System.Collections.IEnumerable ICalendarShellClient.DateNavigationHeaderItems => DateNavigationHeaderItems;
object IShellClient.SelectedMenuItem
{
get => null;
set { }
}
System.Windows.Input.ICommand ICalendarShellClient.TodayClickedCommand => TodayClickedCommand;
System.Windows.Input.ICommand ICalendarShellClient.DateClickedCommand => DateClickedCommand;
System.Windows.Input.ICommand ICalendarShellClient.PreviousDateRangeCommand => PreviousDateRangeCommand;
System.Windows.Input.ICommand ICalendarShellClient.NextDateRangeCommand => NextDateRangeCommand;
System.Windows.Input.ICommand ICalendarShellClient.SyncCommand => SyncCommand;
public bool CanSynchronizeCalendars => !AccountCalendarStateService.IsAnySynchronizationInProgress;
public MenuItemCollection MenuItems { get; private set; }
public MenuItemCollection FooterItems { get; private set; }
[ObservableProperty] [ObservableProperty]
private int _selectedMenuItemIndex = -1; private int _selectedMenuItemIndex = -1;
[ObservableProperty]
private bool isCalendarEnabled;
/// <summary>
/// Gets or sets the display date of the calendar.
/// </summary>
[ObservableProperty]
private DateTimeOffset _displayDate;
/// <summary>
/// Gets or sets the highlighted range in the CalendarView and displayed date range in FlipView.
/// </summary>
[ObservableProperty]
private DateRange highlightedDateRange;
[ObservableProperty] [ObservableProperty]
private ObservableRangeCollection<string> dateNavigationHeaderItems = []; private ObservableRangeCollection<string> dateNavigationHeaderItems = [];
@@ -70,47 +64,28 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
public bool IsVerticalCalendar => StatePersistenceService.CalendarDisplayType == CalendarDisplayType.Month; public bool IsVerticalCalendar => StatePersistenceService.CalendarDisplayType == CalendarDisplayType.Month;
[ObservableProperty] // For updating account calendars asynchronously.
private bool isStoreUpdateItemVisible; private SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
private readonly SettingsItem _settingsItem = new(); public CalendarAppShellViewModel(IPreferencesService preferencesService,
private readonly StoreUpdateMenuItem _storeUpdateMenuItem = new(); IStatePersistanceService statePersistanceService,
private readonly SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1); IAccountService accountService,
private readonly CalendarPageViewModel _calendarPageViewModel; ICalendarService calendarService,
private readonly IMailDialogService _dialogService; IAccountCalendarStateService accountCalendarStateService,
private readonly IStoreUpdateService _storeUpdateService; INavigationService navigationService)
private readonly IAccountService _accountService;
private readonly ICalendarService _calendarService;
private readonly IDateContextProvider _dateContextProvider;
private bool _runtimeSubscriptionsAttached;
private bool _hasRegisteredPersistentRecipients;
private DateTime? _navigationDate;
public CalendarAppShellViewModel(
IPreferencesService preferencesService,
IStatePersistanceService statePersistanceService,
IAccountService accountService,
ICalendarService calendarService,
IAccountCalendarStateService accountCalendarStateService,
INavigationService navigationService,
CalendarPageViewModel calendarPageViewModel,
IMailDialogService dialogService,
IStoreUpdateService storeUpdateService,
IDateContextProvider dateContextProvider)
{ {
PreferencesService = preferencesService;
StatePersistenceService = statePersistanceService;
AccountCalendarStateService = accountCalendarStateService;
NavigationService = navigationService;
_accountService = accountService; _accountService = accountService;
_calendarService = calendarService; _calendarService = calendarService;
_calendarPageViewModel = calendarPageViewModel;
_dialogService = dialogService;
_storeUpdateService = storeUpdateService;
_dateContextProvider = dateContextProvider;
_calendarPageViewModel.PropertyChanged += CalendarPageViewModelPropertyChanged; AccountCalendarStateService = accountCalendarStateService;
AccountCalendarStateService.PropertyChanged += AccountCalendarStateServicePropertyChanged; AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested;
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged += AccountCalendarStateCollectivelyChanged;
NavigationService = navigationService;
PreferencesService = preferencesService;
StatePersistenceService = statePersistanceService;
StatePersistenceService.StatePropertyChanged += PrefefencesChanged;
} }
protected override void OnDispatcherAssigned() protected override void OnDispatcherAssigned()
@@ -118,157 +93,45 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
base.OnDispatcherAssigned(); base.OnDispatcherAssigned();
AccountCalendarStateService.Dispatcher = Dispatcher; AccountCalendarStateService.Dispatcher = Dispatcher;
MenuItems = new MenuItemCollection(Dispatcher);
FooterItems = new MenuItemCollection(Dispatcher);
_ = RefreshFooterItemsAsync(false);
}
private void CalendarPageViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(CalendarPageViewModel.CurrentVisibleRange))
{
OnPropertyChanged(nameof(CurrentVisibleRange));
}
if (e.PropertyName == nameof(CalendarPageViewModel.CurrentVisibleRange) ||
e.PropertyName == nameof(CalendarPageViewModel.VisibleDateRangeText))
{
OnPropertyChanged(nameof(VisibleDateRangeText));
UpdateDateNavigationHeaderItems();
}
} }
private void PrefefencesChanged(object sender, string e) private void PrefefencesChanged(object sender, string e)
{ {
if (e != nameof(StatePersistenceService.CalendarDisplayType)) if (e == nameof(StatePersistenceService.CalendarDisplayType))
return;
Messenger.Send(new CalendarDisplayTypeChangedMessage(StatePersistenceService.CalendarDisplayType));
OnPropertyChanged(nameof(IsVerticalCalendar));
UpdateDateNavigationHeaderItems();
NavigateCalendarDate(GetDisplayTypeSwitchDate());
}
private async void PreferencesServiceChanged(object sender, string e)
{
if (e == nameof(IPreferencesService.IsStoreUpdateNotificationsEnabled))
{ {
await RefreshFooterItemsAsync(false); Messenger.Send(new CalendarDisplayTypeChangedMessage(StatePersistenceService.CalendarDisplayType));
UpdateDateNavigationHeaderItems();
// Change the calendar.
DateClicked(new CalendarViewDayClickedEventArgs(GetDisplayTypeSwitchDate()));
} }
} }
public override async void OnNavigatedTo(NavigationMode mode, object parameters) public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{ {
if (!_hasRegisteredPersistentRecipients) base.OnNavigatedTo(mode, parameters);
// Account list may have changed while this shell was inactive.
if (mode == NavigationMode.Back)
{ {
RegisterRecipients(); await InitializeAccountCalendarsAsync();
_hasRegisteredPersistentRecipients = true; return;
} }
AttachRuntimeSubscriptions();
var activationContext = parameters as ShellModeActivationContext;
var shouldRunStartupFlows = activationContext?.IsInitialActivation ?? true;
var navigationArgs = activationContext?.Parameter as CalendarPageNavigationArgs;
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
PreferencesService.PreferenceChanged += PreferencesServiceChanged;
await RefreshFooterItemsAsync(mode == NavigationMode.New);
UpdateDateNavigationHeaderItems(); UpdateDateNavigationHeaderItems();
await InitializeAccountCalendarsAsync(); await InitializeAccountCalendarsAsync();
ValidateConfiguredNewEventCalendar();
if (navigationArgs != null) TodayClicked();
{
NavigationService.Navigate(WinoPage.CalendarPage, navigationArgs);
}
else if (shouldRunStartupFlows || _calendarPageViewModel.CurrentVisibleRange == null)
{
TodayClicked();
}
}
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
DetachRuntimeSubscriptions();
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
_ = ExecuteUIThread(() =>
{
DateNavigationHeaderItems.Clear();
AccountCalendarStateService.ClearGroupedAccountCalendars();
SelectedDateNavigationHeaderIndex = -1;
});
_calendarPageViewModel.CleanupForShellDeactivation();
}
public void PrepareForShellShutdown()
{
DetachRuntimeSubscriptions();
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
if (_hasRegisteredPersistentRecipients)
{
UnregisterRecipients();
_hasRegisteredPersistentRecipients = false;
}
DateNavigationHeaderItems.Clear();
SelectedDateNavigationHeaderIndex = -1;
SelectedMenuItemIndex = -1;
MenuItems?.Clear();
FooterItems?.Clear();
AccountCalendarStateService.ClearGroupedAccountCalendars();
_calendarPageViewModel.CleanupForShellDeactivation();
}
private void AccountCalendarStateServicePropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(IAccountCalendarStateService.IsAnySynchronizationInProgress))
return;
OnPropertyChanged(nameof(CanSynchronizeCalendars));
SyncCommand.NotifyCanExecuteChanged();
}
private void AttachRuntimeSubscriptions()
{
if (_runtimeSubscriptionsAttached)
return;
AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested;
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged += AccountCalendarStateCollectivelyChanged;
StatePersistenceService.StatePropertyChanged += PrefefencesChanged;
_runtimeSubscriptionsAttached = true;
}
private void DetachRuntimeSubscriptions()
{
if (!_runtimeSubscriptionsAttached)
return;
AccountCalendarStateService.AccountCalendarSelectionStateChanged -= UpdateAccountCalendarRequested;
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged -= AccountCalendarStateCollectivelyChanged;
StatePersistenceService.StatePropertyChanged -= PrefefencesChanged;
_runtimeSubscriptionsAttached = false;
}
private async Task RefreshFooterItemsAsync(bool showNotification)
{
await ExecuteUIThread(() =>
{
FooterItems.Clear();
});
}
private async Task StartStoreUpdateAsync()
{
await _storeUpdateService.StartUpdateAsync().ConfigureAwait(false);
await RefreshFooterItemsAsync(false).ConfigureAwait(false);
} }
private async void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e) private async void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e)
{ {
// When using three-state checkbox, multiple accounts will be selected/unselected at the same time.
// Reporting all these changes one by one to the UI is not efficient and may cause problems in the future.
// Update all calendar states at once.
try try
{ {
await _accountCalendarUpdateSemaphoreSlim.WaitAsync(); await _accountCalendarUpdateSemaphoreSlim.WaitAsync();
@@ -299,11 +162,16 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
foreach (var account in accounts) foreach (var account in accounts)
{ {
if (!GroupedAccountCalendarViewModel.SupportsCalendar(account))
continue;
var accountCalendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false); var accountCalendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
var calendarViewModels = accountCalendars.Select(calendar => new AccountCalendarViewModel(account, calendar)).ToList(); var calendarViewModels = new List<AccountCalendarViewModel>();
foreach (var calendar in accountCalendars)
{
var calendarViewModel = new AccountCalendarViewModel(account, calendar);
calendarViewModels.Add(calendarViewModel);
}
var groupedAccountCalendarViewModel = new GroupedAccountCalendarViewModel(account, calendarViewModels); var groupedAccountCalendarViewModel = new GroupedAccountCalendarViewModel(account, calendarViewModels);
await Dispatcher.ExecuteOnUIThread(() => await Dispatcher.ExecuteOnUIThread(() =>
@@ -313,154 +181,121 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
} }
} }
private void NavigateCalendarDate(DateTime date)
{
_navigationDate = date.Date;
ForceNavigateCalendarDate();
}
private void ForceNavigateCalendarDate() private void ForceNavigateCalendarDate()
{ {
var args = new CalendarPageNavigationArgs if (SelectedMenuItemIndex == -1)
{ {
NavigationDate = _navigationDate ?? DateTime.Now.Date var args = new CalendarPageNavigationArgs()
};
NavigationService.Navigate(WinoPage.CalendarPage, args);
_navigationDate = null;
}
[RelayCommand(CanExecute = nameof(CanSynchronizeCalendars))]
private async Task Sync()
{
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
foreach (var account in accounts)
{
Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions
{ {
AccountId = account.Id, NavigationDate = _navigationDate ?? DateTime.Now.Date
Type = CalendarSynchronizationType.Strict };
}));
// Already on calendar. Just navigate.
NavigationService.Navigate(WinoPage.CalendarPage, args);
_navigationDate = null;
}
else
{
SelectedMenuItemIndex = -1;
} }
} }
partial void OnSelectedMenuItemIndexChanged(int oldValue, int newValue)
{
switch (newValue)
{
case -1:
ForceNavigateCalendarDate();
break;
case 0:
NavigationService.Navigate(WinoPage.ManageAccountsPage);
break;
case 1:
NavigationService.Navigate(WinoPage.SettingsPage);
break;
default:
break;
}
}
[RelayCommand]
private async Task Sync()
{
// Sync all calendars.
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
foreach (var account in accounts)
{
var t = new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions()
{
AccountId = account.Id,
Type = CalendarSynchronizationType.CalendarEvents
});
Messenger.Send(t);
}
}
/// <summary>
/// When calendar type switches, we need to navigate to the most ideal date.
/// This method returns that date.
/// </summary>
private DateTime GetDisplayTypeSwitchDate() private DateTime GetDisplayTypeSwitchDate()
{ {
var today = _dateContextProvider.GetToday();
var settings = PreferencesService.GetCurrentCalendarSettings(); var settings = PreferencesService.GetCurrentCalendarSettings();
var referenceRange = CurrentVisibleRange switch (StatePersistenceService.CalendarDisplayType)
?? CalendarRangeResolver.Resolve(new CalendarDisplayRequest(StatePersistenceService.CalendarDisplayType, today), settings, today); {
var targetRange = CalendarRangeResolver.ChangeDisplayType(referenceRange, StatePersistenceService.CalendarDisplayType, settings, today); case CalendarDisplayType.Day:
return targetRange.AnchorDate.ToDateTime(TimeOnly.MinValue); if (HighlightedDateRange.IsInRange(DateTime.Now)) return DateTime.Now.Date;
return HighlightedDateRange.StartDate;
case CalendarDisplayType.Week:
if (HighlightedDateRange == null || HighlightedDateRange.IsInRange(DateTime.Now))
{
return DateTime.Now.Date.GetWeekStartDateForDate(settings.FirstDayOfWeek);
}
return HighlightedDateRange.StartDate.GetWeekStartDateForDate(settings.FirstDayOfWeek);
case CalendarDisplayType.WorkWeek:
break;
case CalendarDisplayType.Month:
break;
default:
break;
}
return DateTime.Today.Date;
} }
private DateTime? _navigationDate;
private readonly IAccountService _accountService;
private readonly ICalendarService _calendarService;
#region Commands
[RelayCommand] [RelayCommand]
private void TodayClicked() private void TodayClicked()
{ {
NavigateCalendarDate(_dateContextProvider.GetToday().ToDateTime(TimeOnly.MinValue)); _navigationDate = DateTime.Now.Date;
ForceNavigateCalendarDate();
} }
[RelayCommand] [RelayCommand]
private void PreviousDateRange() public void ManageAccounts() => NavigationService.Navigate(WinoPage.AccountManagementPage);
{
NavigateRelativePeriod(-1);
}
[RelayCommand]
private void NextDateRange()
{
NavigateRelativePeriod(1);
}
private void NavigateRelativePeriod(int direction)
{
var today = _dateContextProvider.GetToday();
var settings = PreferencesService.GetCurrentCalendarSettings();
var referenceRange = CurrentVisibleRange
?? CalendarRangeResolver.Resolve(new CalendarDisplayRequest(StatePersistenceService.CalendarDisplayType, today), settings, today);
var targetRange = CalendarRangeResolver.Navigate(referenceRange, direction, settings, today);
NavigateCalendarDate(targetRange.AnchorDate.ToDateTime(TimeOnly.MinValue));
}
public async Task HandleNavigationItemInvokedAsync(IMenuItem menuItem)
{
switch (menuItem)
{
case NewMailMenuItem:
await NewEventAsync().ConfigureAwait(false);
break;
case SettingsItem:
NavigationService.Navigate(WinoPage.SettingsPage);
break;
case StoreUpdateMenuItem:
await StartStoreUpdateAsync().ConfigureAwait(false);
break;
}
}
[RelayCommand]
private async Task NewEventAsync()
{
var pickedCalendar = TryResolveConfiguredNewEventCalendar();
if (pickedCalendar == null)
{
var availableGroups = AccountCalendarStateService.GroupedAccountCalendars
.Where(group => group.AccountCalendars.Count > 0)
.Select(group => new CalendarPickerAccountGroup
{
Account = group.Account,
Calendars = group.AccountCalendars.Select(calendar => calendar.AccountCalendar).ToList()
})
.ToList();
if (availableGroups.Count == 0)
{
_dialogService.InfoBarMessage(
Translator.CalendarEventCompose_NoCalendarsTitle,
Translator.CalendarEventCompose_NoCalendarsMessage,
InfoBarMessageType.Warning);
return;
}
var pickingResult = await _dialogService.ShowSingleCalendarPickerDialogAsync(availableGroups);
if (pickingResult.ShouldNavigateToCalendarSettings)
{
NavigationService.Navigate(WinoPage.CalendarPreferenceSettingsPage);
return;
}
pickedCalendar = pickingResult.PickedCalendar;
}
if (pickedCalendar == null)
return;
var (startDate, endDate) = GetDefaultComposeDateRange();
NavigationService.Navigate(WinoPage.CalendarEventComposePage, new CalendarEventComposeNavigationArgs
{
SelectedCalendarId = pickedCalendar.Id,
StartDate = startDate,
EndDate = endDate
});
}
public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args)
{
if (args.Handled || args.Mode != WinoApplicationMode.Calendar)
return;
if (args.Action == KeyboardShortcutAction.NewEvent)
{
await NewEventAsync();
args.Handled = true;
}
}
[RelayCommand] [RelayCommand]
private void DateClicked(CalendarViewDayClickedEventArgs clickedDateArgs) private void DateClicked(CalendarViewDayClickedEventArgs clickedDateArgs)
=> NavigateCalendarDate(clickedDateArgs.ClickedDate); {
_navigationDate = clickedDateArgs.ClickedDate;
ForceNavigateCalendarDate();
}
#endregion
protected override void RegisterRecipients() protected override void RegisterRecipients()
{ {
@@ -468,6 +303,9 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
UnregisterRecipients(); UnregisterRecipients();
Messenger.Register<VisibleDateRangeChangedMessage>(this);
Messenger.Register<CalendarEnableStatusChangedMessage>(this);
Messenger.Register<NavigateManageAccountsRequested>(this);
Messenger.Register<CalendarDisplayTypeChangedMessage>(this); Messenger.Register<CalendarDisplayTypeChangedMessage>(this);
Messenger.Register<AccountRemovedMessage>(this); Messenger.Register<AccountRemovedMessage>(this);
} }
@@ -476,17 +314,99 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
{ {
base.UnregisterRecipients(); base.UnregisterRecipients();
Messenger.Unregister<VisibleDateRangeChangedMessage>(this);
Messenger.Unregister<CalendarEnableStatusChangedMessage>(this);
Messenger.Unregister<NavigateManageAccountsRequested>(this);
Messenger.Unregister<CalendarDisplayTypeChangedMessage>(this); Messenger.Unregister<CalendarDisplayTypeChangedMessage>(this);
Messenger.Unregister<AccountRemovedMessage>(this); Messenger.Unregister<AccountRemovedMessage>(this);
} }
public void Receive(VisibleDateRangeChangedMessage message) => HighlightedDateRange = message.DateRange;
/// <summary>
/// Sets the header navigation items based on visible date range and calendar type.
/// </summary>
private void UpdateDateNavigationHeaderItems() private void UpdateDateNavigationHeaderItems()
{ {
var headerText = VisibleDateRangeText; var settings = PreferencesService.GetCurrentCalendarSettings();
DateNavigationHeaderItems.ReplaceRange(string.IsNullOrWhiteSpace(headerText) ? [] : [headerText]); var cultureInfo = settings.CultureInfo ?? CultureInfo.CurrentUICulture;
var visibleRange = HighlightedDateRange ?? new DateRange(DateTime.Today, DateTime.Today.AddDays(1));
var headerText = GetHeaderText(visibleRange, cultureInfo);
DateNavigationHeaderItems.ReplaceRange([headerText]);
SelectedDateNavigationHeaderIndex = DateNavigationHeaderItems.Count > 0 ? 0 : -1; SelectedDateNavigationHeaderIndex = DateNavigationHeaderItems.Count > 0 ? 0 : -1;
} }
private string GetHeaderText(DateRange visibleRange, CultureInfo cultureInfo)
{
var startDate = visibleRange.StartDate.Date;
var endDate = visibleRange.EndDate.Date > startDate ? visibleRange.EndDate.Date.AddDays(-1) : startDate;
switch (StatePersistenceService.CalendarDisplayType)
{
case CalendarDisplayType.Day:
return startDate.ToString("MMMM d, dddd", cultureInfo);
case CalendarDisplayType.Week:
case CalendarDisplayType.WorkWeek:
if (startDate.Month == endDate.Month && startDate.Year == endDate.Year)
{
return $"{startDate.ToString("MMMM d", cultureInfo)} - {endDate.ToString("%d", cultureInfo)}";
}
return $"{startDate.ToString("MMMM d", cultureInfo)} - {endDate.ToString("MMMM d", cultureInfo)}";
case CalendarDisplayType.Month:
return GetDominantMonthHeaderText(startDate, endDate, cultureInfo);
default:
return startDate.ToString("d", cultureInfo);
}
}
private static string GetDominantMonthHeaderText(DateTime startDate, DateTime endDate, CultureInfo cultureInfo)
{
if (endDate < startDate)
{
endDate = startDate;
}
var monthDayCounts = new Dictionary<(int Year, int Month), int>();
for (var day = startDate; day <= endDate; day = day.AddDays(1))
{
var key = (day.Year, day.Month);
if (monthDayCounts.TryGetValue(key, out var count))
{
monthDayCounts[key] = count + 1;
}
else
{
monthDayCounts[key] = 1;
}
}
var dominantKey = (Year: startDate.Year, Month: startDate.Month);
var dominantCount = -1;
foreach (var pair in monthDayCounts)
{
if (pair.Value > dominantCount)
{
dominantCount = pair.Value;
dominantKey = pair.Key;
}
}
return new DateTime(dominantKey.Year, dominantKey.Month, 1).ToString("Y", cultureInfo);
}
partial void OnHighlightedDateRangeChanged(DateRange value) => UpdateDateNavigationHeaderItems();
public async void Receive(CalendarEnableStatusChangedMessage message)
=> await ExecuteUIThread(() => IsCalendarEnabled = message.IsEnabled);
public void Receive(NavigateManageAccountsRequested message) => SelectedMenuItemIndex = 1;
public void Receive(CalendarDisplayTypeChangedMessage message) public void Receive(CalendarDisplayTypeChangedMessage message)
{ {
OnPropertyChanged(nameof(IsVerticalCalendar)); OnPropertyChanged(nameof(IsVerticalCalendar));
@@ -494,69 +414,5 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
} }
public async void Receive(AccountRemovedMessage message) public async void Receive(AccountRemovedMessage message)
{ => await InitializeAccountCalendarsAsync();
await InitializeAccountCalendarsAsync();
ValidateConfiguredNewEventCalendar();
}
private AccountCalendar TryResolveConfiguredNewEventCalendar()
{
ValidateConfiguredNewEventCalendar();
if (PreferencesService.NewEventButtonBehavior != NewEventButtonBehavior.AlwaysUseSpecificCalendar
|| !PreferencesService.DefaultNewEventCalendarId.HasValue)
{
return null;
}
return AccountCalendarStateService.AllCalendars
.FirstOrDefault(calendar => calendar.Id == PreferencesService.DefaultNewEventCalendarId.Value)?
.AccountCalendar;
}
private void ValidateConfiguredNewEventCalendar()
{
if (PreferencesService.NewEventButtonBehavior != NewEventButtonBehavior.AlwaysUseSpecificCalendar
|| !PreferencesService.DefaultNewEventCalendarId.HasValue)
{
return;
}
var exists = AccountCalendarStateService.AllCalendars
.Any(calendar => calendar.Id == PreferencesService.DefaultNewEventCalendarId.Value);
if (!exists)
{
PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AskEachTime;
PreferencesService.DefaultNewEventCalendarId = null;
}
}
private static (DateTime StartDate, DateTime EndDate) GetDefaultComposeDateRange()
{
var localNow = DateTime.Now;
var roundedMinutes = localNow.Minute switch
{
< 30 => 30,
30 when localNow.Second == 0 && localNow.Millisecond == 0 => 30,
_ => 60
};
var startDate = new DateTime(localNow.Year, localNow.Month, localNow.Day, localNow.Hour, 0, 0);
startDate = roundedMinutes == 60 ? startDate.AddHours(1) : startDate.AddMinutes(roundedMinutes);
return (startDate, startDate.AddMinutes(30));
}
void IShellClient.Activate(ShellModeActivationContext activationContext)
=> OnNavigatedTo(NavigationMode.New, activationContext);
void IShellClient.Deactivate()
=> OnNavigatedFrom(NavigationMode.New, null!);
Task IShellClient.HandleNavigationItemInvokedAsync(IMenuItem menuItem)
=> menuItem == null ? Task.CompletedTask : HandleNavigationItemInvokedAsync(menuItem);
Task IShellClient.HandleNavigationSelectionChangedAsync(IMenuItem menuItem)
=> Task.CompletedTask;
} }
@@ -1,763 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using EmailValidation;
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Validation;
using Wino.Core.ViewModels;
namespace Wino.Calendar.ViewModels;
public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel
{
private readonly IAccountService _accountService;
private readonly ICalendarService _calendarService;
private readonly INavigationService _navigationService;
private readonly IMailDialogService _dialogService;
private readonly IContactService _contactService;
private readonly IPreferencesService _preferencesService;
private readonly IUnderlyingThemeService _underlyingThemeService;
private readonly IWinoRequestDelegator _winoRequestDelegator;
private readonly CalendarEventComposeResultValidator _composeResultValidator = new();
public Func<Task<string>> GetHtmlNotesAsync { get; set; }
public ObservableCollection<AccountCalendarViewModel> AvailableCalendars { get; } = [];
public ObservableCollection<GroupedAccountCalendarViewModel> AvailableCalendarGroups { get; } = [];
public ObservableCollection<CalendarComposeAttendeeViewModel> Attendees { get; } = [];
public ObservableCollection<CalendarComposeAttachmentViewModel> Attachments { get; } = [];
public ObservableCollection<ShowAsOption> ShowAsOptions { get; } = [];
public ObservableCollection<ReminderOption> ReminderOptions { get; } = [];
public ObservableCollection<int> RecurrenceIntervalOptions { get; } = [];
public ObservableCollection<CalendarComposeFrequencyOption> RecurrenceFrequencyOptions { get; } = [];
public ObservableCollection<CalendarComposeWeekdayOption> WeekdayOptions { get; } = [];
[ObservableProperty]
public partial AccountCalendarViewModel SelectedCalendar { get; set; }
[ObservableProperty]
public partial string Title { get; set; } = string.Empty;
[ObservableProperty]
public partial string Location { get; set; } = string.Empty;
[ObservableProperty]
public partial bool IsAllDay { get; set; }
[ObservableProperty]
public partial DateTimeOffset StartDate { get; set; }
[ObservableProperty]
public partial TimeSpan StartTime { get; set; }
[ObservableProperty]
public partial TimeSpan EndTime { get; set; }
[ObservableProperty]
public partial DateTimeOffset AllDayEndDate { get; set; }
[ObservableProperty]
public partial bool IsRecurring { get; set; }
[ObservableProperty]
public partial int SelectedRecurrenceInterval { get; set; } = 1;
[ObservableProperty]
public partial CalendarComposeFrequencyOption SelectedRecurrenceFrequencyOption { get; set; }
[ObservableProperty]
public partial DateTimeOffset? RecurrenceEndDate { get; set; }
[ObservableProperty]
public partial string RecurrenceSummary { get; set; } = string.Empty;
[ObservableProperty]
public partial ReminderOption SelectedReminderOption { get; set; }
[ObservableProperty]
public partial ShowAsOption SelectedShowAsOption { get; set; }
[ObservableProperty]
public partial bool IsDarkWebviewRenderer { get; set; }
[ObservableProperty]
public partial CalendarEventComposeResult LastCreatedResult { get; set; }
public CalendarSettings CurrentSettings { get; }
public string TimePickerClockIdentifier => CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "24HourClock" : "12HourClock";
public bool HasAttachments => Attachments.Count > 0;
public bool IsSelectedCalendarCalDav => SelectedCalendar?.Account?.ProviderType == MailProviderType.IMAP4 &&
SelectedCalendar.Account.ServerInformation?.CalendarSupportMode == ImapCalendarSupportMode.CalDav;
public bool CanAddAttachments => !IsSelectedCalendarCalDav;
public string AttachmentsDisabledTooltipText => IsSelectedCalendarCalDav
? Translator.CalendarEventCompose_AttachmentsNotSupportedForCalDav
: string.Empty;
public string SelectedCalendarDisplayText => SelectedCalendar?.Name ?? Translator.CalendarEventCompose_SelectCalendar;
public string SelectedCalendarAccountText => SelectedCalendar?.Account?.Address ?? string.Empty;
public bool IsDailyRecurrenceSelected => SelectedRecurrenceFrequencyOption?.Frequency == CalendarItemRecurrenceFrequency.Daily;
public CalendarEventComposePageViewModel(IAccountService accountService,
ICalendarService calendarService,
INavigationService navigationService,
IMailDialogService dialogService,
IContactService contactService,
IPreferencesService preferencesService,
IUnderlyingThemeService underlyingThemeService,
IWinoRequestDelegator winoRequestDelegator)
{
_accountService = accountService;
_calendarService = calendarService;
_navigationService = navigationService;
_dialogService = dialogService;
_contactService = contactService;
_preferencesService = preferencesService;
_underlyingThemeService = underlyingThemeService;
_winoRequestDelegator = winoRequestDelegator;
CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark();
Attachments.CollectionChanged += AttachmentsCollectionChanged;
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Free));
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Tentative));
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Busy));
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.OutOfOffice));
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.WorkingElsewhere));
foreach (var reminderMinutes in _calendarService.GetPredefinedReminderMinutes().OrderByDescending(x => x))
{
ReminderOptions.Add(new ReminderOption(reminderMinutes));
}
foreach (var interval in Enumerable.Range(1, 99))
{
RecurrenceIntervalOptions.Add(interval);
}
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Daily, Translator.CalendarEventCompose_FrequencyDay));
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Weekly, Translator.CalendarEventCompose_FrequencyWeek));
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Monthly, Translator.CalendarEventCompose_FrequencyMonth));
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Yearly, Translator.CalendarEventCompose_FrequencyYear));
SelectedRecurrenceFrequencyOption = RecurrenceFrequencyOptions.FirstOrDefault();
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Monday, "MO", Translator.CalendarEventCompose_Weekday_Monday));
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Tuesday, "TU", Translator.CalendarEventCompose_Weekday_Tuesday));
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Wednesday, "WE", Translator.CalendarEventCompose_Weekday_Wednesday));
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Thursday, "TH", Translator.CalendarEventCompose_Weekday_Thursday));
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Friday, "FR", Translator.CalendarEventCompose_Weekday_Friday));
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Saturday, "SA", Translator.CalendarEventCompose_Weekday_Saturday));
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Sunday, "SU", Translator.CalendarEventCompose_Weekday_Sunday));
SelectedReminderOption = GetDefaultReminderOption();
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == CalendarItemShowAs.Busy);
var (defaultStart, defaultEnd) = GetDefaultComposeDateRange();
ApplyDateRange(defaultStart, defaultEnd, false);
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
await LoadAvailableCalendarsAsync();
var args = parameters as CalendarEventComposeNavigationArgs;
ApplyNavigationArgs(args);
UpdateRecurrenceSummary();
}
partial void OnSelectedCalendarChanged(AccountCalendarViewModel value)
{
if (value == null)
return;
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == value.DefaultShowAs)
?? ShowAsOptions.FirstOrDefault();
if (IsSelectedCalendarCalDav && Attachments.Count > 0)
{
Attachments.Clear();
}
OnPropertyChanged(nameof(IsSelectedCalendarCalDav));
OnPropertyChanged(nameof(CanAddAttachments));
OnPropertyChanged(nameof(AttachmentsDisabledTooltipText));
OnPropertyChanged(nameof(SelectedCalendarDisplayText));
OnPropertyChanged(nameof(SelectedCalendarAccountText));
}
partial void OnIsAllDayChanged(bool value)
{
if (value)
{
if (AllDayEndDate.Date <= StartDate.Date)
{
AllDayEndDate = StartDate.AddDays(1);
}
}
UpdateRecurrenceSummary();
}
partial void OnStartDateChanged(DateTimeOffset value)
{
if (IsAllDay && AllDayEndDate.Date <= value.Date)
{
AllDayEndDate = value.AddDays(1);
}
if (IsRecurring && WeekdayOptions.All(option => !option.IsSelected))
{
SelectSingleWeekday(value.DayOfWeek);
}
UpdateRecurrenceSummary();
}
partial void OnStartTimeChanged(TimeSpan value) => UpdateRecurrenceSummary();
partial void OnEndTimeChanged(TimeSpan value) => UpdateRecurrenceSummary();
partial void OnAllDayEndDateChanged(DateTimeOffset value) => UpdateRecurrenceSummary();
partial void OnIsRecurringChanged(bool value)
{
if (value && WeekdayOptions.All(option => !option.IsSelected))
{
SelectSingleWeekday(StartDate.DayOfWeek);
}
UpdateRecurrenceSummary();
}
partial void OnSelectedRecurrenceIntervalChanged(int value) => UpdateRecurrenceSummary();
partial void OnSelectedRecurrenceFrequencyOptionChanged(CalendarComposeFrequencyOption value)
{
OnPropertyChanged(nameof(IsDailyRecurrenceSelected));
UpdateRecurrenceSummary();
}
partial void OnRecurrenceEndDateChanged(DateTimeOffset? value) => UpdateRecurrenceSummary();
[RelayCommand]
private async Task AddAttachmentsAsync()
{
if (!CanAddAttachments)
return;
var pickedFiles = await _dialogService.PickFilesMetadataAsync("*");
if (pickedFiles.Count == 0)
return;
await ExecuteUIThread(() =>
{
foreach (var file in pickedFiles)
{
TryAddAttachment(file.FileName, file.FullFilePath, file.FileExtension, file.Size);
}
});
}
public bool TryAddAttachment(string filePath, long size)
{
if (string.IsNullOrWhiteSpace(filePath))
return false;
var fileName = Path.GetFileName(filePath);
var fileExtension = Path.GetExtension(filePath);
return TryAddAttachment(fileName, filePath, fileExtension, size);
}
[RelayCommand]
private void RemoveAttachment(CalendarComposeAttachmentViewModel attachment)
{
if (attachment == null)
return;
Attachments.Remove(attachment);
}
[RelayCommand]
private void ClearRecurrenceEndDate()
{
RecurrenceEndDate = null;
}
[RelayCommand]
private void Cancel()
{
_navigationService.GoBack();
}
[RelayCommand]
private async Task CreateAsync()
{
var uniqueAttendees = Attendees
.GroupBy(attendee => attendee.Email, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.ToList();
var createdResult = await BuildResultAsync(uniqueAttendees);
try
{
_composeResultValidator.Validate(createdResult);
}
catch (CalendarEventComposeValidationException ex)
{
ShowValidationMessage(ex.Message);
return;
}
LastCreatedResult = createdResult;
await _winoRequestDelegator.ExecuteAsync(new CalendarOperationPreparationRequest(
CalendarSynchronizerOperation.CreateEvent,
ComposeResult: createdResult));
NavigateBackToCalendar(createdResult.StartDate);
}
private void NavigateBackToCalendar(DateTime targetDate)
{
_navigationService.Navigate(
WinoPage.CalendarPage,
new CalendarPageNavigationArgs
{
NavigationDate = targetDate,
ForceReload = true
});
}
public async Task<List<AccountContact>> SearchContactsAsync(string queryText)
{
if (string.IsNullOrWhiteSpace(queryText) || queryText.Length < 2)
return [];
return await _contactService.GetAddressInformationAsync(queryText).ConfigureAwait(false);
}
public async Task<CalendarComposeAttendeeViewModel> GetAttendeeAsync(string tokenText)
{
if (!EmailValidator.Validate(tokenText))
return null;
var existing = Attendees.Any(attendee => attendee.Email.Equals(tokenText, StringComparison.OrdinalIgnoreCase));
if (existing)
return null;
var info = await _contactService.GetAddressInformationByAddressAsync(tokenText).ConfigureAwait(false);
if (info != null)
{
return CalendarComposeAttendeeViewModel.FromContact(info);
}
return new CalendarComposeAttendeeViewModel(string.Empty, tokenText);
}
public void AddAttendee(CalendarComposeAttendeeViewModel attendee)
{
if (Attendees.Any(existing => existing.Email.Equals(attendee.Email, StringComparison.OrdinalIgnoreCase)))
return;
Attendees.Add(attendee);
}
[RelayCommand]
private void RemoveAttendee(CalendarComposeAttendeeViewModel attendee)
{
if (attendee == null)
return;
Attendees.Remove(attendee);
}
public void NotifyAddressExists()
{
_dialogService.InfoBarMessage(
Translator.Info_ContactExistsTitle,
Translator.Info_ContactExistsMessage,
InfoBarMessageType.Warning);
}
public void NotifyInvalidEmail(string address)
{
_dialogService.InfoBarMessage(
Translator.Info_InvalidAddressTitle,
string.Format(Translator.Info_InvalidAddressMessage, address),
InfoBarMessageType.Warning);
}
private async Task LoadAvailableCalendarsAsync()
{
var accountCalendars = new List<AccountCalendarViewModel>();
var groupedCalendars = new List<GroupedAccountCalendarViewModel>();
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
foreach (var account in accounts)
{
if (!GroupedAccountCalendarViewModel.SupportsCalendar(account))
continue;
var calendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
var viewModels = calendars
.Select(calendar => new AccountCalendarViewModel(account, calendar))
.ToList();
accountCalendars.AddRange(viewModels);
if (viewModels.Count > 0)
{
groupedCalendars.Add(new GroupedAccountCalendarViewModel(account, viewModels));
}
}
await ExecuteUIThread(() =>
{
AvailableCalendars.Clear();
AvailableCalendarGroups.Clear();
foreach (var calendar in accountCalendars.OrderBy(calendar => calendar.Account.Name).ThenBy(calendar => calendar.Name))
{
AvailableCalendars.Add(calendar);
}
foreach (var group in groupedCalendars.OrderBy(group => group.Account.Name))
{
AvailableCalendarGroups.Add(group);
}
});
}
private void ApplyNavigationArgs(CalendarEventComposeNavigationArgs args)
{
var (defaultStart, defaultEnd) = GetDefaultComposeDateRange();
var startDate = args?.StartDate != default ? args!.StartDate : defaultStart;
var endDate = args?.EndDate != default ? args!.EndDate : defaultEnd;
var isAllDay = args?.IsAllDay ?? false;
Title = args?.Title ?? string.Empty;
Location = args?.Location ?? string.Empty;
ApplyDateRange(startDate, endDate, isAllDay);
SelectedCalendar = ResolveSelectedCalendar(args?.SelectedCalendarId);
if (SelectedCalendar != null)
{
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == SelectedCalendar.DefaultShowAs)
?? SelectedShowAsOption
?? ShowAsOptions.FirstOrDefault();
}
}
private AccountCalendarViewModel ResolveSelectedCalendar(Guid? selectedCalendarId)
{
if (selectedCalendarId.HasValue)
{
var selectedCalendar = AvailableCalendars.FirstOrDefault(calendar => calendar.Id == selectedCalendarId.Value);
if (selectedCalendar != null)
return selectedCalendar;
}
return AvailableCalendars.FirstOrDefault(calendar => calendar.IsPrimary) ?? AvailableCalendars.FirstOrDefault();
}
private void ApplyDateRange(DateTime startDate, DateTime endDate, bool isAllDay)
{
IsAllDay = isAllDay;
StartDate = new DateTimeOffset(startDate.Date);
StartTime = startDate.TimeOfDay;
EndTime = endDate.TimeOfDay;
AllDayEndDate = new DateTimeOffset((isAllDay ? endDate.Date : startDate.Date.AddDays(1)));
}
private async Task<CalendarEventComposeResult> BuildResultAsync(List<CalendarComposeAttendeeViewModel> uniqueAttendees)
{
if (RecurrenceEndDate.HasValue && RecurrenceEndDate.Value.Date < StartDate.Date)
{
throw new CalendarEventComposeValidationException(Translator.CalendarEventCompose_ValidationInvalidRecurrenceEnd);
}
var htmlNotes = GetHtmlNotesAsync == null ? string.Empty : await GetHtmlNotesAsync();
var effectiveStart = GetEffectiveStartDateTime();
var effectiveEnd = GetEffectiveEndDateTime();
return new CalendarEventComposeResult
{
CalendarId = SelectedCalendar?.Id ?? Guid.Empty,
AccountId = SelectedCalendar?.Account.Id ?? Guid.Empty,
Title = Title.Trim(),
Location = Location?.Trim() ?? string.Empty,
HtmlNotes = htmlNotes,
StartDate = effectiveStart,
EndDate = effectiveEnd,
IsAllDay = IsAllDay,
TimeZoneId = TimeZoneInfo.Local.Id,
ShowAs = SelectedShowAsOption?.ShowAs ?? SelectedCalendar?.DefaultShowAs ?? CalendarItemShowAs.Busy,
SelectedReminders = BuildSelectedReminders(),
Attendees = BuildAttendees(uniqueAttendees),
Attachments = CanAddAttachments
? Attachments.Select(attachment => attachment.ToDraftModel()).ToList()
: [],
Recurrence = BuildRecurrenceRule(),
RecurrenceSummary = RecurrenceSummary
};
}
private List<Reminder> BuildSelectedReminders()
{
if (SelectedReminderOption == null)
return [];
return
[
new Reminder
{
Id = Guid.NewGuid(),
CalendarItemId = Guid.Empty,
DurationInSeconds = SelectedReminderOption.Minutes * 60L,
ReminderType = CalendarItemReminderType.Popup
}
];
}
private static List<CalendarEventAttendee> BuildAttendees(IEnumerable<CalendarComposeAttendeeViewModel> attendees)
{
return attendees
.Select(attendee => new CalendarEventAttendee
{
Id = Guid.NewGuid(),
CalendarItemId = Guid.Empty,
Name = attendee.HasDistinctDisplayName ? attendee.DisplayName : string.Empty,
Email = attendee.Email,
AttendenceStatus = AttendeeStatus.NeedsAction,
IsOrganizer = false,
ResolvedContact = attendee.ResolvedContact
})
.ToList();
}
private ReminderOption GetDefaultReminderOption()
{
var reminderMinutes = Math.Max(1, _preferencesService.DefaultReminderDurationInSeconds / 60);
return ReminderOptions.FirstOrDefault(option => option.Minutes == reminderMinutes)
?? ReminderOptions.FirstOrDefault();
}
private void UpdateRecurrenceSummary()
{
if (!HasInitializedComposeDateRange())
{
RecurrenceSummary = string.Empty;
return;
}
var effectiveStart = GetEffectiveStartDateTime();
var effectiveEnd = GetEffectiveEndDateTime();
var selectedDays = IsDailyRecurrenceSelected
? WeekdayOptions
.Where(option => option.IsSelected)
.Select(option => option.DayOfWeek)
.ToList()
: [];
RecurrenceSummary = CalendarRecurrenceSummaryFormatter.BuildSummary(
IsRecurring,
effectiveStart,
effectiveEnd,
IsAllDay,
CurrentSettings,
SelectedRecurrenceInterval,
SelectedRecurrenceFrequencyOption?.Frequency ?? CalendarItemRecurrenceFrequency.Weekly,
selectedDays,
RecurrenceEndDate);
}
private bool HasInitializedComposeDateRange()
{
if (StartDate == default)
{
return false;
}
return !IsAllDay || AllDayEndDate != default;
}
private string BuildRecurrenceRule()
{
if (!IsRecurring || SelectedRecurrenceFrequencyOption == null)
return string.Empty;
var parts = new List<string>
{
$"FREQ={SelectedRecurrenceFrequencyOption.Frequency.ToString().ToUpperInvariant()}",
$"INTERVAL={SelectedRecurrenceInterval}"
};
var selectedDays = IsDailyRecurrenceSelected
? WeekdayOptions
.Where(option => option.IsSelected)
.Select(option => option.RuleValue)
.ToList()
: [];
if (selectedDays.Count > 0)
{
parts.Add($"BYDAY={string.Join(",", selectedDays)}");
}
if (RecurrenceEndDate.HasValue)
{
var untilValue = IsAllDay
? RecurrenceEndDate.Value.ToString("yyyyMMdd", CultureInfo.InvariantCulture)
: RecurrenceEndDate.Value.Date.AddDays(1).AddSeconds(-1).ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture);
parts.Add($"UNTIL={untilValue}");
}
return $"RRULE:{string.Join(";", parts)}";
}
private DateTime GetEffectiveStartDateTime()
=> StartDate.Date.Add(IsAllDay ? TimeSpan.Zero : StartTime);
private DateTime GetEffectiveEndDateTime()
=> IsAllDay
? AllDayEndDate.Date
: StartDate.Date.Add(EndTime);
private static (DateTime StartDate, DateTime EndDate) GetDefaultComposeDateRange()
{
var localNow = DateTime.Now;
var roundedMinutes = localNow.Minute switch
{
< 30 => 30,
30 when localNow.Second == 0 && localNow.Millisecond == 0 => 30,
_ => 60
};
var startDate = new DateTime(localNow.Year, localNow.Month, localNow.Day, localNow.Hour, 0, 0);
startDate = roundedMinutes == 60 ? startDate.AddHours(1) : startDate.AddMinutes(roundedMinutes);
return (startDate, startDate.AddMinutes(30));
}
private CalendarComposeWeekdayOption CreateWeekdayOption(DayOfWeek dayOfWeek, string ruleValue, string label)
{
var option = new CalendarComposeWeekdayOption(dayOfWeek, ruleValue, label);
option.PropertyChanged += WeekdayOptionPropertyChanged;
return option;
}
private void WeekdayOptionPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(CalendarComposeWeekdayOption.IsSelected))
{
UpdateRecurrenceSummary();
}
}
private void SelectSingleWeekday(DayOfWeek dayOfWeek)
{
foreach (var option in WeekdayOptions)
{
option.IsSelected = option.DayOfWeek == dayOfWeek;
}
}
private void ShowValidationMessage(string message)
{
_dialogService.InfoBarMessage(
Translator.CalendarEventCompose_ValidationTitle,
message,
InfoBarMessageType.Warning);
}
private void AttachmentsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(HasAttachments));
}
private bool TryAddAttachment(string fileName, string filePath, string fileExtension, long size)
{
if (!CanAddAttachments ||
string.IsNullOrWhiteSpace(filePath) ||
Attachments.Any(existing => existing.FilePath.Equals(filePath, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
Attachments.Add(new CalendarComposeAttachmentViewModel(fileName, filePath, fileExtension, size));
return true;
}
}
public partial class CalendarComposeFrequencyOption : ObservableObject
{
public CalendarItemRecurrenceFrequency Frequency { get; }
public string DisplayText { get; }
public CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency frequency, string displayText)
{
Frequency = frequency;
DisplayText = displayText;
}
public string PluralLabel(int interval)
{
if (interval == 1)
return DisplayText;
return Frequency switch
{
CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDayPlural,
CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeekPlural,
CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonthPlural,
CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYearPlural,
_ => DisplayText
};
}
}
public partial class CalendarComposeWeekdayOption : ObservableObject
{
public DayOfWeek DayOfWeek { get; }
public string RuleValue { get; }
public string Label { get; }
public string FullDayName => DayOfWeek switch
{
DayOfWeek.Monday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[1],
DayOfWeek.Tuesday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[2],
DayOfWeek.Wednesday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[3],
DayOfWeek.Thursday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[4],
DayOfWeek.Friday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[5],
DayOfWeek.Saturday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[6],
DayOfWeek.Sunday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[0],
_ => string.Empty
};
[ObservableProperty]
public partial bool IsSelected { get; set; }
public CalendarComposeWeekdayOption(DayOfWeek dayOfWeek, string ruleValue, string label)
{
DayOfWeek = dayOfWeek;
RuleValue = ruleValue;
Label = label;
}
}
@@ -1,44 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Interfaces;
namespace Wino.Calendar.ViewModels;
public partial class CalendarNotificationSettingsPageViewModel : CalendarSettingsSectionViewModelBase
{
[ObservableProperty]
public partial int SelectedDefaultReminderIndex { get; set; }
[ObservableProperty]
public partial int SelectedDefaultSnoozeIndex { get; set; }
public CalendarNotificationSettingsPageViewModel(
IPreferencesService preferencesService,
ICalendarService calendarService,
IAccountService accountService)
: base(preferencesService, calendarService, accountService)
{
LoadReminderOptions();
LoadSnoozeOptions();
SelectedDefaultReminderIndex = GetSelectedReminderIndex();
SelectedDefaultSnoozeIndex = GetSelectedSnoozeIndex();
IsLoaded = true;
}
partial void OnSelectedDefaultReminderIndexChanged(int value)
{
if (!IsLoaded)
return;
SaveReminderIndex(value);
}
partial void OnSelectedDefaultSnoozeIndexChanged(int value)
{
if (!IsLoaded)
return;
SaveSnoozeIndex(value);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,62 +0,0 @@
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Calendar.ViewModels;
public partial class CalendarPreferenceSettingsPageViewModel : CalendarSettingsSectionViewModelBase
{
[ObservableProperty]
public partial CalendarNewEventBehaviorOption SelectedNewEventBehaviorOption { get; set; }
[ObservableProperty]
public partial AccountCalendarViewModel SelectedNewEventCalendar { get; set; }
public bool ShouldShowSpecificNewEventCalendar
=> SelectedNewEventBehaviorOption?.Behavior == NewEventButtonBehavior.AlwaysUseSpecificCalendar;
public CalendarPreferenceSettingsPageViewModel(
IPreferencesService preferencesService,
ICalendarService calendarService,
IAccountService accountService)
: base(preferencesService, calendarService, accountService)
{
LoadNewEventBehaviorOptions();
SelectedNewEventBehaviorOption = GetSelectedNewEventBehaviorOption();
IsLoaded = true;
LoadCalendarsAsync(ApplyStoredNewEventCalendarPreference);
}
partial void OnSelectedNewEventBehaviorOptionChanged(CalendarNewEventBehaviorOption value)
{
if (!IsLoaded)
return;
OnPropertyChanged(nameof(ShouldShowSpecificNewEventCalendar));
SaveNewEventBehavior(SelectedNewEventBehaviorOption, SelectedNewEventCalendar);
}
partial void OnSelectedNewEventCalendarChanged(AccountCalendarViewModel value)
{
if (!IsLoaded)
return;
SaveNewEventBehavior(SelectedNewEventBehaviorOption, value);
}
private void ApplyStoredNewEventCalendarPreference()
{
var configuredCalendar = ResolveSelectedNewEventCalendar();
if (PreferencesService.NewEventButtonBehavior == NewEventButtonBehavior.AlwaysUseSpecificCalendar && configuredCalendar == null)
{
SelectedNewEventBehaviorOption = NewEventBehaviorOptions.First(option => option.Behavior == NewEventButtonBehavior.AskEachTime);
SelectedNewEventCalendar = null;
return;
}
SelectedNewEventCalendar = configuredCalendar ?? ResolveFallbackNewEventCalendar();
}
}
@@ -1,191 +0,0 @@
using System;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Interfaces;
namespace Wino.Calendar.ViewModels;
public partial class CalendarRenderingSettingsPageViewModel : CalendarSettingsSectionViewModelBase
{
[ObservableProperty]
public partial double CellHourHeight { get; set; }
[ObservableProperty]
public partial int SelectedFirstDayOfWeekIndex { get; set; }
[ObservableProperty]
public partial bool Is24HourHeaders { get; set; }
[ObservableProperty]
public partial bool IsWorkingHoursEnabled { get; set; }
[ObservableProperty]
public partial TimeSpan WorkingHourStart { get; set; }
[ObservableProperty]
public partial TimeSpan WorkingHourEnd { get; set; }
[ObservableProperty]
public partial int WorkingDayStartIndex { get; set; }
[ObservableProperty]
public partial int WorkingDayEndIndex { get; set; }
[ObservableProperty]
public partial string TimedDayHeaderDateFormat { get; set; } = "ddd dd";
[ObservableProperty]
public partial int SelectedTimedDayHeaderFormatPresetIndex { get; set; } = -1;
public CalendarRenderingSettingsPageViewModel(
IPreferencesService preferencesService,
ICalendarService calendarService,
IAccountService accountService)
: base(preferencesService, calendarService, accountService)
{
SelectedFirstDayOfWeekIndex = DayNames.IndexOf(CalendarCulture.DateTimeFormat.GetDayName(preferencesService.FirstDayOfWeek));
Is24HourHeaders = preferencesService.Prefer24HourTimeFormat;
IsWorkingHoursEnabled = preferencesService.IsWorkingHoursEnabled;
WorkingHourStart = preferencesService.WorkingHourStart;
WorkingHourEnd = preferencesService.WorkingHourEnd;
CellHourHeight = preferencesService.HourHeight;
WorkingDayStartIndex = DayNames.IndexOf(CalendarCulture.DateTimeFormat.GetDayName(preferencesService.WorkingDayStart));
WorkingDayEndIndex = DayNames.IndexOf(CalendarCulture.DateTimeFormat.GetDayName(preferencesService.WorkingDayEnd));
TimedDayHeaderDateFormat = preferencesService.CalendarTimedDayHeaderDateFormat;
SelectedTimedDayHeaderFormatPresetIndex = TimedDayHeaderFormatPresets.IndexOf(TimedDayHeaderDateFormat);
IsLoaded = true;
}
partial void OnCellHourHeightChanged(double oldValue, double newValue) => SaveSettings();
partial void OnIs24HourHeadersChanged(bool value)
{
OnPropertyChanged(nameof(TimedHourLabelPreview));
SaveSettings();
}
partial void OnSelectedFirstDayOfWeekIndexChanged(int value) => SaveSettings();
partial void OnIsWorkingHoursEnabledChanged(bool value) => SaveSettings();
partial void OnWorkingHourStartChanged(TimeSpan value) => SaveSettings();
partial void OnWorkingHourEndChanged(TimeSpan value) => SaveSettings();
partial void OnWorkingDayStartIndexChanged(int value) => SaveSettings();
partial void OnWorkingDayEndIndexChanged(int value) => SaveSettings();
partial void OnTimedDayHeaderDateFormatChanged(string value)
{
OnPropertyChanged(nameof(TimedDayHeaderFormatPreview));
OnPropertyChanged(nameof(TimedHourLabelPreview));
var normalizedFormat = string.IsNullOrWhiteSpace(value) ? "ddd dd" : value.Trim();
var matchingPresetIndex = TimedDayHeaderFormatPresets
.Select((format, index) => new { format, index })
.Where(item => string.Equals(item.format, normalizedFormat, StringComparison.Ordinal))
.Select(item => item.index)
.DefaultIfEmpty(-1)
.First();
if (SelectedTimedDayHeaderFormatPresetIndex != matchingPresetIndex)
{
SelectedTimedDayHeaderFormatPresetIndex = matchingPresetIndex;
}
SaveSettings();
}
partial void OnSelectedTimedDayHeaderFormatPresetIndexChanged(int value)
{
if (value < 0 || value >= TimedDayHeaderFormatPresets.Count)
return;
var selectedPreset = TimedDayHeaderFormatPresets[value];
if (string.Equals(TimedDayHeaderDateFormat, selectedPreset, StringComparison.Ordinal))
return;
TimedDayHeaderDateFormat = selectedPreset;
}
public string TimedDayHeaderFormatPreview
{
get
{
var format = string.IsNullOrWhiteSpace(TimedDayHeaderDateFormat) ? "ddd dd" : TimedDayHeaderDateFormat.Trim();
var previewDates = new[]
{
new DateTime(2026, 3, 23),
new DateTime(2026, 3, 24),
new DateTime(2026, 3, 25)
};
try
{
return string.Join(" · ", previewDates.Select(date => date.ToString(format, CalendarCulture)));
}
catch (FormatException)
{
return string.Join(" · ", previewDates.Select(date => date.ToString("ddd dd", CalendarCulture)));
}
}
}
public string TimedHourLabelPreview
=> string.Join(" · ", new[] { 0, 9, 14, 24 }.Select(CurrentSettingsPreviewLabel));
private string CurrentSettingsPreviewLabel(int hour)
{
if (Is24HourHeaders)
return hour.ToString(CalendarCulture);
var displayHour = hour % 24;
return DateTime.Today.AddHours(displayHour).ToString("h tt", CalendarCulture);
}
private void SaveSettings()
{
if (!IsLoaded)
return;
PreferencesService.FirstDayOfWeek = SelectedFirstDayOfWeekIndex switch
{
0 => DayOfWeek.Sunday,
1 => DayOfWeek.Monday,
2 => DayOfWeek.Tuesday,
3 => DayOfWeek.Wednesday,
4 => DayOfWeek.Thursday,
5 => DayOfWeek.Friday,
6 => DayOfWeek.Saturday,
_ => throw new ArgumentOutOfRangeException()
};
PreferencesService.WorkingDayStart = WorkingDayStartIndex switch
{
0 => DayOfWeek.Sunday,
1 => DayOfWeek.Monday,
2 => DayOfWeek.Tuesday,
3 => DayOfWeek.Wednesday,
4 => DayOfWeek.Thursday,
5 => DayOfWeek.Friday,
6 => DayOfWeek.Saturday,
_ => throw new ArgumentOutOfRangeException()
};
PreferencesService.WorkingDayEnd = WorkingDayEndIndex switch
{
0 => DayOfWeek.Sunday,
1 => DayOfWeek.Monday,
2 => DayOfWeek.Tuesday,
3 => DayOfWeek.Wednesday,
4 => DayOfWeek.Thursday,
5 => DayOfWeek.Friday,
6 => DayOfWeek.Saturday,
_ => throw new ArgumentOutOfRangeException()
};
PreferencesService.Prefer24HourTimeFormat = Is24HourHeaders;
PreferencesService.IsWorkingHoursEnabled = IsWorkingHoursEnabled;
PreferencesService.WorkingHourStart = WorkingHourStart;
PreferencesService.WorkingHourEnd = WorkingHourEnd;
PreferencesService.HourHeight = CellHourHeight;
PreferencesService.CalendarTimedDayHeaderDateFormat = TimedDayHeaderDateFormat;
}
}
@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Translations;
using Wino.Core.ViewModels;
using Wino.Messaging.Client.Calendar;
using Wino.Messaging.Client.Navigation;
namespace Wino.Calendar.ViewModels;
public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
{
[ObservableProperty]
public partial double CellHourHeight { get; set; }
[ObservableProperty]
public partial int SelectedFirstDayOfWeekIndex { get; set; }
[ObservableProperty]
public partial bool Is24HourHeaders { get; set; }
[ObservableProperty]
public partial TimeSpan WorkingHourStart { get; set; }
[ObservableProperty]
public partial TimeSpan WorkingHourEnd { get; set; }
[ObservableProperty]
public partial List<string> DayNames { get; set; } = [];
[ObservableProperty]
public partial int WorkingDayStartIndex { get; set; }
[ObservableProperty]
public partial int WorkingDayEndIndex { get; set; }
[ObservableProperty]
public partial List<string> ReminderOptions { get; set; } = [];
[ObservableProperty]
public partial int SelectedDefaultReminderIndex { get; set; }
public IPreferencesService PreferencesService { get; }
private readonly ICalendarService _calendarService;
private readonly IAccountService _accountService;
public ObservableCollection<MailAccount> Accounts { get; } = new ObservableCollection<MailAccount>();
private readonly bool _isLoaded = false;
public CalendarSettingsPageViewModel(IPreferencesService preferencesService, ICalendarService calendarService, IAccountService accountService)
{
PreferencesService = preferencesService;
_calendarService = calendarService;
_accountService = accountService;
var currentLanguageLanguageCode = WinoTranslationDictionary.GetLanguageFileNameRelativePath(preferencesService.CurrentLanguage);
var cultureInfo = new CultureInfo(currentLanguageLanguageCode);
// Populate the day names list
for (var i = 0; i < 7; i++)
{
DayNames.Add(cultureInfo.DateTimeFormat.DayNames[i]);
}
var cultureFirstDayName = cultureInfo.DateTimeFormat.GetDayName(preferencesService.FirstDayOfWeek);
SelectedFirstDayOfWeekIndex = DayNames.IndexOf(cultureFirstDayName);
Is24HourHeaders = preferencesService.Prefer24HourTimeFormat;
WorkingHourStart = preferencesService.WorkingHourStart;
WorkingHourEnd = preferencesService.WorkingHourEnd;
CellHourHeight = preferencesService.HourHeight;
WorkingDayStartIndex = DayNames.IndexOf(cultureInfo.DateTimeFormat.GetDayName(preferencesService.WorkingDayStart));
WorkingDayEndIndex = DayNames.IndexOf(cultureInfo.DateTimeFormat.GetDayName(preferencesService.WorkingDayEnd));
// Initialize reminder options
var predefinedMinutes = _calendarService.GetPredefinedReminderMinutes();
ReminderOptions.Add("None");
foreach (var minutes in predefinedMinutes)
{
var displayText = minutes switch
{
>= 60 => $"{minutes / 60} Hour{(minutes / 60 > 1 ? "s" : "")}",
_ => $"{minutes} Minute{(minutes > 1 ? "s" : "")}"
};
ReminderOptions.Add(displayText);
}
// Set selected index based on current default reminder setting
if (preferencesService.DefaultReminderDurationInSeconds == 0)
{
SelectedDefaultReminderIndex = 0; // None
}
else
{
var minutes = (int)(preferencesService.DefaultReminderDurationInSeconds / 60);
var index = Array.IndexOf(predefinedMinutes, minutes);
SelectedDefaultReminderIndex = index >= 0 ? index + 1 : 0;
}
_isLoaded = true;
// Load accounts with calendar support
LoadAccountsAsync();
}
private async void LoadAccountsAsync()
{
var accounts = await _accountService.GetAccountsAsync();
await Dispatcher.ExecuteOnUIThread(() =>
{
Accounts.Clear();
foreach (var account in accounts)
{
Accounts.Add(account);
}
});
}
[RelayCommand]
private void NavigateToAccountSettings(MailAccount account)
{
if (account == null) return;
Messenger.Send(new BreadcrumbNavigationRequested(
string.Format(Translator.CalendarAccountSettings_Description, account.Name),
WinoPage.CalendarAccountSettingsPage,
account.Id));
}
partial void OnCellHourHeightChanged(double oldValue, double newValue) => SaveSettings();
partial void OnIs24HourHeadersChanged(bool value) => SaveSettings();
partial void OnSelectedFirstDayOfWeekIndexChanged(int value) => SaveSettings();
partial void OnWorkingHourStartChanged(TimeSpan value) => SaveSettings();
partial void OnWorkingHourEndChanged(TimeSpan value) => SaveSettings();
partial void OnWorkingDayStartIndexChanged(int value) => SaveSettings();
partial void OnWorkingDayEndIndexChanged(int value) => SaveSettings();
partial void OnSelectedDefaultReminderIndexChanged(int value) => SaveSettings();
public void SaveSettings()
{
if (!_isLoaded) return;
PreferencesService.FirstDayOfWeek = SelectedFirstDayOfWeekIndex switch
{
0 => DayOfWeek.Sunday,
1 => DayOfWeek.Monday,
2 => DayOfWeek.Tuesday,
3 => DayOfWeek.Wednesday,
4 => DayOfWeek.Thursday,
5 => DayOfWeek.Friday,
6 => DayOfWeek.Saturday,
_ => throw new ArgumentOutOfRangeException()
};
PreferencesService.WorkingDayStart = WorkingDayStartIndex switch
{
0 => DayOfWeek.Sunday,
1 => DayOfWeek.Monday,
2 => DayOfWeek.Tuesday,
3 => DayOfWeek.Wednesday,
4 => DayOfWeek.Thursday,
5 => DayOfWeek.Friday,
6 => DayOfWeek.Saturday,
_ => throw new ArgumentOutOfRangeException()
};
PreferencesService.WorkingDayEnd = WorkingDayEndIndex switch
{
0 => DayOfWeek.Sunday,
1 => DayOfWeek.Monday,
2 => DayOfWeek.Tuesday,
3 => DayOfWeek.Wednesday,
4 => DayOfWeek.Thursday,
5 => DayOfWeek.Friday,
6 => DayOfWeek.Saturday,
_ => throw new ArgumentOutOfRangeException()
};
PreferencesService.Prefer24HourTimeFormat = Is24HourHeaders;
PreferencesService.WorkingHourStart = WorkingHourStart;
PreferencesService.WorkingHourEnd = WorkingHourEnd;
PreferencesService.HourHeight = CellHourHeight;
// Save default reminder setting
if (SelectedDefaultReminderIndex == 0)
{
PreferencesService.DefaultReminderDurationInSeconds = 0; // None
}
else
{
var predefinedMinutes = _calendarService.GetPredefinedReminderMinutes();
var minutes = predefinedMinutes[SelectedDefaultReminderIndex - 1];
PreferencesService.DefaultReminderDurationInSeconds = minutes * 60;
}
Messenger.Send(new CalendarSettingsUpdatedMessage());
}
}
@@ -1,197 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Translations;
using Wino.Core.ViewModels;
namespace Wino.Calendar.ViewModels;
public abstract class CalendarSettingsSectionViewModelBase : CalendarBaseViewModel
{
protected CalendarSettingsSectionViewModelBase(
IPreferencesService preferencesService,
ICalendarService calendarService,
IAccountService accountService)
{
PreferencesService = preferencesService;
CalendarService = calendarService;
AccountService = accountService;
var languageCode = WinoTranslationDictionary.GetLanguageFileNameRelativePath(preferencesService.CurrentLanguage);
CalendarCulture = new CultureInfo(languageCode);
for (var index = 0; index < 7; index++)
{
DayNames.Add(CalendarCulture.DateTimeFormat.DayNames[index]);
}
}
protected IPreferencesService PreferencesService { get; }
protected ICalendarService CalendarService { get; }
protected IAccountService AccountService { get; }
protected CultureInfo CalendarCulture { get; }
protected bool IsLoaded { get; set; }
public ObservableCollection<string> DayNames { get; } = [];
public ObservableCollection<string> ReminderOptions { get; } = [];
public ObservableCollection<string> SnoozeOptions { get; } = [];
public ObservableCollection<CalendarNewEventBehaviorOption> NewEventBehaviorOptions { get; } = [];
public ObservableCollection<AccountCalendarViewModel> AvailableNewEventCalendars { get; } = [];
public ObservableCollection<string> TimedDayHeaderFormatPresets { get; } =
[
"ddd dd",
"dddd dd",
"ddd d MMM",
"dd MMM ddd",
"M/d ddd"
];
protected void LoadReminderOptions()
{
ReminderOptions.Clear();
var predefinedMinutes = CalendarService.GetPredefinedReminderMinutes();
ReminderOptions.Add("None");
foreach (var minutes in predefinedMinutes)
{
var displayText = minutes switch
{
>= 60 => $"{minutes / 60} Hour{(minutes / 60 > 1 ? "s" : "")}",
_ => $"{minutes} Minute{(minutes > 1 ? "s" : "")}"
};
ReminderOptions.Add(displayText);
}
}
protected int GetSelectedReminderIndex()
{
if (PreferencesService.DefaultReminderDurationInSeconds == 0)
return 0;
var minutes = (int)(PreferencesService.DefaultReminderDurationInSeconds / 60);
var predefinedMinutes = CalendarService.GetPredefinedReminderMinutes();
var index = Array.IndexOf(predefinedMinutes, minutes);
return index >= 0 ? index + 1 : 0;
}
protected void SaveReminderIndex(int selectedDefaultReminderIndex)
{
if (selectedDefaultReminderIndex == 0)
{
PreferencesService.DefaultReminderDurationInSeconds = 0;
return;
}
var predefinedMinutes = CalendarService.GetPredefinedReminderMinutes();
var minutes = predefinedMinutes[selectedDefaultReminderIndex - 1];
PreferencesService.DefaultReminderDurationInSeconds = minutes * 60;
}
protected void LoadSnoozeOptions()
{
SnoozeOptions.Clear();
foreach (var snoozeMinutes in CalendarReminderSnoozeOptions.GetSupportedSnoozeMinutes())
{
SnoozeOptions.Add(string.Format(Translator.CalendarReminder_SnoozeMinutesOption, snoozeMinutes));
}
}
protected int GetSelectedSnoozeIndex()
{
var supportedSnoozeMinutes = CalendarReminderSnoozeOptions.GetSupportedSnoozeMinutes().ToArray();
var selectedIndex = Array.IndexOf(supportedSnoozeMinutes, PreferencesService.DefaultSnoozeDurationInMinutes);
return selectedIndex >= 0 ? selectedIndex : 0;
}
protected void SaveSnoozeIndex(int selectedDefaultSnoozeIndex)
{
var supportedSnoozeMinutes = CalendarReminderSnoozeOptions.GetSupportedSnoozeMinutes();
if (supportedSnoozeMinutes.Count == 0)
return;
var selectedIndex = Math.Clamp(selectedDefaultSnoozeIndex, 0, supportedSnoozeMinutes.Count - 1);
PreferencesService.DefaultSnoozeDurationInMinutes = supportedSnoozeMinutes[selectedIndex];
}
protected void LoadNewEventBehaviorOptions()
{
NewEventBehaviorOptions.Clear();
NewEventBehaviorOptions.Add(new CalendarNewEventBehaviorOption(NewEventButtonBehavior.AskEachTime, Translator.CalendarSettings_NewEventBehavior_AskEachTime));
NewEventBehaviorOptions.Add(new CalendarNewEventBehaviorOption(NewEventButtonBehavior.AlwaysUseSpecificCalendar, Translator.CalendarSettings_NewEventBehavior_AlwaysUseSpecificCalendar));
}
protected CalendarNewEventBehaviorOption GetSelectedNewEventBehaviorOption()
=> NewEventBehaviorOptions.FirstOrDefault(option => option.Behavior == PreferencesService.NewEventButtonBehavior)
?? NewEventBehaviorOptions.First();
protected async void LoadCalendarsAsync(Action applySelection)
{
var accounts = await AccountService.GetAccountsAsync().ConfigureAwait(false);
var calendarsByAccount = new List<AccountCalendarViewModel>();
foreach (var account in accounts)
{
var calendars = await CalendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
calendarsByAccount.AddRange(calendars.Select(calendar => new AccountCalendarViewModel(account, calendar)));
}
await ExecuteUIThread(() =>
{
AvailableNewEventCalendars.Clear();
foreach (var calendar in calendarsByAccount)
{
AvailableNewEventCalendars.Add(calendar);
}
applySelection();
});
}
protected AccountCalendarViewModel ResolveSelectedNewEventCalendar()
{
var configuredCalendarId = PreferencesService.DefaultNewEventCalendarId;
return configuredCalendarId.HasValue
? AvailableNewEventCalendars.FirstOrDefault(calendar => calendar.Id == configuredCalendarId.Value)
: null;
}
protected AccountCalendarViewModel ResolveFallbackNewEventCalendar()
=> AvailableNewEventCalendars.FirstOrDefault(calendar => calendar.IsPrimary)
?? AvailableNewEventCalendars.FirstOrDefault();
protected void SaveNewEventBehavior(CalendarNewEventBehaviorOption selectedBehaviorOption, AccountCalendarViewModel selectedCalendar)
{
var newEventBehavior = selectedBehaviorOption?.Behavior ?? NewEventButtonBehavior.AskEachTime;
if (newEventBehavior == NewEventButtonBehavior.AlwaysUseSpecificCalendar && selectedCalendar != null)
{
PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AlwaysUseSpecificCalendar;
PreferencesService.DefaultNewEventCalendarId = selectedCalendar.Id;
return;
}
PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AskEachTime;
PreferencesService.DefaultNewEventCalendarId = null;
}
}
public sealed class CalendarNewEventBehaviorOption
{
public CalendarNewEventBehaviorOption(NewEventButtonBehavior behavior, string displayText)
{
Behavior = behavior;
DisplayText = displayText;
}
public NewEventButtonBehavior Behavior { get; }
public string DisplayText { get; }
}
@@ -55,12 +55,6 @@ public partial class AccountCalendarViewModel : ObservableObject, IAccountCalend
set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i); set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i);
} }
public bool IsReadOnly
{
get => AccountCalendar.IsReadOnly;
set => SetProperty(AccountCalendar.IsReadOnly, value, AccountCalendar, (u, i) => u.IsReadOnly = i);
}
public bool IsSynchronizationEnabled public bool IsSynchronizationEnabled
{ {
get => AccountCalendar.IsSynchronizationEnabled; get => AccountCalendar.IsSynchronizationEnabled;
@@ -85,9 +79,5 @@ public partial class AccountCalendarViewModel : ObservableObject, IAccountCalend
set => SetProperty(AccountCalendar.DefaultShowAs, value, AccountCalendar, (u, s) => u.DefaultShowAs = s); set => SetProperty(AccountCalendar.DefaultShowAs, value, AccountCalendar, (u, s) => u.DefaultShowAs = s);
} }
public Guid Id { get => ((IAccountCalendar)AccountCalendar).Id; set => ((IAccountCalendar)AccountCalendar).Id = value; } public Guid Id { get => ((IAccountCalendar)AccountCalendar).Id; set => ((IAccountCalendar)AccountCalendar).Id = value; }
public MailAccount MailAccount public MailAccount MailAccount { get => MailAccount; set => MailAccount = value; }
{
get => AccountCalendar.MailAccount ?? Account;
set => AccountCalendar.MailAccount = value;
}
} }
@@ -1,57 +0,0 @@
using System;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Extensions;
namespace Wino.Calendar.ViewModels.Data;
public class CalendarComposeAttachmentViewModel
{
public Guid Id { get; } = Guid.NewGuid();
public string FileName { get; }
public string FilePath { get; }
public string FileExtension { get; }
public long Size { get; }
public string ReadableSize => Size.GetBytesReadable();
public MailAttachmentType AttachmentType { get; }
public CalendarComposeAttachmentViewModel(string fileName, string filePath, string fileExtension, long size)
{
FileName = fileName;
FilePath = filePath;
FileExtension = fileExtension;
Size = size;
AttachmentType = GetAttachmentType(fileExtension);
}
public CalendarEventComposeAttachmentDraft ToDraftModel()
{
return new CalendarEventComposeAttachmentDraft
{
Id = Id,
FileName = FileName,
FilePath = FilePath,
FileExtension = FileExtension,
Size = Size
};
}
private static MailAttachmentType GetAttachmentType(string extension)
{
if (string.IsNullOrWhiteSpace(extension))
return MailAttachmentType.None;
return extension.ToLowerInvariant() switch
{
".exe" => MailAttachmentType.Executable,
".rar" => MailAttachmentType.RarArchive,
".zip" => MailAttachmentType.Archive,
".ogg" or ".mp3" or ".wav" or ".aac" or ".alac" => MailAttachmentType.Audio,
".mp4" or ".wmv" or ".avi" or ".flv" => MailAttachmentType.Video,
".pdf" => MailAttachmentType.PDF,
".htm" or ".html" => MailAttachmentType.HTML,
".png" or ".jpg" or ".jpeg" or ".gif" or ".jiff" => MailAttachmentType.Image,
_ => MailAttachmentType.Other
};
}
}
@@ -1,23 +0,0 @@
using Wino.Core.Domain.Entities.Shared;
namespace Wino.Calendar.ViewModels.Data;
public class CalendarComposeAttendeeViewModel : IContactDisplayItem
{
public string DisplayName { get; }
public string Email { get; }
public AccountContact ResolvedContact { get; }
public string Address => Email;
public AccountContact PreviewContact => ResolvedContact;
public bool HasDistinctDisplayName => !string.IsNullOrWhiteSpace(DisplayName) && !DisplayName.Equals(Email, System.StringComparison.OrdinalIgnoreCase);
public CalendarComposeAttendeeViewModel(string displayName, string email, AccountContact resolvedContact = null)
{
DisplayName = string.IsNullOrWhiteSpace(displayName) ? email : displayName;
Email = email;
ResolvedContact = resolvedContact;
}
public static CalendarComposeAttendeeViewModel FromContact(AccountContact contact)
=> new(contact.Name, contact.Address, contact);
}
@@ -33,10 +33,8 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
} }
set set
{ {
// All-day events use floating dates and should not shift across timezones. // When setting from UI (in local time), convert to event's timezone for storage.
CalendarItem.StartDate = CalendarItem.IsAllDayEvent CalendarItem.StartDate = value.ToTimeZoneFromLocal(CalendarItem.StartTimeZone);
? value.Date
: value.ToTimeZoneFromLocal(CalendarItem.StartTimeZone);
} }
} }
@@ -72,7 +70,6 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
public bool IsRecurringEvent => CalendarItem.IsRecurringEvent; public bool IsRecurringEvent => CalendarItem.IsRecurringEvent;
public bool IsRecurringChild => CalendarItem.IsRecurringChild; public bool IsRecurringChild => CalendarItem.IsRecurringChild;
public bool IsRecurringParent => CalendarItem.IsRecurringParent; public bool IsRecurringParent => CalendarItem.IsRecurringParent;
public bool CanDragDrop => CalendarItem.CanChangeStartAndEndDate;
[ObservableProperty] [ObservableProperty]
public partial bool IsSelected { get; set; } public partial bool IsSelected { get; set; }
@@ -158,7 +155,6 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
OnPropertyChanged(nameof(IsRecurringEvent)); OnPropertyChanged(nameof(IsRecurringEvent));
OnPropertyChanged(nameof(IsRecurringChild)); OnPropertyChanged(nameof(IsRecurringChild));
OnPropertyChanged(nameof(IsRecurringParent)); OnPropertyChanged(nameof(IsRecurringParent));
OnPropertyChanged(nameof(CanDragDrop));
OnPropertyChanged(nameof(AssignedCalendar)); OnPropertyChanged(nameof(AssignedCalendar));
OnPropertyChanged(nameof(DisplayTitle)); OnPropertyChanged(nameof(DisplayTitle));
} }
@@ -1,11 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Linq; using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Calendar.ViewModels.Data; namespace Wino.Calendar.ViewModels.Data;
@@ -17,14 +16,10 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
public MailAccount Account { get; } public MailAccount Account { get; }
public ObservableCollection<AccountCalendarViewModel> AccountCalendars { get; } public ObservableCollection<AccountCalendarViewModel> AccountCalendars { get; }
public static bool SupportsCalendar(MailAccount account)
=> account?.IsCalendarAccessGranted == true;
public GroupedAccountCalendarViewModel(MailAccount account, IEnumerable<AccountCalendarViewModel> calendarViewModels) public GroupedAccountCalendarViewModel(MailAccount account, IEnumerable<AccountCalendarViewModel> calendarViewModels)
{ {
Account = account; Account = account;
AccountCalendars = new ObservableCollection<AccountCalendarViewModel>(calendarViewModels); AccountCalendars = new ObservableCollection<AccountCalendarViewModel>(calendarViewModels);
AccountColorHex = account.AccountColorHex;
ManageIsCheckedState(); ManageIsCheckedState();
@@ -36,7 +31,7 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
AccountCalendars.CollectionChanged += CalendarListUpdated; AccountCalendars.CollectionChanged += CalendarListUpdated;
} }
private void CalendarListUpdated(object sender, NotifyCollectionChangedEventArgs e) private void CalendarListUpdated(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{ {
if (e.Action == NotifyCollectionChangedAction.Add) if (e.Action == NotifyCollectionChangedAction.Add)
{ {
@@ -63,11 +58,13 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
private void CalendarPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) private void CalendarPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{ {
if (sender is AccountCalendarViewModel viewModel && if (sender is AccountCalendarViewModel viewModel)
e.PropertyName == nameof(AccountCalendarViewModel.IsChecked))
{ {
ManageIsCheckedState(); if (e.PropertyName == nameof(AccountCalendarViewModel.IsChecked))
UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true); {
ManageIsCheckedState();
UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true);
}
} }
} }
@@ -77,59 +74,11 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
public partial bool? IsCheckedState { get; set; } = true; public partial bool? IsCheckedState { get; set; } = true;
[ObservableProperty] private bool _isExternalPropChangeBlocked = false;
public partial string AccountColorHex { get; set; } = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSynchronize), nameof(IsSynchronizationProgressVisible), nameof(IsProgressIndeterminate))]
public partial bool IsSynchronizationInProgress { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))]
public partial int TotalItemsToSync { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))]
public partial int RemainingItemsToSync { get; set; }
[ObservableProperty]
public partial string SynchronizationStatus { get; set; } = string.Empty;
public bool CanSynchronize => !IsSynchronizationInProgress;
public bool IsSynchronizationProgressVisible => IsSynchronizationInProgress;
public bool IsProgressIndeterminate => IsSynchronizationInProgress && TotalItemsToSync <= 0;
public string AccountAddressDisplay => string.IsNullOrWhiteSpace(Account?.Address) ? string.Empty : $" ({Account.Address})";
public double SynchronizationProgress
{
get
{
if (TotalItemsToSync <= 0)
return 0;
return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100;
}
}
public double SynchronizationProgressValue => SynchronizationProgress;
private bool _isExternalPropChangeBlocked;
public void ApplySynchronizationProgress(AccountSynchronizationProgress progress)
{
if (progress == null || progress.AccountId != Account.Id)
return;
IsSynchronizationInProgress = progress.IsInProgress;
TotalItemsToSync = progress.TotalUnits;
RemainingItemsToSync = progress.RemainingUnits;
SynchronizationStatus = progress.Status ?? string.Empty;
}
private void ManageIsCheckedState() private void ManageIsCheckedState()
{ {
if (_isExternalPropChangeBlocked) if (_isExternalPropChangeBlocked) return;
return;
_isExternalPropChangeBlocked = true; _isExternalPropChangeBlocked = true;
@@ -151,13 +100,17 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
partial void OnIsCheckedStateChanged(bool? oldValue, bool? newValue) partial void OnIsCheckedStateChanged(bool? oldValue, bool? newValue)
{ {
if (_isExternalPropChangeBlocked) if (_isExternalPropChangeBlocked) return;
return;
// Update is triggered by user on the three-state checkbox.
// We should not report all changes one by one.
_isExternalPropChangeBlocked = true; _isExternalPropChangeBlocked = true;
if (newValue == null) if (newValue == null)
{ {
// Only primary calendars must be checked.
foreach (var calendar in AccountCalendars) foreach (var calendar in AccountCalendars)
{ {
UpdateCalendarCheckedState(calendar, calendar.IsPrimary); UpdateCalendarCheckedState(calendar, calendar.IsPrimary);
@@ -172,6 +125,7 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
} }
_isExternalPropChangeBlocked = false; _isExternalPropChangeBlocked = false;
CollectiveSelectionStateChanged?.Invoke(this, EventArgs.Empty); CollectiveSelectionStateChanged?.Invoke(this, EventArgs.Empty);
} }
@@ -179,29 +133,13 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
{ {
var currentValue = accountCalendarViewModel.IsChecked; var currentValue = accountCalendarViewModel.IsChecked;
if (currentValue == newValue && !ignoreValueCheck) if (currentValue == newValue && !ignoreValueCheck) return;
return;
accountCalendarViewModel.IsChecked = newValue; accountCalendarViewModel.IsChecked = newValue;
if (_isExternalPropChangeBlocked) // No need to report.
return; if (_isExternalPropChangeBlocked == true) return;
CalendarSelectionStateChanged?.Invoke(this, accountCalendarViewModel); CalendarSelectionStateChanged?.Invoke(this, accountCalendarViewModel);
} }
public void UpdateAccount(MailAccount updatedAccount)
{
if (updatedAccount == null || updatedAccount.Id != Account.Id)
return;
Account.Name = updatedAccount.Name;
Account.Address = updatedAccount.Address;
Account.AccountColorHex = updatedAccount.AccountColorHex;
Account.AttentionReason = updatedAccount.AttentionReason;
Account.MergedInboxId = updatedAccount.MergedInboxId;
AccountColorHex = updatedAccount.AccountColorHex;
OnPropertyChanged(nameof(Account));
OnPropertyChanged(nameof(AccountAddressDisplay));
}
} }
@@ -12,11 +12,9 @@ using Serilog;
using Wino.Calendar.ViewModels.Data; using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Services; using Wino.Core.Services;
using Wino.Core.ViewModels; using Wino.Core.ViewModels;
@@ -33,8 +31,6 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
private readonly IWinoRequestDelegator _winoRequestDelegator; private readonly IWinoRequestDelegator _winoRequestDelegator;
private readonly INavigationService _navigationService; private readonly INavigationService _navigationService;
private readonly IUnderlyingThemeService _underlyingThemeService; private readonly IUnderlyingThemeService _underlyingThemeService;
private readonly INotificationBuilder _notificationBuilder;
private readonly IContactService _contactService;
public CalendarSettings CurrentSettings { get; } public CalendarSettings CurrentSettings { get; }
public INativeAppService NativeAppService => _nativeAppService; public INativeAppService NativeAppService => _nativeAppService;
@@ -147,9 +143,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
IMailDialogService dialogService, IMailDialogService dialogService,
IWinoRequestDelegator winoRequestDelegator, IWinoRequestDelegator winoRequestDelegator,
INavigationService navigationService, INavigationService navigationService,
INotificationBuilder notificationBuilder, IUnderlyingThemeService underlyingThemeService)
IUnderlyingThemeService underlyingThemeService,
IContactService contactService)
{ {
_calendarService = calendarService; _calendarService = calendarService;
_nativeAppService = nativeAppService; _nativeAppService = nativeAppService;
@@ -158,23 +152,22 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
_winoRequestDelegator = winoRequestDelegator; _winoRequestDelegator = winoRequestDelegator;
_navigationService = navigationService; _navigationService = navigationService;
_underlyingThemeService = underlyingThemeService; _underlyingThemeService = underlyingThemeService;
_notificationBuilder = notificationBuilder;
_contactService = contactService;
CurrentSettings = _preferencesService.GetCurrentCalendarSettings(); CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark(); IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark();
foreach (var showAs in CalendarItemActionOptions.ShowAsOptions) // Initialize Show As options
{ ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Free));
ShowAsOptions.Add(new ShowAsOption(showAs)); ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Tentative));
} ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Busy));
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.OutOfOffice));
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.WorkingElsewhere));
SelectedShowAsOption = ShowAsOptions[2]; // Default to Busy
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == CalendarItemShowAs.Busy) ?? ShowAsOptions.FirstOrDefault(); // Initialize RSVP status options
RsvpStatusOptions.Add(new RsvpStatusOption(CalendarItemStatus.Accepted));
foreach (var responseStatus in CalendarItemActionOptions.ResponseOptions) RsvpStatusOptions.Add(new RsvpStatusOption(CalendarItemStatus.Tentative));
{ RsvpStatusOptions.Add(new RsvpStatusOption(CalendarItemStatus.Cancelled));
RsvpStatusOptions.Add(new RsvpStatusOption(responseStatus));
}
} }
public override async void OnNavigatedTo(NavigationMode mode, object parameters) public override async void OnNavigatedTo(NavigationMode mode, object parameters)
@@ -187,20 +180,20 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
await LoadCalendarItemTargetAsync(args); await LoadCalendarItemTargetAsync(args);
} }
protected override async void OnCalendarItemUpdated(CalendarItem calendarItem, EntityUpdateSource source) protected override async void OnCalendarItemUpdated(CalendarItem calendarItem, CalendarItemUpdateSource source)
{ {
base.OnCalendarItemUpdated(calendarItem, source); base.OnCalendarItemUpdated(calendarItem, source);
// If the current event was updated, reload it // If the current event was updated, reload it
if (IsCurrentEventMatch(calendarItem)) if (CurrentEvent?.CalendarItem?.Id == calendarItem.Id || CurrentEvent?.CalendarItem.RecurringCalendarItemId == calendarItem.Id)
{ {
// Reflect client-side optimistic changes immediately; fallback to DB for server updates. // Reflect client-side optimistic changes immediately; fallback to DB for server updates.
if (source == EntityUpdateSource.ClientUpdated || source == EntityUpdateSource.ClientReverted) if (source == CalendarItemUpdateSource.ClientUpdated || source == CalendarItemUpdateSource.ClientReverted)
{ {
var previousAttendees = CurrentEvent?.Attendees?.ToList() ?? []; var previousAttendees = CurrentEvent?.Attendees?.ToList() ?? [];
CurrentEvent = new CalendarItemViewModel(calendarItem) CurrentEvent = new CalendarItemViewModel(calendarItem)
{ {
IsBusy = source == EntityUpdateSource.ClientUpdated IsBusy = source == CalendarItemUpdateSource.ClientUpdated
}; };
foreach (var attendee in previousAttendees) foreach (var attendee in previousAttendees)
@@ -221,54 +214,17 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
} }
} }
protected override async void OnCalendarItemAdded(CalendarItem calendarItem, EntityUpdateSource source) protected override void OnCalendarItemDeleted(CalendarItem calendarItem)
{ {
base.OnCalendarItemAdded(calendarItem, source); base.OnCalendarItemDeleted(calendarItem);
if (!IsCurrentEventMatch(calendarItem))
return;
if (source == EntityUpdateSource.ClientUpdated || source == EntityUpdateSource.ClientReverted)
{
CurrentEvent = new CalendarItemViewModel(calendarItem)
{
IsBusy = source == EntityUpdateSource.ClientUpdated
};
return;
}
var refreshedEvent = await _calendarService.GetCalendarItemAsync(calendarItem.Id);
if (refreshedEvent != null)
{
CurrentEvent = new CalendarItemViewModel(refreshedEvent);
await LoadAttendeesAsync(refreshedEvent.Id, refreshedEvent);
}
}
protected override void OnCalendarItemDeleted(CalendarItem calendarItem, EntityUpdateSource source)
{
base.OnCalendarItemDeleted(calendarItem, source);
// If the current event was deleted, navigate back // If the current event was deleted, navigate back
if (IsCurrentEventMatch(calendarItem)) if (CurrentEvent?.CalendarItem?.Id == calendarItem.Id || CurrentEvent?.CalendarItem.RecurringCalendarItemId == calendarItem.Id)
{ {
NavigateBackToCalendar(forceReload: true); _navigationService.GoBack();
} }
} }
private bool IsCurrentEventMatch(CalendarItem calendarItem)
{
if (CurrentEvent?.CalendarItem == null || calendarItem == null)
return false;
var trackedLocalItemId = calendarItem.RemoteEventId.GetClientTrackingId();
return CurrentEvent.CalendarItem.Id == calendarItem.Id ||
(trackedLocalItemId.HasValue && CurrentEvent.CalendarItem.Id == trackedLocalItemId.Value) ||
CurrentEvent.CalendarItem.RecurringCalendarItemId == calendarItem.Id;
}
private async Task LoadCalendarItemTargetAsync(CalendarItemTarget target) private async Task LoadCalendarItemTargetAsync(CalendarItemTarget target)
{ {
try try
@@ -300,36 +256,18 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
private async Task LoadAttendeesAsync(Guid calendarItemId, CalendarItem calendarItem) private async Task LoadAttendeesAsync(Guid calendarItemId, CalendarItem calendarItem)
{ {
CurrentEvent.Attendees.Clear();
var attendees = await _calendarService.GetAttendeesAsync(calendarItemId); var attendees = await _calendarService.GetAttendeesAsync(calendarItemId);
// Resolve contacts for all attendees in a single batch DB query.
var emails = attendees
.Where(a => !string.IsNullOrEmpty(a.Email))
.Select(a => a.Email)
.ToList();
if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail))
emails.Add(calendarItem.OrganizerEmail);
var contacts = await _contactService.GetContactsByAddressesAsync(emails).ConfigureAwait(false);
var contactLookup = contacts.ToDictionary(c => c.Address, StringComparer.OrdinalIgnoreCase);
foreach (var attendee in attendees)
{
if (!string.IsNullOrEmpty(attendee.Email) && contactLookup.TryGetValue(attendee.Email, out var contact))
attendee.ResolvedContact = contact;
}
// Separate organizer from other attendees to ensure organizer is always first // Separate organizer from other attendees to ensure organizer is always first
var organizer = attendees.FirstOrDefault(a => a.IsOrganizer); var organizer = attendees.FirstOrDefault(a => a.IsOrganizer);
var nonOrganizerAttendees = attendees.Where(a => !a.IsOrganizer).ToList(); var nonOrganizerAttendees = attendees.Where(a => !a.IsOrganizer).ToList();
var attendeesForUi = new List<CalendarEventAttendee>();
// If the organizer is in the list, add them first // If the organizer is in the list, add them first
if (organizer != null) if (organizer != null)
{ {
attendeesForUi.Add(organizer); CurrentEvent.Attendees.Add(organizer);
} }
else if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail)) else if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail))
{ {
@@ -343,31 +281,14 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
IsOrganizer = true, IsOrganizer = true,
AttendenceStatus = AttendeeStatus.Accepted AttendenceStatus = AttendeeStatus.Accepted
}; };
CurrentEvent.Attendees.Add(organizerAttendee);
if (contactLookup.TryGetValue(calendarItem.OrganizerEmail, out var organizerContact))
organizerAttendee.ResolvedContact = organizerContact;
attendeesForUi.Add(organizerAttendee);
} }
// Add all other attendees after the organizer // Add all other attendees after the organizer
foreach (var item in nonOrganizerAttendees) foreach (var item in nonOrganizerAttendees)
{ {
attendeesForUi.Add(item); CurrentEvent.Attendees.Add(item);
} }
await ExecuteUIThread(() =>
{
if (CurrentEvent == null)
return;
CurrentEvent.Attendees.Clear();
foreach (var attendee in attendeesForUi)
{
CurrentEvent.Attendees.Add(attendee);
}
});
} }
private async Task LoadAttachmentsAsync(Guid calendarItemId) private async Task LoadAttachmentsAsync(Guid calendarItemId)
@@ -440,11 +361,6 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
private async Task SaveAsync() private async Task SaveAsync()
{ {
if (CurrentEvent == null) return; if (CurrentEvent == null) return;
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
try try
{ {
@@ -495,7 +411,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
await _winoRequestDelegator.ExecuteAsync(preparationRequest); await _winoRequestDelegator.ExecuteAsync(preparationRequest);
NavigateBackToCalendar(forceReload: true); _navigationService.GoBack();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -511,11 +427,6 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
private async Task DeleteAsync() private async Task DeleteAsync()
{ {
if (CurrentEvent == null) return; if (CurrentEvent == null) return;
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
// If the event is a master recurring event, ask for confirmation // If the event is a master recurring event, ask for confirmation
if (CurrentEvent.IsRecurringParent) if (CurrentEvent.IsRecurringParent)
@@ -537,7 +448,8 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
await _winoRequestDelegator.ExecuteAsync(preparationRequest); await _winoRequestDelegator.ExecuteAsync(preparationRequest);
NavigateBackToCalendar(forceReload: true); // Navigate back after successful deletion
_navigationService.GoBack();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -545,28 +457,6 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
} }
} }
private void NavigateBackToCalendar(bool forceReload)
{
var navigationDate = CurrentEvent?.CalendarItem.LocalStartDate ?? DateTime.Now;
_navigationService.Navigate(
WinoPage.CalendarPage,
new CalendarPageNavigationArgs
{
NavigationDate = navigationDate,
ForceReload = forceReload
});
}
public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args)
{
if (args.Handled || args.Mode != WinoApplicationMode.Calendar || args.Action != KeyboardShortcutAction.Delete)
return;
await DeleteAsync();
args.Handled = true;
}
[RelayCommand] [RelayCommand]
private Task JoinOnlineAsync() private Task JoinOnlineAsync()
{ {
@@ -576,24 +466,6 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
return _nativeAppService.LaunchUriAsync(new Uri(CurrentEvent.CalendarItem.HtmlLink)); return _nativeAppService.LaunchUriAsync(new Uri(CurrentEvent.CalendarItem.HtmlLink));
} }
[RelayCommand]
private Task CreateTestNotificationAsync()
{
if (CurrentEvent?.CalendarItem == null)
return Task.CompletedTask;
var reminderDurationInSeconds = Reminders?
.Where(x => x.DurationInSeconds > 0)
.OrderByDescending(x => x.DurationInSeconds)
.Select(x => x.DurationInSeconds)
.FirstOrDefault() ?? 0;
if (reminderDurationInSeconds <= 0)
reminderDurationInSeconds = Math.Max(_preferencesService.DefaultReminderDurationInSeconds, 30 * 60);
return _notificationBuilder.CreateCalendarReminderNotificationAsync(CurrentEvent.CalendarItem, reminderDurationInSeconds);
}
[RelayCommand] [RelayCommand]
private void ToggleRsvpPanel() private void ToggleRsvpPanel()
{ {
@@ -620,11 +492,6 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
private async Task SendRsvpResponse(AttendeeStatus status) private async Task SendRsvpResponse(AttendeeStatus status)
{ {
if (CurrentEvent == null) return; if (CurrentEvent == null) return;
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
try try
{ {
@@ -29,6 +29,5 @@ public interface IAccountCalendarStateService : INotifyPropertyChanged
/// </summary> /// </summary>
IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; } IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; }
IEnumerable<AccountCalendarViewModel> AllCalendars { get; } IEnumerable<AccountCalendarViewModel> AllCalendars { get; }
bool IsAnySynchronizationInProgress { get; }
ReadOnlyObservableGroupedCollection<MailAccount, AccountCalendarViewModel> GroupedCalendars { get; set; } ReadOnlyObservableGroupedCollection<MailAccount, AccountCalendarViewModel> GroupedCalendars { get; set; }
} }
@@ -1,8 +0,0 @@
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Calendar.ViewModels.Messages;
public sealed record CalendarItemContextActionRequestedMessage(
CalendarItemViewModel CalendarItemViewModel,
CalendarContextMenuAction Action);
@@ -1,12 +1,16 @@
using Wino.Calendar.ViewModels.Data; using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Calendar.ViewModels.Messages; namespace Wino.Calendar.ViewModels.Messages;
public class CalendarItemTappedMessage public class CalendarItemTappedMessage
{ {
public CalendarItemTappedMessage(CalendarItemViewModel calendarItemViewModel) public CalendarItemTappedMessage(CalendarItemViewModel calendarItemViewModel, CalendarDayModel clickedPeriod)
{ {
CalendarItemViewModel = calendarItemViewModel; CalendarItemViewModel = calendarItemViewModel;
ClickedPeriod = clickedPeriod;
} }
public CalendarItemViewModel CalendarItemViewModel { get; } public CalendarItemViewModel CalendarItemViewModel { get; }
public CalendarDayModel ClickedPeriod { get; }
} }
@@ -10,8 +10,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="TimePeriodLibrary.NET" /> <PackageReference Include="TimePeriodLibrary.NET" />
<PackageReference Include="EmailValidation" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
-10
View File
@@ -1,10 +0,0 @@
namespace Wino.Core.Domain;
public static class AppUrls
{
public const string Website = "https://www.winomail.app";
public const string Discord = "https://discord.gg/windows-apps-hub-714581497222398064";
public const string GitHub = "https://github.com/bkaankose/Wino-Mail/";
public const string PrivacyPolicy = "https://www.winomail.app/support/privacy";
public const string Paypal = "https://paypal.me/bkaankose?country.x=PL&locale.x=en_US";
}
@@ -1,6 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Wino.Core.Domain.Models.Updates;
namespace Wino.Core.Domain; namespace Wino.Core.Domain;
@@ -9,6 +8,4 @@ namespace Wino.Core.Domain;
[JsonSerializable(typeof(int))] [JsonSerializable(typeof(int))]
[JsonSerializable(typeof(List<string>))] [JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(bool))] [JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(UpdateNotes))]
[JsonSerializable(typeof(List<UpdateNoteSection>))]
public partial class BasicTypesJsonContext : JsonSerializerContext; public partial class BasicTypesJsonContext : JsonSerializerContext;
@@ -1,23 +0,0 @@
using System.Collections.Generic;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain;
public static class CalendarItemActionOptions
{
public static IReadOnlyList<CalendarItemShowAs> ShowAsOptions { get; } =
[
CalendarItemShowAs.Free,
CalendarItemShowAs.Tentative,
CalendarItemShowAs.Busy,
CalendarItemShowAs.OutOfOffice,
CalendarItemShowAs.WorkingElsewhere
];
public static IReadOnlyList<CalendarItemStatus> ResponseOptions { get; } =
[
CalendarItemStatus.Accepted,
CalendarItemStatus.Tentative,
CalendarItemStatus.Cancelled
];
}
@@ -1,126 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Core.Domain;
public static class CalendarRecurrenceSummaryFormatter
{
private static readonly DayOfWeek[] OrderedDays =
[
DayOfWeek.Monday,
DayOfWeek.Tuesday,
DayOfWeek.Wednesday,
DayOfWeek.Thursday,
DayOfWeek.Friday,
DayOfWeek.Saturday,
DayOfWeek.Sunday
];
public static string BuildSummary(
bool isRecurring,
DateTimeOffset effectiveStart,
DateTimeOffset effectiveEnd,
bool isAllDay,
CalendarSettings settings,
int interval,
CalendarItemRecurrenceFrequency frequency,
IReadOnlyCollection<DayOfWeek> daysOfWeek,
DateTimeOffset? recurrenceEndDate)
{
var culture = settings?.CultureInfo ?? CultureInfo.CurrentCulture;
var timeSummary = isAllDay
? Translator.CalendarItemAllDay
: string.Format(
culture,
Translator.CalendarEventCompose_TimeRangeSummary,
effectiveStart.ToString(settings?.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", culture),
effectiveEnd.ToString(settings?.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", culture));
if (!isRecurring)
{
return string.Format(
culture,
Translator.CalendarEventCompose_SingleOccurrenceSummary,
effectiveStart.ToString("dddd yyyy-MM-dd", culture),
timeSummary);
}
var normalizedDays = NormalizeDays(daysOfWeek);
var isEveryDay = (frequency == CalendarItemRecurrenceFrequency.Daily && interval == 1) ||
(frequency == CalendarItemRecurrenceFrequency.Weekly && interval == 1 && normalizedDays.Count == 7);
var cadenceSummary = isEveryDay
? $"{Translator.CalendarEventCompose_Every} {Translator.CalendarEventCompose_FrequencyDay}"
: interval == 1
? $"{Translator.CalendarEventCompose_Every} {GetSingularFrequencyLabel(frequency)}"
: $"{Translator.CalendarEventCompose_Every} {interval.ToString(culture)} {GetPluralFrequencyLabel(frequency)}";
var weekdaySummary = string.Empty;
if (frequency == CalendarItemRecurrenceFrequency.Weekly && normalizedDays.Count > 0 && normalizedDays.Count < 7)
{
weekdaySummary = string.Format(
culture,
Translator.CalendarEventCompose_WeekdaySummary,
string.Join(", ", normalizedDays.Select(day => culture.DateTimeFormat.GetDayName(day))));
}
var untilSummary = recurrenceEndDate.HasValue
? string.Format(
culture,
Translator.CalendarEventCompose_UntilSummary,
recurrenceEndDate.Value.ToString("ddd yyyy-MM-dd", culture))
: string.Empty;
return string.Format(
culture,
Translator.CalendarEventCompose_RecurringSummarySmart,
cadenceSummary,
weekdaySummary,
timeSummary,
effectiveStart.ToString("dddd yyyy-MM-dd", culture),
untilSummary).Trim();
}
private static IReadOnlyList<DayOfWeek> NormalizeDays(IReadOnlyCollection<DayOfWeek> daysOfWeek)
{
if (daysOfWeek == null || daysOfWeek.Count == 0)
{
return [];
}
return daysOfWeek
.Distinct()
.OrderBy(day => Array.IndexOf(OrderedDays, day))
.ToList();
}
private static string GetSingularFrequencyLabel(CalendarItemRecurrenceFrequency frequency)
{
return frequency switch
{
CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDay,
CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeek,
CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonth,
CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYear,
_ => Translator.CalendarEventCompose_FrequencyWeek
};
}
private static string GetPluralFrequencyLabel(CalendarItemRecurrenceFrequency frequency)
{
return frequency switch
{
CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDayPlural,
CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeekPlural,
CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonthPlural,
CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYearPlural,
_ => Translator.CalendarEventCompose_FrequencyWeekPlural
};
}
}
@@ -8,9 +8,6 @@ public static class CalendarReminderSnoozeOptions
{ {
private static readonly int[] SupportedSnoozeMinutes = [5, 10, 15, 30]; private static readonly int[] SupportedSnoozeMinutes = [5, 10, 15, 30];
public static IReadOnlyList<int> GetSupportedSnoozeMinutes()
=> SupportedSnoozeMinutes;
public static IReadOnlyList<int> GetAllowedSnoozeMinutes(long reminderDurationInSeconds, long defaultReminderDurationInSeconds) public static IReadOnlyList<int> GetAllowedSnoozeMinutes(long reminderDurationInSeconds, long defaultReminderDurationInSeconds)
{ {
var reminderMinutes = (int)Math.Max(0, reminderDurationInSeconds / 60); var reminderMinutes = (int)Math.Max(0, reminderDurationInSeconds / 60);
@@ -0,0 +1,41 @@
using System.Linq;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Core.Domain.Collections;
public class DayRangeCollection : ObservableRangeCollection<DayRangeRenderModel>
{
/// <summary>
/// Gets the range of dates that are currently displayed in the collection.
/// </summary>
public DateRange DisplayRange
{
get
{
if (Count == 0) return null;
var minimumLoadedDate = this[0].CalendarRenderOptions.DateRange.StartDate;
var maximumLoadedDate = this[Count - 1].CalendarRenderOptions.DateRange.EndDate;
return new DateRange(minimumLoadedDate, maximumLoadedDate);
}
}
public void RemoveCalendarItem(ICalendarItem calendarItem)
{
foreach (var dayRange in this)
{
}
}
public void AddCalendarItem(ICalendarItem calendarItem)
{
foreach (var dayRange in this)
{
var calendarDayModel = dayRange.CalendarDays.FirstOrDefault(x => x.Period.HasInside(calendarItem.Period.Start));
calendarDayModel?.EventsCollection.AddCalendarItem(calendarItem);
}
}
}
+2 -8
View File
@@ -1,4 +1,4 @@
namespace Wino.Core.Domain; namespace Wino.Core.Domain;
public static class Constants public static class Constants
{ {
@@ -6,8 +6,6 @@ public static class Constants
/// MIME header that exists in all the drafts created from Wino. /// MIME header that exists in all the drafts created from Wino.
/// </summary> /// </summary>
public const string WinoLocalDraftHeader = "X-Wino-Draft-Id"; public const string WinoLocalDraftHeader = "X-Wino-Draft-Id";
public const string DispositionNotificationToHeader = "Disposition-Notification-To";
public const string OriginalMessageIdHeader = "Original-Message-ID";
public const string LocalDraftStartPrefix = "localDraft_"; public const string LocalDraftStartPrefix = "localDraft_";
public const string CalendarEventRecurrenceRuleSeperator = "___"; public const string CalendarEventRecurrenceRuleSeperator = "___";
@@ -18,15 +16,12 @@ public static class Constants
public const string ToastCalendarItemIdKey = nameof(ToastCalendarItemIdKey); public const string ToastCalendarItemIdKey = nameof(ToastCalendarItemIdKey);
public const string ToastCalendarActionKey = nameof(ToastCalendarActionKey); public const string ToastCalendarActionKey = nameof(ToastCalendarActionKey);
public const string ToastCalendarNavigateAction = nameof(ToastCalendarNavigateAction); public const string ToastCalendarNavigateAction = nameof(ToastCalendarNavigateAction);
public const string ToastCalendarJoinOnlineAction = nameof(ToastCalendarJoinOnlineAction);
public const string ToastCalendarSnoozeAction = nameof(ToastCalendarSnoozeAction); public const string ToastCalendarSnoozeAction = nameof(ToastCalendarSnoozeAction);
public const string ToastCalendarSnoozeDurationInputId = nameof(ToastCalendarSnoozeDurationInputId); public const string ToastCalendarSnoozeDurationInputId = nameof(ToastCalendarSnoozeDurationInputId);
public const string ToastModeKey = nameof(ToastModeKey); public const string ToastModeKey = nameof(ToastModeKey);
public const string ToastModeMail = nameof(ToastModeMail); public const string ToastModeMail = nameof(ToastModeMail);
public const string ToastModeCalendar = nameof(ToastModeCalendar); public const string ToastModeCalendar = nameof(ToastModeCalendar);
public const string ToastDismissActionKey = nameof(ToastDismissActionKey);
public const string ToastStoreUpdateActionKey = nameof(ToastStoreUpdateActionKey);
public const string ToastStoreUpdateActionInstall = nameof(ToastStoreUpdateActionInstall);
public const string ClientLogFile = "Client_.log"; public const string ClientLogFile = "Client_.log";
public const string ServerLogFile = "Server_.log"; public const string ServerLogFile = "Server_.log";
public const string LogArchiveFileName = "WinoLogs.zip"; public const string LogArchiveFileName = "WinoLogs.zip";
@@ -34,4 +29,3 @@ public static class Constants
public const string WinoMailIdentiifer = nameof(WinoMailIdentiifer); public const string WinoMailIdentiifer = nameof(WinoMailIdentiifer);
public const string WinoCalendarIdentifier = nameof(WinoCalendarIdentifier); public const string WinoCalendarIdentifier = nameof(WinoCalendarIdentifier);
} }
@@ -16,7 +16,6 @@ public class AccountCalendar : IAccountCalendar
public string SynchronizationDeltaToken { get; set; } public string SynchronizationDeltaToken { get; set; }
public string Name { get; set; } public string Name { get; set; }
public bool IsPrimary { get; set; } public bool IsPrimary { get; set; }
public bool IsReadOnly { get; set; }
public bool IsSynchronizationEnabled { get; set; } = true; public bool IsSynchronizationEnabled { get; set; } = true;
public bool IsExtended { get; set; } = true; public bool IsExtended { get; set; } = true;
public CalendarItemShowAs DefaultShowAs { get; set; } = CalendarItemShowAs.Busy; public CalendarItemShowAs DefaultShowAs { get; set; } = CalendarItemShowAs.Busy;
@@ -26,7 +25,6 @@ public class AccountCalendar : IAccountCalendar
/// </summary> /// </summary>
public string TextColorHex { get; set; } public string TextColorHex { get; set; }
public string BackgroundColorHex { get; set; } public string BackgroundColorHex { get; set; }
public bool IsBackgroundColorUserOverridden { get; set; }
public string TimeZone { get; set; } public string TimeZone { get; set; }
[Ignore] [Ignore]
@@ -1,10 +1,10 @@
using System; using System;
using SQLite; using SQLite;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Calendar; namespace Wino.Core.Domain.Entities.Calendar;
// TODO: Connect to Contact store with Wino People.
public class CalendarEventAttendee public class CalendarEventAttendee
{ {
[PrimaryKey] [PrimaryKey]
@@ -16,11 +16,4 @@ public class CalendarEventAttendee
public bool IsOrganizer { get; set; } public bool IsOrganizer { get; set; }
public bool IsOptionalAttendee { get; set; } public bool IsOptionalAttendee { get; set; }
public string Comment { get; set; } public string Comment { get; set; }
/// <summary>
/// Resolved contact from the contact store. Populated at runtime via IContactService;
/// not persisted to the database.
/// </summary>
[Ignore]
public AccountContact ResolvedContact { get; set; }
} }
@@ -154,24 +154,6 @@ public class CalendarItem : ICalendarItem
[Ignore] [Ignore]
public IAccountCalendar AssignedCalendar { get; set; } public IAccountCalendar AssignedCalendar { get; set; }
[Ignore]
public bool CanChangeStartAndEndDate
{
get
{
if (IsLocked)
{
return false;
}
var accountAddress = AssignedCalendar?.MailAccount?.Address;
return string.IsNullOrWhiteSpace(OrganizerEmail) ||
string.IsNullOrWhiteSpace(accountAddress) ||
string.Equals(OrganizerEmail, accountAddress, StringComparison.OrdinalIgnoreCase);
}
}
/// <summary> /// <summary>
/// Id to load information related to this event (attendees, reminders, etc.). /// Id to load information related to this event (attendees, reminders, etc.).
/// For child events, if they have their own data, use their own Id. /// For child events, if they have their own data, use their own Id.
@@ -1,16 +0,0 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Mail;
public class EmailTemplate
{
[PrimaryKey]
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string HtmlContent { get; set; } = string.Empty;
}
@@ -2,8 +2,6 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using SQLite; using SQLite;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Mail; namespace Wino.Core.Domain.Entities.Mail;
@@ -44,16 +42,6 @@ public class RemoteAccountAlias
/// Used for Gmail only. /// Used for Gmail only.
/// </summary> /// </summary>
public string AliasSenderName { get; set; } public string AliasSenderName { get; set; }
/// <summary>
/// Whether the alias was entered by the user or discovered from the provider.
/// </summary>
public AliasSource Source { get; set; } = AliasSource.Manual;
/// <summary>
/// Represents Wino's confidence that the alias can be used for sending.
/// </summary>
public AliasSendCapability SendCapability { get; set; } = AliasSendCapability.Unknown;
} }
public class MailAccountAlias : RemoteAccountAlias public class MailAccountAlias : RemoteAccountAlias
@@ -82,28 +70,4 @@ public class MailAccountAlias : RemoteAccountAlias
[Ignore] [Ignore]
public ObservableCollection<X509Certificate2> Certificates { get; set; } = []; public ObservableCollection<X509Certificate2> Certificates { get; set; } = [];
[Ignore]
public bool IsCapabilityConfirmed => SendCapability == AliasSendCapability.Confirmed;
[Ignore]
public bool IsCapabilityUnknown => SendCapability == AliasSendCapability.Unknown;
[Ignore]
public bool IsCapabilityDenied => SendCapability == AliasSendCapability.Denied;
[Ignore]
public string CapabilityDisplayName => SendCapability switch
{
AliasSendCapability.Confirmed => Translator.AccountAlias_Status_Confirmed,
AliasSendCapability.Denied => Translator.AccountAlias_Status_Denied,
_ => Translator.AccountAlias_Status_Unknown
};
[Ignore]
public string SourceDisplayName => Source switch
{
AliasSource.ProviderDiscovered => Translator.AccountAlias_Source_ProviderDiscovered,
_ => Translator.AccountAlias_Source_Manual
};
} }
@@ -1,25 +0,0 @@
using System;
using SQLite;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Mail;
public class MailCategory
{
[PrimaryKey]
public Guid Id { get; set; }
public Guid MailAccountId { get; set; }
public string RemoteId { get; set; }
public string Name { get; set; }
public bool IsFavorite { get; set; }
public string BackgroundColorHex { get; set; }
public string TextColorHex { get; set; }
public MailCategorySource Source { get; set; } = MailCategorySource.Local;
}
@@ -1,14 +0,0 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Mail;
public class MailCategoryAssignment
{
[PrimaryKey]
public Guid Id { get; set; }
public Guid MailCategoryId { get; set; }
public Guid MailCopyUniqueId { get; set; }
}
@@ -92,11 +92,6 @@ public class MailCopy
/// </summary> /// </summary>
public bool IsFlagged { get; set; } public bool IsFlagged { get; set; }
/// <summary>
/// Whether this mail should stay pinned to the top locally.
/// </summary>
public bool IsPinned { get; set; }
/// <summary> /// <summary>
/// To support Outlook. /// To support Outlook.
/// Gmail doesn't use it. /// Gmail doesn't use it.
@@ -160,21 +155,6 @@ public class MailCopy
[Ignore] [Ignore]
public AccountContact SenderContact { get; set; } public AccountContact SenderContact { get; set; }
[Ignore]
public bool IsReadReceiptRequested { get; set; }
[Ignore]
public SentMailReceiptStatus ReadReceiptStatus { get; set; }
[Ignore]
public DateTime? ReadReceiptAcknowledgedAtUtc { get; set; }
[Ignore]
public Guid? ReadReceiptMessageUniqueId { get; set; }
[Ignore]
public List<MailCategory> Categories { get; set; } = [];
public IEnumerable<Guid> GetContainingIds() => [UniqueId]; public IEnumerable<Guid> GetContainingIds() => [UniqueId];
public override string ToString() => $"{Subject} <-> {Id}"; public override string ToString() => $"{Subject} <-> {Id}";
} }
@@ -24,11 +24,6 @@ public class MailItemFolder : IMailItemFolder
public bool IsSynchronizationEnabled { get; set; } public bool IsSynchronizationEnabled { get; set; }
public bool IsHidden { get; set; } public bool IsHidden { get; set; }
public bool ShowUnreadCount { get; set; } public bool ShowUnreadCount { get; set; }
// User-defined ordering within its navigation section (Pinned / Categories / More).
// 0 means "no custom order set" — the folder falls back to the default sort
// (alphabetic for More, canonical SpecialFolderType order as a tiebreak for Pinned).
public int Order { get; set; }
public DateTime? LastSynchronizedDate { get; set; } public DateTime? LastSynchronizedDate { get; set; }
// For IMAP // For IMAP
@@ -1,25 +0,0 @@
using System;
using SQLite;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Mail;
public class SentMailReceiptState
{
[PrimaryKey]
public Guid MailUniqueId { get; set; }
public Guid AccountId { get; set; }
public string MessageId { get; set; }
public bool IsReceiptRequested { get; set; }
public DateTime RequestedAtUtc { get; set; }
public SentMailReceiptStatus Status { get; set; }
public DateTime? AcknowledgedAtUtc { get; set; }
public Guid? ReceiptMessageUniqueId { get; set; }
}
@@ -11,7 +11,7 @@ namespace Wino.Core.Domain.Entities.Shared;
// TODO: This can easily evolve to Contact store, just like People app in Windows 10/11. // TODO: This can easily evolve to Contact store, just like People app in Windows 10/11.
// Do it. // Do it.
public class AccountContact : IEquatable<AccountContact>, IContactDisplayItem public class AccountContact : IEquatable<AccountContact>
{ {
/// <summary> /// <summary>
/// E-mail address of the contact. /// E-mail address of the contact.
@@ -25,10 +25,9 @@ public class AccountContact : IEquatable<AccountContact>, IContactDisplayItem
public string Name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// File ID for the contact picture stored on disk. /// Base64 encoded profile image of the contact.
/// The actual file lives at {ApplicationDataFolderPath}/contacts/{ContactPictureFileId}.jpg.
/// </summary> /// </summary>
public Guid? ContactPictureFileId { get; set; } public string Base64ContactPicture { get; set; }
/// <summary> /// <summary>
/// All registered accounts have their contacts registered as root. /// All registered accounts have their contacts registered as root.
@@ -43,9 +42,6 @@ public class AccountContact : IEquatable<AccountContact>, IContactDisplayItem
/// </summary> /// </summary>
public bool IsOverridden { get; set; } = false; public bool IsOverridden { get; set; } = false;
public string DisplayName => string.IsNullOrWhiteSpace(Name) ? Address : Name;
AccountContact IContactDisplayItem.PreviewContact => this;
public override bool Equals(object obj) public override bool Equals(object obj)
{ {
return Equals(obj as AccountContact); return Equals(obj as AccountContact);
@@ -1,19 +0,0 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Shared;
/// <summary>
/// A named group of contacts that can be expanded to individual addresses during mail composition.
/// </summary>
public class ContactGroup
{
[PrimaryKey]
public Guid Id { get; set; }
/// <summary>Display name of the group (e.g., "Team Alpha", "Family").</summary>
public string Name { get; set; }
/// <summary>Optional description for the group.</summary>
public string Description { get; set; }
}
@@ -1,21 +0,0 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Shared;
/// <summary>
/// Associates an e-mail address with a <see cref="ContactGroup"/>.
/// </summary>
public class ContactGroupMember
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
/// <summary>Group this member belongs to.</summary>
[Indexed]
public Guid GroupId { get; set; }
/// <summary>E-mail address of the member (FK to AccountContact.Address).</summary>
[Indexed]
public string MemberAddress { get; set; }
}
@@ -1,8 +0,0 @@
namespace Wino.Core.Domain.Entities.Shared;
public interface IContactDisplayItem
{
string DisplayName { get; }
string Address { get; }
AccountContact PreviewContact { get; }
}
@@ -12,11 +12,6 @@ public class KeyboardShortcut
[PrimaryKey] [PrimaryKey]
public Guid Id { get; set; } public Guid Id { get; set; }
/// <summary>
/// The application mode this shortcut applies to.
/// </summary>
public WinoApplicationMode Mode { get; set; } = WinoApplicationMode.Mail;
/// <summary> /// <summary>
/// The key combination string (e.g., "D", "Delete", "F1"). /// The key combination string (e.g., "D", "Delete", "F1").
/// </summary> /// </summary>
@@ -28,9 +23,9 @@ public class KeyboardShortcut
public ModifierKeys ModifierKeys { get; set; } public ModifierKeys ModifierKeys { get; set; }
/// <summary> /// <summary>
/// The shortcut action this shortcut triggers. /// The mail operation this shortcut triggers.
/// </summary> /// </summary>
public KeyboardShortcutAction Action { get; set; } public MailOperation MailOperation { get; set; }
/// <summary> /// <summary>
/// Whether this shortcut is enabled. /// Whether this shortcut is enabled.
@@ -60,6 +55,6 @@ public class KeyboardShortcut
modifierText += "Win+"; modifierText += "Win+";
return modifierText + Key; return modifierText + Key;
} }
} }
} }
@@ -78,13 +78,6 @@ public class MailAccount
/// </summary> /// </summary>
public SpecialImapProvider SpecialImapProvider { get; set; } public SpecialImapProvider SpecialImapProvider { get; set; }
/// <summary>
/// Gets or sets whether mail access is granted for this account.
/// When false, mail folders, aliases, compose flows, and mail synchronization are unavailable.
/// Default is true for legacy accounts to preserve existing behavior.
/// </summary>
public bool IsMailAccessGranted { get; set; } = true;
/// <summary> /// <summary>
/// Gets or sets whether calendar access is granted for this account. /// Gets or sets whether calendar access is granted for this account.
/// When false, synchronizers will not process EventMessages or calendar invitations. /// When false, synchronizers will not process EventMessages or calendar invitations.
@@ -119,16 +112,6 @@ public class MailAccount
/// </summary> /// </summary>
public DateTime? LastFolderStructureSyncDate { get; set; } public DateTime? LastFolderStructureSyncDate { get; set; }
/// <summary>
/// Gets or sets when the account was created in Wino.
/// </summary>
public DateTime? CreatedAt { get; set; }
/// <summary>
/// Gets or sets the timespan used for the account's initial mail synchronization.
/// </summary>
public InitialSynchronizationRange InitialSynchronizationRange { get; set; } = InitialSynchronizationRange.SixMonths;
/// <summary> /// <summary>
/// Gets whether the account can perform ProfileInformation sync type. /// Gets whether the account can perform ProfileInformation sync type.
/// </summary> /// </summary>
@@ -137,12 +120,7 @@ public class MailAccount
/// <summary> /// <summary>
/// Gets whether the account can perform AliasInformation sync type. /// Gets whether the account can perform AliasInformation sync type.
/// </summary> /// </summary>
public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail || ProviderType == MailProviderType.Outlook; public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail;
/// <summary>
/// Gets whether the account can perform category definition sync type.
/// </summary>
public bool IsCategorySyncSupported => ProviderType == MailProviderType.Outlook;
public override string ToString() => Name; public override string ToString() => Name;
} }
@@ -1,30 +0,0 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Shared;
public class WinoAccount
{
[PrimaryKey]
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public string AccountStatus { get; set; } = string.Empty;
public bool HasPassword { get; set; }
public bool HasGoogleLogin { get; set; }
public bool HasFacebookLogin { get; set; }
public string AccessToken { get; set; } = string.Empty;
public DateTime AccessTokenExpiresAtUtc { get; set; }
public string RefreshToken { get; set; } = string.Empty;
public DateTime RefreshTokenExpiresAtUtc { get; set; }
public DateTime LastAuthenticatedUtc { get; set; }
}
@@ -1,9 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum AccountSetupStepStatus
{
Pending,
InProgress,
Succeeded,
Failed
}
-12
View File
@@ -1,12 +0,0 @@
using System;
namespace Wino.Core.Domain.Enums;
[Flags]
public enum AiActionType
{
None = 0,
Translate = 1,
Rewrite = 2,
Summarize = 4,
}
@@ -1,8 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum AliasSendCapability
{
Unknown = 0,
Confirmed = 1,
Denied = 2
}
-7
View File
@@ -1,7 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum AliasSource
{
Manual = 0,
ProviderDiscovered = 1
}
+1 -2
View File
@@ -16,6 +16,5 @@ public enum AppLanguage
Greek, Greek,
PortugeseBrazil, PortugeseBrazil,
Italian, Italian,
Romanian, Romanian
Korean
} }
@@ -1,10 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum CalendarContextMenuActionType
{
Open,
JoinOnline,
Delete,
ShowAs,
Respond
}
@@ -0,0 +1,10 @@
namespace Wino.Core.Domain.Enums;
/// <summary>
/// Trigger to load more data.
/// </summary>
public enum CalendarInitInitiative
{
User,
App
}
@@ -1,17 +1,17 @@
namespace Wino.Core.Domain.Enums; namespace Wino.Core.Domain.Enums;
/// <summary> /// <summary>
/// Indicates the source of an entity update. /// Indicates the source of a calendar item update.
/// </summary> /// </summary>
public enum EntityUpdateSource public enum CalendarItemUpdateSource
{ {
/// <summary> /// <summary>
/// Update originated from client-side optimistic UI changes (ApplyUIChanges). /// Update originated from client-side UI changes (ApplyUIChanges).
/// </summary> /// </summary>
ClientUpdated, ClientUpdated,
/// <summary> /// <summary>
/// Update originated from reverting client-side optimistic UI changes (RevertUIChanges). /// Update originated from client-side UI revert (RevertUIChanges).
/// </summary> /// </summary>
ClientReverted, ClientReverted,
@@ -5,7 +5,6 @@ public enum CalendarSynchronizationType
ExecuteRequests, // Execute all requests in the queue. ExecuteRequests, // Execute all requests in the queue.
CalendarMetadata, // Sync calendar metadata. CalendarMetadata, // Sync calendar metadata.
CalendarEvents, // Sync all events for all calendars. CalendarEvents, // Sync all events for all calendars.
Strict, // Run metadata and event synchronization in sequence.
SingleCalendar, // Sync events for only specified calendars. SingleCalendar, // Sync events for only specified calendars.
UpdateProfile // Update profile information only. UpdateProfile // Update profile information only.
} }
@@ -1,10 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum InitialSynchronizationRange
{
SixMonths = 0,
ThreeMonths = 1,
NineMonths = 2,
OneYear = 3,
Everything = 4
}
@@ -1,16 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum KeyboardShortcutAction
{
None,
NewMail,
ToggleReadUnread,
ToggleFlag,
ToggleArchive,
Delete,
Move,
Reply,
ReplyAll,
Send,
NewEvent
}
@@ -1,7 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum MailCategorySource
{
Local,
Outlook
}
@@ -1,63 +0,0 @@
using System;
namespace Wino.Core.Domain.Enums;
[Flags]
public enum MailCopyChangeFlags
{
None = 0,
Id = 1 << 0,
FolderId = 1 << 1,
ThreadId = 1 << 2,
MessageId = 1 << 3,
References = 1 << 4,
InReplyTo = 1 << 5,
FromName = 1 << 6,
FromAddress = 1 << 7,
Subject = 1 << 8,
PreviewText = 1 << 9,
CreationDate = 1 << 10,
Importance = 1 << 11,
IsRead = 1 << 12,
IsFlagged = 1 << 13,
IsPinned = 1 << 14,
IsFocused = 1 << 15,
HasAttachments = 1 << 16,
ItemType = 1 << 17,
DraftId = 1 << 18,
IsDraft = 1 << 19,
FileId = 1 << 20,
AssignedFolder = 1 << 21,
AssignedAccount = 1 << 22,
SenderContact = 1 << 23,
UniqueId = 1 << 24,
ReadReceiptState = 1 << 25,
Categories = 1 << 26,
All = Id |
FolderId |
ThreadId |
MessageId |
References |
InReplyTo |
FromName |
FromAddress |
Subject |
PreviewText |
CreationDate |
Importance |
IsRead |
IsFlagged |
IsPinned |
IsFocused |
HasAttachments |
ItemType |
DraftId |
IsDraft |
FileId |
AssignedFolder |
AssignedAccount |
SenderContact |
UniqueId |
ReadReceiptState |
Categories
}
-11
View File
@@ -9,11 +9,9 @@ public enum MailSynchronizerOperation
CreateDraft, CreateDraft,
Send, Send,
ChangeFlag, ChangeFlag,
ChangeJunkState,
AlwaysMoveTo, AlwaysMoveTo,
MoveToFocused, MoveToFocused,
Archive, Archive,
UpdateCategories,
} }
public enum FolderSynchronizerOperation public enum FolderSynchronizerOperation
@@ -23,27 +21,18 @@ public enum FolderSynchronizerOperation
MarkFolderRead, MarkFolderRead,
DeleteFolder, DeleteFolder,
CreateSubFolder, CreateSubFolder,
CreateRootFolder,
} }
public enum CalendarSynchronizerOperation public enum CalendarSynchronizerOperation
{ {
CreateEvent, CreateEvent,
UpdateEvent, UpdateEvent,
ChangeStartAndEndDate,
DeleteEvent, DeleteEvent,
AcceptEvent, AcceptEvent,
DeclineEvent, DeclineEvent,
TentativeEvent, TentativeEvent,
} }
public enum CategorySynchronizerOperation
{
CreateCategory,
UpdateCategory,
DeleteCategory,
}
// UI requests // UI requests
public enum MailOperation public enum MailOperation
{ {
@@ -3,7 +3,6 @@
public enum MailSynchronizationType public enum MailSynchronizationType
{ {
UpdateProfile, // Only update profile information UpdateProfile, // Only update profile information
Categories, // Only update mail categories
ExecuteRequests, // Run the queued requests, and then synchronize if needed. ExecuteRequests, // Run the queued requests, and then synchronize if needed.
FoldersOnly, // Only synchronize folder metadata. FoldersOnly, // Only synchronize folder metadata.
InboxOnly, // Only Inbox, Sent, Draft and Deleted folders. InboxOnly, // Only Inbox, Sent, Draft and Deleted folders.
@@ -0,0 +1,22 @@
namespace Wino.Core.Domain.Enums;
/// <summary>
/// Indicates the source of a mail update.
/// </summary>
public enum MailUpdateSource
{
/// <summary>
/// Update originated from client-side UI changes (ApplyUIChanges).
/// </summary>
ClientUpdated,
/// <summary>
/// Update originated from client-side UI revert (RevertUIChanges).
/// </summary>
ClientReverted,
/// <summary>
/// Update originated from server synchronization or database operations.
/// </summary>
Server
}
@@ -1,7 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum NewEventButtonBehavior
{
AskEachTime = 0,
AlwaysUseSpecificCalendar = 1
}
@@ -1,9 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum SentMailReceiptStatus
{
None = 0,
Requested = 1,
Acknowledged = 2,
FailedToCorrelate = 3
}
@@ -1,7 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum SynchronizationProgressCategory
{
Mail,
Calendar
}
@@ -1,8 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum ThreeButtonDialogResult
{
Primary,
Secondary,
Cancel
}
@@ -1,7 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum WinoAddOnProductType
{
AI_PACK,
UNLIMITED_ACCOUNTS
}
@@ -3,7 +3,5 @@
public enum WinoApplicationMode public enum WinoApplicationMode
{ {
Mail, Mail,
Calendar, Calendar
Contacts,
Settings
} }
+4 -16
View File
@@ -11,8 +11,8 @@ public enum WinoPage
SettingsPage, SettingsPage,
ContactsPage, ContactsPage,
MailRenderingPage, MailRenderingPage,
WelcomePage,
AccountDetailsPage, AccountDetailsPage,
FolderCustomizationPage,
MergedAccountDetailsPage, MergedAccountDetailsPage,
ManageAccountsPage, ManageAccountsPage,
AccountManagementPage, AccountManagementPage,
@@ -20,31 +20,19 @@ public enum WinoPage
AboutPage, AboutPage,
PersonalizationPage, PersonalizationPage,
MessageListPage, MessageListPage,
MailNotificationSettingsPage,
MailListPage, MailListPage,
ReadComposePanePage, ReadComposePanePage,
LanguageTimePage,
AppPreferencesPage, AppPreferencesPage,
SettingOptionsPage, SettingOptionsPage,
AliasManagementPage, AliasManagementPage,
MailCategoryManagementPage, EditAccountDetailsPage,
ImapCalDavSettingsPage, ImapCalDavSettingsPage,
KeyboardShortcutsPage, KeyboardShortcutsPage,
CalendarPage, CalendarPage,
CalendarSettingsPage, CalendarSettingsPage,
CalendarRenderingSettingsPage,
CalendarNotificationSettingsPage,
CalendarPreferenceSettingsPage,
CalendarAccountSettingsPage, CalendarAccountSettingsPage,
EventDetailsPage, EventDetailsPage,
CalendarEventComposePage,
SignatureAndEncryptionPage, SignatureAndEncryptionPage,
EmailTemplatesPage, StoragePage
CreateEmailTemplatePage,
StoragePage,
WinoAccountManagementPage,
WelcomePageV2,
WelcomeHostPage,
ProviderSelectionPage,
AccountSetupProgressPage,
SpecialImapCredentialsPage
} }
@@ -1,10 +0,0 @@
using System;
namespace Wino.Core.Domain.Exceptions;
public sealed class CalendarEventComposeValidationException : Exception
{
public CalendarEventComposeValidationException(string message) : base(message)
{
}
}
@@ -9,18 +9,22 @@ public class ImapClientPoolException : Exception
{ {
} }
public ImapClientPoolException(string message, CustomServerInformation customServerInformation) : base(message) public ImapClientPoolException(string message, CustomServerInformation customServerInformation, string protocolLog) : base(message)
{ {
CustomServerInformation = customServerInformation; CustomServerInformation = customServerInformation;
ProtocolLog = protocolLog;
} }
public ImapClientPoolException(string message) : base(message) public ImapClientPoolException(string message, string protocolLog) : base(message)
{ {
ProtocolLog = protocolLog;
} }
public ImapClientPoolException(Exception innerException) : base(innerException.Message, innerException) public ImapClientPoolException(Exception innerException, string protocolLog) : base(innerException.Message, innerException)
{ {
ProtocolLog = protocolLog;
} }
public CustomServerInformation CustomServerInformation { get; } public CustomServerInformation CustomServerInformation { get; }
public string ProtocolLog { get; }
} }
@@ -0,0 +1,17 @@
using Wino.Core.Domain.Models.AutoDiscovery;
namespace Wino.Core.Domain.Exceptions;
public class ImapConnectionFailedPackage
{
public ImapConnectionFailedPackage(string errorMessage, string protocolLog, AutoDiscoverySettings settings)
{
ErrorMessage = errorMessage;
ProtocolLog = protocolLog;
Settings = settings;
}
public AutoDiscoverySettings Settings { get; }
public string ErrorMessage { get; set; }
public string ProtocolLog { get; }
}
@@ -1,67 +0,0 @@
using System;
namespace Wino.Core.Domain.Extensions;
public static class CalendarRemoteEventIdExtensions
{
private const string ClientTrackingSeparator = "::";
private const string CalDavClientTrackingPrefix = "caldav-";
private const string LocalClientTrackingPrefix = "local-";
public static string GetProviderRemoteEventId(this string remoteEventId)
{
if (string.IsNullOrWhiteSpace(remoteEventId))
return string.Empty;
var separatorIndex = remoteEventId.IndexOf(ClientTrackingSeparator, StringComparison.Ordinal);
return separatorIndex >= 0 ? remoteEventId[..separatorIndex] : remoteEventId;
}
public static Guid? GetClientTrackingId(this string remoteEventId)
{
if (string.IsNullOrWhiteSpace(remoteEventId))
return null;
if (remoteEventId.Contains(ClientTrackingSeparator, StringComparison.Ordinal))
{
var trackedPart = remoteEventId[(remoteEventId.LastIndexOf(ClientTrackingSeparator, StringComparison.Ordinal) + ClientTrackingSeparator.Length)..];
if (TryParseGuid(trackedPart, out var trackedId))
return trackedId;
}
if (TryParseGuid(remoteEventId, out var directId))
return directId;
if (remoteEventId.StartsWith(CalDavClientTrackingPrefix, StringComparison.OrdinalIgnoreCase) &&
TryParseGuid(remoteEventId[CalDavClientTrackingPrefix.Length..], out var calDavId))
{
return calDavId;
}
if (remoteEventId.StartsWith(LocalClientTrackingPrefix, StringComparison.OrdinalIgnoreCase) &&
TryParseGuid(remoteEventId[LocalClientTrackingPrefix.Length..], out var localId))
{
return localId;
}
return null;
}
public static string WithClientTrackingId(this string providerRemoteEventId, Guid? clientTrackingId)
{
if (string.IsNullOrWhiteSpace(providerRemoteEventId) || !clientTrackingId.HasValue)
return providerRemoteEventId ?? string.Empty;
return $"{providerRemoteEventId}{ClientTrackingSeparator}{clientTrackingId.Value:N}";
}
private static bool TryParseGuid(string value, out Guid parsedGuid)
{
parsedGuid = Guid.Empty;
if (string.IsNullOrWhiteSpace(value))
return false;
return Guid.TryParseExact(value, "N", out parsedGuid) || Guid.TryParse(value, out parsedGuid);
}
}
@@ -78,12 +78,8 @@ public static class DateTimeExtensions
} }
public static DateTime GetLocalStartDate(this CalendarItem calendarItem) public static DateTime GetLocalStartDate(this CalendarItem calendarItem)
=> calendarItem.IsAllDayEvent => calendarItem.StartDate.ToLocalTimeFromTimeZone(calendarItem.StartTimeZone);
? calendarItem.StartDate
: calendarItem.StartDate.ToLocalTimeFromTimeZone(calendarItem.StartTimeZone);
public static DateTime GetLocalEndDate(this CalendarItem calendarItem) public static DateTime GetLocalEndDate(this CalendarItem calendarItem)
=> calendarItem.IsAllDayEvent => calendarItem.EndDate.ToLocalTimeFromTimeZone(calendarItem.EndTimeZone);
? calendarItem.EndDate
: calendarItem.EndDate.ToLocalTimeFromTimeZone(calendarItem.EndTimeZone);
} }
@@ -1,23 +0,0 @@
using System;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Extensions;
public static class InitialSynchronizationRangeExtensions
{
public static DateTime? ToCutoffDateUtc(this InitialSynchronizationRange range, DateTime utcNow)
{
var normalizedUtcNow = utcNow.Kind == DateTimeKind.Utc
? utcNow
: utcNow.ToUniversalTime();
return range switch
{
InitialSynchronizationRange.ThreeMonths => normalizedUtcNow.AddMonths(-3),
InitialSynchronizationRange.SixMonths => normalizedUtcNow.AddMonths(-6),
InitialSynchronizationRange.NineMonths => normalizedUtcNow.AddMonths(-9),
InitialSynchronizationRange.OneYear => normalizedUtcNow.AddYears(-1),
_ => null
};
}
}
@@ -1,26 +1,10 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
namespace Wino.Core.Domain.Extensions; namespace Wino.Core.Domain.Extensions;
public static class MailHeaderExtensions public static class MailHeaderExtensions
{ {
public static string NormalizeMessageId(string value)
{
if (value == null)
return null;
var normalized = StripAngleBrackets(value)?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? string.Empty : normalized;
}
public static string ToHeaderMessageId(string value)
{
var normalized = NormalizeMessageId(value);
return string.IsNullOrEmpty(normalized) ? string.Empty : $"<{normalized}>";
}
/// <summary> /// <summary>
/// Strips angle brackets from a Message-ID or In-Reply-To value. /// Strips angle brackets from a Message-ID or In-Reply-To value.
/// RFC 5322 Message-IDs are formatted as &lt;id@domain&gt;, but MimeKit /// RFC 5322 Message-IDs are formatted as &lt;id@domain&gt;, but MimeKit
@@ -45,53 +29,14 @@ public static class MailHeaderExtensions
/// like "&lt;id1@domain&gt; &lt;id2@domain&gt;". This converts them to "id1@domain;id2@domain". /// like "&lt;id1@domain&gt; &lt;id2@domain&gt;". This converts them to "id1@domain;id2@domain".
/// </summary> /// </summary>
public static string NormalizeReferences(string rawReferences) public static string NormalizeReferences(string rawReferences)
=> JoinStoredReferences(SplitMessageIds(rawReferences));
public static IEnumerable<string> SplitMessageIds(string values)
{ {
if (string.IsNullOrWhiteSpace(values)) if (string.IsNullOrEmpty(rawReferences)) return rawReferences;
return [];
return values var ids = rawReferences
.Split(new[] { ' ', '\t', '\r', '\n', ';', ',' }, StringSplitOptions.RemoveEmptyEntries) .Split(new[] { ' ', '\t', '\r', '\n', ';', ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(NormalizeMessageId) .Select(StripAngleBrackets)
.Where(id => !string.IsNullOrEmpty(id)); .Where(id => !string.IsNullOrEmpty(id));
}
public static string JoinStoredReferences(IEnumerable<string> values) return string.Join(";", ids);
=> string.Join(";", NormalizeDistinctMessageIds(values));
public static string BuildReferencesHeaderValue(IEnumerable<string> values)
=> string.Join(" ", NormalizeDistinctMessageIds(values).Select(ToHeaderMessageId));
public static List<string> BuildReferencesChain(IEnumerable<string> existingReferences, string parentMessageId)
{
var results = NormalizeDistinctMessageIds(existingReferences).ToList();
var normalizedParentMessageId = NormalizeMessageId(parentMessageId);
if (!string.IsNullOrEmpty(normalizedParentMessageId) &&
!results.Contains(normalizedParentMessageId, StringComparer.OrdinalIgnoreCase))
{
results.Add(normalizedParentMessageId);
}
return results;
}
private static IEnumerable<string> NormalizeDistinctMessageIds(IEnumerable<string> values)
{
if (values == null)
yield break;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in values)
{
var normalized = NormalizeMessageId(value);
if (string.IsNullOrEmpty(normalized) || !seen.Add(normalized))
continue;
yield return normalized;
}
} }
} }
@@ -1,111 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MimeKit;
namespace Wino.Core.Domain.Extensions;
public static class ReadReceiptExtensions
{
public static bool HasReadReceiptRequest(this MimeMessage mimeMessage)
=> mimeMessage?.Headers?.Contains(Constants.DispositionNotificationToHeader) == true
&& !string.IsNullOrWhiteSpace(mimeMessage.Headers[Constants.DispositionNotificationToHeader]);
public static void SetReadReceiptRequest(this MimeMessage mimeMessage, string address, bool isRequested)
{
if (mimeMessage == null)
return;
mimeMessage.Headers.Remove(Constants.DispositionNotificationToHeader);
if (isRequested && !string.IsNullOrWhiteSpace(address))
{
mimeMessage.Headers.Add(Constants.DispositionNotificationToHeader, address.Trim());
}
}
public static bool LooksLikeReadReceipt(this MimeMessage mimeMessage)
{
if (mimeMessage?.Body == null)
return false;
return mimeMessage.BodyParts.Any(IsReadReceiptEntity) || IsReadReceiptEntity(mimeMessage.Body);
}
public static ReadReceiptParseResult ParseReadReceipt(this MimeMessage mimeMessage)
{
if (!mimeMessage.LooksLikeReadReceipt())
return ReadReceiptParseResult.Empty;
var entity = mimeMessage.BodyParts.FirstOrDefault(IsReadReceiptEntity) ?? mimeMessage.Body;
var lines = ReadEntityLines(entity);
string originalMessageId = null;
foreach (var line in lines)
{
if (line.StartsWith(Constants.OriginalMessageIdHeader + ":", StringComparison.OrdinalIgnoreCase))
{
originalMessageId = line.Substring(line.IndexOf(':') + 1).Trim();
break;
}
}
var acknowledgedAtUtc = mimeMessage.Date != DateTimeOffset.MinValue
? mimeMessage.Date.UtcDateTime
: (DateTime?)null;
return new ReadReceiptParseResult(
true,
MailHeaderExtensions.NormalizeMessageId(originalMessageId),
acknowledgedAtUtc);
}
private static bool IsReadReceiptEntity(MimeEntity entity)
{
if (entity?.ContentType == null)
return false;
if (entity.ContentType.MimeType.Equals("message/disposition-notification", StringComparison.OrdinalIgnoreCase))
return true;
var reportType = entity.ContentType.Parameters["report-type"];
return entity.ContentType.MimeType.Equals("multipart/report", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(reportType)
&& reportType.Equals("disposition-notification", StringComparison.OrdinalIgnoreCase);
}
private static IEnumerable<string> ReadEntityLines(MimeEntity entity)
{
if (entity is TextPart textPart)
{
return SplitLines(textPart.Text);
}
if (entity is MimePart mimePart)
{
using var memoryStream = new MemoryStream();
mimePart.Content?.DecodeTo(memoryStream);
memoryStream.Position = 0;
using var reader = new StreamReader(memoryStream);
return SplitLines(reader.ReadToEnd());
}
using var serializedStream = new MemoryStream();
entity.WriteTo(serializedStream);
serializedStream.Position = 0;
using var serializedReader = new StreamReader(serializedStream);
return SplitLines(serializedReader.ReadToEnd());
}
private static IEnumerable<string> SplitLines(string content)
=> string.IsNullOrWhiteSpace(content)
? []
: content.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries);
}
public sealed record ReadReceiptParseResult(bool IsReadReceipt, string OriginalMessageId, DateTime? AcknowledgedAtUtc)
{
public static ReadReceiptParseResult Empty { get; } = new(false, string.Empty, null);
}
@@ -10,7 +10,6 @@ public interface IAccountCalendar
string TextColorHex { get; set; } string TextColorHex { get; set; }
string BackgroundColorHex { get; set; } string BackgroundColorHex { get; set; }
bool IsPrimary { get; set; } bool IsPrimary { get; set; }
bool IsReadOnly { get; set; }
bool IsSynchronizationEnabled { get; set; } bool IsSynchronizationEnabled { get; set; }
Guid AccountId { get; set; } Guid AccountId { get; set; }
string RemoteCalendarId { get; set; } string RemoteCalendarId { get; set; }
@@ -1,43 +1,35 @@
using System.Collections.Generic; using System.Collections.Generic;
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Interfaces;
public interface IAccountMenuItem : IMenuItem public interface IAccountMenuItem : IMenuItem
{ {
bool IsEnabled { get; set; } bool IsEnabled { get; set; }
bool IsSynchronizationInProgress { get; set; }
/// <summary> /// <summary>
/// Calculated synchronization progress percentage (0-100). /// Calculated synchronization progress percentage (0-100). -1 for indeterminate.
/// </summary> /// </summary>
double SynchronizationProgress { get; } double SynchronizationProgress { get; }
/// <summary>
/// Progress value clamped for XAML progress controls.
/// </summary>
double SynchronizationProgressValue { get; }
/// <summary> /// <summary>
/// Total items to sync. 0 for indeterminate progress. /// Total items to sync. 0 for indeterminate progress.
/// </summary> /// </summary>
int TotalItemsToSync { get; set; } int TotalItemsToSync { get; set; }
/// <summary> /// <summary>
/// Remaining items to sync. /// Remaining items to sync.
/// </summary> /// </summary>
int RemainingItemsToSync { get; set; } int RemainingItemsToSync { get; set; }
/// <summary> /// <summary>
/// Current synchronization status message. /// Current synchronization status message.
/// </summary> /// </summary>
string SynchronizationStatus { get; set; } string SynchronizationStatus { get; set; }
int UnreadItemCount { get; set; } int UnreadItemCount { get; set; }
IEnumerable<MailAccount> HoldingAccounts { get; } IEnumerable<MailAccount> HoldingAccounts { get; }
void ApplySynchronizationProgress(AccountSynchronizationProgress progress);
void UpdateAccount(MailAccount account); void UpdateAccount(MailAccount account);
} }
@@ -23,20 +23,6 @@ public interface IAccountService
/// <returns>All local accounts</returns> /// <returns>All local accounts</returns>
Task<List<MailAccount>> GetAccountsAsync(); Task<List<MailAccount>> GetAccountsAsync();
/// <summary>
/// Checks whether an account with the same display name already exists.
/// </summary>
/// <param name="name">Account display name.</param>
/// <param name="excludedAccountId">Optional account id to exclude from the check.</param>
Task<bool> AccountNameExistsAsync(string name, Guid? excludedAccountId = null);
/// <summary>
/// Checks whether an account with the same primary address already exists.
/// </summary>
/// <param name="address">Primary e-mail address.</param>
/// <param name="excludedAccountId">Optional account id to exclude from the check.</param>
Task<bool> AccountAddressExistsAsync(string address, Guid? excludedAccountId = null);
/// <summary> /// <summary>
/// Returns single MailAccount /// Returns single MailAccount
/// </summary> /// </summary>
@@ -161,7 +147,6 @@ public interface IAccountService
/// <param name="accountId">Account id.</param> /// <param name="accountId">Account id.</param>
/// <returns>Primary alias for the account.</returns> /// <returns>Primary alias for the account.</returns>
Task<MailAccountAlias> GetPrimaryAccountAliasAsync(Guid accountId); Task<MailAccountAlias> GetPrimaryAccountAliasAsync(Guid accountId);
Task UpdateAliasSendCapabilityAsync(Guid accountId, string aliasAddress, AliasSendCapability capability);
Task<bool> IsAccountFocusedEnabledAsync(Guid accountId); Task<bool> IsAccountFocusedEnabledAsync(Guid accountId);
/// <summary> /// <summary>
@@ -1,10 +0,0 @@
using System.Collections.Generic;
using Wino.Core.Domain.Models.Ai;
namespace Wino.Core.Domain.Interfaces;
public interface IAiActionOptionsService
{
IReadOnlyList<AiTranslateLanguageOption> GetTranslateLanguageOptions();
IReadOnlyList<AiRewriteModeOption> GetRewriteModeOptions();
}
@@ -3,8 +3,8 @@
public interface IAuthenticatorConfig public interface IAuthenticatorConfig
{ {
string OutlookAuthenticatorClientId { get; } string OutlookAuthenticatorClientId { get; }
string[] GetOutlookScope(bool isMailAccessGranted, bool isCalendarAccessGranted); string[] OutlookScope { get; }
string GmailAuthenticatorClientId { get; } string GmailAuthenticatorClientId { get; }
string[] GetGmailScope(bool isMailAccessGranted, bool isCalendarAccessGranted); string[] GmailScope { get; }
string GmailTokenStoreIdentifier { get; } string GmailTokenStoreIdentifier { get; }
} }
@@ -1,5 +1,4 @@
using System; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
@@ -31,22 +30,12 @@ public interface IBaseSynchronizer
/// <param name="mailUniqueId">Mail unique id to check.</param> /// <param name="mailUniqueId">Mail unique id to check.</param>
bool HasPendingOperation(Guid mailUniqueId); bool HasPendingOperation(Guid mailUniqueId);
/// <summary>
/// Returns mail unique ids that currently have queued or executing operations.
/// </summary>
IReadOnlyCollection<Guid> GetPendingOperationUniqueIds();
/// <summary> /// <summary>
/// Returns whether there is an in-progress (queued or currently executing) operation for the given calendar item id. /// Returns whether there is an in-progress (queued or currently executing) operation for the given calendar item id.
/// </summary> /// </summary>
/// <param name="calendarItemId">Calendar item id to check.</param> /// <param name="calendarItemId">Calendar item id to check.</param>
bool HasPendingCalendarOperation(Guid calendarItemId); bool HasPendingCalendarOperation(Guid calendarItemId);
/// <summary>
/// Returns calendar item ids that currently have queued or executing operations.
/// </summary>
IReadOnlyCollection<Guid> GetPendingCalendarOperationIds();
/// <summary> /// <summary>
/// Synchronizes profile information with the server. /// Synchronizes profile information with the server.
/// Sender name and Profile picture are updated. /// Sender name and Profile picture are updated.
@@ -1,10 +0,0 @@
using System.Collections.Generic;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Core.Domain.Interfaces;
public interface ICalendarContextMenuItemService
{
IReadOnlyList<CalendarContextMenuItem> GetContextMenuItems(CalendarItem calendarItem);
}
@@ -18,7 +18,6 @@ public interface ICalendarService
Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar); Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar);
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar); Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar); Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
Task SetPrimaryCalendarAsync(Guid accountId, Guid accountCalendarId);
Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees); Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
/// <summary> /// <summary>
@@ -41,7 +40,6 @@ public interface ICalendarService
Task<List<CalendarEventAttendee>> GetAttendeesAsync(Guid calendarEventTrackingId); Task<List<CalendarEventAttendee>> GetAttendeesAsync(Guid calendarEventTrackingId);
Task<List<CalendarEventAttendee>> ManageEventAttendeesAsync(Guid calendarItemId, List<CalendarEventAttendee> allAttendees); Task<List<CalendarEventAttendee>> ManageEventAttendeesAsync(Guid calendarItemId, List<CalendarEventAttendee> allAttendees);
Task UpdateCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees); Task UpdateCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
Task<List<CalendarItem>> SearchCalendarItemsAsync(string searchQuery, int limit, CancellationToken cancellationToken = default);
Task<List<Reminder>> GetRemindersAsync(Guid calendarItemId); Task<List<Reminder>> GetRemindersAsync(Guid calendarItemId);
Task SaveRemindersAsync(Guid calendarItemId, List<Reminder> reminders); Task SaveRemindersAsync(Guid calendarItemId, List<Reminder> reminders);
Task SnoozeCalendarItemAsync(Guid calendarItemId, DateTime snoozedUntilLocal); Task SnoozeCalendarItemAsync(Guid calendarItemId, DateTime snoozedUntilLocal);
@@ -2,8 +2,6 @@
public interface IConfigurationService public interface IConfigurationService
{ {
bool Contains(string key);
void Set(string key, object value); void Set(string key, object value);
T Get<T>(string key, T defaultValue = default); T Get<T>(string key, T defaultValue = default);
@@ -1,26 +0,0 @@
using System;
using System.Threading.Tasks;
namespace Wino.Core.Domain.Interfaces;
/// <summary>
/// Manages contact picture files stored on disk instead of as base64 in SQLite,
/// eliminating DB bloat and enabling native WIC hardware-accelerated image loading.
/// </summary>
public interface IContactPictureFileService
{
/// <summary>
/// Returns the full file path for the given file ID, or null if the file does not exist on disk.
/// </summary>
string GetContactPicturePath(Guid fileId);
/// <summary>
/// Saves raw image bytes to disk and returns the new file ID.
/// </summary>
Task<Guid> SaveContactPictureAsync(byte[] imageData);
/// <summary>
/// Deletes the picture file for the given file ID if it exists.
/// </summary>
Task DeleteContactPictureAsync(Guid fileId);
}
+2 -18
View File
@@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using MimeKit; using MimeKit;
@@ -12,30 +11,15 @@ public interface IContactService
{ {
Task<List<AccountContact>> GetAddressInformationAsync(string queryText); Task<List<AccountContact>> GetAddressInformationAsync(string queryText);
Task<AccountContact> GetAddressInformationByAddressAsync(string address); Task<AccountContact> GetAddressInformationByAddressAsync(string address);
Task<List<AccountContact>> GetContactsByAddressesAsync(IEnumerable<string> addresses);
Task SaveAddressInformationAsync(MimeMessage message); Task SaveAddressInformationAsync(MimeMessage message);
Task SaveAddressInformationAsync(IEnumerable<AccountContact> contacts); Task SaveAddressInformationAsync(IEnumerable<AccountContact> contacts);
Task<AccountContact> CreateNewContactAsync(string address, string displayName); Task<AccountContact> CreateNewContactAsync(string address, string displayName);
// Paged contact queries for ContactsPage // New methods for ContactsPage
Task<List<AccountContact>> GetAllContactsAsync(); Task<List<AccountContact>> GetAllContactsAsync();
Task<List<AccountContact>> SearchContactsAsync(string searchQuery); Task<List<AccountContact>> SearchContactsAsync(string searchQuery);
Task<PagedContactsResult> GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false); Task<PagedContactsResult> GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false);
Task<AccountContact> UpdateContactAsync(AccountContact contact); Task<AccountContact> UpdateContactAsync(AccountContact contact);
Task DeleteContactAsync(string address); Task DeleteContactAsync(string address);
Task DeleteContactsAsync(IEnumerable<string> addresses); Task DeleteContactsAsync(IEnumerable<string> addresses);
// Group / distribution list support
Task<List<ContactGroup>> GetGroupsAsync();
Task<ContactGroup> CreateGroupAsync(string name, string description = null);
Task DeleteGroupAsync(Guid groupId);
Task<List<AccountContact>> GetGroupMembersAsync(Guid groupId);
Task AddGroupMemberAsync(Guid groupId, string memberAddress);
Task RemoveGroupMemberAsync(Guid groupId, string memberAddress);
/// <summary>
/// Expands a contact group to the individual <see cref="AccountContact"/> entries of its members.
/// Returns an empty list if the group does not exist or has no members.
/// </summary>
Task<List<AccountContact>> ExpandGroupAsync(Guid groupId);
} }
@@ -1,9 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Common; using Wino.Core.Domain.Models.Common;
using Wino.Core.Domain.Models.Printing;
namespace Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Interfaces;
@@ -27,6 +28,6 @@ public interface IDialogServiceBase
Task<AccountCreationDialogResult> ShowAccountProviderSelectionDialogAsync(List<IProviderDetail> availableProviders); Task<AccountCreationDialogResult> ShowAccountProviderSelectionDialogAsync(List<IProviderDetail> availableProviders);
IAccountCreationDialog GetAccountCreationDialog(AccountCreationDialogResult accountCreationDialogResult); IAccountCreationDialog GetAccountCreationDialog(AccountCreationDialogResult accountCreationDialogResult);
Task<List<SharedFile>> PickFilesAsync(params object[] typeFilters); Task<List<SharedFile>> PickFilesAsync(params object[] typeFilters);
Task<List<PickedFileMetadata>> PickFilesMetadataAsync(params object[] typeFilters);
Task<string> PickFilePathAsync(string saveFileName); Task<string> PickFilePathAsync(string saveFileName);
Task<WebView2PrintSettingsModel> ShowPrintDialogAsync(WebView2PrintSettingsModel initialSettings = null);
} }
@@ -1,15 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Mail;
namespace Wino.Core.Domain.Interfaces;
public interface IEmailTemplateService
{
Task<List<EmailTemplate>> GetEmailTemplatesAsync();
Task<EmailTemplate> GetEmailTemplateAsync(Guid templateId);
Task<EmailTemplate> CreateEmailTemplateAsync(EmailTemplate template);
Task<EmailTemplate> UpdateEmailTemplateAsync(EmailTemplate template);
Task<EmailTemplate> DeleteEmailTemplateAsync(EmailTemplate template);
}
@@ -14,22 +14,6 @@ public interface IFolderMenuItem : IBaseFolderMenuItem
public interface IMergedAccountFolderMenuItem : IBaseFolderMenuItem { } public interface IMergedAccountFolderMenuItem : IBaseFolderMenuItem { }
public interface IMailCategoryMenuItem : IBaseFolderMenuItem
{
Entities.Mail.MailCategory MailCategory { get; }
string TextColorHex { get; }
string BackgroundColorHex { get; }
bool HasTextColor { get; }
}
public interface IMergedMailCategoryMenuItem : IBaseFolderMenuItem
{
IReadOnlyList<Entities.Mail.MailCategory> Categories { get; }
string TextColorHex { get; }
string BackgroundColorHex { get; }
bool HasTextColor { get; }
}
public interface IBaseFolderMenuItem : IMenuItem public interface IBaseFolderMenuItem : IMenuItem
{ {
string FolderName { get; } string FolderName { get; }
@@ -22,25 +22,6 @@ public interface IFolderService
Task<int> GetFolderNotificationBadgeAsync(Guid folderId); Task<int> GetFolderNotificationBadgeAsync(Guid folderId);
Task ChangeStickyStatusAsync(Guid folderId, bool isSticky); Task ChangeStickyStatusAsync(Guid folderId, bool isSticky);
/// <summary>
/// Toggles a folder's visibility in the navigation menu.
/// Hidden folders are still synchronized if sync is enabled.
/// </summary>
Task ChangeFolderHiddenStatusAsync(Guid folderId, bool isHidden);
/// <summary>
/// Persists a new custom ordering for the given folders.
/// The first id becomes Order=1, second Order=2, etc.
/// Caller is responsible for notifying the shell to refresh.
/// </summary>
Task UpdateFolderOrdersAsync(Guid accountId, IReadOnlyList<Guid> orderedFolderIds);
/// <summary>
/// Wipes every user folder customization for the account: clears custom Order,
/// un-hides folders, and restores IsSticky on system folders.
/// </summary>
Task ResetFolderCustomizationAsync(Guid accountId);
Task<MailAccount> UpdateSystemFolderConfigurationAsync(Guid accountId, SystemFolderConfiguration configuration); Task<MailAccount> UpdateSystemFolderConfigurationAsync(Guid accountId, SystemFolderConfiguration configuration);
Task ChangeFolderSynchronizationStateAsync(Guid folderId, bool isSynchronizationEnabled); Task ChangeFolderSynchronizationStateAsync(Guid folderId, bool isSynchronizationEnabled);
Task ChangeFolderShowUnreadCountStateAsync(Guid folderId, bool showUnreadCount); Task ChangeFolderShowUnreadCountStateAsync(Guid folderId, bool showUnreadCount);

Some files were not shown because too many files have changed in this diff Show More