22 Commits

Author SHA1 Message Date
Burak Kaan Köse 133f768af4 Remove commented code. 2025-02-03 19:15:54 +01:00
Burak Kaan Köse 380883a3d5 Remove smtp encoding constraint. 2025-02-03 19:13:56 +01:00
Burak Kaan Köse 662ce99f94 Fixed an issue where disabled folders causing an exception on forced sync. 2025-02-02 17:53:41 +01:00
Burak Kaan Köse 006404aa87 Fix icons for yahoo and apple. 2025-02-02 17:53:24 +01:00
Burak Kaan Köse 42070a2e5b merge main 2025-02-02 15:48:47 +01:00
Burak Kaan Köse 6fcfb7ff7b Remove ARM32. Upgrade server to .NET 9. 2025-02-02 15:34:42 +01:00
Burak Kaan Köse 616b56f4bd Server crash handlers. 2025-02-01 23:15:01 +01:00
Burak Kaan Köse 16272fa049 NRE on canceled token accounts during setup. 2025-02-01 20:43:55 +01:00
Burak Kaan Köse 3021850fa0 Fixing the build issue.... 2025-02-01 20:25:22 +01:00
Burak Kaan Köse bb2dbe1457 Bump google sdk package. 2025-01-30 19:33:28 +01:00
Burak Kaan Köse 62fc21be7e Yahoo custom settings. 2025-01-29 18:55:54 +01:00
Burak Kaan Köse 50d0ebc6c8 Fixing remote highest mode seq checks for qresync and condstore synchronizers. 2025-01-29 16:12:10 +01:00
Burak Kaan Köse ad900d596c Update mailkit to resolve qresync bug with iCloud. 2025-01-29 16:06:31 +01:00
Burak Kaan Köse 7d8da33f42 Delegating idle synchronizations to server to post-sync operations. 2025-01-28 23:57:09 +01:00
Burak Kaan Köse b73eb3efcb Bumping some nugets. More on the imap synchronizers. 2025-01-28 22:56:19 +01:00
Burak Kaan Köse b7b51ac4e6 Batching condstore downloads into 50, using SORT extension for searches if supported. 2025-01-25 00:58:40 +01:00
Burak Kaan Köse bf1480705d Update privacy policy url. 2025-01-25 00:57:49 +01:00
Burak Kaan Köse 973ab1570d Support for killing synchronizers. 2025-01-25 00:00:10 +01:00
Burak Kaan Köse 20010e77ae iCloud special imap handling. 2025-01-21 23:57:58 +01:00
Burak Kaan Köse 05280dfd42 Adding iCloud and Yahoo as special IMAP handling scenario. 2025-01-19 23:52:27 +01:00
Burak Kaan Köse e0f87f1374 IDLE implementation, imap synchronization strategies basics and condstore synchronization. 2025-01-19 20:35:41 +01:00
Burak Kaan Köse 46cbf443cf Fixing an issue where scrollviewer overrides a part of template in mail list. Adjusted zoomed out header grid's corner radius. 2025-01-19 20:35:12 +01:00
1848 changed files with 55670 additions and 133868 deletions
+2 -7
View File
@@ -8,9 +8,6 @@ dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = crlf
[XamlTypeInfo.g.cs]
dotnet_diagnostic.CS0612.severity = none
dotnet_diagnostic.CS0618.severity = none
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
@@ -152,7 +149,7 @@ csharp_preferred_modifier_order = public,private,protected,internal,static,exter
# Code-block preferences
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = file_scoped:error
csharp_style_namespace_declarations = block_scoped:silent
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
@@ -290,6 +287,4 @@ csharp_style_prefer_top_level_statements = true:silent
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_prefer_readonly_struct = true:suggestion
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
csharp_style_prefer_primary_constructors = true:silent
csharp_prefer_system_threading_lock = true:suggestion
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
-191
View File
@@ -1,191 +0,0 @@
# Copilot Instructions for Wino-Mail Project
## Project Overview
Wino Mail is a native Windows mail client targeting Windows 10 1809+ and Windows 11. The project is **transitioning from UWP to WinUI 3** - always work with WinUI projects (Wino.Mail.WinUI, Wino.Core.WinUI), never edit the old Wino.Mail UWP project.
### Key Technologies
- **WinUI 3** for UI (previously UWP/WinUI 2)
- **MVVM Toolkit** (CommunityToolkit.Mvvm) for ViewModels with source generators
- **Messenger** pattern (WeakReferenceMessenger.Default) for event pub-sub throughout the codebase
- **SQLite** database stored in publisher cache folder (not local storage)
- **WebView2** for mail rendering/composition with custom HTML/JavaScript editors
- **MimeKit/MailKit** for IMAP/SMTP operations
- **Microsoft Graph SDK** for Outlook synchronization
- **Gmail API** for Gmail synchronization
### 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.Mail.ViewModels → Mail-specific ViewModels
Wino.Core.ViewModels → Shared ViewModels (settings, personalization)
Wino.Mail.WinUI → **Active WinUI 3 UI project** (use this)
Wino.Mail → **Deprecated UWP project** (DO NOT EDIT)
```
## Architecture Patterns
### 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** (OutlookChangeProcessor, etc.) → Apply changes to local database
5. Database updates trigger **Messenger** events (MailAddedMessage, MailUpdatedMessage, etc.)
### Queue-Based Sync (New Pattern - See QUEUE_SYNC_IMPLEMENTATION.md)
- Initial sync now queues mail IDs first (MailItemQueue table), downloads metadata only (no MIME)
- MIME content downloaded on-demand when user opens mail
- Synchronizers override `QueueMailIdsForInitialSyncAsync()`, `DownloadMailsFromQueueAsync()`, `CreateMinimalMailCopyAsync()`
- Check `MailItemFolder.IsInitialSyncCompleted` to determine sync state
### Dependency Injection Setup
Services registered in extension methods across projects:
- `RegisterCoreServices()` in Wino.Core/CoreContainerSetup.cs
- `RegisterSharedServices()` in Wino.Services/ServicesContainerSetup.cs
- `RegisterCoreUWPServices()` in CoreUWPContainerSetup.cs
- ViewModels registered in App.xaml.cs with AddTransient/AddSingleton
### Messenger Pattern (Event Pub-Sub)
- All ViewModels inherit from CoreBaseViewModel or MailBaseViewModel which implement IRecipient<T>
- Register/unregister message handlers in `RegisterRecipients()` / `UnregisterRecipients()`
- Send messages via `WeakReferenceMessenger.Default.Send(new MessageType(...))`
- Common messages: MailAddedMessage, MailUpdatedMessage, NavigationRequested, ThemeChanged
## ViewModels Development Guidelines
### Observable Properties - Critical Pattern
- **ALWAYS** use `public partial` observable properties with MVVM Toolkit source generators
- **NEVER** use private fields with `[ObservableProperty]` attribute
- **Correct:**
```csharp
[ObservableProperty]
public partial string SearchQuery { get; set; } = string.Empty;
```
- **Incorrect:**
```csharp
[ObservableProperty]
private string searchQuery = string.Empty; // WRONG - will not work
```
### ViewModels Structure
- Inherit from MailBaseViewModel (for mail features) or CoreBaseViewModel (for shared features)
- Use `[RelayCommand]` for command methods - source generator creates Command properties
- Implement IRecipient<TMessage> for message handlers
- Use `IMailDialogService` for Mail-related dialogs, `IDialogServiceBase` for core dialogs
- Call `RegisterRecipients()` in constructor/OnNavigatedTo, `UnregisterRecipients()` in OnNavigatedFrom
## Localization System
### Translation Workflow (Custom T4-based System)
1. Add English strings ONLY to `Wino.Core.Domain/Translations/en_US/resources.json`
2. Build the project - source generators automatically create Translator properties
3. Use `Translator.{PropertyName}` in ViewModels, XAML (with x:Bind, OneTime mode)
4. **NEVER** edit other language files - Crowdin manages translations automatically
5. **NEVER** hardcode user-facing strings
### Usage Examples
```csharp
// ViewModel
_dialogService.InfoBarMessage(Translator.Info_MissingFolderTitle, message);
// XAML
<TextBlock Text="{x:Bind Translator.Settings_Title, Mode=OneTime}" />
```
## UI Data Binding and Converters
### WinUI 3 Automatic Conversions
- **NEVER** create IValueConverter classes or add them to Converters.xaml
- **NEVER** use BoolToVisibilityConverter - WinUI 3 SDK automatically converts bool to Visibility
- 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
- **ALWAYS** use XamlHelpers static methods instead of converters
- Add xmlns: `xmlns:helpers="using:Wino.Helpers"`
- Usage: `{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(PropertyName), Mode=OneWay}`
- Available methods: ReverseBoolToVisibilityConverter, CountToBooleanConverter, BoolToSelectionMode, Base64ToBitmapImage
- Add new methods to XamlHelpers.cs when needed, don't create converters
## WebView2 Mail Rendering
### Architecture
- **reader.html** (Wino.Mail.WinUI/JS/) for reading mails
- **editor.html** for composing mails (uses Jodit editor, not Quill as originally planned)
- WebView2 uses virtual host mapping: `https://wino.mail/reader.html`
- JavaScript interop via `ExecuteScriptFunctionAsync()` to call functions like `RenderHTML()`
- MIME content downloaded on-demand, not during sync
### Key Patterns
- Set environment variables for WebView2 before initialization (overlay scrollbars, cache)
- Wait for DOMContentLoaded event before script execution
- Handle theme changes by updating editor CSS dynamically
- Cancel external navigation, open in browser via Launcher.LaunchUriAsync()
## File Structure and Project Organization
### Critical Rules
- **NEVER** edit files in Wino.Mail (UWP) project - it's deprecated
- **ALWAYS** work with Wino.Mail.WinUI for UI components
- Place ViewModels in Wino.Mail.ViewModels (mail-specific) or Wino.Core.ViewModels (shared)
- Create abstract base classes in Views/Abstract folders
- Mail-specific dialog services go in Wino.Mail.WinUI/Services
### Database and Storage
- SQLite database in publisher cache folder (not app local storage)
- EML files stored in app local storage, referenced by MailCopy.FileId
- Paths resolved via MimeFileService.GetMimeMessagePath()
- Database entities in Wino.Core.Domain/Entities
## Error Handling and User Feedback
### Exception Handling Patterns
```csharp
try {
await operation();
} catch (UnavailableSpecialFolderException ex) {
_dialogService.InfoBarMessage(title, message, InfoBarMessageType.Warning, buttonText, action);
} catch (NotImplementedException) {
_dialogService.ShowNotSupportedMessage();
}
```
### Dialog Service Methods
- `InfoBarMessage()` - simple notifications with optional action button
- `ShowConfirmationDialogAsync()` - yes/no dialogs
- `PickFilesAsync()` - file selection
- Always check for null/empty results from dialog operations
## Code Style and Best Practices
- Use `var` where type is obvious from right side
- String interpolation over string.Format for simple cases
- Keep methods focused and single-responsibility
- Add XML documentation for public APIs
- Avoid introducing new NuGet packages - maximize use of existing libraries
- Wrap async operations in try-catch blocks
- Log errors via IWinoLogger but don't expose technical details to users
## Development Workflow
### Building and Running
- Open WinoMail.slnx in Visual Studio 2022+
- Target platforms: x86, x64, ARM64 (ARM32 being phased out)
- Minimum: Windows 10 1809, Target: Windows 11 22H2
- Set Wino.Mail.WinUI as startup project
### Testing
- Test suite in Wino.Core.Tests
- Manual testing required for UI/WebView2 interactions
- Test synchronization with real accounts when modifying synchronizers
### Common Pitfalls
- Forgetting to register ViewModels in App.xaml.cs RegisterViewModels()
- Not calling RegisterRecipients() for message handlers
- Using private fields with [ObservableProperty] (won't work - must be 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)
-125
View File
@@ -1,125 +0,0 @@
name: PR WinUI Build
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
permissions:
contents: read
packages: read
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
source-url: https://nuget.pkg.github.com/bkaankose/index.json
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- 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
source-url: https://nuget.pkg.github.com/bkaankose/index.json
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- 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
@@ -399,4 +399,3 @@ Wino/obj/x86/Debug/XamlSaveStateFile.xml
Wino/obj/x86/Debug/XamlSaveStateFile.xml
*.cache
.vs/Wino/v16/.suo
/.claude/settings.local.json
-161
View File
@@ -1,161 +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)`).
- 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`.
-7
View File
@@ -1,7 +0,0 @@
<Project>
<PropertyGroup>
<LangVersion>preview</LangVersion>
<IsAotCompatible>true</IsAotCompatible>
<Configurations>Debug;Release</Configurations>
</PropertyGroup>
</Project>
-80
View File
@@ -1,80 +0,0 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="ColorHashSharp" Version="1.1.0" />
<PackageVersion Include="CommunityToolkit.Common" Version="8.4.2" />
<PackageVersion Include="CommunityToolkit.Diagnostics" Version="8.4.2" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="CommunityToolkit.WinUI.Animations" 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.Sizers" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Controls.TabbedCommandBar" 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.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.250926-build.2293" />
<PackageVersion Include="CommunityToolkit.WinUI.Lottie" Version="8.2.250604" />
<PackageVersion Include="CommunityToolkit.Labs.WinUI.DependencyPropertyGenerator" Version="0.1.250926-build.2293" />
<PackageVersion Include="EmailValidation" Version="1.3.0" />
<PackageVersion Include="gravatar-dotnet" Version="0.1.3" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="Ical.Net" Version="5.2.1" />
<PackageVersion Include="IsExternalInit" Version="1.0.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageVersion Include="Microsoft.Graph" Version="5.103.0" />
<PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.4.0" />
<PackageVersion Include="Microsoft.Identity.Client" Version="4.83.3" />
<PackageVersion Include="Microsoft.Identity.Client.Broker" Version="4.83.3" />
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.3" />
<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.1" />
<PackageVersion Include="Wino.Mail.Contracts" Version="1.0.18" />
<PackageVersion Include="MimeKit" Version="4.15.1" />
<PackageVersion Include="morelinq" Version="4.4.0" />
<PackageVersion Include="Nito.AsyncEx" Version="5.1.2" />
<PackageVersion Include="Nito.AsyncEx.Tasks" Version="5.1.2" />
<PackageVersion Include="NodaTime" Version="3.3.1" />
<PackageVersion Include="Sentry.Serilog" Version="6.3.1" />
<PackageVersion Include="Serilog" Version="4.3.1" />
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.ApplicationInsights" Version="4.0.0" />
<PackageVersion Include="SkiaSharp" Version="3.119.2" />
<PackageVersion Include="SkiaSharp.Views.WinUI" Version="3.119.2" />
<PackageVersion Include="sqlite-net-pcl" Version="1.10.196-beta" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.5" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.3.0" />
<PackageVersion Include="H.NotifyIcon.WinUI" Version="2.4.1" />
<PackageVersion Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" />
<PackageVersion Include="Google.Apis.Auth" Version="1.73.0" />
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.73.0.4073" />
<PackageVersion Include="Google.Apis.Drive.v3" Version="1.73.0.4098" />
<PackageVersion Include="Google.Apis.Gmail.v1" Version="1.73.0.4029" />
<PackageVersion Include="Google.Apis.PeopleService.v1" Version="1.72.0.3973" />
<PackageVersion Include="HtmlKit" Version="1.2.0" />
<PackageVersion Include="MailKit" Version="4.15.1" />
<PackageVersion Include="TimePeriodLibrary.NET" Version="2.1.6" />
<PackageVersion Include="System.Reactive" Version="6.1.0" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.5" />
<PackageVersion Include="System.Text.Encodings.Web" Version="10.0.5" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.250930001-experimental1" />
<PackageVersion Include="WinUIEx" Version="2.9.0" />
<!-- Testing packages -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="FluentAssertions" Version="8.9.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
</ItemGroup>
</Project>
+21 -30
View File
@@ -1,54 +1,45 @@
<p align="center">
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true&mode=full">
<img src="https://www.winomail.app/images/v2/Logo.png" width="90" height="90" alt="Wino Mail logo">
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true
&mode=full">
<img src="https://www.winomail.app/images/wino_logo.png" width=90 height=90>
</a>
<h3 align="center">Wino Mail</h3>
<p align="center">
Native mail and calendar client for Windows.
Native mail client for Windows device families.
</p>
</p>
<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
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.
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
## Features
- 📨 Outlook and Gmail API integration
- 🌐 IMAP/SMTP support for custom mail servers
- 📅 Calendar support with event creation/compose and reminders
- 👥 Contact management and people-centric account experience
- ✅ Core mail actions: send, receive, read/unread, move, spam, and more
- 🔗 Linked/Merged accounts
- 🔔 Toast notifications with background sync
- ⚡ Instant startup-oriented architecture
- 🔎 Offline-capable workflows and search improvements
- 🎛️ Modern responsive WinUI interface with personalization options
- 🌗 Dark/Light mode for mail reader and app surfaces
- API integration for Outlook and Gmail
- IMAP/SMTP support for custom mail servers
- Send, receive, mark as (read,important,spam etc), move mails.
- Linked/Merged Accounts
- Toast notifications with background sync.
- Instant startup performance
- Offline use / search.
- Modern and responsive UI
- Lots of personalization options
- Dark / Light mode for mail reader
## Download
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">
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200" alt="Get Wino Mail from Microsoft Store"/>
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true
&mode=full">
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200"/>
</a>
## 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)
## 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.
@@ -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 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.
+9 -8
View File
@@ -1,16 +1,17 @@
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Authentication;
public abstract class BaseAuthenticator
namespace Wino.Authentication
{
public abstract MailProviderType ProviderType { get; }
protected IAuthenticatorConfig AuthenticatorConfig { get; }
protected BaseAuthenticator(IAuthenticatorConfig authenticatorConfig)
public abstract class BaseAuthenticator
{
public abstract MailProviderType ProviderType { get; }
protected IAuthenticatorConfig AuthenticatorConfig { get; }
AuthenticatorConfig = authenticatorConfig;
protected BaseAuthenticator(IAuthenticatorConfig authenticatorConfig)
{
AuthenticatorConfig = authenticatorConfig;
}
}
}
+33 -32
View File
@@ -7,44 +7,45 @@ using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Authentication;
namespace Wino.Authentication;
public class GmailAuthenticator : BaseAuthenticator, IGmailAuthenticator
namespace Wino.Authentication
{
public GmailAuthenticator(IAuthenticatorConfig authConfig) : base(authConfig)
public class GmailAuthenticator : BaseAuthenticator, IGmailAuthenticator
{
}
public string ClientId => AuthenticatorConfig.GmailAuthenticatorClientId;
public bool ProposeCopyAuthURL { get; set; }
public override MailProviderType ProviderType => MailProviderType.Gmail;
/// <summary>
/// Generates the token information for the given account.
/// For gmail, interactivity is automatically handled when you get the token.
/// </summary>
/// <param name="account">Account to get token for.</param>
public Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
=> GetTokenInformationAsync(account);
public async Task<TokenInformationEx> GetTokenInformationAsync(MailAccount account)
{
var userCredential = await GetGoogleUserCredentialAsync(account);
if (userCredential.Token.IsStale)
public GmailAuthenticator(IAuthenticatorConfig authConfig) : base(authConfig)
{
await userCredential.RefreshTokenAsync(CancellationToken.None);
}
return new TokenInformationEx(userCredential.Token.AccessToken, account.Address);
}
public string ClientId => AuthenticatorConfig.GmailAuthenticatorClientId;
public bool ProposeCopyAuthURL { get; set; }
private Task<UserCredential> GetGoogleUserCredentialAsync(MailAccount account)
{
return GoogleWebAuthorizationBroker.AuthorizeAsync(new ClientSecrets()
public override MailProviderType ProviderType => MailProviderType.Gmail;
/// <summary>
/// Generates the token information for the given account.
/// For gmail, interactivity is automatically handled when you get the token.
/// </summary>
/// <param name="account">Account to get token for.</param>
public Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
=> GetTokenInformationAsync(account);
public async Task<TokenInformationEx> GetTokenInformationAsync(MailAccount account)
{
ClientId = ClientId
}, AuthenticatorConfig.GmailScope, account.Id.ToString(), CancellationToken.None, new FileDataStore(AuthenticatorConfig.GmailTokenStoreIdentifier));
var userCredential = await GetGoogleUserCredentialAsync(account);
if (userCredential.Token.IsStale)
{
await userCredential.RefreshTokenAsync(CancellationToken.None);
}
return new TokenInformationEx(userCredential.Token.AccessToken, account.Address);
}
private Task<UserCredential> GetGoogleUserCredentialAsync(MailAccount account)
{
return GoogleWebAuthorizationBroker.AuthorizeAsync(new ClientSecrets()
{
ClientId = ClientId
}, AuthenticatorConfig.GmailScope, account.Id.ToString(), CancellationToken.None, new FileDataStore(AuthenticatorConfig.GmailTokenStoreIdentifier));
}
}
}
+85 -108
View File
@@ -11,139 +11,116 @@ using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Authentication;
namespace Wino.Authentication;
public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
namespace Wino.Authentication
{
private const string TokenCacheFileName = "OutlookCache.bin";
private bool isTokenCacheAttached = false;
// Outlook
private const string Authority = "https://login.microsoftonline.com/common";
public override MailProviderType ProviderType => MailProviderType.Outlook;
private readonly IPublicClientApplication _publicClientApplication;
private readonly INativeAppService _nativeAppService;
private readonly IApplicationConfiguration _applicationConfiguration;
public OutlookAuthenticator(INativeAppService nativeAppService,
IApplicationConfiguration applicationConfiguration,
IAuthenticatorConfig authenticatorConfig) : base(authenticatorConfig)
public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
{
_nativeAppService = nativeAppService;
_applicationConfiguration = applicationConfiguration;
private const string TokenCacheFileName = "OutlookCache.bin";
private bool isTokenCacheAttached = false;
var authenticationRedirectUri = nativeAppService.GetWebAuthenticationBrokerUri();
// Outlook
private const string Authority = "https://login.microsoftonline.com/common";
var options = new BrokerOptions(BrokerOptions.OperatingSystems.Windows)
public override MailProviderType ProviderType => MailProviderType.Outlook;
private readonly IPublicClientApplication _publicClientApplication;
private readonly IApplicationConfiguration _applicationConfiguration;
public OutlookAuthenticator(INativeAppService nativeAppService,
IApplicationConfiguration applicationConfiguration,
IAuthenticatorConfig authenticatorConfig) : base(authenticatorConfig)
{
Title = "Wino Mail",
ListOperatingSystemAccounts = true,
};
_applicationConfiguration = applicationConfiguration;
PublicClientApplicationBuilder outlookAppBuilder = null;
var authenticationRedirectUri = nativeAppService.GetWebAuthenticationBrokerUri();
// Being created from an app notification.
// This is where we avoid all interactive shit for authentication.
if (nativeAppService.GetCoreWindowHwnd == null)
{
outlookAppBuilder = PublicClientApplicationBuilder.Create(AuthenticatorConfig.OutlookAuthenticatorClientId)
.WithDefaultRedirectUri()
var options = new BrokerOptions(BrokerOptions.OperatingSystems.Windows)
{
Title = "Wino Mail",
ListOperatingSystemAccounts = true,
};
var outlookAppBuilder = PublicClientApplicationBuilder.Create(AuthenticatorConfig.OutlookAuthenticatorClientId)
.WithParentActivityOrWindow(nativeAppService.GetCoreWindowHwnd)
.WithBroker(options)
.WithAuthority(Authority);
}
else
{
outlookAppBuilder = PublicClientApplicationBuilder.Create(AuthenticatorConfig.OutlookAuthenticatorClientId)
.WithBroker(options)
.WithParentActivityOrWindow(_nativeAppService.GetCoreWindowHwnd)
.WithDefaultRedirectUri()
.WithAuthority(Authority);
_publicClientApplication = outlookAppBuilder.Build();
}
_publicClientApplication = outlookAppBuilder.Build();
}
public string[] Scope => AuthenticatorConfig.OutlookScope;
public string[] Scope => AuthenticatorConfig.OutlookScope;
private async Task EnsureTokenCacheAttachedAsync()
{
if (!isTokenCacheAttached)
private async Task EnsureTokenCacheAttachedAsync()
{
var storageProperties = new StorageCreationPropertiesBuilder(TokenCacheFileName, _applicationConfiguration.PublisherSharedFolderPath).Build();
var msalcachehelper = await MsalCacheHelper.CreateAsync(storageProperties);
msalcachehelper.RegisterCache(_publicClientApplication.UserTokenCache);
if (!isTokenCacheAttached)
{
var storageProperties = new StorageCreationPropertiesBuilder(TokenCacheFileName, _applicationConfiguration.PublisherSharedFolderPath).Build();
var msalcachehelper = await MsalCacheHelper.CreateAsync(storageProperties);
msalcachehelper.RegisterCache(_publicClientApplication.UserTokenCache);
isTokenCacheAttached = true;
isTokenCacheAttached = true;
}
}
}
public async Task<TokenInformationEx> GetTokenInformationAsync(MailAccount account)
{
await EnsureTokenCacheAttachedAsync();
var storedAccount = (await _publicClientApplication.GetAccountsAsync()).FirstOrDefault(
a => string.Equals(a.Username?.Trim(), account.Address?.Trim(), StringComparison.OrdinalIgnoreCase));
if (storedAccount == null)
return await GenerateTokenInformationAsync(account);
try
{
var authResult = await _publicClientApplication.AcquireTokenSilent(Scope, storedAccount).ExecuteAsync();
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
}
catch (MsalUiRequiredException)
{
// Somehow MSAL is not able to refresh the token silently.
// Force interactive login which will include calendar scopes.
// The calling code should update account.IsCalendarAccessGranted = true after successful authentication.
return await GenerateTokenInformationAsync(account);
}
catch (Exception)
{
throw;
}
}
public async Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
{
try
public async Task<TokenInformationEx> GetTokenInformationAsync(MailAccount account)
{
await EnsureTokenCacheAttachedAsync();
// Interactive authentication required but window doesn't exist.
// This can happen when being called from a notification background task and the token is expired.
// Force account attention;
var storedAccount = (await _publicClientApplication.GetAccountsAsync()).FirstOrDefault(a => a.Username == account.Address);
if (_nativeAppService.GetCoreWindowHwnd == null) throw new AuthenticationAttentionException(account);
if (storedAccount == null)
return await GenerateTokenInformationAsync(account);
AuthenticationResult authResult = await _publicClientApplication
.AcquireTokenInteractive(Scope)
.ExecuteAsync();
// If the account is null, it means it's the initial creation of it.
// 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))
try
{
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.");
var authResult = await _publicClientApplication.AcquireTokenSilent(Scope, storedAccount).ExecuteAsync();
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
}
catch (MsalUiRequiredException)
{
// Somehow MSAL is not able to refresh the token silently.
// Force interactive login.
return await GenerateTokenInformationAsync(account);
}
catch (Exception)
{
throw;
}
}
public async Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
{
try
{
await EnsureTokenCacheAttachedAsync();
var authResult = await _publicClientApplication
.AcquireTokenInteractive(Scope)
.ExecuteAsync();
// If the account is null, it means it's the initial creation of it.
// 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.");
}
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
}
catch (MsalClientException msalClientException)
{
if (msalClientException.ErrorCode == "authentication_canceled" || msalClientException.ErrorCode == "access_denied")
throw new AccountSetupCanceledException();
throw;
}
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
throw new AuthenticationException(Translator.Exception_UnknowErrorDuringAuthentication, new Exception(Translator.Exception_TokenGenerationFailed));
}
catch (MsalClientException msalClientException)
{
if (msalClientException.ErrorCode == "authentication_canceled" || msalClientException.ErrorCode == "access_denied")
throw new AccountSetupCanceledException();
throw;
}
throw new AuthenticationException(Translator.Exception_UnknowErrorDuringAuthentication, new Exception(Translator.Exception_TokenGenerationFailed));
}
}
+16 -16
View File
@@ -1,26 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<TargetFramework>netstandard2.0</TargetFramework>
<RootNamespace>Wino.Authentication</RootNamespace>
<Platforms>x86;x64;arm64</Platforms>
<Configurations>Debug;Release</Configurations>
<LangVersion>12</LangVersion>
<Platforms>AnyCPU;x64;x86</Platforms>
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Diagnostics" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Google.Apis.Auth" />
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="Microsoft.Identity.Client.Broker" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" />
<PackageReference Include="Sentry.Serilog" />
<PackageReference Include="CommunityToolkit.Diagnostics" Version="8.3.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
<PackageReference Include="Google.Apis.Auth" Version="1.68.0" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.66.2" />
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.66.2" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.66.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
<ProjectReference Include="..\Wino.Messages\Wino.Messaging.csproj" />
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
<ProjectReference Include="..\Wino.Messages\Wino.Messaging.csproj" />
</ItemGroup>
</Project>
</Project>
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.Toolkit.Uwp.Notifications;
using Windows.ApplicationModel;
using Windows.ApplicationModel.Background;
namespace Wino.BackgroundTasks
{
/// <summary>
/// Creates a toast notification to notify user when the Store update happens.
/// </summary>
public sealed class AppUpdatedTask : IBackgroundTask
{
public void Run(IBackgroundTaskInstance taskInstance)
{
var def = taskInstance.GetDeferral();
var builder = new ToastContentBuilder();
builder.SetToastScenario(ToastScenario.Default);
Package package = Package.Current;
PackageId packageId = package.Id;
PackageVersion version = packageId.Version;
var versionText = string.Format("{0}.{1}.{2}.{3}", version.Major, version.Minor, version.Build, version.Revision);
// TODO: Handle with Translator, but it's not initialized here yet.
builder.AddText("Wino Mail is updated!");
builder.AddText(string.Format("New version {0} is ready.", versionText));
builder.Show();
def.Complete();
}
}
}
@@ -0,0 +1,29 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("Wino.BackgroundTasks")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Wino.BackgroundTasks")]
[assembly: AssemblyCopyright("Copyright © 2023")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: ComVisible(false)]
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{D9EF0F59-F5F2-4D6C-A5BA-84043D8F3E08}</ProjectGuid>
<OutputType>winmdobj</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>Wino.BackgroundTasks</RootNamespace>
<AssemblyName>Wino.BackgroundTasks</AssemblyName>
<DefaultLanguage>en-US</DefaultLanguage>
<TargetPlatformIdentifier>UAP</TargetPlatformIdentifier>
<TargetPlatformVersion Condition=" '$(TargetPlatformVersion)' == '' ">10.0.22621.0</TargetPlatformVersion>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<MinimumVisualStudioVersion>14</MinimumVisualStudioVersion>
<FileAlignment>512</FileAlignment>
<ProjectTypeGuids>{A5A43C5B-DE2A-4C0C-9213-0A381AF9435A};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<AllowCrossPlatformRetargeting>false</AllowCrossPlatformRetargeting>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'">
<PlatformTarget>x86</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<OutputPath>bin\x86\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
<NoWarn>;2008</NoWarn>
<DebugType>full</DebugType>
<UseVSHostingProcess>false</UseVSHostingProcess>
<ErrorReport>prompt</ErrorReport>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'">
<PlatformTarget>x86</PlatformTarget>
<OutputPath>bin\x86\Release\</OutputPath>
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
<Optimize>true</Optimize>
<NoWarn>;2008</NoWarn>
<DebugType>pdbonly</DebugType>
<UseVSHostingProcess>false</UseVSHostingProcess>
<ErrorReport>prompt</ErrorReport>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|ARM64'">
<PlatformTarget>ARM64</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<OutputPath>bin\ARM64\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
<NoWarn>;2008</NoWarn>
<DebugType>full</DebugType>
<UseVSHostingProcess>false</UseVSHostingProcess>
<ErrorReport>prompt</ErrorReport>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|ARM64'">
<PlatformTarget>ARM64</PlatformTarget>
<OutputPath>bin\ARM64\Release\</OutputPath>
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
<Optimize>true</Optimize>
<NoWarn>;2008</NoWarn>
<DebugType>pdbonly</DebugType>
<UseVSHostingProcess>false</UseVSHostingProcess>
<ErrorReport>prompt</ErrorReport>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'">
<PlatformTarget>x64</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<OutputPath>bin\x64\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
<NoWarn>;2008</NoWarn>
<DebugType>full</DebugType>
<UseVSHostingProcess>false</UseVSHostingProcess>
<ErrorReport>prompt</ErrorReport>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'">
<PlatformTarget>x64</PlatformTarget>
<OutputPath>bin\x64\Release\</OutputPath>
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
<Optimize>true</Optimize>
<NoWarn>;2008</NoWarn>
<DebugType>pdbonly</DebugType>
<UseVSHostingProcess>false</UseVSHostingProcess>
<ErrorReport>prompt</ErrorReport>
</PropertyGroup>
<PropertyGroup>
<RestoreProjectStyle>PackageReference</RestoreProjectStyle>
</PropertyGroup>
<ItemGroup>
<Compile Include="AppUpdatedTask.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Identity.Client">
<Version>4.66.2</Version>
</PackageReference>
<PackageReference Include="Microsoft.NETCore.UniversalWindowsPlatform">
<Version>6.2.14</Version>
</PackageReference>
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications">
<Version>7.1.3</Version>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj">
<Project>{CF3312E5-5DA0-4867-9945-49EA7598AF1F}</Project>
<Name>Wino.Core.Domain</Name>
</ProjectReference>
<ProjectReference Include="..\Wino.Core.UWP\Wino.Core.UWP.csproj">
<Project>{395f19ba-1e42-495c-9db5-1a6f537fccb8}</Project>
<Name>Wino.Core.UWP</Name>
</ProjectReference>
<ProjectReference Include="..\Wino.Core\Wino.Core.csproj">
<Project>{e6b1632a-8901-41e8-9ddf-6793c7698b0b}</Project>
<Name>Wino.Core</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<SDKReference Include="WindowsDesktop, Version=10.0.22621.0">
<Name>Windows Desktop Extensions for the UWP</Name>
</SDKReference>
</ItemGroup>
<PropertyGroup Condition=" '$(VisualStudioVersion)' == '' or '$(VisualStudioVersion)' &lt; '14.0' ">
<VisualStudioVersion>14.0</VisualStudioVersion>
</PropertyGroup>
<Import Project="$(MSBuildExtensionsPath)\Microsoft\WindowsXaml\v$(VisualStudioVersion)\Microsoft.Windows.UI.Xaml.CSharp.targets" />
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
Other similar extension points exist, see Microsoft.Common.targets.
<Target Name="BeforeBuild">
</Target>
<Target Name="AfterBuild">
</Target>
-->
</Project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity
Name="58272BurakKSE.WinoCalendar"
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
Version="1.0.0.0" />
<Properties>
<DisplayName>Wino Calendar</DisplayName>
<PublisherDisplayName>Burak KÖSE</PublisherDisplayName>
<Logo>Images\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.0.0" MaxVersionTested="10.0.0.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.14393.0" MaxVersionTested="10.0.14393.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate"/>
</Resources>
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="Wino Calendar"
Description="Wino.Calendar.Packaging"
BackgroundColor="transparent"
Square150x150Logo="Images\Square150x150Logo.png"
Square44x44Logo="Images\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Images\Wide310x150Logo.png" />
<uap:SplashScreen Image="Images\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<!-- Registration of full trust backend application. -->
<uap:Extension Category="windows.appService">
<uap:AppService Name="WinoInteropService" />
</uap:Extension>
<!-- Protocol activation: Google OAuth -->
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="google.pw.oauth2">
<uap:DisplayName>Wino Google Authentication Protocol</uap:DisplayName>
</uap:Protocol>
</uap:Extension>
<!-- Protocol activation: Launch UWP app from Full Trust Process -->
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="wino.calendar.launch">
<uap:DisplayName>Wino Calendara Launcher Protocol</uap:DisplayName>
</uap:Protocol>
</uap:Extension>
<!-- Startup Task -->
<uap5:Extension
Category="windows.startupTask"
Executable="Wino.Server\Wino.Server.exe"
EntryPoint="Windows.FullTrustApplication">
<uap5:StartupTask
TaskId="WinoServer"
Enabled="false"
DisplayName="Wino Mail" />
</uap5:Extension>
<desktop:Extension Category="windows.fullTrustProcess" Executable="Wino.Server\Wino.Server.exe">
<desktop:FullTrustProcess>
<desktop:ParameterGroup GroupId="WinoServer" Parameters="Calendar" />
</desktop:FullTrustProcess>
</desktop:Extension>
</Extensions>
</Application>
</Applications>
<Capabilities>
<Capability Name="internetClient" />
<rescap:Capability Name="runFullTrust" />
<rescap:Capability Name="confirmAppClose" />
</Capabilities>
</Package>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition="'$(VisualStudioVersion)' == '' or '$(VisualStudioVersion)' &lt; '15.0'">
<VisualStudioVersion>15.0</VisualStudioVersion>
</PropertyGroup>
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x86">
<Configuration>Debug</Configuration>
<Platform>x86</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x86">
<Configuration>Release</Configuration>
<Platform>x86</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|ARM64">
<Configuration>Debug</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|ARM64">
<Configuration>Release</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|AnyCPU">
<Configuration>Debug</Configuration>
<Platform>AnyCPU</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|AnyCPU">
<Configuration>Release</Configuration>
<Platform>AnyCPU</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup>
<WapProjPath Condition="'$(WapProjPath)'==''">$(MSBuildExtensionsPath)\Microsoft\DesktopBridge\</WapProjPath>
</PropertyGroup>
<Import Project="$(WapProjPath)\Microsoft.DesktopBridge.props" />
<PropertyGroup>
<ProjectGuid>7485b18c-f5ab-4abe-ba7f-05b6623c67c8</ProjectGuid>
<TargetPlatformVersion>10.0.22621.0</TargetPlatformVersion>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<DefaultLanguage>en-US</DefaultLanguage>
<AppxPackageSigningEnabled>false</AppxPackageSigningEnabled>
<NoWarn>$(NoWarn);NU1702</NoWarn>
<EntryPointProjectUniqueName>..\Wino.Calendar\Wino.Calendar.csproj</EntryPointProjectUniqueName>
<GenerateTemporaryStoreCertificate>True</GenerateTemporaryStoreCertificate>
</PropertyGroup>
<ItemGroup>
<AppxManifest Include="Package.appxmanifest">
<SubType>Designer</SubType>
</AppxManifest>
</ItemGroup>
<ItemGroup>
<Content Include="Images\SplashScreen.scale-200.png" />
<Content Include="Images\LockScreenLogo.scale-200.png" />
<Content Include="Images\Square150x150Logo.scale-200.png" />
<Content Include="Images\Square44x44Logo.scale-200.png" />
<Content Include="Images\Square44x44Logo.targetsize-24_altform-unplated.png" />
<Content Include="Images\StoreLogo.png" />
<Content Include="Images\Wide310x150Logo.scale-200.png" />
<None Include="Package.StoreAssociation.xml" />
</ItemGroup>
<Import Project="$(WapProjPath)\Microsoft.DesktopBridge.targets" />
<ItemGroup>
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.1742" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Wino.Calendar\Wino.Calendar.csproj" />
<ProjectReference Include="..\Wino.Server\Wino.Server.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
using Wino.Calendar.ViewModels.Interfaces;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.ViewModels;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.UI;
namespace Wino.Calendar.ViewModels
{
public partial class AccountDetailsPageViewModel : CalendarBaseViewModel
{
private readonly IAccountService _accountService;
public AccountProviderDetailViewModel Account { get; private set; }
public ICalendarDialogService CalendarDialogService { get; }
public IAccountCalendarStateService AccountCalendarStateService { get; }
public AccountDetailsPageViewModel(ICalendarDialogService calendarDialogService, IAccountService accountService, IAccountCalendarStateService accountCalendarStateService)
{
CalendarDialogService = calendarDialogService;
_accountService = accountService;
AccountCalendarStateService = accountCalendarStateService;
}
[RelayCommand]
private async Task RenameAccount()
{
if (Account == null)
return;
var updatedAccount = await CalendarDialogService.ShowEditAccountDialogAsync(Account.Account);
if (updatedAccount != null)
{
await _accountService.UpdateAccountAsync(updatedAccount);
ReportUIChange(new AccountUpdatedMessage(updatedAccount));
}
}
public override void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
}
}
}
@@ -0,0 +1,147 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
using Wino.Core.Domain;
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.Authentication;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.ViewModels;
using Wino.Messaging.Server;
namespace Wino.Calendar.ViewModels
{
public partial class AccountManagementViewModel : AccountManagementPageViewModelBase
{
private readonly IProviderService _providerService;
public AccountManagementViewModel(ICalendarDialogService dialogService,
IWinoServerConnectionManager winoServerConnectionManager,
INavigationService navigationService,
IAccountService accountService,
IProviderService providerService,
IStoreManagementService storeManagementService,
IAuthenticationProvider authenticationProvider,
IPreferencesService preferencesService) : base(dialogService, winoServerConnectionManager, navigationService, accountService, providerService, storeManagementService, authenticationProvider, preferencesService)
{
CalendarDialogService = dialogService;
_providerService = providerService;
}
public ICalendarDialogService CalendarDialogService { get; }
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
await InitializeAccountsAsync();
}
public override async Task InitializeAccountsAsync()
{
Accounts.Clear();
var accounts = await AccountService.GetAccountsAsync().ConfigureAwait(false);
await ExecuteUIThread(() =>
{
foreach (var account in accounts)
{
var accountDetails = GetAccountProviderDetails(account);
Accounts.Add(accountDetails);
}
});
await ManageStorePurchasesAsync().ConfigureAwait(false);
}
[RelayCommand]
private async Task AddNewAccountAsync()
{
if (IsAccountCreationBlocked)
{
var isPurchaseClicked = await DialogService.ShowConfirmationDialogAsync(Translator.DialogMessage_AccountLimitMessage, Translator.DialogMessage_AccountLimitTitle, Translator.Buttons_Purchase);
if (!isPurchaseClicked) return;
await PurchaseUnlimitedAccountAsync();
return;
}
var availableProviders = _providerService.GetAvailableProviders();
var accountCreationDialogResult = await DialogService.ShowAccountProviderSelectionDialogAsync(availableProviders);
if (accountCreationDialogResult == null) return;
var accountCreationCancellationTokenSource = new CancellationTokenSource();
var accountCreationDialog = CalendarDialogService.GetAccountCreationDialog(accountCreationDialogResult);
accountCreationDialog.ShowDialog(accountCreationCancellationTokenSource);
accountCreationDialog.State = AccountCreationDialogState.SigningIn;
// For OAuth authentications, we just generate token and assign it to the MailAccount.
var createdAccount = new MailAccount()
{
ProviderType = accountCreationDialogResult.ProviderType,
Name = accountCreationDialogResult.AccountName,
Id = Guid.NewGuid()
};
var tokenInformationResponse = await WinoServerConnectionManager
.GetResponseAsync<TokenInformationEx, AuthorizationRequested>(new AuthorizationRequested(accountCreationDialogResult.ProviderType,
createdAccount,
createdAccount.ProviderType == MailProviderType.Gmail), accountCreationCancellationTokenSource.Token);
if (accountCreationDialog.State == AccountCreationDialogState.Canceled)
throw new AccountSetupCanceledException();
tokenInformationResponse.ThrowIfFailed();
await AccountService.CreateAccountAsync(createdAccount, null);
// Sync profile information if supported.
if (createdAccount.IsProfileInfoSyncSupported)
{
// Start profile information synchronization.
// It's only available for Outlook and Gmail synchronizers.
var profileSyncOptions = new MailSynchronizationOptions()
{
AccountId = createdAccount.Id,
Type = MailSynchronizationType.UpdateProfile
};
var profileSynchronizationResponse = await WinoServerConnectionManager.GetResponseAsync<MailSynchronizationResult, NewMailSynchronizationRequested>(new NewMailSynchronizationRequested(profileSyncOptions, SynchronizationSource.Client));
var profileSynchronizationResult = profileSynchronizationResponse.Data;
if (profileSynchronizationResult.CompletedState != SynchronizationCompletedState.Success)
throw new Exception(Translator.Exception_FailedToSynchronizeProfileInformation);
createdAccount.SenderName = profileSynchronizationResult.ProfileInformation.SenderName;
createdAccount.Base64ProfilePictureData = profileSynchronizationResult.ProfileInformation.Base64ProfilePictureData;
await AccountService.UpdateProfileInformationAsync(createdAccount.Id, profileSynchronizationResult.ProfileInformation);
}
accountCreationDialog.State = AccountCreationDialogState.FetchingEvents;
// Start synchronizing events.
var synchronizationOptions = new CalendarSynchronizationOptions()
{
AccountId = createdAccount.Id,
Type = CalendarSynchronizationType.CalendarMetadata
};
var synchronizationResponse = await WinoServerConnectionManager.GetResponseAsync<CalendarSynchronizationResult, NewCalendarSynchronizationRequested>(new NewCalendarSynchronizationRequested(synchronizationOptions, SynchronizationSource.Client));
}
}
}
@@ -0,0 +1,367 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Interfaces;
using Wino.Core.Domain.Collections;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.ViewModels;
using Wino.Messaging.Client.Calendar;
using Wino.Messaging.Client.Navigation;
using Wino.Messaging.Server;
namespace Wino.Calendar.ViewModels
{
public partial class AppShellViewModel : CalendarBaseViewModel,
IRecipient<VisibleDateRangeChangedMessage>,
IRecipient<CalendarEnableStatusChangedMessage>,
IRecipient<NavigateManageAccountsRequested>,
IRecipient<CalendarDisplayTypeChangedMessage>,
IRecipient<DetailsPageStateChangedMessage>
{
public IPreferencesService PreferencesService { get; }
public IStatePersistanceService StatePersistenceService { get; }
public IAccountCalendarStateService AccountCalendarStateService { get; }
public INavigationService NavigationService { get; }
public IWinoServerConnectionManager ServerConnectionManager { get; }
[ObservableProperty]
private bool _isEventDetailsPageActive;
[ObservableProperty]
private int _selectedMenuItemIndex = -1;
[ObservableProperty]
private bool isCalendarEnabled;
/// <summary>
/// Gets or sets the active connection status of the Wino server.
/// </summary>
[ObservableProperty]
private WinoServerConnectionStatus activeConnectionStatus;
/// <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]
private ObservableRangeCollection<string> dateNavigationHeaderItems = [];
[ObservableProperty]
private int _selectedDateNavigationHeaderIndex;
public bool IsVerticalCalendar => StatePersistenceService.CalendarDisplayType == CalendarDisplayType.Month;
// For updating account calendars asynchronously.
private SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
public AppShellViewModel(IPreferencesService preferencesService,
IStatePersistanceService statePersistanceService,
IAccountService accountService,
ICalendarService calendarService,
IAccountCalendarStateService accountCalendarStateService,
INavigationService navigationService,
IWinoServerConnectionManager serverConnectionManager)
{
_accountService = accountService;
_calendarService = calendarService;
AccountCalendarStateService = accountCalendarStateService;
AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested;
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged += AccountCalendarStateCollectivelyChanged;
NavigationService = navigationService;
ServerConnectionManager = serverConnectionManager;
PreferencesService = preferencesService;
StatePersistenceService = statePersistanceService;
StatePersistenceService.StatePropertyChanged += PrefefencesChanged;
}
private void SelectedCalendarItemsChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
throw new NotImplementedException();
}
private void PrefefencesChanged(object sender, string e)
{
if (e == nameof(StatePersistenceService.CalendarDisplayType))
{
Messenger.Send(new CalendarDisplayTypeChangedMessage(StatePersistenceService.CalendarDisplayType));
// Change the calendar.
DateClicked(new CalendarViewDayClickedEventArgs(GetDisplayTypeSwitchDate()));
}
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
UpdateDateNavigationHeaderItems();
await InitializeAccountCalendarsAsync();
TodayClicked();
}
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
{
await _accountCalendarUpdateSemaphoreSlim.WaitAsync();
foreach (var calendar in e.AccountCalendars)
{
await _calendarService.UpdateAccountCalendarAsync(calendar.AccountCalendar).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error while waiting for account calendar update semaphore.");
}
finally
{
_accountCalendarUpdateSemaphoreSlim.Release();
}
}
private async void UpdateAccountCalendarRequested(object sender, AccountCalendarViewModel e)
=> await _calendarService.UpdateAccountCalendarAsync(e.AccountCalendar).ConfigureAwait(false);
private async Task InitializeAccountCalendarsAsync()
{
await Dispatcher.ExecuteOnUIThread(() => AccountCalendarStateService.ClearGroupedAccountCalendar());
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
foreach (var account in accounts)
{
var accountCalendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
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);
await Dispatcher.ExecuteOnUIThread(() =>
{
AccountCalendarStateService.AddGroupedAccountCalendar(groupedAccountCalendarViewModel);
});
}
}
private void ForceNavigateCalendarDate()
{
if (SelectedMenuItemIndex == -1)
{
var args = new CalendarPageNavigationArgs()
{
NavigationDate = _navigationDate ?? DateTime.Now.Date
};
// 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.CalendarMetadata
}, SynchronizationSource.Client);
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()
{
var settings = PreferencesService.GetCurrentCalendarSettings();
switch (StatePersistenceService.CalendarDisplayType)
{
case CalendarDisplayType.Day:
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;
case CalendarDisplayType.Year:
break;
default:
break;
}
return DateTime.Today.Date;
}
private DateTime? _navigationDate;
private readonly IAccountService _accountService;
private readonly ICalendarService _calendarService;
#region Commands
[RelayCommand]
private void TodayClicked()
{
_navigationDate = DateTime.Now.Date;
ForceNavigateCalendarDate();
}
[RelayCommand]
public void ManageAccounts() => NavigationService.Navigate(WinoPage.AccountManagementPage);
[RelayCommand]
private Task ReconnectServerAsync() => ServerConnectionManager.ConnectAsync();
[RelayCommand]
private void DateClicked(CalendarViewDayClickedEventArgs clickedDateArgs)
{
_navigationDate = clickedDateArgs.ClickedDate;
ForceNavigateCalendarDate();
}
#endregion
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()
{
DateNavigationHeaderItems.Clear();
// TODO: From settings
var testInfo = new CultureInfo("en-US");
switch (StatePersistenceService.CalendarDisplayType)
{
case CalendarDisplayType.Day:
case CalendarDisplayType.Week:
case CalendarDisplayType.WorkWeek:
case CalendarDisplayType.Month:
DateNavigationHeaderItems.ReplaceRange(testInfo.DateTimeFormat.MonthNames);
break;
case CalendarDisplayType.Year:
break;
default:
break;
}
SetDateNavigationHeaderItems();
}
partial void OnHighlightedDateRangeChanged(DateRange value) => SetDateNavigationHeaderItems();
private void SetDateNavigationHeaderItems()
{
if (HighlightedDateRange == null) return;
if (DateNavigationHeaderItems.Count == 0)
{
UpdateDateNavigationHeaderItems();
}
// TODO: Year view
var monthIndex = HighlightedDateRange.GetMostVisibleMonthIndex();
SelectedDateNavigationHeaderIndex = Math.Max(monthIndex - 1, -1);
}
public async void Receive(CalendarEnableStatusChangedMessage message)
=> await ExecuteUIThread(() => IsCalendarEnabled = message.IsEnabled);
public void Receive(NavigateManageAccountsRequested message) => SelectedMenuItemIndex = 1;
public void Receive(CalendarDisplayTypeChangedMessage message) => OnPropertyChanged(nameof(IsVerticalCalendar));
public async void Receive(DetailsPageStateChangedMessage message)
{
await ExecuteUIThread(() =>
{
IsEventDetailsPageActive = message.IsActivated;
// TODO: This is for Wino Mail. Generalize this later on.
StatePersistenceService.IsReaderNarrowed = message.IsActivated;
StatePersistenceService.IsReadingMail = message.IsActivated;
});
}
}
}
@@ -1,123 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
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.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.ViewModels;
using Wino.Messaging.Client.Calendar;
namespace Wino.Calendar.ViewModels;
/// <summary>
/// ViewModel for managing calendar account settings.
/// </summary>
public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewModel
{
private readonly ICalendarService _calendarService;
private readonly IAccountService _accountService;
[ObservableProperty]
public partial MailAccount Account { get; set; }
[ObservableProperty]
public partial AccountCalendar AccountCalendar { get; set; }
[ObservableProperty]
public partial string AccountColorHex { get; set; } = "#0078D4";
[ObservableProperty]
public partial bool IsSyncEnabled { get; set; }
public ObservableCollection<ShowAsOption> ShowAsOptions { get; } = new ObservableCollection<ShowAsOption>();
[ObservableProperty]
public partial ShowAsOption SelectedDefaultShowAsOption { get; set; }
public CalendarAccountSettingsPageViewModel(ICalendarService calendarService, IAccountService accountService)
{
_calendarService = calendarService;
_accountService = accountService;
// Initialize ShowAs options
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));
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
if (parameters is AccountCalendar selectedCalendar)
{
Account = await _accountService.GetAccountAsync(selectedCalendar.AccountId);
AccountCalendar = await _calendarService.GetAccountCalendarAsync(selectedCalendar.Id) ?? selectedCalendar;
}
else if (parameters is Guid accountId)
{
Account = await _accountService.GetAccountAsync(accountId);
var calendars = await _calendarService.GetAccountCalendarsAsync(accountId);
AccountCalendar = calendars.FirstOrDefault(c => c.IsPrimary) ?? calendars.FirstOrDefault();
}
else
{
return;
}
if (Account == null || AccountCalendar == null)
return;
// Initialize properties from AccountCalendar
AccountColorHex = AccountCalendar.BackgroundColorHex ?? "#0078D4";
IsSyncEnabled = AccountCalendar.IsSynchronizationEnabled;
SelectedDefaultShowAsOption = ShowAsOptions.FirstOrDefault(o => o.ShowAs == AccountCalendar.DefaultShowAs) ?? ShowAsOptions[2];
}
partial void OnAccountColorHexChanged(string value)
{
if (AccountCalendar != null && !string.IsNullOrEmpty(value))
{
AccountCalendar.BackgroundColorHex = value;
AccountCalendar.IsBackgroundColorUserOverridden = true;
SaveChangesAsync();
}
}
partial void OnIsSyncEnabledChanged(bool value)
{
if (AccountCalendar != null)
{
AccountCalendar.IsSynchronizationEnabled = value;
SaveChangesAsync();
}
}
partial void OnSelectedDefaultShowAsOptionChanged(ShowAsOption value)
{
if (AccountCalendar != null && value != null)
{
AccountCalendar.DefaultShowAs = value.ShowAs;
SaveChangesAsync();
}
}
private async void SaveChangesAsync()
{
if (AccountCalendar == null)
return;
await _calendarService.UpdateAccountCalendarAsync(AccountCalendar);
// Send message to update UI
Messenger.Send(new CalendarListUpdated(AccountCalendar));
}
}
@@ -1,563 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Interfaces;
using Wino.Core.Domain;
using Wino.Core.Domain.Collections;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums;
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.Navigation;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.ViewModels;
using Wino.Messaging.Client.Calendar;
using Wino.Messaging.Server;
using Wino.Messaging.UI;
namespace Wino.Calendar.ViewModels;
public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
ICalendarShellClient,
IRecipient<CalendarDisplayTypeChangedMessage>,
IRecipient<AccountRemovedMessage>
{
public IPreferencesService PreferencesService { get; }
public IStatePersistanceService StatePersistenceService { get; }
public IAccountCalendarStateService AccountCalendarStateService { 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]
private int _selectedMenuItemIndex = -1;
[ObservableProperty]
private ObservableRangeCollection<string> dateNavigationHeaderItems = [];
[ObservableProperty]
private int _selectedDateNavigationHeaderIndex;
public bool IsVerticalCalendar => StatePersistenceService.CalendarDisplayType == CalendarDisplayType.Month;
[ObservableProperty]
private bool isStoreUpdateItemVisible;
private readonly SettingsItem _settingsItem = new();
private readonly StoreUpdateMenuItem _storeUpdateMenuItem = new();
private readonly SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
private readonly CalendarPageViewModel _calendarPageViewModel;
private readonly IMailDialogService _dialogService;
private readonly IStoreUpdateService _storeUpdateService;
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;
_calendarService = calendarService;
_calendarPageViewModel = calendarPageViewModel;
_dialogService = dialogService;
_storeUpdateService = storeUpdateService;
_dateContextProvider = dateContextProvider;
_calendarPageViewModel.PropertyChanged += CalendarPageViewModelPropertyChanged;
AccountCalendarStateService.PropertyChanged += AccountCalendarStateServicePropertyChanged;
}
protected override void OnDispatcherAssigned()
{
base.OnDispatcherAssigned();
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)
{
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);
}
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
if (!_hasRegisteredPersistentRecipients)
{
RegisterRecipients();
_hasRegisteredPersistentRecipients = true;
}
AttachRuntimeSubscriptions();
var activationContext = parameters as ShellModeActivationContext;
var shouldRunStartupFlows = activationContext?.IsInitialActivation ?? true;
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
PreferencesService.PreferenceChanged += PreferencesServiceChanged;
await RefreshFooterItemsAsync(mode == NavigationMode.New);
UpdateDateNavigationHeaderItems();
await InitializeAccountCalendarsAsync();
ValidateConfiguredNewEventCalendar();
if (activationContext?.Parameter is CalendarItemTarget calendarItemTarget)
{
NavigationService.Navigate(WinoPage.EventDetailsPage, calendarItemTarget);
return;
}
if (activationContext?.Parameter is CalendarPageNavigationArgs calendarPageNavigationArgs)
{
NavigationService.Navigate(WinoPage.CalendarPage, calendarPageNavigationArgs);
return;
}
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)
{
try
{
await _accountCalendarUpdateSemaphoreSlim.WaitAsync();
foreach (var calendar in e.AccountCalendars)
{
await _calendarService.UpdateAccountCalendarAsync(calendar.AccountCalendar).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error while waiting for account calendar update semaphore.");
}
finally
{
_accountCalendarUpdateSemaphoreSlim.Release();
}
}
private async void UpdateAccountCalendarRequested(object sender, AccountCalendarViewModel e)
=> await _calendarService.UpdateAccountCalendarAsync(e.AccountCalendar).ConfigureAwait(false);
private async Task InitializeAccountCalendarsAsync()
{
await Dispatcher.ExecuteOnUIThread(() => AccountCalendarStateService.ClearGroupedAccountCalendars());
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
foreach (var account in accounts)
{
var accountCalendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
var calendarViewModels = accountCalendars.Select(calendar => new AccountCalendarViewModel(account, calendar)).ToList();
var groupedAccountCalendarViewModel = new GroupedAccountCalendarViewModel(account, calendarViewModels);
await Dispatcher.ExecuteOnUIThread(() =>
{
AccountCalendarStateService.AddGroupedAccountCalendar(groupedAccountCalendarViewModel);
});
}
}
private void NavigateCalendarDate(DateTime date)
{
_navigationDate = date.Date;
ForceNavigateCalendarDate();
}
private void ForceNavigateCalendarDate()
{
var args = new CalendarPageNavigationArgs
{
NavigationDate = _navigationDate ?? DateTime.Now.Date
};
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,
Type = CalendarSynchronizationType.Strict
}));
}
}
private DateTime GetDisplayTypeSwitchDate()
{
var today = _dateContextProvider.GetToday();
var settings = PreferencesService.GetCurrentCalendarSettings();
var referenceRange = CurrentVisibleRange
?? CalendarRangeResolver.Resolve(new CalendarDisplayRequest(StatePersistenceService.CalendarDisplayType, today), settings, today);
var targetRange = CalendarRangeResolver.ChangeDisplayType(referenceRange, StatePersistenceService.CalendarDisplayType, settings, today);
return targetRange.AnchorDate.ToDateTime(TimeOnly.MinValue);
}
[RelayCommand]
private void TodayClicked()
{
NavigateCalendarDate(_dateContextProvider.GetToday().ToDateTime(TimeOnly.MinValue));
}
[RelayCommand]
private void PreviousDateRange()
{
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]
private void DateClicked(CalendarViewDayClickedEventArgs clickedDateArgs)
=> NavigateCalendarDate(clickedDateArgs.ClickedDate);
protected override void RegisterRecipients()
{
base.RegisterRecipients();
UnregisterRecipients();
Messenger.Register<CalendarDisplayTypeChangedMessage>(this);
Messenger.Register<AccountRemovedMessage>(this);
}
protected override void UnregisterRecipients()
{
base.UnregisterRecipients();
Messenger.Unregister<CalendarDisplayTypeChangedMessage>(this);
Messenger.Unregister<AccountRemovedMessage>(this);
}
private void UpdateDateNavigationHeaderItems()
{
var headerText = VisibleDateRangeText;
DateNavigationHeaderItems.ReplaceRange(string.IsNullOrWhiteSpace(headerText) ? [] : [headerText]);
SelectedDateNavigationHeaderIndex = DateNavigationHeaderItems.Count > 0 ? 0 : -1;
}
public void Receive(CalendarDisplayTypeChangedMessage message)
{
OnPropertyChanged(nameof(IsVerticalCalendar));
UpdateDateNavigationHeaderItems();
}
public async void Receive(AccountRemovedMessage message)
{
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,760 +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)
{
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,127 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Translations;
using Wino.Core.ViewModels;
using Wino.Messaging.Client.Calendar;
namespace Wino.Calendar.ViewModels
{
public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
{
[ObservableProperty]
private double _cellHourHeight;
[ObservableProperty]
private int _selectedFirstDayOfWeekIndex;
[ObservableProperty]
private bool _is24HourHeaders;
[ObservableProperty]
private TimeSpan _workingHourStart;
[ObservableProperty]
private TimeSpan _workingHourEnd;
[ObservableProperty]
private List<string> _dayNames = [];
[ObservableProperty]
private int _workingDayStartIndex;
[ObservableProperty]
private int _workingDayEndIndex;
public IPreferencesService PreferencesService { get; }
private readonly bool _isLoaded = false;
public CalendarSettingsPageViewModel(IPreferencesService preferencesService)
{
PreferencesService = preferencesService;
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));
_isLoaded = true;
}
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();
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;
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; }
}
@@ -0,0 +1,13 @@
using Microsoft.Extensions.DependencyInjection;
using Wino.Core;
namespace Wino.Calendar.ViewModels
{
public static class CalendarViewModelContainerSetup
{
public static void RegisterCalendarViewModelServices(this IServiceCollection services)
{
services.RegisterCoreServices();
}
}
}
@@ -2,86 +2,69 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Calendar.ViewModels.Data;
public partial class AccountCalendarViewModel : ObservableObject, IAccountCalendar
namespace Wino.Calendar.ViewModels.Data
{
public MailAccount Account { get; }
public AccountCalendar AccountCalendar { get; }
public AccountCalendarViewModel(MailAccount account, AccountCalendar accountCalendar)
public partial class AccountCalendarViewModel : ObservableObject, IAccountCalendar
{
Account = account;
AccountCalendar = accountCalendar;
public MailAccount Account { get; }
public AccountCalendar AccountCalendar { get; }
IsChecked = accountCalendar.IsExtended;
}
public AccountCalendarViewModel(MailAccount account, AccountCalendar accountCalendar)
{
Account = account;
AccountCalendar = accountCalendar;
[ObservableProperty]
private bool _isChecked;
IsChecked = accountCalendar.IsExtended;
}
partial void OnIsCheckedChanged(bool value) => IsExtended = value;
[ObservableProperty]
private bool _isChecked;
public string Name
{
get => AccountCalendar.Name;
set => SetProperty(AccountCalendar.Name, value, AccountCalendar, (u, n) => u.Name = n);
}
partial void OnIsCheckedChanged(bool value) => IsExtended = value;
public string TextColorHex
{
get => AccountCalendar.TextColorHex;
set => SetProperty(AccountCalendar.TextColorHex, value, AccountCalendar, (u, t) => u.TextColorHex = t);
}
public string Name
{
get => AccountCalendar.Name;
set => SetProperty(AccountCalendar.Name, value, AccountCalendar, (u, n) => u.Name = n);
}
public string BackgroundColorHex
{
get => AccountCalendar.BackgroundColorHex;
set => SetProperty(AccountCalendar.BackgroundColorHex, value, AccountCalendar, (u, b) => u.BackgroundColorHex = b);
}
public string TextColorHex
{
get => AccountCalendar.TextColorHex;
set => SetProperty(AccountCalendar.TextColorHex, value, AccountCalendar, (u, t) => u.TextColorHex = t);
}
public bool IsExtended
{
get => AccountCalendar.IsExtended;
set => SetProperty(AccountCalendar.IsExtended, value, AccountCalendar, (u, i) => u.IsExtended = i);
}
public string BackgroundColorHex
{
get => AccountCalendar.BackgroundColorHex;
set => SetProperty(AccountCalendar.BackgroundColorHex, value, AccountCalendar, (u, b) => u.BackgroundColorHex = b);
}
public bool IsPrimary
{
get => AccountCalendar.IsPrimary;
set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i);
}
public bool IsExtended
{
get => AccountCalendar.IsExtended;
set => SetProperty(AccountCalendar.IsExtended, value, AccountCalendar, (u, i) => u.IsExtended = i);
}
public bool IsSynchronizationEnabled
{
get => AccountCalendar.IsSynchronizationEnabled;
set => SetProperty(AccountCalendar.IsSynchronizationEnabled, value, AccountCalendar, (u, i) => u.IsSynchronizationEnabled = i);
}
public bool IsPrimary
{
get => AccountCalendar.IsPrimary;
set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i);
}
public Guid AccountId
{
get => AccountCalendar.AccountId;
set => SetProperty(AccountCalendar.AccountId, value, AccountCalendar, (u, a) => u.AccountId = a);
}
public Guid AccountId
{
get => AccountCalendar.AccountId;
set => SetProperty(AccountCalendar.AccountId, value, AccountCalendar, (u, a) => u.AccountId = a);
}
public string RemoteCalendarId
{
get => AccountCalendar.RemoteCalendarId;
set => SetProperty(AccountCalendar.RemoteCalendarId, value, AccountCalendar, (u, r) => u.RemoteCalendarId = r);
}
public CalendarItemShowAs DefaultShowAs
{
get => AccountCalendar.DefaultShowAs;
set => SetProperty(AccountCalendar.DefaultShowAs, value, AccountCalendar, (u, s) => u.DefaultShowAs = s);
}
public Guid Id { get => ((IAccountCalendar)AccountCalendar).Id; set => ((IAccountCalendar)AccountCalendar).Id = value; }
public MailAccount MailAccount
{
get => AccountCalendar.MailAccount ?? Account;
set => AccountCalendar.MailAccount = value;
public string RemoteCalendarId
{
get => AccountCalendar.RemoteCalendarId;
set => SetProperty(AccountCalendar.RemoteCalendarId, value, AccountCalendar, (u, r) => u.RemoteCalendarId = r);
}
public Guid Id { get => ((IAccountCalendar)AccountCalendar).Id; set => ((IAccountCalendar)AccountCalendar).Id = value; }
}
}
@@ -1,71 +0,0 @@
using System;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums;
using Wino.Core.Extensions;
namespace Wino.Calendar.ViewModels.Data;
public partial class CalendarAttachmentViewModel : ObservableObject
{
public CalendarAttachment Attachment { get; }
public Guid Id => Attachment.Id;
public string FileName => Attachment.FileName;
public string ReadableSize { get; }
public MailAttachmentType AttachmentType { get; }
public bool IsDownloaded => Attachment.IsDownloaded;
[ObservableProperty]
public partial bool IsBusy { get; set; }
public CalendarAttachmentViewModel(CalendarAttachment attachment)
{
Attachment = attachment;
ReadableSize = attachment.Size.GetBytesReadable();
var extension = Path.GetExtension(FileName);
AttachmentType = GetAttachmentType(extension);
}
private MailAttachmentType GetAttachmentType(string extension)
{
if (string.IsNullOrEmpty(extension))
return MailAttachmentType.None;
switch (extension.ToLower())
{
case ".exe":
return MailAttachmentType.Executable;
case ".rar":
return MailAttachmentType.RarArchive;
case ".zip":
return MailAttachmentType.Archive;
case ".ogg":
case ".mp3":
case ".wav":
case ".aac":
case ".alac":
return MailAttachmentType.Audio;
case ".mp4":
case ".wmv":
case ".avi":
case ".flv":
return MailAttachmentType.Video;
case ".pdf":
return MailAttachmentType.PDF;
case ".htm":
case ".html":
return MailAttachmentType.HTML;
case ".png":
case ".jpg":
case ".jpeg":
case ".gif":
case ".jiff":
return MailAttachmentType.Image;
default:
return MailAttachmentType.Other;
}
}
}
@@ -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);
}
@@ -2,197 +2,45 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Itenso.TimePeriod;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Calendar.ViewModels.Data;
public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, ICalendarItemViewModel
namespace Wino.Calendar.ViewModels.Data
{
public CalendarItem CalendarItem { get; }
public string Title => CalendarItem.Title;
public Guid Id => CalendarItem.Id;
public IAccountCalendar AssignedCalendar => CalendarItem.AssignedCalendar;
/// <summary>
/// Gets or sets the start date converted to user's local timezone for display.
/// The underlying CalendarItem stores dates according to their timezone.
/// </summary>
public DateTime StartDate
public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, ICalendarItemViewModel
{
get
public CalendarItem CalendarItem { get; }
public string Title => CalendarItem.Title;
public Guid Id => CalendarItem.Id;
public IAccountCalendar AssignedCalendar => CalendarItem.AssignedCalendar;
public DateTime StartDate { get => CalendarItem.StartDate; set => CalendarItem.StartDate = value; }
public DateTime EndDate => CalendarItem.EndDate;
public double DurationInSeconds { get => CalendarItem.DurationInSeconds; set => CalendarItem.DurationInSeconds = value; }
public ITimePeriod Period => CalendarItem.Period;
public bool IsAllDayEvent => CalendarItem.IsAllDayEvent;
public bool IsMultiDayEvent => CalendarItem.IsMultiDayEvent;
public bool IsRecurringEvent => CalendarItem.IsRecurringEvent;
public bool IsRecurringChild => CalendarItem.IsRecurringChild;
public bool IsRecurringParent => CalendarItem.IsRecurringParent;
[ObservableProperty]
private bool _isSelected;
public ObservableCollection<CalendarEventAttendee> Attendees { get; } = new ObservableCollection<CalendarEventAttendee>();
public CalendarItemViewModel(CalendarItem calendarItem)
{
// Get start date in user's local timezone
return CalendarItem.LocalStartDate;
}
set
{
// All-day events use floating dates and should not shift across timezones.
CalendarItem.StartDate = CalendarItem.IsAllDayEvent
? value.Date
: value.ToTimeZoneFromLocal(CalendarItem.StartTimeZone);
CalendarItem = calendarItem;
}
public override string ToString() => CalendarItem.Title;
}
/// <summary>
/// Gets the end date converted to user's local timezone for display.
/// The underlying CalendarItem stores dates according to their timezone.
/// </summary>
public DateTime EndDate
{
get
{
// Get end date in user's local timezone
return CalendarItem.LocalEndDate;
}
}
public double DurationInSeconds { get => CalendarItem.DurationInSeconds; set => CalendarItem.DurationInSeconds = value; }
/// <summary>
/// Gets the time period in local time.
/// </summary>
public ITimePeriod Period
{
get
{
// Return a period using local times for UI display
return new TimeRange(StartDate, EndDate);
}
}
public bool IsAllDayEvent => CalendarItem.IsAllDayEvent;
public bool IsMultiDayEvent => CalendarItem.IsMultiDayEvent;
public bool IsRecurringEvent => CalendarItem.IsRecurringEvent;
public bool IsRecurringChild => CalendarItem.IsRecurringChild;
public bool IsRecurringParent => CalendarItem.IsRecurringParent;
public bool CanDragDrop => CalendarItem.CanChangeStartAndEndDate;
[ObservableProperty]
public partial bool IsSelected { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
/// <summary>
/// The period of the day where this item is currently being displayed.
/// Used for multi-day event title formatting.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DisplayTitle))]
public partial ITimePeriod DisplayingPeriod { get; set; }
/// <summary>
/// Calendar settings for time formatting.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DisplayTitle))]
public partial CalendarSettings CalendarSettings { get; set; }
/// <summary>
/// Gets the display title based on the current displaying period.
/// </summary>
public string DisplayTitle
{
get
{
if (DisplayingPeriod == null || CalendarSettings == null)
return Title;
return GetDisplayTitle(DisplayingPeriod, CalendarSettings);
}
}
public ObservableCollection<CalendarEventAttendee> Attendees { get; } = new ObservableCollection<CalendarEventAttendee>();
public CalendarItemViewModel(CalendarItem calendarItem)
{
CalendarItem = calendarItem;
}
/// <summary>
/// Updates the underlying CalendarItem with new data and raises property change notifications.
/// </summary>
/// <param name="calendarItem">The updated calendar item data.</param>
public void UpdateFrom(CalendarItem calendarItem)
{
if (calendarItem == null || calendarItem.Id != CalendarItem.Id)
return;
// Update all mutable properties
CalendarItem.Title = calendarItem.Title;
CalendarItem.Description = calendarItem.Description;
CalendarItem.Location = calendarItem.Location;
CalendarItem.StartDate = calendarItem.StartDate;
CalendarItem.StartTimeZone = calendarItem.StartTimeZone;
CalendarItem.EndTimeZone = calendarItem.EndTimeZone;
CalendarItem.DurationInSeconds = calendarItem.DurationInSeconds;
CalendarItem.Recurrence = calendarItem.Recurrence;
CalendarItem.RecurringCalendarItemId = calendarItem.RecurringCalendarItemId;
CalendarItem.OrganizerDisplayName = calendarItem.OrganizerDisplayName;
CalendarItem.OrganizerEmail = calendarItem.OrganizerEmail;
CalendarItem.IsLocked = calendarItem.IsLocked;
CalendarItem.IsHidden = calendarItem.IsHidden;
CalendarItem.CustomEventColorHex = calendarItem.CustomEventColorHex;
CalendarItem.HtmlLink = calendarItem.HtmlLink;
CalendarItem.Status = calendarItem.Status;
CalendarItem.Visibility = calendarItem.Visibility;
CalendarItem.ShowAs = calendarItem.ShowAs;
CalendarItem.UpdatedAt = calendarItem.UpdatedAt;
CalendarItem.AssignedCalendar = calendarItem.AssignedCalendar;
// Raise property changed for all bindable properties
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(StartDate));
OnPropertyChanged(nameof(EndDate));
OnPropertyChanged(nameof(DurationInSeconds));
OnPropertyChanged(nameof(Period));
OnPropertyChanged(nameof(IsAllDayEvent));
OnPropertyChanged(nameof(IsMultiDayEvent));
OnPropertyChanged(nameof(IsRecurringEvent));
OnPropertyChanged(nameof(IsRecurringChild));
OnPropertyChanged(nameof(IsRecurringParent));
OnPropertyChanged(nameof(CanDragDrop));
OnPropertyChanged(nameof(AssignedCalendar));
OnPropertyChanged(nameof(DisplayTitle));
}
/// <summary>
/// Gets the display title for this calendar item when rendered in a specific day.
/// </summary>
public string GetDisplayTitle(ITimePeriod displayingPeriod, CalendarSettings calendarSettings)
{
if (!IsMultiDayEvent)
return Title;
var periodRelation = Period.GetRelation(displayingPeriod);
if (periodRelation == PeriodRelation.StartInside || periodRelation == PeriodRelation.EnclosingStartTouching)
{
// Event starts within this day: "HH:mm -> Title"
return $"{calendarSettings.GetTimeString(StartDate.TimeOfDay)} -> {Title}";
}
else if (periodRelation == PeriodRelation.EndInside || periodRelation == PeriodRelation.EnclosingEndTouching)
{
// Event ends within this day: "Title <- HH:mm"
return $"{Title} <- {calendarSettings.GetTimeString(EndDate.TimeOfDay)}";
}
else if (periodRelation == PeriodRelation.Enclosing)
{
// Event spans the entire day
return $"{Translator.CalendarItemAllDay} {Title}";
}
else
{
return Title;
}
}
public override string ToString() => CalendarItem.Title;
}
@@ -1,202 +1,146 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Calendar.ViewModels.Data;
public partial class GroupedAccountCalendarViewModel : ObservableObject
namespace Wino.Calendar.ViewModels.Data
{
public event EventHandler CollectiveSelectionStateChanged;
public event EventHandler<AccountCalendarViewModel> CalendarSelectionStateChanged;
public MailAccount Account { get; }
public ObservableCollection<AccountCalendarViewModel> AccountCalendars { get; }
public GroupedAccountCalendarViewModel(MailAccount account, IEnumerable<AccountCalendarViewModel> calendarViewModels)
public partial class GroupedAccountCalendarViewModel : ObservableObject
{
Account = account;
AccountCalendars = new ObservableCollection<AccountCalendarViewModel>(calendarViewModels);
AccountColorHex = account.AccountColorHex;
public event EventHandler CollectiveSelectionStateChanged;
public event EventHandler<AccountCalendarViewModel> CalendarSelectionStateChanged;
ManageIsCheckedState();
public MailAccount Account { get; }
public ObservableCollection<AccountCalendarViewModel> AccountCalendars { get; }
foreach (var calendarViewModel in calendarViewModels)
public GroupedAccountCalendarViewModel(MailAccount account, IEnumerable<AccountCalendarViewModel> calendarViewModels)
{
calendarViewModel.PropertyChanged += CalendarPropertyChanged;
}
Account = account;
AccountCalendars = new ObservableCollection<AccountCalendarViewModel>(calendarViewModels);
AccountCalendars.CollectionChanged += CalendarListUpdated;
}
private void CalendarListUpdated(object sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Add)
{
foreach (AccountCalendarViewModel calendar in e.NewItems)
{
calendar.PropertyChanged += CalendarPropertyChanged;
}
}
else if (e.Action == NotifyCollectionChangedAction.Remove)
{
foreach (AccountCalendarViewModel calendar in e.OldItems)
{
calendar.PropertyChanged -= CalendarPropertyChanged;
}
}
else if (e.Action == NotifyCollectionChangedAction.Reset)
{
foreach (AccountCalendarViewModel calendar in e.OldItems)
{
calendar.PropertyChanged -= CalendarPropertyChanged;
}
}
}
private void CalendarPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (sender is AccountCalendarViewModel viewModel &&
e.PropertyName == nameof(AccountCalendarViewModel.IsChecked))
{
ManageIsCheckedState();
UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true);
}
}
[ObservableProperty]
public partial bool IsExpanded { get; set; } = true;
[ObservableProperty]
public partial bool? IsCheckedState { get; set; } = true;
[ObservableProperty]
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 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()
{
if (_isExternalPropChangeBlocked)
return;
_isExternalPropChangeBlocked = true;
if (AccountCalendars.All(c => c.IsChecked))
{
IsCheckedState = true;
}
else if (AccountCalendars.All(c => !c.IsChecked))
{
IsCheckedState = false;
}
else
{
IsCheckedState = null;
}
_isExternalPropChangeBlocked = false;
}
partial void OnIsCheckedStateChanged(bool? oldValue, bool? newValue)
{
if (_isExternalPropChangeBlocked)
return;
_isExternalPropChangeBlocked = true;
if (newValue == null)
{
foreach (var calendar in AccountCalendars)
foreach (var calendarViewModel in calendarViewModels)
{
UpdateCalendarCheckedState(calendar, calendar.IsPrimary);
calendarViewModel.PropertyChanged += CalendarPropertyChanged;
}
AccountCalendars.CollectionChanged += CalendarListUpdated;
}
else
private void CalendarListUpdated(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
foreach (var calendar in AccountCalendars)
if (e.Action == NotifyCollectionChangedAction.Add)
{
UpdateCalendarCheckedState(calendar, newValue.GetValueOrDefault());
foreach (AccountCalendarViewModel calendar in e.NewItems)
{
calendar.PropertyChanged += CalendarPropertyChanged;
}
}
else if (e.Action == NotifyCollectionChangedAction.Remove)
{
foreach (AccountCalendarViewModel calendar in e.OldItems)
{
calendar.PropertyChanged -= CalendarPropertyChanged;
}
}
else if (e.Action == NotifyCollectionChangedAction.Reset)
{
foreach (AccountCalendarViewModel calendar in e.OldItems)
{
calendar.PropertyChanged -= CalendarPropertyChanged;
}
}
}
_isExternalPropChangeBlocked = false;
CollectiveSelectionStateChanged?.Invoke(this, EventArgs.Empty);
}
private void CalendarPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (sender is AccountCalendarViewModel viewModel)
{
if (e.PropertyName == nameof(AccountCalendarViewModel.IsChecked))
{
ManageIsCheckedState();
UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true);
}
}
}
private void UpdateCalendarCheckedState(AccountCalendarViewModel accountCalendarViewModel, bool newValue, bool ignoreValueCheck = false)
{
var currentValue = accountCalendarViewModel.IsChecked;
[ObservableProperty]
private bool _isExpanded = true;
if (currentValue == newValue && !ignoreValueCheck)
return;
[ObservableProperty]
private bool? isCheckedState = true;
accountCalendarViewModel.IsChecked = newValue;
private bool _isExternalPropChangeBlocked = false;
if (_isExternalPropChangeBlocked)
return;
private void ManageIsCheckedState()
{
if (_isExternalPropChangeBlocked) return;
CalendarSelectionStateChanged?.Invoke(this, accountCalendarViewModel);
}
_isExternalPropChangeBlocked = true;
public void UpdateAccount(MailAccount updatedAccount)
{
if (updatedAccount == null || updatedAccount.Id != Account.Id)
return;
if (AccountCalendars.All(c => c.IsChecked))
{
IsCheckedState = true;
}
else if (AccountCalendars.All(c => !c.IsChecked))
{
IsCheckedState = false;
}
else
{
IsCheckedState = null;
}
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));
_isExternalPropChangeBlocked = false;
}
partial void OnIsCheckedStateChanged(bool? newValue)
{
if (_isExternalPropChangeBlocked) return;
// Update is triggered by user on the three-state checkbox.
// We should not report all changes one by one.
_isExternalPropChangeBlocked = true;
if (newValue == null)
{
// Only primary calendars must be checked.
foreach (var calendar in AccountCalendars)
{
UpdateCalendarCheckedState(calendar, calendar.IsPrimary);
}
}
else
{
foreach (var calendar in AccountCalendars)
{
UpdateCalendarCheckedState(calendar, newValue.GetValueOrDefault());
}
}
_isExternalPropChangeBlocked = false;
CollectiveSelectionStateChanged?.Invoke(this, EventArgs.Empty);
}
private void UpdateCalendarCheckedState(AccountCalendarViewModel accountCalendarViewModel, bool newValue, bool ignoreValueCheck = false)
{
var currentValue = accountCalendarViewModel.IsChecked;
if (currentValue == newValue && !ignoreValueCheck) return;
accountCalendarViewModel.IsChecked = newValue;
// No need to report.
if (_isExternalPropChangeBlocked == true) return;
CalendarSelectionStateChanged?.Invoke(this, accountCalendarViewModel);
}
}
}
@@ -1,869 +1,116 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Services;
using Wino.Core.ViewModels;
using Wino.Messaging.Client.Calendar;
namespace Wino.Calendar.ViewModels;
public partial class EventDetailsPageViewModel : CalendarBaseViewModel
namespace Wino.Calendar.ViewModels
{
private readonly ICalendarService _calendarService;
private readonly INativeAppService _nativeAppService;
private readonly IPreferencesService _preferencesService;
private readonly IMailDialogService _dialogService;
private readonly IWinoRequestDelegator _winoRequestDelegator;
private readonly INavigationService _navigationService;
private readonly IUnderlyingThemeService _underlyingThemeService;
private readonly INotificationBuilder _notificationBuilder;
private readonly IContactService _contactService;
public CalendarSettings CurrentSettings { get; }
public INativeAppService NativeAppService => _nativeAppService;
[ObservableProperty]
public partial bool IsDarkWebviewRenderer { get; set; }
public ObservableCollection<CalendarAttachmentViewModel> Attachments { get; } = new ObservableCollection<CalendarAttachmentViewModel>();
/// <summary>
/// Returns true if the current event has attachments.
/// </summary>
public bool HasAttachments => Attachments.Count > 0;
#region Details
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanViewSeries))]
[NotifyPropertyChangedFor(nameof(CanEditSeries))]
[NotifyPropertyChangedFor(nameof(IsCurrentUserOrganizer))]
[NotifyPropertyChangedFor(nameof(CurrentRsvpText))]
[NotifyPropertyChangedFor(nameof(CurrentRsvpStatus))]
public partial CalendarItemViewModel CurrentEvent { get; set; }
partial void OnCurrentEventChanged(CalendarItemViewModel value)
public partial class EventDetailsPageViewModel : CalendarBaseViewModel
{
// Notify the view to re-render the description
Messenger.Send(new CalendarDescriptionRenderingRequested());
}
private readonly ICalendarService _calendarService;
private readonly INativeAppService _nativeAppService;
private readonly IPreferencesService _preferencesService;
[ObservableProperty]
public partial CalendarItemViewModel SeriesParent { get; set; }
[ObservableProperty]
public partial List<Reminder> Reminders { get; set; }
public CalendarSettings CurrentSettings { get; }
public ObservableCollection<ReminderOption> ReminderOptions { get; } = new ObservableCollection<ReminderOption>();
#region Details
/// <summary>
/// Returns true if the event is part of a recurring series (as a child occurrence).
/// Used to enable "View Series" functionality.
/// </summary>
public bool CanViewSeries => CurrentEvent?.IsRecurringChild ?? false;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanViewSeries))]
private CalendarItemViewModel _currentEvent;
/// <summary>
/// Returns true if the "Edit Series" button should be visible.
/// Only visible for child occurrences of recurring events, not for master events or single events.
/// </summary>
public bool CanEditSeries => CurrentEvent?.IsRecurringChild ?? false;
[ObservableProperty]
private CalendarItemViewModel _seriesParent;
/// <summary>
/// Returns true if the current user is the organizer of the event.
/// Used to determine if the user can invite attendees or modify the event.
/// </summary>
public bool IsCurrentUserOrganizer => CurrentEvent?.Attendees?.Any(a => a.IsOrganizer) ?? true;
public bool CanViewSeries => CurrentEvent?.IsRecurringChild ?? false;
#endregion
#endregion
#region Show As Options
public ObservableCollection<ShowAsOption> ShowAsOptions { get; } = new ObservableCollection<ShowAsOption>();
[ObservableProperty]
public partial ShowAsOption SelectedShowAsOption { get; set; }
#endregion
#region RSVP Panel
[ObservableProperty]
public partial bool IsRsvpPanelVisible { get; set; }
public bool IncludeRsvpMessage => !string.IsNullOrEmpty(RsvpMessage);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IncludeRsvpMessage))]
public partial string RsvpMessage { get; set; } = string.Empty;
public ObservableCollection<RsvpStatusOption> RsvpStatusOptions { get; } = new ObservableCollection<RsvpStatusOption>();
public CalendarItemStatus CurrentRsvpStatus
{
get
public EventDetailsPageViewModel(ICalendarService calendarService, INativeAppService nativeAppService, IPreferencesService preferencesService)
{
return CurrentEvent?.CalendarItem?.Status ?? CalendarItemStatus.NotResponded;
}
}
_calendarService = calendarService;
_nativeAppService = nativeAppService;
_preferencesService = preferencesService;
public string CurrentRsvpText
{
get
{
if (CurrentEvent?.CalendarItem == null) return Translator.CalendarEventResponse_Accept;
return CurrentEvent.CalendarItem.Status switch
{
CalendarItemStatus.Accepted => Translator.CalendarEventResponse_AcceptedResponse,
CalendarItemStatus.Tentative => Translator.CalendarEventResponse_TentativeResponse,
CalendarItemStatus.Cancelled => Translator.CalendarEventResponse_DeclinedResponse,
CalendarItemStatus.NotResponded => Translator.CalendarEventResponse_NotResponded,
_ => Translator.CalendarEventResponse_NotResponded
};
}
}
#endregion
public EventDetailsPageViewModel(ICalendarService calendarService,
INativeAppService nativeAppService,
IPreferencesService preferencesService,
IMailDialogService dialogService,
IWinoRequestDelegator winoRequestDelegator,
INavigationService navigationService,
INotificationBuilder notificationBuilder,
IUnderlyingThemeService underlyingThemeService,
IContactService contactService)
{
_calendarService = calendarService;
_nativeAppService = nativeAppService;
_preferencesService = preferencesService;
_dialogService = dialogService;
_winoRequestDelegator = winoRequestDelegator;
_navigationService = navigationService;
_underlyingThemeService = underlyingThemeService;
_notificationBuilder = notificationBuilder;
_contactService = contactService;
CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark();
foreach (var showAs in CalendarItemActionOptions.ShowAsOptions)
{
ShowAsOptions.Add(new ShowAsOption(showAs));
CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
}
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == CalendarItemShowAs.Busy) ?? ShowAsOptions.FirstOrDefault();
foreach (var responseStatus in CalendarItemActionOptions.ResponseOptions)
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
RsvpStatusOptions.Add(new RsvpStatusOption(responseStatus));
}
}
base.OnNavigatedTo(mode, parameters);
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
Messenger.Send(new DetailsPageStateChangedMessage(true));
if (parameters == null || parameters is not CalendarItemTarget args)
return;
await LoadCalendarItemTargetAsync(args);
}
protected override async void OnCalendarItemUpdated(CalendarItem calendarItem, EntityUpdateSource source)
{
base.OnCalendarItemUpdated(calendarItem, source);
// If the current event was updated, reload it
if (IsCurrentEventMatch(calendarItem))
{
// Reflect client-side optimistic changes immediately; fallback to DB for server updates.
if (source == EntityUpdateSource.ClientUpdated || source == EntityUpdateSource.ClientReverted)
{
var previousAttendees = CurrentEvent?.Attendees?.ToList() ?? [];
CurrentEvent = new CalendarItemViewModel(calendarItem)
{
IsBusy = source == EntityUpdateSource.ClientUpdated
};
foreach (var attendee in previousAttendees)
{
CurrentEvent.Attendees.Add(attendee);
}
return;
}
// Refresh from DB when update comes from server sync.
var refreshedEvent = await _calendarService.GetCalendarItemAsync(calendarItem.Id);
if (refreshedEvent != null)
{
CurrentEvent = new CalendarItemViewModel(refreshedEvent);
await LoadAttendeesAsync(refreshedEvent.Id, refreshedEvent);
}
}
}
protected override async void OnCalendarItemAdded(CalendarItem calendarItem, EntityUpdateSource source)
{
base.OnCalendarItemAdded(calendarItem, source);
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 (IsCurrentEventMatch(calendarItem))
{
NavigateBackToCalendar(forceReload: true);
}
}
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)
{
try
{
var currentEventItem = await _calendarService.GetCalendarItemTargetAsync(target);
if (currentEventItem == null)
if (parameters == null || parameters is not CalendarItemTarget args)
return;
CurrentEvent = new CalendarItemViewModel(currentEventItem);
await LoadAttendeesAsync(currentEventItem.Id, currentEventItem);
// Initialize SelectedShowAsOption based on current event's ShowAs
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(o => o.ShowAs == currentEventItem.ShowAs) ?? ShowAsOptions[2];
// Load reminders for this calendar item
Reminders = await _calendarService.GetRemindersAsync(currentEventItem.Id);
InitializeReminderOptions();
// Load attachments
await LoadAttachmentsAsync(currentEventItem.Id);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
}
}
private async Task LoadAttendeesAsync(Guid calendarItemId, CalendarItem calendarItem)
{
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;
await LoadCalendarItemTargetAsync(args);
}
// Separate organizer from other attendees to ensure organizer is always first
var organizer = attendees.FirstOrDefault(a => a.IsOrganizer);
var nonOrganizerAttendees = attendees.Where(a => !a.IsOrganizer).ToList();
var attendeesForUi = new List<CalendarEventAttendee>();
// If the organizer is in the list, add them first
if (organizer != null)
private async Task LoadCalendarItemTargetAsync(CalendarItemTarget target)
{
attendeesForUi.Add(organizer);
}
else if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail))
{
// If the organizer is not in the attendees list, create and add them first
var organizerAttendee = new CalendarEventAttendee
try
{
Id = Guid.NewGuid(),
CalendarItemId = calendarItem.Id,
Name = calendarItem.OrganizerDisplayName ?? calendarItem.OrganizerEmail,
Email = calendarItem.OrganizerEmail,
IsOrganizer = true,
AttendenceStatus = AttendeeStatus.Accepted
};
var currentEventItem = await _calendarService.GetCalendarItemTargetAsync(target);
if (contactLookup.TryGetValue(calendarItem.OrganizerEmail, out var organizerContact))
organizerAttendee.ResolvedContact = organizerContact;
if (currentEventItem == null)
return;
attendeesForUi.Add(organizerAttendee);
}
CurrentEvent = new CalendarItemViewModel(currentEventItem);
// Add all other attendees after the organizer
foreach (var item in nonOrganizerAttendees)
{
attendeesForUi.Add(item);
}
var attendees = await _calendarService.GetAttendeesAsync(currentEventItem.EventTrackingId);
await ExecuteUIThread(() =>
{
if (CurrentEvent == null)
return;
CurrentEvent.Attendees.Clear();
foreach (var attendee in attendeesForUi)
{
CurrentEvent.Attendees.Add(attendee);
}
});
}
private async Task LoadAttachmentsAsync(Guid calendarItemId)
{
Attachments.Clear();
try
{
var attachments = await _calendarService.GetAttachmentsAsync(calendarItemId);
foreach (var attachment in attachments)
{
Attachments.Add(new CalendarAttachmentViewModel(attachment));
}
OnPropertyChanged(nameof(HasAttachments));
}
catch (Exception ex)
{
Debug.WriteLine($"Error loading attachments: {ex.Message}");
}
}
private void InitializeReminderOptions()
{
ReminderOptions.Clear();
// Add predefined options from service
var predefinedMinutes = _calendarService.GetPredefinedReminderMinutes();
var predefinedOptions = predefinedMinutes.Select(m => new ReminderOption(m)).ToList();
// Add custom reminders from synced data
if (Reminders != null)
{
foreach (var reminder in Reminders)
{
// Convert seconds to minutes
var minutesDiff = (int)(reminder.DurationInSeconds / 60);
// Check if this is a custom value not in predefined list
if (!predefinedMinutes.Contains(minutesDiff))
foreach (var item in attendees)
{
predefinedOptions.Add(new ReminderOption(minutesDiff, isCustom: true));
CurrentEvent.Attendees.Add(item);
}
}
}
// Sort by minutes descending and add to collection
foreach (var option in predefinedOptions.OrderByDescending(o => o.Minutes))
{
ReminderOptions.Add(option);
}
// Set selected state based on current reminders
if (Reminders != null)
{
foreach (var reminder in Reminders)
catch (Exception ex)
{
// Convert seconds to minutes
var minutesDiff = (int)(reminder.DurationInSeconds / 60);
var matchingOption = ReminderOptions.FirstOrDefault(o => o.Minutes == minutesDiff);
matchingOption?.IsSelected = true;
Debug.WriteLine(ex.Message);
}
}
}
[RelayCommand]
private async Task SaveAsync()
{
if (CurrentEvent == null) return;
try
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
// Capture original state BEFORE making any changes for potential revert
var originalItem = await _calendarService.GetCalendarItemAsync(CurrentEvent.CalendarItem.Id);
var originalAttendees = await _calendarService.GetAttendeesAsync(CurrentEvent.CalendarItem.Id);
base.OnNavigatedFrom(mode, parameters);
// Get selected reminder options
var selectedOptions = ReminderOptions.Where(o => o.IsSelected).ToList();
// Create separate Reminder entities for each selected option
var newReminders = new List<Reminder>();
foreach (var option in selectedOptions)
{
var durationInSeconds = option.Minutes * 60; // Convert minutes to seconds
newReminders.Add(new Reminder
{
Id = Guid.NewGuid(),
CalendarItemId = CurrentEvent.Id,
DurationInSeconds = durationInSeconds,
ReminderType = CalendarItemReminderType.Popup
});
}
// Save reminders to database
await _calendarService.SaveRemindersAsync(CurrentEvent.CalendarItem.Id, newReminders);
Reminders = newReminders;
// Update ShowAs if changed
if (SelectedShowAsOption != null)
{
CurrentEvent.CalendarItem.ShowAs = SelectedShowAsOption.ShowAs;
}
// Update the calendar item and attendees in database
await _calendarService.UpdateCalendarItemAsync(CurrentEvent.CalendarItem, CurrentEvent.Attendees.ToList());
// Queue the update request to synchronizer with original state for revert capability
var preparationRequest = new CalendarOperationPreparationRequest(
CalendarSynchronizerOperation.UpdateEvent,
CurrentEvent.CalendarItem,
CurrentEvent.Attendees.ToList(),
ResponseMessage: null,
OriginalItem: originalItem,
OriginalAttendees: originalAttendees);
await _winoRequestDelegator.ExecuteAsync(preparationRequest);
NavigateBackToCalendar(forceReload: true);
}
catch (Exception ex)
{
Debug.WriteLine($"Error saving event: {ex.Message}");
_dialogService.InfoBarMessage(
Translator.Info_AttachmentSaveFailedTitle,
ex.Message,
InfoBarMessageType.Error);
}
}
[RelayCommand]
private async Task DeleteAsync()
{
if (CurrentEvent == null) return;
// If the event is a master recurring event, ask for confirmation
if (CurrentEvent.IsRecurringParent)
{
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
Translator.DialogMessage_DeleteRecurringSeriesMessage,
Translator.DialogMessage_DeleteRecurringSeriesTitle,
Translator.Buttons_Delete);
if (!confirmed) return;
Messenger.Send(new DetailsPageStateChangedMessage(false));
}
try
[RelayCommand]
private async Task SaveAsync()
{
var preparationRequest = new CalendarOperationPreparationRequest(
CalendarSynchronizerOperation.DeleteEvent,
CurrentEvent.CalendarItem,
null);
await _winoRequestDelegator.ExecuteAsync(preparationRequest);
NavigateBackToCalendar(forceReload: true);
}
catch (Exception ex)
[RelayCommand]
private async Task DeleteAsync()
{
Debug.WriteLine($"Error deleting calendar event: {ex.Message}");
}
}
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]
private Task JoinOnlineAsync()
{
if (CurrentEvent == null || string.IsNullOrEmpty(CurrentEvent.CalendarItem.HtmlLink))
return Task.CompletedTask;
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]
private void ToggleRsvpPanel()
{
IsRsvpPanelVisible = !IsRsvpPanelVisible;
if (IsRsvpPanelVisible && CurrentEvent?.CalendarItem != null)
[RelayCommand]
private Task JoinOnline()
{
// Initialize selection based on current status
foreach (var item in RsvpStatusOptions)
{
item.IsSelected = CurrentEvent?.CalendarItem?.Status == item.Status;
}
if (CurrentEvent == null || string.IsNullOrEmpty(CurrentEvent.CalendarItem.HtmlLink)) return Task.CompletedTask;
return _nativeAppService.LaunchUriAsync(new Uri(CurrentEvent.CalendarItem.HtmlLink));
}
}
[RelayCommand]
private void CloseRsvpPanel()
{
IsRsvpPanelVisible = false;
RsvpMessage = string.Empty;
}
[RelayCommand]
private async Task SendRsvpResponse(AttendeeStatus status)
{
if (CurrentEvent == null) return;
try
[RelayCommand]
private async Task Respond(CalendarItemStatus status)
{
// Get the optional response message if user wants to include it
var responseMessage = IncludeRsvpMessage ? RsvpMessage : null;
// Map status to operation
CalendarSynchronizerOperation operation = status switch
{
AttendeeStatus.Accepted => CalendarSynchronizerOperation.AcceptEvent,
AttendeeStatus.Tentative => CalendarSynchronizerOperation.TentativeEvent,
AttendeeStatus.Declined => CalendarSynchronizerOperation.DeclineEvent,
_ => throw new InvalidOperationException($"Invalid RSVP status: {status}")
};
// Create preparation request with the optional message
var preparationRequest = new CalendarOperationPreparationRequest(
operation,
CurrentEvent.CalendarItem,
null,
responseMessage);
await _winoRequestDelegator.ExecuteAsync(preparationRequest);
// Reload attendees to get the updated status from the server
await LoadAttendeesAsync(CurrentEvent.CalendarItem.Id, CurrentEvent.CalendarItem);
OnPropertyChanged(nameof(CurrentRsvpText));
OnPropertyChanged(nameof(CurrentRsvpStatus));
CloseRsvpPanel();
if (CurrentEvent == null) return;
}
catch (Exception ex)
{
Debug.WriteLine($"Error sending RSVP response: {ex.Message}");
_dialogService.InfoBarMessage(
Translator.Info_AttachmentSaveFailedTitle,
ex.Message,
InfoBarMessageType.Error);
}
}
[RelayCommand]
private async Task ViewSeriesAsync()
{
if (CurrentEvent == null || !CurrentEvent.IsRecurringChild) return;
try
{
// Get the master event from the recurring series
var masterEventId = CurrentEvent.CalendarItem.RecurringCalendarItemId.Value;
var masterEvent = await _calendarService.GetCalendarItemAsync(masterEventId);
if (masterEvent == null) return;
// Load the master event without navigation
var target = new CalendarItemTarget(masterEvent, CalendarEventTargetType.Series);
await LoadCalendarItemTargetAsync(target);
}
catch (Exception ex)
{
Debug.WriteLine($"Error loading series: {ex.Message}");
}
}
[RelayCommand]
private async Task OpenAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel)
{
if (attachmentViewModel == null || CurrentEvent?.CalendarItem == null) return;
try
{
attachmentViewModel.IsBusy = true;
// If not downloaded, download it first
if (!attachmentViewModel.IsDownloaded)
{
await DownloadAttachmentAsync(attachmentViewModel);
}
// Launch the file
if (!string.IsNullOrEmpty(attachmentViewModel.Attachment.LocalFilePath) &&
File.Exists(attachmentViewModel.Attachment.LocalFilePath))
{
await _nativeAppService.LaunchFileAsync(attachmentViewModel.Attachment.LocalFilePath);
}
}
catch (Exception ex)
{
Log.Error(ex, "Failed to open calendar attachment.");
_dialogService.InfoBarMessage(
Translator.Info_AttachmentOpenFailedTitle,
Translator.Info_AttachmentOpenFailedMessage,
InfoBarMessageType.Error);
}
finally
{
attachmentViewModel.IsBusy = false;
}
}
[RelayCommand]
private async Task SaveAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel)
{
if (attachmentViewModel == null) return;
try
{
attachmentViewModel.IsBusy = true;
var pickedPath = await _dialogService.PickWindowsFolderAsync();
if (string.IsNullOrEmpty(pickedPath)) return;
// Download if not already downloaded
if (!attachmentViewModel.IsDownloaded)
{
await DownloadAttachmentAsync(attachmentViewModel);
}
// Copy to selected location
if (!string.IsNullOrEmpty(attachmentViewModel.Attachment.LocalFilePath) &&
File.Exists(attachmentViewModel.Attachment.LocalFilePath))
{
var destinationPath = Path.Combine(pickedPath, attachmentViewModel.FileName);
File.Copy(attachmentViewModel.Attachment.LocalFilePath, destinationPath, overwrite: true);
_dialogService.InfoBarMessage(
Translator.Info_AttachmentSaveSuccessTitle,
Translator.Info_AttachmentSaveSuccessMessage,
InfoBarMessageType.Success);
}
}
catch (Exception ex)
{
Log.Error(ex, "Failed to save calendar attachment.");
_dialogService.InfoBarMessage(
Translator.Info_AttachmentSaveFailedTitle,
Translator.Info_AttachmentSaveFailedMessage,
InfoBarMessageType.Error);
}
finally
{
attachmentViewModel.IsBusy = false;
}
}
private async Task DownloadAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel)
{
if (CurrentEvent?.CalendarItem == null) return;
// Create attachments folder for this calendar item
var attachmentsFolder = Path.Combine(
_nativeAppService.GetCalendarAttachmentsFolderPath(),
CurrentEvent.CalendarItem.Id.ToString());
Directory.CreateDirectory(attachmentsFolder);
var localFilePath = Path.Combine(attachmentsFolder, attachmentViewModel.FileName);
// Download attachment using synchronizer
await SynchronizationManager.Instance.DownloadCalendarAttachmentAsync(
CurrentEvent.CalendarItem,
attachmentViewModel.Attachment,
localFilePath);
// Mark as downloaded
await _calendarService.MarkAttachmentDownloadedAsync(
attachmentViewModel.Id,
localFilePath);
// Update view model
attachmentViewModel.Attachment.IsDownloaded = true;
attachmentViewModel.Attachment.LocalFilePath = localFilePath;
OnPropertyChanged(nameof(attachmentViewModel.IsDownloaded));
}
}
public partial class ReminderOption : ObservableObject
{
public int Minutes { get; }
public bool IsCustom { get; }
[ObservableProperty]
public partial bool IsSelected { get; set; }
public string DisplayText
{
get
{
if (Minutes >= 60)
{
var hours = Minutes / 60;
return hours == 1 ? "1 Hour" : $"{hours} Hours";
}
return Minutes == 1 ? "1 Minute" : $"{Minutes} Minutes";
}
}
public ReminderOption(int minutes, bool isCustom = false)
{
Minutes = minutes;
IsCustom = isCustom;
}
}
public partial class ShowAsOption : ObservableObject
{
public CalendarItemShowAs ShowAs { get; }
public string DisplayText
{
get
{
return ShowAs switch
{
CalendarItemShowAs.Free => Translator.CalendarShowAs_Free,
CalendarItemShowAs.Tentative => Translator.CalendarShowAs_Tentative,
CalendarItemShowAs.Busy => Translator.CalendarShowAs_Busy,
CalendarItemShowAs.OutOfOffice => Translator.CalendarShowAs_OutOfOffice,
CalendarItemShowAs.WorkingElsewhere => Translator.CalendarShowAs_WorkingElsewhere,
_ => Translator.CalendarShowAs_Busy
};
}
}
public ShowAsOption(CalendarItemShowAs showAs)
{
ShowAs = showAs;
}
}
public partial class RsvpStatusOption : ObservableObject
{
public CalendarItemStatus Status { get; }
public string StatusText
{
get
{
return Status switch
{
CalendarItemStatus.Accepted => Translator.CalendarEventResponse_Accept,
CalendarItemStatus.Tentative => Translator.CalendarEventResponse_Tentative,
CalendarItemStatus.Cancelled => Translator.CalendarEventResponse_Decline,
_ => Translator.CalendarEventResponse_Accept
};
}
}
[ObservableProperty]
public partial bool IsSelected { get; set; }
public RsvpStatusOption(CalendarItemStatus status)
{
Status = status;
}
}
@@ -2,33 +2,30 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Collections;
using System.Linq;
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
namespace Wino.Calendar.ViewModels.Interfaces;
public interface IAccountCalendarStateService : INotifyPropertyChanged
namespace Wino.Calendar.ViewModels.Interfaces
{
IDispatcher Dispatcher { get; set; }
ReadOnlyObservableCollection<GroupedAccountCalendarViewModel> GroupedAccountCalendars { get; }
public interface IAccountCalendarStateService : INotifyPropertyChanged
{
ReadOnlyObservableCollection<GroupedAccountCalendarViewModel> GroupedAccountCalendars { get; }
event EventHandler<GroupedAccountCalendarViewModel> CollectiveAccountGroupSelectionStateChanged;
event EventHandler<AccountCalendarViewModel> AccountCalendarSelectionStateChanged;
event EventHandler<GroupedAccountCalendarViewModel> CollectiveAccountGroupSelectionStateChanged;
event EventHandler<AccountCalendarViewModel> AccountCalendarSelectionStateChanged;
public void AddGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
public void RemoveGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
public void ClearGroupedAccountCalendars();
public void AddGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
public void RemoveGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
public void ClearGroupedAccountCalendar();
public void AddAccountCalendar(AccountCalendarViewModel accountCalendar);
public void RemoveAccountCalendar(AccountCalendarViewModel accountCalendar);
public void AddAccountCalendar(AccountCalendarViewModel accountCalendar);
public void RemoveAccountCalendar(AccountCalendarViewModel accountCalendar);
/// <summary>
/// Enumeration of currently selected calendars.
/// </summary>
IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; }
IEnumerable<AccountCalendarViewModel> AllCalendars { get; }
bool IsAnySynchronizationInProgress { get; }
ReadOnlyObservableGroupedCollection<MailAccount, AccountCalendarViewModel> GroupedCalendars { get; set; }
/// <summary>
/// Enumeration of currently selected calendars.
/// </summary>
IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; }
IEnumerable<IGrouping<MailAccount, AccountCalendarViewModel>> GroupedAccountCalendarsEnumerable { get; }
}
}
@@ -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,13 +1,14 @@
using Wino.Calendar.ViewModels.Data;
namespace Wino.Calendar.ViewModels.Messages;
public class CalendarItemDoubleTappedMessage
namespace Wino.Calendar.ViewModels.Messages
{
public CalendarItemDoubleTappedMessage(CalendarItemViewModel calendarItemViewModel)
public class CalendarItemDoubleTappedMessage
{
CalendarItemViewModel = calendarItemViewModel;
}
public CalendarItemDoubleTappedMessage(CalendarItemViewModel calendarItemViewModel)
{
CalendarItemViewModel = calendarItemViewModel;
}
public CalendarItemViewModel CalendarItemViewModel { get; }
public CalendarItemViewModel CalendarItemViewModel { get; }
}
}
@@ -1,13 +1,14 @@
using Wino.Calendar.ViewModels.Data;
namespace Wino.Calendar.ViewModels.Messages;
public class CalendarItemRightTappedMessage
namespace Wino.Calendar.ViewModels.Messages
{
public CalendarItemRightTappedMessage(CalendarItemViewModel calendarItemViewModel)
public class CalendarItemRightTappedMessage
{
CalendarItemViewModel = calendarItemViewModel;
}
public CalendarItemRightTappedMessage(CalendarItemViewModel calendarItemViewModel)
{
CalendarItemViewModel = calendarItemViewModel;
}
public CalendarItemViewModel CalendarItemViewModel { get; }
public CalendarItemViewModel CalendarItemViewModel { get; }
}
}
@@ -1,12 +1,17 @@
using Wino.Calendar.ViewModels.Data;
namespace Wino.Calendar.ViewModels.Messages;
using Wino.Core.Domain.Models.Calendar;
public class CalendarItemTappedMessage
namespace Wino.Calendar.ViewModels.Messages
{
public CalendarItemTappedMessage(CalendarItemViewModel calendarItemViewModel)
public class CalendarItemTappedMessage
{
CalendarItemViewModel = calendarItemViewModel;
}
public CalendarItemTappedMessage(CalendarItemViewModel calendarItemViewModel, CalendarDayModel clickedPeriod)
{
CalendarItemViewModel = calendarItemViewModel;
ClickedPeriod = clickedPeriod;
}
public CalendarItemViewModel CalendarItemViewModel { get; }
public CalendarItemViewModel CalendarItemViewModel { get; }
public CalendarDayModel ClickedPeriod { get; }
}
}
@@ -1,17 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Platforms>x86;x64;arm64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>12</LangVersion>
<Platforms>AnyCPU;x64;x86</Platforms>
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TimePeriodLibrary.NET" />
<PackageReference Include="EmailValidation" />
<PackageReference Include="TimePeriodLibrary.NET" Version="2.1.5" />
</ItemGroup>
<ItemGroup>
+308
View File
@@ -0,0 +1,308 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.12.35424.110
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Core.Domain", "Wino.Core.Domain\Wino.Core.Domain.csproj", "{814400B6-5A05-4596-B451-3A116A147DC1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wino.Core.UWP", "Wino.Core.UWP\Wino.Core.UWP.csproj", "{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Core.ViewModels", "Wino.Core.ViewModels\Wino.Core.ViewModels.csproj", "{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Messaging", "Wino.Messages\Wino.Messaging.csproj", "{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Server", "Wino.Server\Wino.Server.csproj", "{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Core", "Wino.Core\Wino.Core.csproj", "{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wino.Calendar", "Wino.Calendar\Wino.Calendar.csproj", "{600F4979-DB7E-409D-B7DA-B60BE4C55C35}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.SourceGenerators", "Wino.SourceGenerators\Wino.SourceGenerators.csproj", "{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}"
EndProject
Project("{C7167F0D-BC9F-4E6E-AFE1-012C56B48DB5}") = "Wino.Calendar.Packaging", "Wino.Calendar.Packaging\Wino.Calendar.Packaging.wapproj", "{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Calendar.ViewModels", "Wino.Calendar.ViewModels\Wino.Calendar.ViewModels.csproj", "{CF850F8C-5042-4376-9CBA-C8F2BB554083}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wino.Services", "Wino.Services\Wino.Services.csproj", "{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wino.Authentication", "Wino.Authentication\Wino.Authentication.csproj", "{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|ARM = Debug|ARM
Debug|ARM64 = Debug|ARM64
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|ARM = Release|ARM
Release|ARM64 = Release|ARM64
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|ARM.ActiveCfg = Debug|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|ARM.Build.0 = Debug|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|ARM64.Build.0 = Debug|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|x64.ActiveCfg = Debug|x64
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|x64.Build.0 = Debug|x64
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|x86.ActiveCfg = Debug|x86
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|x86.Build.0 = Debug|x86
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|Any CPU.Build.0 = Release|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|ARM.ActiveCfg = Release|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|ARM.Build.0 = Release|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|ARM64.ActiveCfg = Release|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|ARM64.Build.0 = Release|Any CPU
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|x64.ActiveCfg = Release|x64
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|x64.Build.0 = Release|x64
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|x86.ActiveCfg = Release|x86
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|x86.Build.0 = Release|x86
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|ARM.ActiveCfg = Debug|Any CPU
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|ARM.Build.0 = Debug|Any CPU
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|ARM64.ActiveCfg = Debug|ARM64
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|ARM64.Build.0 = Debug|ARM64
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|x64.ActiveCfg = Debug|x64
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|x64.Build.0 = Debug|x64
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|x86.ActiveCfg = Debug|x86
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|x86.Build.0 = Debug|x86
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|Any CPU.Build.0 = Release|Any CPU
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|ARM.ActiveCfg = Release|Any CPU
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|ARM.Build.0 = Release|Any CPU
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|ARM64.ActiveCfg = Release|ARM64
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|ARM64.Build.0 = Release|ARM64
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|x64.ActiveCfg = Release|x64
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|x64.Build.0 = Release|x64
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|x86.ActiveCfg = Release|x86
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|x86.Build.0 = Release|x86
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|ARM.ActiveCfg = Debug|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|ARM.Build.0 = Debug|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|ARM64.Build.0 = Debug|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|x64.ActiveCfg = Debug|x64
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|x64.Build.0 = Debug|x64
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|x86.ActiveCfg = Debug|x86
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|x86.Build.0 = Debug|x86
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|Any CPU.Build.0 = Release|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|ARM.ActiveCfg = Release|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|ARM.Build.0 = Release|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|ARM64.ActiveCfg = Release|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|ARM64.Build.0 = Release|Any CPU
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|x64.ActiveCfg = Release|x64
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|x64.Build.0 = Release|x64
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|x86.ActiveCfg = Release|x86
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|x86.Build.0 = Release|x86
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|ARM.ActiveCfg = Debug|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|ARM.Build.0 = Debug|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|ARM64.Build.0 = Debug|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|x64.ActiveCfg = Debug|x64
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|x64.Build.0 = Debug|x64
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|x86.ActiveCfg = Debug|x86
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|x86.Build.0 = Debug|x86
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|Any CPU.Build.0 = Release|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|ARM.ActiveCfg = Release|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|ARM.Build.0 = Release|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|ARM64.ActiveCfg = Release|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|ARM64.Build.0 = Release|Any CPU
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|x64.ActiveCfg = Release|x64
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|x64.Build.0 = Release|x64
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|x86.ActiveCfg = Release|x86
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|x86.Build.0 = Release|x86
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|Any CPU.ActiveCfg = Debug|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|Any CPU.Build.0 = Debug|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|ARM.ActiveCfg = Debug|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|ARM.Build.0 = Debug|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|ARM64.ActiveCfg = Debug|ARM64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|ARM64.Build.0 = Debug|ARM64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|x64.ActiveCfg = Debug|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|x64.Build.0 = Debug|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|x86.ActiveCfg = Debug|x86
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|x86.Build.0 = Debug|x86
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|Any CPU.ActiveCfg = Release|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|Any CPU.Build.0 = Release|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|ARM.ActiveCfg = Release|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|ARM.Build.0 = Release|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|ARM64.ActiveCfg = Release|ARM64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|ARM64.Build.0 = Release|ARM64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|x64.ActiveCfg = Release|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|x64.Build.0 = Release|x64
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|x86.ActiveCfg = Release|x86
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|x86.Build.0 = Release|x86
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|ARM.ActiveCfg = Debug|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|ARM.Build.0 = Debug|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|ARM64.Build.0 = Debug|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|x64.ActiveCfg = Debug|x64
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|x64.Build.0 = Debug|x64
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|x86.ActiveCfg = Debug|x86
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|x86.Build.0 = Debug|x86
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|Any CPU.Build.0 = Release|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|ARM.ActiveCfg = Release|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|ARM.Build.0 = Release|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|ARM64.ActiveCfg = Release|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|ARM64.Build.0 = Release|Any CPU
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|x64.ActiveCfg = Release|x64
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|x64.Build.0 = Release|x64
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|x86.ActiveCfg = Release|x86
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|x86.Build.0 = Release|x86
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|Any CPU.ActiveCfg = Debug|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|Any CPU.Build.0 = Debug|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|Any CPU.Deploy.0 = Debug|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM.ActiveCfg = Debug|ARM
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM.Build.0 = Debug|ARM
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM.Deploy.0 = Debug|ARM
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM64.ActiveCfg = Debug|ARM64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM64.Build.0 = Debug|ARM64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM64.Deploy.0 = Debug|ARM64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x64.ActiveCfg = Debug|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x64.Build.0 = Debug|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x64.Deploy.0 = Debug|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x86.ActiveCfg = Debug|x86
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x86.Build.0 = Debug|x86
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x86.Deploy.0 = Debug|x86
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|Any CPU.ActiveCfg = Release|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|Any CPU.Build.0 = Release|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|Any CPU.Deploy.0 = Release|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM.ActiveCfg = Release|ARM
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM.Build.0 = Release|ARM
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM.Deploy.0 = Release|ARM
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM64.ActiveCfg = Release|ARM64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM64.Build.0 = Release|ARM64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM64.Deploy.0 = Release|ARM64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x64.ActiveCfg = Release|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x64.Build.0 = Release|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x64.Deploy.0 = Release|x64
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x86.ActiveCfg = Release|x86
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x86.Build.0 = Release|x86
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x86.Deploy.0 = Release|x86
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|ARM.ActiveCfg = Debug|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|ARM.Build.0 = Debug|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|ARM64.Build.0 = Debug|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|x64.ActiveCfg = Debug|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|x64.Build.0 = Debug|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|x86.ActiveCfg = Debug|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|x86.Build.0 = Debug|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|Any CPU.Build.0 = Release|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|ARM.ActiveCfg = Release|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|ARM.Build.0 = Release|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|ARM64.ActiveCfg = Release|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|ARM64.Build.0 = Release|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|x64.ActiveCfg = Release|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|x64.Build.0 = Release|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|x86.ActiveCfg = Release|Any CPU
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|x86.Build.0 = Release|Any CPU
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM.ActiveCfg = Debug|ARM
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM.Build.0 = Debug|ARM
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM.Deploy.0 = Debug|ARM
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM64.ActiveCfg = Debug|ARM64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM64.Build.0 = Debug|ARM64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM64.Deploy.0 = Debug|ARM64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x64.ActiveCfg = Debug|x64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x64.Build.0 = Debug|x64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x64.Deploy.0 = Debug|x64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x86.ActiveCfg = Debug|x86
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x86.Build.0 = Debug|x86
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x86.Deploy.0 = Debug|x86
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|Any CPU.Build.0 = Release|Any CPU
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|Any CPU.Deploy.0 = Release|Any CPU
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM.ActiveCfg = Release|ARM
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM.Build.0 = Release|ARM
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM.Deploy.0 = Release|ARM
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM64.ActiveCfg = Release|ARM64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM64.Build.0 = Release|ARM64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM64.Deploy.0 = Release|ARM64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x64.ActiveCfg = Release|x64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x64.Build.0 = Release|x64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x64.Deploy.0 = Release|x64
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x86.ActiveCfg = Release|x86
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x86.Build.0 = Release|x86
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x86.Deploy.0 = Release|x86
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|ARM.ActiveCfg = Debug|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|ARM.Build.0 = Debug|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|ARM64.Build.0 = Debug|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|x64.ActiveCfg = Debug|x64
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|x64.Build.0 = Debug|x64
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|x86.ActiveCfg = Debug|x86
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|x86.Build.0 = Debug|x86
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|Any CPU.Build.0 = Release|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|ARM.ActiveCfg = Release|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|ARM.Build.0 = Release|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|ARM64.ActiveCfg = Release|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|ARM64.Build.0 = Release|Any CPU
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|x64.ActiveCfg = Release|x64
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|x64.Build.0 = Release|x64
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|x86.ActiveCfg = Release|x86
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|x86.Build.0 = Release|x86
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|ARM.ActiveCfg = Debug|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|ARM.Build.0 = Debug|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|ARM64.Build.0 = Debug|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|x64.ActiveCfg = Debug|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|x64.Build.0 = Debug|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|x86.ActiveCfg = Debug|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|x86.Build.0 = Debug|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|Any CPU.Build.0 = Release|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|ARM.ActiveCfg = Release|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|ARM.Build.0 = Release|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|ARM64.ActiveCfg = Release|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|ARM64.Build.0 = Release|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|x64.ActiveCfg = Release|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|x64.Build.0 = Release|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|x86.ActiveCfg = Release|Any CPU
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|x86.Build.0 = Release|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|Any CPU.Build.0 = Debug|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|ARM.ActiveCfg = Debug|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|ARM.Build.0 = Debug|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|ARM64.Build.0 = Debug|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|x64.ActiveCfg = Debug|x64
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|x64.Build.0 = Debug|x64
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|x86.ActiveCfg = Debug|x86
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|x86.Build.0 = Debug|x86
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|Any CPU.ActiveCfg = Release|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|Any CPU.Build.0 = Release|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|ARM.ActiveCfg = Release|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|ARM.Build.0 = Release|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|ARM64.ActiveCfg = Release|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|ARM64.Build.0 = Release|Any CPU
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|x64.ActiveCfg = Release|x64
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|x64.Build.0 = Release|x64
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|x86.ActiveCfg = Release|x86
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|x86.Build.0 = Release|x86
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
@@ -0,0 +1,24 @@
using System.Threading.Tasks;
using Windows.ApplicationModel.Activation;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media.Animation;
using Wino.Activation;
using Wino.Calendar.Views;
namespace Wino.Calendar.Activation
{
public class DefaultActivationHandler : ActivationHandler<IActivatedEventArgs>
{
protected override Task HandleInternalAsync(IActivatedEventArgs args)
{
(Window.Current.Content as Frame).Navigate(typeof(AppShell), null, new DrillInNavigationTransitionInfo());
return Task.CompletedTask;
}
// Only navigate if Frame content doesn't exist.
protected override bool CanHandleInternal(IActivatedEventArgs args)
=> (Window.Current?.Content as Frame)?.Content == null;
}
}
+31
View File
@@ -0,0 +1,31 @@
<core:WinoApplication
x:Class="Wino.Calendar.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.UI.Xaml.Controls"
xmlns:core="using:Wino.Core.UWP"
xmlns:coreStyles="using:Wino.Core.UWP.Styles"
xmlns:local="using:Wino.Calendar"
xmlns:styles="using:Wino.Calendar.Styles">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<core:CoreGeneric />
<styles:WinoCalendarResources />
<ResourceDictionary Source="Styles/CalendarThemeResources.xaml" />
<ResourceDictionary Source="Styles/WinoDayTimelineCanvas.xaml" />
<ResourceDictionary Source="Styles/WinoCalendarView.xaml" />
<ResourceDictionary Source="Styles/WinoCalendarTypeSelectorControl.xaml" />
<!-- Last item must always be the default theme. -->
<ResourceDictionary Source="ms-appx:///Wino.Core.UWP/AppThemes/Mica.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</core:WinoApplication>
+164
View File
@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Windows.ApplicationModel;
using Windows.ApplicationModel.Activation;
using Windows.ApplicationModel.AppService;
using Windows.ApplicationModel.Background;
using Windows.UI.Core.Preview;
using Wino.Activation;
using Wino.Calendar.Activation;
using Wino.Calendar.Services;
using Wino.Calendar.ViewModels;
using Wino.Calendar.ViewModels.Interfaces;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.UWP;
using Wino.Core.ViewModels;
using Wino.Messaging.Client.Connection;
using Wino.Messaging.Server;
using Wino.Services;
namespace Wino.Calendar
{
public sealed partial class App : WinoApplication, IRecipient<NewCalendarSynchronizationRequested>
{
public override string AppCenterKey => "dfdad6ab-95f9-44cc-9112-45ec6730c49e";
private BackgroundTaskDeferral connectionBackgroundTaskDeferral;
private BackgroundTaskDeferral toastActionBackgroundTaskDeferral;
public App()
{
InitializeComponent();
WeakReferenceMessenger.Default.Register(this);
}
public override IServiceProvider ConfigureServices()
{
var services = new ServiceCollection();
services.RegisterSharedServices();
services.RegisterCalendarViewModelServices();
services.RegisterCoreUWPServices();
services.RegisterCoreViewModels();
RegisterUWPServices(services);
RegisterViewModels(services);
RegisterActivationHandlers(services);
return services.BuildServiceProvider();
}
#region Dependency Injection
private void RegisterActivationHandlers(IServiceCollection services)
{
//services.AddTransient<ProtocolActivationHandler>();
//services.AddTransient<ToastNotificationActivationHandler>();
//services.AddTransient<FileActivationHandler>();
}
private void RegisterUWPServices(IServiceCollection services)
{
services.AddSingleton<INavigationService, NavigationService>();
services.AddSingleton<ICalendarDialogService, DialogService>();
services.AddTransient<ISettingsBuilderService, SettingsBuilderService>();
services.AddTransient<IProviderService, ProviderService>();
services.AddSingleton<IAuthenticatorConfig, CalendarAuthenticatorConfig>();
services.AddSingleton<IAccountCalendarStateService, AccountCalendarStateService>();
}
private void RegisterViewModels(IServiceCollection services)
{
services.AddSingleton(typeof(AppShellViewModel));
services.AddSingleton(typeof(CalendarPageViewModel));
services.AddTransient(typeof(CalendarSettingsPageViewModel));
services.AddTransient(typeof(AccountManagementViewModel));
services.AddTransient(typeof(PersonalizationPageViewModel));
services.AddTransient(typeof(AccountDetailsPageViewModel));
services.AddTransient(typeof(EventDetailsPageViewModel));
}
#endregion
protected override void OnApplicationCloseRequested(object sender, SystemNavigationCloseRequestedPreviewEventArgs e)
{
// TODO: Check server running.
}
protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
LogActivation($"OnLaunched -> {args.GetType().Name}, Kind -> {args.Kind}, PreviousExecutionState -> {args.PreviousExecutionState}, IsPrelaunch -> {args.PrelaunchActivated}");
if (!args.PrelaunchActivated)
{
await ActivateWinoAsync(args);
}
}
protected override IEnumerable<ActivationHandler> GetActivationHandlers()
{
return null;
}
protected override ActivationHandler<IActivatedEventArgs> GetDefaultActivationHandler()
=> new DefaultActivationHandler();
protected override void OnBackgroundActivated(BackgroundActivatedEventArgs args)
{
base.OnBackgroundActivated(args);
if (args.TaskInstance.TriggerDetails is AppServiceTriggerDetails appServiceTriggerDetails)
{
LogActivation("OnBackgroundActivated -> AppServiceTriggerDetails received.");
// Only accept connections from callers in the same package
if (appServiceTriggerDetails.CallerPackageFamilyName == Package.Current.Id.FamilyName)
{
// Connection established from the fulltrust process
connectionBackgroundTaskDeferral = args.TaskInstance.GetDeferral();
args.TaskInstance.Canceled += OnConnectionBackgroundTaskCanceled;
AppServiceConnectionManager.Connection = appServiceTriggerDetails.AppServiceConnection;
WeakReferenceMessenger.Default.Send(new WinoServerConnectionEstablished());
}
}
}
public void OnConnectionBackgroundTaskCanceled(IBackgroundTaskInstance sender, BackgroundTaskCancellationReason reason)
{
sender.Canceled -= OnConnectionBackgroundTaskCanceled;
Log.Information($"Server connection background task was canceled. Reason: {reason}");
connectionBackgroundTaskDeferral?.Complete();
connectionBackgroundTaskDeferral = null;
AppServiceConnectionManager.Connection = null;
}
public async void Receive(NewCalendarSynchronizationRequested message)
{
try
{
var synchronizationResultResponse = await AppServiceConnectionManager.GetResponseAsync<CalendarSynchronizationResult, NewCalendarSynchronizationRequested>(message);
synchronizationResultResponse.ThrowIfFailed();
}
catch (WinoServerException serverException)
{
var dialogService = Services.GetService<ICalendarDialogService>();
dialogService.InfoBarMessage(Translator.Info_SyncFailedTitle, serverException.Message, InfoBarMessageType.Error);
}
}
}
}
@@ -0,0 +1,41 @@
using System;
using Windows.Foundation;
namespace Wino.Calendar.Args
{
/// <summary>
/// When a new timeline cell is selected.
/// </summary>
public class TimelineCellSelectedArgs : EventArgs
{
public TimelineCellSelectedArgs(DateTime clickedDate, Point canvasPoint, Point positionerPoint, Size cellSize)
{
ClickedDate = clickedDate;
CanvasPoint = canvasPoint;
PositionerPoint = positionerPoint;
CellSize = cellSize;
}
/// <summary>
/// Clicked date and time information for the cell.
/// </summary>
public DateTime ClickedDate { get; set; }
/// <summary>
/// Position relative to the cell drawing part of the canvas.
/// Used to detect clicked cell from the position.
/// </summary>
public Point CanvasPoint { get; }
/// <summary>
/// Position relative to the main root positioner element of the drawing canvas.
/// Used to show the create event dialog teaching tip in correct position.
/// </summary>
public Point PositionerPoint { get; }
/// <summary>
/// Size of the cell.
/// </summary>
public Size CellSize { get; }
}
}
@@ -0,0 +1,9 @@
using System;
namespace Wino.Calendar.Args
{
/// <summary>
/// When selected timeline cell is unselected.
/// </summary>
public class TimelineCellUnselectedArgs : EventArgs { }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

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