2 Commits

Author SHA1 Message Date
openai-code-agent[bot] c6cd06c65f Initial plan 2026-02-28 01:25:23 +00:00
Burak Kaan Köse c942066878 Filter reminder snooze options by default reminder 2026-02-27 21:57:41 +01:00
695 changed files with 12875 additions and 62891 deletions
-1
View File
@@ -100,7 +100,6 @@ _dialogService.InfoBarMessage(Translator.Info_MissingFolderTitle, message);
- **NEVER** create IValueConverter classes or add them to Converters.xaml
- **NEVER** 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
-10
View File
@@ -8,10 +8,6 @@ on:
- reopened
- ready_for_review
permissions:
contents: read
packages: read
jobs:
build-winui:
name: Build project (${{ matrix.platform }})
@@ -37,9 +33,6 @@ jobs:
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 }}
@@ -61,9 +54,6 @@ jobs:
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
+11 -40
View File
@@ -1,6 +1,6 @@
# AGENTS.md
# CLAUDE.md
This file provides guidance to AI agent when working with code in this repository.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
@@ -12,17 +12,14 @@ Wino Mail is a native Windows mail client (Windows 10 1809+ / Windows 11) replac
# 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 from command line
dotnet build WinoMail.slnx -c Debug
# 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
dotnet test Wino.Core.Tests/Wino.Core.Tests.csproj
# 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
# Build specific platform
dotnet build WinoMail.slnx -c Debug /p:Platform=x64
```
**Prerequisites:** Visual Studio 2022+ with ".NET desktop development" workload, .NET SDK 10+
@@ -31,16 +28,6 @@ dotnet restore Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configfile nuget.config
**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
@@ -103,17 +90,13 @@ private string searchQuery = string.Empty;
- **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
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`
3. Use `Translator.{PropertyName}` in code/XAML
4. **NEVER** edit other language files - Crowdin manages translations
## Storage
@@ -137,7 +120,6 @@ private string searchQuery = string.Empty;
- 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
@@ -147,15 +129,4 @@ private string searchQuery = string.Empty;
- 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`.
+34 -37
View File
@@ -4,9 +4,9 @@
</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.Common" Version="8.4.0" />
<PackageVersion Include="CommunityToolkit.Diagnostics" Version="8.4.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.1-build.4" />
<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" />
@@ -15,66 +15,63 @@
<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="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<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="Ical.Net" Version="4.3.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.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Graph" Version="5.99.0" />
<PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.3.2" />
<PackageVersion Include="Microsoft.Identity.Client" Version="4.79.2" />
<PackageVersion Include="Microsoft.Identity.Client.Broker" Version="4.79.2" />
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.79.2" />
<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="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="3.0.0" />
<PackageVersion Include="MimeKit" Version="4.14.0" />
<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="NodaTime" Version="3.2.3" />
<PackageVersion Include="Sentry.Serilog" Version="6.0.0" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<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="SkiaSharp" Version="3.119.1" />
<PackageVersion Include="SkiaSharp.Views.WinUI" Version="3.119.1" />
<PackageVersion Include="sqlite-net-pcl" Version="1.10.196-beta" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.5" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.1" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
<PackageVersion Include="System.Text.Json" Version="10.0.1" />
<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.Calendar.v3" Version="1.73.0.3993" />
<PackageVersion Include="Google.Apis.Gmail.v1" Version="1.73.0.3987" />
<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="MailKit" Version="4.14.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.Bcl.AsyncInterfaces" Version="10.0.1" />
<PackageVersion Include="System.Text.Encodings.Web" Version="10.0.1" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7175" />
<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="Microsoft.NET.Test.Sdk" Version="17.11.0" />
<PackageVersion Include="xunit" Version="2.9.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="FluentAssertions" Version="7.0.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
</ItemGroup>
</Project>
</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.
@@ -35,6 +35,9 @@ public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewMode
[ObservableProperty]
public partial bool IsSyncEnabled { get; set; }
[ObservableProperty]
public partial bool IsPrimaryCalendar { get; set; }
public ObservableCollection<ShowAsOption> ShowAsOptions { get; } = new ObservableCollection<ShowAsOption>();
[ObservableProperty]
@@ -79,6 +82,7 @@ public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewMode
// Initialize properties from AccountCalendar
AccountColorHex = AccountCalendar.BackgroundColorHex ?? "#0078D4";
IsSyncEnabled = AccountCalendar.IsSynchronizationEnabled;
IsPrimaryCalendar = AccountCalendar.IsPrimary;
SelectedDefaultShowAsOption = ShowAsOptions.FirstOrDefault(o => o.ShowAs == AccountCalendar.DefaultShowAs) ?? ShowAsOptions[2];
}
@@ -87,7 +91,6 @@ public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewMode
if (AccountCalendar != null && !string.IsNullOrEmpty(value))
{
AccountCalendar.BackgroundColorHex = value;
AccountCalendar.IsBackgroundColorUserOverridden = true;
SaveChangesAsync();
}
}
@@ -101,6 +104,15 @@ public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewMode
}
}
partial void OnIsPrimaryCalendarChanged(bool value)
{
if (AccountCalendar != null)
{
AccountCalendar.IsPrimary = value;
SaveChangesAsync();
}
}
partial void OnSelectedDefaultShowAsOptionChanged(ShowAsOption value)
{
if (AccountCalendar != null && value != null)
@@ -1,8 +1,6 @@
using System;
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;
@@ -11,25 +9,25 @@ 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.Extensions;
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.Client.Navigation;
using Wino.Messaging.Server;
using Wino.Messaging.UI;
namespace Wino.Calendar.ViewModels;
public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
ICalendarShellClient,
IRecipient<VisibleDateRangeChangedMessage>,
IRecipient<CalendarEnableStatusChangedMessage>,
IRecipient<NavigateManageAccountsRequested>,
IRecipient<CalendarDisplayTypeChangedMessage>,
IRecipient<AccountRemovedMessage>
{
@@ -37,31 +35,27 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
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 bool isCalendarEnabled;
/// <summary>
/// Gets or sets the display date of the calendar.
/// </summary>
[ObservableProperty]
private DateTimeOffset _displayDate;
/// <summary>
/// Gets or sets the highlighted range in the CalendarView and displayed date range in FlipView.
/// </summary>
[ObservableProperty]
private DateRange highlightedDateRange;
[ObservableProperty]
private ObservableRangeCollection<string> dateNavigationHeaderItems = [];
@@ -70,47 +64,28 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
public bool IsVerticalCalendar => StatePersistenceService.CalendarDisplayType == CalendarDisplayType.Month;
[ObservableProperty]
private bool isStoreUpdateItemVisible;
// For updating account calendars asynchronously.
private SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
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)
public CalendarAppShellViewModel(IPreferencesService preferencesService,
IStatePersistanceService statePersistanceService,
IAccountService accountService,
ICalendarService calendarService,
IAccountCalendarStateService accountCalendarStateService,
INavigationService navigationService)
{
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;
AccountCalendarStateService = accountCalendarStateService;
AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested;
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged += AccountCalendarStateCollectivelyChanged;
NavigationService = navigationService;
PreferencesService = preferencesService;
StatePersistenceService = statePersistanceService;
StatePersistenceService.StatePropertyChanged += PrefefencesChanged;
}
protected override void OnDispatcherAssigned()
@@ -118,149 +93,45 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
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))
if (e == nameof(StatePersistenceService.CalendarDisplayType))
{
await RefreshFooterItemsAsync(false);
Messenger.Send(new CalendarDisplayTypeChangedMessage(StatePersistenceService.CalendarDisplayType));
UpdateDateNavigationHeaderItems();
// Change the calendar.
DateClicked(new CalendarViewDayClickedEventArgs(GetDisplayTypeSwitchDate()));
}
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
if (!_hasRegisteredPersistentRecipients)
base.OnNavigatedTo(mode, parameters);
// Account list may have changed while this shell was inactive.
if (mode == NavigationMode.Back)
{
RegisterRecipients();
_hasRegisteredPersistentRecipients = true;
await InitializeAccountCalendarsAsync();
return;
}
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();
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)
{
// 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();
@@ -292,7 +163,15 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
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 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(() =>
@@ -302,154 +181,121 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
}
}
private void NavigateCalendarDate(DateTime date)
{
_navigationDate = date.Date;
ForceNavigateCalendarDate();
}
private void ForceNavigateCalendarDate()
{
var args = new CalendarPageNavigationArgs
if (SelectedMenuItemIndex == -1)
{
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
var args = new CalendarPageNavigationArgs()
{
AccountId = account.Id,
Type = CalendarSynchronizationType.Strict
}));
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.CalendarEvents
});
Messenger.Send(t);
}
}
/// <summary>
/// When calendar type switches, we need to navigate to the most ideal date.
/// This method returns that date.
/// </summary>
private DateTime GetDisplayTypeSwitchDate()
{
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);
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;
default:
break;
}
return DateTime.Today.Date;
}
private DateTime? _navigationDate;
private readonly IAccountService _accountService;
private readonly ICalendarService _calendarService;
#region Commands
[RelayCommand]
private void TodayClicked()
{
NavigateCalendarDate(_dateContextProvider.GetToday().ToDateTime(TimeOnly.MinValue));
_navigationDate = DateTime.Now.Date;
ForceNavigateCalendarDate();
}
[RelayCommand]
private void PreviousDateRange()
{
NavigateRelativePeriod(-1);
}
public void ManageAccounts() => NavigationService.Navigate(WinoPage.AccountManagementPage);
[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);
{
_navigationDate = clickedDateArgs.ClickedDate;
ForceNavigateCalendarDate();
}
#endregion
protected override void RegisterRecipients()
{
@@ -457,6 +303,9 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
UnregisterRecipients();
Messenger.Register<VisibleDateRangeChangedMessage>(this);
Messenger.Register<CalendarEnableStatusChangedMessage>(this);
Messenger.Register<NavigateManageAccountsRequested>(this);
Messenger.Register<CalendarDisplayTypeChangedMessage>(this);
Messenger.Register<AccountRemovedMessage>(this);
}
@@ -465,17 +314,99 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
{
base.UnregisterRecipients();
Messenger.Unregister<VisibleDateRangeChangedMessage>(this);
Messenger.Unregister<CalendarEnableStatusChangedMessage>(this);
Messenger.Unregister<NavigateManageAccountsRequested>(this);
Messenger.Unregister<CalendarDisplayTypeChangedMessage>(this);
Messenger.Unregister<AccountRemovedMessage>(this);
}
public void Receive(VisibleDateRangeChangedMessage message) => HighlightedDateRange = message.DateRange;
/// <summary>
/// Sets the header navigation items based on visible date range and calendar type.
/// </summary>
private void UpdateDateNavigationHeaderItems()
{
var headerText = VisibleDateRangeText;
DateNavigationHeaderItems.ReplaceRange(string.IsNullOrWhiteSpace(headerText) ? [] : [headerText]);
var settings = PreferencesService.GetCurrentCalendarSettings();
var cultureInfo = settings.CultureInfo ?? CultureInfo.CurrentUICulture;
var visibleRange = HighlightedDateRange ?? new DateRange(DateTime.Today, DateTime.Today.AddDays(1));
var headerText = GetHeaderText(visibleRange, cultureInfo);
DateNavigationHeaderItems.ReplaceRange([headerText]);
SelectedDateNavigationHeaderIndex = DateNavigationHeaderItems.Count > 0 ? 0 : -1;
}
private string GetHeaderText(DateRange visibleRange, CultureInfo cultureInfo)
{
var startDate = visibleRange.StartDate.Date;
var endDate = visibleRange.EndDate.Date > startDate ? visibleRange.EndDate.Date.AddDays(-1) : startDate;
switch (StatePersistenceService.CalendarDisplayType)
{
case CalendarDisplayType.Day:
return startDate.ToString("MMMM d, dddd", cultureInfo);
case CalendarDisplayType.Week:
case CalendarDisplayType.WorkWeek:
if (startDate.Month == endDate.Month && startDate.Year == endDate.Year)
{
return $"{startDate.ToString("MMMM d", cultureInfo)} - {endDate.ToString("%d", cultureInfo)}";
}
return $"{startDate.ToString("MMMM d", cultureInfo)} - {endDate.ToString("MMMM d", cultureInfo)}";
case CalendarDisplayType.Month:
return GetDominantMonthHeaderText(startDate, endDate, cultureInfo);
default:
return startDate.ToString("d", cultureInfo);
}
}
private static string GetDominantMonthHeaderText(DateTime startDate, DateTime endDate, CultureInfo cultureInfo)
{
if (endDate < startDate)
{
endDate = startDate;
}
var monthDayCounts = new Dictionary<(int Year, int Month), int>();
for (var day = startDate; day <= endDate; day = day.AddDays(1))
{
var key = (day.Year, day.Month);
if (monthDayCounts.TryGetValue(key, out var count))
{
monthDayCounts[key] = count + 1;
}
else
{
monthDayCounts[key] = 1;
}
}
var dominantKey = (Year: startDate.Year, Month: startDate.Month);
var dominantCount = -1;
foreach (var pair in monthDayCounts)
{
if (pair.Value > dominantCount)
{
dominantCount = pair.Value;
dominantKey = pair.Key;
}
}
return new DateTime(dominantKey.Year, dominantKey.Month, 1).ToString("Y", cultureInfo);
}
partial void OnHighlightedDateRangeChanged(DateRange value) => UpdateDateNavigationHeaderItems();
public async void Receive(CalendarEnableStatusChangedMessage message)
=> await ExecuteUIThread(() => IsCalendarEnabled = message.IsEnabled);
public void Receive(NavigateManageAccountsRequested message) => SelectedMenuItemIndex = 1;
public void Receive(CalendarDisplayTypeChangedMessage message)
{
OnPropertyChanged(nameof(IsVerticalCalendar));
@@ -483,69 +414,5 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
}
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;
=> await InitializeAccountCalendarsAsync();
}
@@ -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,210 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Translations;
using Wino.Core.ViewModels;
using Wino.Messaging.Client.Calendar;
using Wino.Messaging.Client.Navigation;
namespace Wino.Calendar.ViewModels;
public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
{
[ObservableProperty]
public partial double CellHourHeight { get; set; }
[ObservableProperty]
public partial int SelectedFirstDayOfWeekIndex { get; set; }
[ObservableProperty]
public partial bool Is24HourHeaders { get; set; }
[ObservableProperty]
public partial TimeSpan WorkingHourStart { get; set; }
[ObservableProperty]
public partial TimeSpan WorkingHourEnd { get; set; }
[ObservableProperty]
public partial List<string> DayNames { get; set; } = [];
[ObservableProperty]
public partial int WorkingDayStartIndex { get; set; }
[ObservableProperty]
public partial int WorkingDayEndIndex { get; set; }
[ObservableProperty]
public partial List<string> ReminderOptions { get; set; } = [];
[ObservableProperty]
public partial int SelectedDefaultReminderIndex { get; set; }
public IPreferencesService PreferencesService { get; }
private readonly ICalendarService _calendarService;
private readonly IAccountService _accountService;
public ObservableCollection<MailAccount> Accounts { get; } = new ObservableCollection<MailAccount>();
private readonly bool _isLoaded = false;
public CalendarSettingsPageViewModel(IPreferencesService preferencesService, ICalendarService calendarService, IAccountService accountService)
{
PreferencesService = preferencesService;
_calendarService = calendarService;
_accountService = accountService;
var currentLanguageLanguageCode = WinoTranslationDictionary.GetLanguageFileNameRelativePath(preferencesService.CurrentLanguage);
var cultureInfo = new CultureInfo(currentLanguageLanguageCode);
// Populate the day names list
for (var i = 0; i < 7; i++)
{
DayNames.Add(cultureInfo.DateTimeFormat.DayNames[i]);
}
var cultureFirstDayName = cultureInfo.DateTimeFormat.GetDayName(preferencesService.FirstDayOfWeek);
SelectedFirstDayOfWeekIndex = DayNames.IndexOf(cultureFirstDayName);
Is24HourHeaders = preferencesService.Prefer24HourTimeFormat;
WorkingHourStart = preferencesService.WorkingHourStart;
WorkingHourEnd = preferencesService.WorkingHourEnd;
CellHourHeight = preferencesService.HourHeight;
WorkingDayStartIndex = DayNames.IndexOf(cultureInfo.DateTimeFormat.GetDayName(preferencesService.WorkingDayStart));
WorkingDayEndIndex = DayNames.IndexOf(cultureInfo.DateTimeFormat.GetDayName(preferencesService.WorkingDayEnd));
// Initialize reminder options
var predefinedMinutes = _calendarService.GetPredefinedReminderMinutes();
ReminderOptions.Add("None");
foreach (var minutes in predefinedMinutes)
{
var displayText = minutes switch
{
>= 60 => $"{minutes / 60} Hour{(minutes / 60 > 1 ? "s" : "")}",
_ => $"{minutes} Minute{(minutes > 1 ? "s" : "")}"
};
ReminderOptions.Add(displayText);
}
// Set selected index based on current default reminder setting
if (preferencesService.DefaultReminderDurationInSeconds == 0)
{
SelectedDefaultReminderIndex = 0; // None
}
else
{
var minutes = (int)(preferencesService.DefaultReminderDurationInSeconds / 60);
var index = Array.IndexOf(predefinedMinutes, minutes);
SelectedDefaultReminderIndex = index >= 0 ? index + 1 : 0;
}
_isLoaded = true;
// Load accounts with calendar support
LoadAccountsAsync();
}
private async void LoadAccountsAsync()
{
var accounts = await _accountService.GetAccountsAsync();
await Dispatcher.ExecuteOnUIThread(() =>
{
Accounts.Clear();
foreach (var account in accounts)
{
Accounts.Add(account);
}
});
}
[RelayCommand]
private void NavigateToAccountSettings(MailAccount account)
{
if (account == null) return;
Messenger.Send(new BreadcrumbNavigationRequested(
string.Format(Translator.CalendarAccountSettings_Description, account.Name),
WinoPage.CalendarAccountSettingsPage,
account.Id));
}
partial void OnCellHourHeightChanged(double oldValue, double newValue) => SaveSettings();
partial void OnIs24HourHeadersChanged(bool value) => SaveSettings();
partial void OnSelectedFirstDayOfWeekIndexChanged(int value) => SaveSettings();
partial void OnWorkingHourStartChanged(TimeSpan value) => SaveSettings();
partial void OnWorkingHourEndChanged(TimeSpan value) => SaveSettings();
partial void OnWorkingDayStartIndexChanged(int value) => SaveSettings();
partial void OnWorkingDayEndIndexChanged(int value) => SaveSettings();
partial void OnSelectedDefaultReminderIndexChanged(int value) => SaveSettings();
public void SaveSettings()
{
if (!_isLoaded) return;
PreferencesService.FirstDayOfWeek = SelectedFirstDayOfWeekIndex switch
{
0 => DayOfWeek.Sunday,
1 => DayOfWeek.Monday,
2 => DayOfWeek.Tuesday,
3 => DayOfWeek.Wednesday,
4 => DayOfWeek.Thursday,
5 => DayOfWeek.Friday,
6 => DayOfWeek.Saturday,
_ => throw new ArgumentOutOfRangeException()
};
PreferencesService.WorkingDayStart = WorkingDayStartIndex switch
{
0 => DayOfWeek.Sunday,
1 => DayOfWeek.Monday,
2 => DayOfWeek.Tuesday,
3 => DayOfWeek.Wednesday,
4 => DayOfWeek.Thursday,
5 => DayOfWeek.Friday,
6 => DayOfWeek.Saturday,
_ => throw new ArgumentOutOfRangeException()
};
PreferencesService.WorkingDayEnd = WorkingDayEndIndex switch
{
0 => DayOfWeek.Sunday,
1 => DayOfWeek.Monday,
2 => DayOfWeek.Tuesday,
3 => DayOfWeek.Wednesday,
4 => DayOfWeek.Thursday,
5 => DayOfWeek.Friday,
6 => DayOfWeek.Saturday,
_ => throw new ArgumentOutOfRangeException()
};
PreferencesService.Prefer24HourTimeFormat = Is24HourHeaders;
PreferencesService.WorkingHourStart = WorkingHourStart;
PreferencesService.WorkingHourEnd = WorkingHourEnd;
PreferencesService.HourHeight = CellHourHeight;
// Save default reminder setting
if (SelectedDefaultReminderIndex == 0)
{
PreferencesService.DefaultReminderDurationInSeconds = 0; // None
}
else
{
var predefinedMinutes = _calendarService.GetPredefinedReminderMinutes();
var minutes = predefinedMinutes[SelectedDefaultReminderIndex - 1];
PreferencesService.DefaultReminderDurationInSeconds = minutes * 60;
}
Messenger.Send(new CalendarSettingsUpdatedMessage());
}
}
@@ -1,197 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Translations;
using Wino.Core.ViewModels;
namespace Wino.Calendar.ViewModels;
public abstract class CalendarSettingsSectionViewModelBase : CalendarBaseViewModel
{
protected CalendarSettingsSectionViewModelBase(
IPreferencesService preferencesService,
ICalendarService calendarService,
IAccountService accountService)
{
PreferencesService = preferencesService;
CalendarService = calendarService;
AccountService = accountService;
var languageCode = WinoTranslationDictionary.GetLanguageFileNameRelativePath(preferencesService.CurrentLanguage);
CalendarCulture = new CultureInfo(languageCode);
for (var index = 0; index < 7; index++)
{
DayNames.Add(CalendarCulture.DateTimeFormat.DayNames[index]);
}
}
protected IPreferencesService PreferencesService { get; }
protected ICalendarService CalendarService { get; }
protected IAccountService AccountService { get; }
protected CultureInfo CalendarCulture { get; }
protected bool IsLoaded { get; set; }
public ObservableCollection<string> DayNames { get; } = [];
public ObservableCollection<string> ReminderOptions { get; } = [];
public ObservableCollection<string> SnoozeOptions { get; } = [];
public ObservableCollection<CalendarNewEventBehaviorOption> NewEventBehaviorOptions { get; } = [];
public ObservableCollection<AccountCalendarViewModel> AvailableNewEventCalendars { get; } = [];
public ObservableCollection<string> TimedDayHeaderFormatPresets { get; } =
[
"ddd dd",
"dddd dd",
"ddd d MMM",
"dd MMM ddd",
"M/d ddd"
];
protected void LoadReminderOptions()
{
ReminderOptions.Clear();
var predefinedMinutes = CalendarService.GetPredefinedReminderMinutes();
ReminderOptions.Add("None");
foreach (var minutes in predefinedMinutes)
{
var displayText = minutes switch
{
>= 60 => $"{minutes / 60} Hour{(minutes / 60 > 1 ? "s" : "")}",
_ => $"{minutes} Minute{(minutes > 1 ? "s" : "")}"
};
ReminderOptions.Add(displayText);
}
}
protected int GetSelectedReminderIndex()
{
if (PreferencesService.DefaultReminderDurationInSeconds == 0)
return 0;
var minutes = (int)(PreferencesService.DefaultReminderDurationInSeconds / 60);
var predefinedMinutes = CalendarService.GetPredefinedReminderMinutes();
var index = Array.IndexOf(predefinedMinutes, minutes);
return index >= 0 ? index + 1 : 0;
}
protected void SaveReminderIndex(int selectedDefaultReminderIndex)
{
if (selectedDefaultReminderIndex == 0)
{
PreferencesService.DefaultReminderDurationInSeconds = 0;
return;
}
var predefinedMinutes = CalendarService.GetPredefinedReminderMinutes();
var minutes = predefinedMinutes[selectedDefaultReminderIndex - 1];
PreferencesService.DefaultReminderDurationInSeconds = minutes * 60;
}
protected void LoadSnoozeOptions()
{
SnoozeOptions.Clear();
foreach (var snoozeMinutes in CalendarReminderSnoozeOptions.GetSupportedSnoozeMinutes())
{
SnoozeOptions.Add(string.Format(Translator.CalendarReminder_SnoozeMinutesOption, snoozeMinutes));
}
}
protected int GetSelectedSnoozeIndex()
{
var supportedSnoozeMinutes = CalendarReminderSnoozeOptions.GetSupportedSnoozeMinutes().ToArray();
var selectedIndex = Array.IndexOf(supportedSnoozeMinutes, PreferencesService.DefaultSnoozeDurationInMinutes);
return selectedIndex >= 0 ? selectedIndex : 0;
}
protected void SaveSnoozeIndex(int selectedDefaultSnoozeIndex)
{
var supportedSnoozeMinutes = CalendarReminderSnoozeOptions.GetSupportedSnoozeMinutes();
if (supportedSnoozeMinutes.Count == 0)
return;
var selectedIndex = Math.Clamp(selectedDefaultSnoozeIndex, 0, supportedSnoozeMinutes.Count - 1);
PreferencesService.DefaultSnoozeDurationInMinutes = supportedSnoozeMinutes[selectedIndex];
}
protected void LoadNewEventBehaviorOptions()
{
NewEventBehaviorOptions.Clear();
NewEventBehaviorOptions.Add(new CalendarNewEventBehaviorOption(NewEventButtonBehavior.AskEachTime, Translator.CalendarSettings_NewEventBehavior_AskEachTime));
NewEventBehaviorOptions.Add(new CalendarNewEventBehaviorOption(NewEventButtonBehavior.AlwaysUseSpecificCalendar, Translator.CalendarSettings_NewEventBehavior_AlwaysUseSpecificCalendar));
}
protected CalendarNewEventBehaviorOption GetSelectedNewEventBehaviorOption()
=> NewEventBehaviorOptions.FirstOrDefault(option => option.Behavior == PreferencesService.NewEventButtonBehavior)
?? NewEventBehaviorOptions.First();
protected async void LoadCalendarsAsync(Action applySelection)
{
var accounts = await AccountService.GetAccountsAsync().ConfigureAwait(false);
var calendarsByAccount = new List<AccountCalendarViewModel>();
foreach (var account in accounts)
{
var calendars = await CalendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
calendarsByAccount.AddRange(calendars.Select(calendar => new AccountCalendarViewModel(account, calendar)));
}
await ExecuteUIThread(() =>
{
AvailableNewEventCalendars.Clear();
foreach (var calendar in calendarsByAccount)
{
AvailableNewEventCalendars.Add(calendar);
}
applySelection();
});
}
protected AccountCalendarViewModel ResolveSelectedNewEventCalendar()
{
var configuredCalendarId = PreferencesService.DefaultNewEventCalendarId;
return configuredCalendarId.HasValue
? AvailableNewEventCalendars.FirstOrDefault(calendar => calendar.Id == configuredCalendarId.Value)
: null;
}
protected AccountCalendarViewModel ResolveFallbackNewEventCalendar()
=> AvailableNewEventCalendars.FirstOrDefault(calendar => calendar.IsPrimary)
?? AvailableNewEventCalendars.FirstOrDefault();
protected void SaveNewEventBehavior(CalendarNewEventBehaviorOption selectedBehaviorOption, AccountCalendarViewModel selectedCalendar)
{
var newEventBehavior = selectedBehaviorOption?.Behavior ?? NewEventButtonBehavior.AskEachTime;
if (newEventBehavior == NewEventButtonBehavior.AlwaysUseSpecificCalendar && selectedCalendar != null)
{
PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AlwaysUseSpecificCalendar;
PreferencesService.DefaultNewEventCalendarId = selectedCalendar.Id;
return;
}
PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AskEachTime;
PreferencesService.DefaultNewEventCalendarId = null;
}
}
public sealed class CalendarNewEventBehaviorOption
{
public CalendarNewEventBehaviorOption(NewEventButtonBehavior behavior, string displayText)
{
Behavior = behavior;
DisplayText = displayText;
}
public NewEventButtonBehavior Behavior { get; }
public string DisplayText { get; }
}
@@ -79,9 +79,5 @@ public partial class AccountCalendarViewModel : ObservableObject, IAccountCalend
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 MailAccount MailAccount { get => MailAccount; set => MailAccount = value; }
}
@@ -1,57 +0,0 @@
using System;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Extensions;
namespace Wino.Calendar.ViewModels.Data;
public class CalendarComposeAttachmentViewModel
{
public Guid Id { get; } = Guid.NewGuid();
public string FileName { get; }
public string FilePath { get; }
public string FileExtension { get; }
public long Size { get; }
public string ReadableSize => Size.GetBytesReadable();
public MailAttachmentType AttachmentType { get; }
public CalendarComposeAttachmentViewModel(string fileName, string filePath, string fileExtension, long size)
{
FileName = fileName;
FilePath = filePath;
FileExtension = fileExtension;
Size = size;
AttachmentType = GetAttachmentType(fileExtension);
}
public CalendarEventComposeAttachmentDraft ToDraftModel()
{
return new CalendarEventComposeAttachmentDraft
{
Id = Id,
FileName = FileName,
FilePath = FilePath,
FileExtension = FileExtension,
Size = Size
};
}
private static MailAttachmentType GetAttachmentType(string extension)
{
if (string.IsNullOrWhiteSpace(extension))
return MailAttachmentType.None;
return extension.ToLowerInvariant() switch
{
".exe" => MailAttachmentType.Executable,
".rar" => MailAttachmentType.RarArchive,
".zip" => MailAttachmentType.Archive,
".ogg" or ".mp3" or ".wav" or ".aac" or ".alac" => MailAttachmentType.Audio,
".mp4" or ".wmv" or ".avi" or ".flv" => MailAttachmentType.Video,
".pdf" => MailAttachmentType.PDF,
".htm" or ".html" => MailAttachmentType.HTML,
".png" or ".jpg" or ".jpeg" or ".gif" or ".jiff" => MailAttachmentType.Image,
_ => MailAttachmentType.Other
};
}
}
@@ -1,23 +0,0 @@
using Wino.Core.Domain.Entities.Shared;
namespace Wino.Calendar.ViewModels.Data;
public class CalendarComposeAttendeeViewModel : IContactDisplayItem
{
public string DisplayName { get; }
public string Email { get; }
public AccountContact ResolvedContact { get; }
public string Address => Email;
public AccountContact PreviewContact => ResolvedContact;
public bool HasDistinctDisplayName => !string.IsNullOrWhiteSpace(DisplayName) && !DisplayName.Equals(Email, System.StringComparison.OrdinalIgnoreCase);
public CalendarComposeAttendeeViewModel(string displayName, string email, AccountContact resolvedContact = null)
{
DisplayName = string.IsNullOrWhiteSpace(displayName) ? email : displayName;
Email = email;
ResolvedContact = resolvedContact;
}
public static CalendarComposeAttendeeViewModel FromContact(AccountContact contact)
=> new(contact.Name, contact.Address, contact);
}
@@ -33,10 +33,8 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
}
set
{
// All-day events use floating dates and should not shift across timezones.
CalendarItem.StartDate = CalendarItem.IsAllDayEvent
? value.Date
: value.ToTimeZoneFromLocal(CalendarItem.StartTimeZone);
// When setting from UI (in local time), convert to event's timezone for storage.
CalendarItem.StartDate = value.ToTimeZoneFromLocal(CalendarItem.StartTimeZone);
}
}
@@ -72,7 +70,6 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
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; }
@@ -158,7 +155,6 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
OnPropertyChanged(nameof(IsRecurringEvent));
OnPropertyChanged(nameof(IsRecurringChild));
OnPropertyChanged(nameof(IsRecurringParent));
OnPropertyChanged(nameof(CanDragDrop));
OnPropertyChanged(nameof(AssignedCalendar));
OnPropertyChanged(nameof(DisplayTitle));
}
@@ -1,11 +1,10 @@
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;
@@ -21,7 +20,6 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
{
Account = account;
AccountCalendars = new ObservableCollection<AccountCalendarViewModel>(calendarViewModels);
AccountColorHex = account.AccountColorHex;
ManageIsCheckedState();
@@ -33,7 +31,7 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
AccountCalendars.CollectionChanged += CalendarListUpdated;
}
private void CalendarListUpdated(object sender, NotifyCollectionChangedEventArgs e)
private void CalendarListUpdated(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Add)
{
@@ -60,11 +58,13 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
private void CalendarPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (sender is AccountCalendarViewModel viewModel &&
e.PropertyName == nameof(AccountCalendarViewModel.IsChecked))
if (sender is AccountCalendarViewModel viewModel)
{
ManageIsCheckedState();
UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true);
if (e.PropertyName == nameof(AccountCalendarViewModel.IsChecked))
{
ManageIsCheckedState();
UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true);
}
}
}
@@ -74,58 +74,11 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
[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 bool _isExternalPropChangeBlocked = false;
private void ManageIsCheckedState()
{
if (_isExternalPropChangeBlocked)
return;
if (_isExternalPropChangeBlocked) return;
_isExternalPropChangeBlocked = true;
@@ -147,13 +100,17 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
partial void OnIsCheckedStateChanged(bool? oldValue, bool? newValue)
{
if (_isExternalPropChangeBlocked)
return;
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);
@@ -168,6 +125,7 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
}
_isExternalPropChangeBlocked = false;
CollectiveSelectionStateChanged?.Invoke(this, EventArgs.Empty);
}
@@ -175,28 +133,13 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
{
var currentValue = accountCalendarViewModel.IsChecked;
if (currentValue == newValue && !ignoreValueCheck)
return;
if (currentValue == newValue && !ignoreValueCheck) return;
accountCalendarViewModel.IsChecked = newValue;
if (_isExternalPropChangeBlocked)
return;
// No need to report.
if (_isExternalPropChangeBlocked == true) return;
CalendarSelectionStateChanged?.Invoke(this, accountCalendarViewModel);
}
public void UpdateAccount(MailAccount updatedAccount)
{
if (updatedAccount == null || updatedAccount.Id != Account.Id)
return;
Account.Name = updatedAccount.Name;
Account.Address = updatedAccount.Address;
Account.AccountColorHex = updatedAccount.AccountColorHex;
Account.AttentionReason = updatedAccount.AttentionReason;
Account.MergedInboxId = updatedAccount.MergedInboxId;
AccountColorHex = updatedAccount.AccountColorHex;
OnPropertyChanged(nameof(Account));
}
}
@@ -12,11 +12,9 @@ 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;
@@ -33,8 +31,6 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
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;
@@ -147,9 +143,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
IMailDialogService dialogService,
IWinoRequestDelegator winoRequestDelegator,
INavigationService navigationService,
INotificationBuilder notificationBuilder,
IUnderlyingThemeService underlyingThemeService,
IContactService contactService)
IUnderlyingThemeService underlyingThemeService)
{
_calendarService = calendarService;
_nativeAppService = nativeAppService;
@@ -158,23 +152,22 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
_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));
}
// Initialize Show As 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));
SelectedShowAsOption = ShowAsOptions[2]; // Default to Busy
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == CalendarItemShowAs.Busy) ?? ShowAsOptions.FirstOrDefault();
foreach (var responseStatus in CalendarItemActionOptions.ResponseOptions)
{
RsvpStatusOptions.Add(new RsvpStatusOption(responseStatus));
}
// Initialize RSVP status options
RsvpStatusOptions.Add(new RsvpStatusOption(CalendarItemStatus.Accepted));
RsvpStatusOptions.Add(new RsvpStatusOption(CalendarItemStatus.Tentative));
RsvpStatusOptions.Add(new RsvpStatusOption(CalendarItemStatus.Cancelled));
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
@@ -187,20 +180,20 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
await LoadCalendarItemTargetAsync(args);
}
protected override async void OnCalendarItemUpdated(CalendarItem calendarItem, EntityUpdateSource source)
protected override async void OnCalendarItemUpdated(CalendarItem calendarItem, CalendarItemUpdateSource source)
{
base.OnCalendarItemUpdated(calendarItem, source);
// If the current event was updated, reload it
if (IsCurrentEventMatch(calendarItem))
if (CurrentEvent?.CalendarItem?.Id == calendarItem.Id || CurrentEvent?.CalendarItem.RecurringCalendarItemId == calendarItem.Id)
{
// Reflect client-side optimistic changes immediately; fallback to DB for server updates.
if (source == EntityUpdateSource.ClientUpdated || source == EntityUpdateSource.ClientReverted)
if (source == CalendarItemUpdateSource.ClientUpdated || source == CalendarItemUpdateSource.ClientReverted)
{
var previousAttendees = CurrentEvent?.Attendees?.ToList() ?? [];
CurrentEvent = new CalendarItemViewModel(calendarItem)
{
IsBusy = source == EntityUpdateSource.ClientUpdated
IsBusy = source == CalendarItemUpdateSource.ClientUpdated
};
foreach (var attendee in previousAttendees)
@@ -221,54 +214,17 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
}
}
protected override async void OnCalendarItemAdded(CalendarItem calendarItem, EntityUpdateSource source)
protected override void OnCalendarItemDeleted(CalendarItem calendarItem)
{
base.OnCalendarItemAdded(calendarItem, source);
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);
base.OnCalendarItemDeleted(calendarItem);
// If the current event was deleted, navigate back
if (IsCurrentEventMatch(calendarItem))
if (CurrentEvent?.CalendarItem?.Id == calendarItem.Id || CurrentEvent?.CalendarItem.RecurringCalendarItemId == calendarItem.Id)
{
NavigateBackToCalendar(forceReload: true);
_navigationService.GoBack();
}
}
private bool IsCurrentEventMatch(CalendarItem calendarItem)
{
if (CurrentEvent?.CalendarItem == null || calendarItem == null)
return false;
var trackedLocalItemId = calendarItem.RemoteEventId.GetClientTrackingId();
return CurrentEvent.CalendarItem.Id == calendarItem.Id ||
(trackedLocalItemId.HasValue && CurrentEvent.CalendarItem.Id == trackedLocalItemId.Value) ||
CurrentEvent.CalendarItem.RecurringCalendarItemId == calendarItem.Id;
}
private async Task LoadCalendarItemTargetAsync(CalendarItemTarget target)
{
try
@@ -300,36 +256,18 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
private async Task LoadAttendeesAsync(Guid calendarItemId, CalendarItem calendarItem)
{
CurrentEvent.Attendees.Clear();
var attendees = await _calendarService.GetAttendeesAsync(calendarItemId);
// Resolve contacts for all attendees in a single batch DB query.
var emails = attendees
.Where(a => !string.IsNullOrEmpty(a.Email))
.Select(a => a.Email)
.ToList();
if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail))
emails.Add(calendarItem.OrganizerEmail);
var contacts = await _contactService.GetContactsByAddressesAsync(emails).ConfigureAwait(false);
var contactLookup = contacts.ToDictionary(c => c.Address, StringComparer.OrdinalIgnoreCase);
foreach (var attendee in attendees)
{
if (!string.IsNullOrEmpty(attendee.Email) && contactLookup.TryGetValue(attendee.Email, out var contact))
attendee.ResolvedContact = contact;
}
// Separate organizer from other attendees to ensure organizer is always first
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)
{
attendeesForUi.Add(organizer);
CurrentEvent.Attendees.Add(organizer);
}
else if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail))
{
@@ -343,31 +281,14 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
IsOrganizer = true,
AttendenceStatus = AttendeeStatus.Accepted
};
if (contactLookup.TryGetValue(calendarItem.OrganizerEmail, out var organizerContact))
organizerAttendee.ResolvedContact = organizerContact;
attendeesForUi.Add(organizerAttendee);
CurrentEvent.Attendees.Add(organizerAttendee);
}
// Add all other attendees after the organizer
foreach (var item in nonOrganizerAttendees)
{
attendeesForUi.Add(item);
CurrentEvent.Attendees.Add(item);
}
await ExecuteUIThread(() =>
{
if (CurrentEvent == null)
return;
CurrentEvent.Attendees.Clear();
foreach (var attendee in attendeesForUi)
{
CurrentEvent.Attendees.Add(attendee);
}
});
}
private async Task LoadAttachmentsAsync(Guid calendarItemId)
@@ -490,7 +411,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
await _winoRequestDelegator.ExecuteAsync(preparationRequest);
NavigateBackToCalendar(forceReload: true);
_navigationService.GoBack();
}
catch (Exception ex)
{
@@ -527,7 +448,8 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
await _winoRequestDelegator.ExecuteAsync(preparationRequest);
NavigateBackToCalendar(forceReload: true);
// Navigate back after successful deletion
_navigationService.GoBack();
}
catch (Exception ex)
{
@@ -535,28 +457,6 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
}
}
private void NavigateBackToCalendar(bool forceReload)
{
var navigationDate = CurrentEvent?.CalendarItem.LocalStartDate ?? DateTime.Now;
_navigationService.Navigate(
WinoPage.CalendarPage,
new CalendarPageNavigationArgs
{
NavigationDate = navigationDate,
ForceReload = forceReload
});
}
public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args)
{
if (args.Handled || args.Mode != WinoApplicationMode.Calendar || args.Action != KeyboardShortcutAction.Delete)
return;
await DeleteAsync();
args.Handled = true;
}
[RelayCommand]
private Task JoinOnlineAsync()
{
@@ -566,24 +466,6 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
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()
{
@@ -29,6 +29,5 @@ public interface IAccountCalendarStateService : INotifyPropertyChanged
/// </summary>
IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; }
IEnumerable<AccountCalendarViewModel> AllCalendars { get; }
bool IsAnySynchronizationInProgress { get; }
ReadOnlyObservableGroupedCollection<MailAccount, AccountCalendarViewModel> GroupedCalendars { get; set; }
}
@@ -1,8 +0,0 @@
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Calendar.ViewModels.Messages;
public sealed record CalendarItemContextActionRequestedMessage(
CalendarItemViewModel CalendarItemViewModel,
CalendarContextMenuAction Action);
@@ -1,12 +1,16 @@
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Calendar.ViewModels.Messages;
public class CalendarItemTappedMessage
{
public CalendarItemTappedMessage(CalendarItemViewModel calendarItemViewModel)
public CalendarItemTappedMessage(CalendarItemViewModel calendarItemViewModel, CalendarDayModel clickedPeriod)
{
CalendarItemViewModel = calendarItemViewModel;
ClickedPeriod = clickedPeriod;
}
public CalendarItemViewModel CalendarItemViewModel { get; }
public CalendarDayModel ClickedPeriod { get; }
}
@@ -10,8 +10,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TimePeriodLibrary.NET" />
<PackageReference Include="EmailValidation" />
<PackageReference Include="TimePeriodLibrary.NET" />
</ItemGroup>
<ItemGroup>
-10
View File
@@ -1,10 +0,0 @@
namespace Wino.Core.Domain;
public static class AppUrls
{
public const string Website = "https://www.winomail.app";
public const string Discord = "https://discord.gg/windows-apps-hub-714581497222398064";
public const string GitHub = "https://github.com/bkaankose/Wino-Mail/";
public const string PrivacyPolicy = "https://www.winomail.app/support/privacy";
public const string Paypal = "https://paypal.me/bkaankose?country.x=PL&locale.x=en_US";
}
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Wino.Core.Domain.Models.Updates;
namespace Wino.Core.Domain;
@@ -9,6 +8,4 @@ namespace Wino.Core.Domain;
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(UpdateNotes))]
[JsonSerializable(typeof(List<UpdateNoteSection>))]
public partial class BasicTypesJsonContext : JsonSerializerContext;
@@ -1,23 +0,0 @@
using System.Collections.Generic;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain;
public static class CalendarItemActionOptions
{
public static IReadOnlyList<CalendarItemShowAs> ShowAsOptions { get; } =
[
CalendarItemShowAs.Free,
CalendarItemShowAs.Tentative,
CalendarItemShowAs.Busy,
CalendarItemShowAs.OutOfOffice,
CalendarItemShowAs.WorkingElsewhere
];
public static IReadOnlyList<CalendarItemStatus> ResponseOptions { get; } =
[
CalendarItemStatus.Accepted,
CalendarItemStatus.Tentative,
CalendarItemStatus.Cancelled
];
}
@@ -1,126 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Core.Domain;
public static class CalendarRecurrenceSummaryFormatter
{
private static readonly DayOfWeek[] OrderedDays =
[
DayOfWeek.Monday,
DayOfWeek.Tuesday,
DayOfWeek.Wednesday,
DayOfWeek.Thursday,
DayOfWeek.Friday,
DayOfWeek.Saturday,
DayOfWeek.Sunday
];
public static string BuildSummary(
bool isRecurring,
DateTimeOffset effectiveStart,
DateTimeOffset effectiveEnd,
bool isAllDay,
CalendarSettings settings,
int interval,
CalendarItemRecurrenceFrequency frequency,
IReadOnlyCollection<DayOfWeek> daysOfWeek,
DateTimeOffset? recurrenceEndDate)
{
var culture = settings?.CultureInfo ?? CultureInfo.CurrentCulture;
var timeSummary = isAllDay
? Translator.CalendarItemAllDay
: string.Format(
culture,
Translator.CalendarEventCompose_TimeRangeSummary,
effectiveStart.ToString(settings?.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", culture),
effectiveEnd.ToString(settings?.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", culture));
if (!isRecurring)
{
return string.Format(
culture,
Translator.CalendarEventCompose_SingleOccurrenceSummary,
effectiveStart.ToString("dddd yyyy-MM-dd", culture),
timeSummary);
}
var normalizedDays = NormalizeDays(daysOfWeek);
var isEveryDay = (frequency == CalendarItemRecurrenceFrequency.Daily && interval == 1) ||
(frequency == CalendarItemRecurrenceFrequency.Weekly && interval == 1 && normalizedDays.Count == 7);
var cadenceSummary = isEveryDay
? $"{Translator.CalendarEventCompose_Every} {Translator.CalendarEventCompose_FrequencyDay}"
: interval == 1
? $"{Translator.CalendarEventCompose_Every} {GetSingularFrequencyLabel(frequency)}"
: $"{Translator.CalendarEventCompose_Every} {interval.ToString(culture)} {GetPluralFrequencyLabel(frequency)}";
var weekdaySummary = string.Empty;
if (frequency == CalendarItemRecurrenceFrequency.Weekly && normalizedDays.Count > 0 && normalizedDays.Count < 7)
{
weekdaySummary = string.Format(
culture,
Translator.CalendarEventCompose_WeekdaySummary,
string.Join(", ", normalizedDays.Select(day => culture.DateTimeFormat.GetDayName(day))));
}
var untilSummary = recurrenceEndDate.HasValue
? string.Format(
culture,
Translator.CalendarEventCompose_UntilSummary,
recurrenceEndDate.Value.ToString("ddd yyyy-MM-dd", culture))
: string.Empty;
return string.Format(
culture,
Translator.CalendarEventCompose_RecurringSummarySmart,
cadenceSummary,
weekdaySummary,
timeSummary,
effectiveStart.ToString("dddd yyyy-MM-dd", culture),
untilSummary).Trim();
}
private static IReadOnlyList<DayOfWeek> NormalizeDays(IReadOnlyCollection<DayOfWeek> daysOfWeek)
{
if (daysOfWeek == null || daysOfWeek.Count == 0)
{
return [];
}
return daysOfWeek
.Distinct()
.OrderBy(day => Array.IndexOf(OrderedDays, day))
.ToList();
}
private static string GetSingularFrequencyLabel(CalendarItemRecurrenceFrequency frequency)
{
return frequency switch
{
CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDay,
CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeek,
CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonth,
CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYear,
_ => Translator.CalendarEventCompose_FrequencyWeek
};
}
private static string GetPluralFrequencyLabel(CalendarItemRecurrenceFrequency frequency)
{
return frequency switch
{
CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDayPlural,
CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeekPlural,
CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonthPlural,
CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYearPlural,
_ => Translator.CalendarEventCompose_FrequencyWeekPlural
};
}
}
@@ -8,9 +8,6 @@ public static class CalendarReminderSnoozeOptions
{
private static readonly int[] SupportedSnoozeMinutes = [5, 10, 15, 30];
public static IReadOnlyList<int> GetSupportedSnoozeMinutes()
=> SupportedSnoozeMinutes;
public static IReadOnlyList<int> GetAllowedSnoozeMinutes(long reminderDurationInSeconds, long defaultReminderDurationInSeconds)
{
var reminderMinutes = (int)Math.Max(0, reminderDurationInSeconds / 60);
@@ -0,0 +1,41 @@
using System.Linq;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Core.Domain.Collections;
public class DayRangeCollection : ObservableRangeCollection<DayRangeRenderModel>
{
/// <summary>
/// Gets the range of dates that are currently displayed in the collection.
/// </summary>
public DateRange DisplayRange
{
get
{
if (Count == 0) return null;
var minimumLoadedDate = this[0].CalendarRenderOptions.DateRange.StartDate;
var maximumLoadedDate = this[Count - 1].CalendarRenderOptions.DateRange.EndDate;
return new DateRange(minimumLoadedDate, maximumLoadedDate);
}
}
public void RemoveCalendarItem(ICalendarItem calendarItem)
{
foreach (var dayRange in this)
{
}
}
public void AddCalendarItem(ICalendarItem calendarItem)
{
foreach (var dayRange in this)
{
var calendarDayModel = dayRange.CalendarDays.FirstOrDefault(x => x.Period.HasInside(calendarItem.Period.Start));
calendarDayModel?.EventsCollection.AddCalendarItem(calendarItem);
}
}
}
+2 -7
View File
@@ -1,4 +1,4 @@
namespace Wino.Core.Domain;
namespace Wino.Core.Domain;
public static class Constants
{
@@ -6,8 +6,6 @@ public static class Constants
/// MIME header that exists in all the drafts created from Wino.
/// </summary>
public const string WinoLocalDraftHeader = "X-Wino-Draft-Id";
public const string DispositionNotificationToHeader = "Disposition-Notification-To";
public const string OriginalMessageIdHeader = "Original-Message-ID";
public const string LocalDraftStartPrefix = "localDraft_";
public const string CalendarEventRecurrenceRuleSeperator = "___";
@@ -18,14 +16,12 @@ public static class Constants
public const string ToastCalendarItemIdKey = nameof(ToastCalendarItemIdKey);
public const string ToastCalendarActionKey = nameof(ToastCalendarActionKey);
public const string ToastCalendarNavigateAction = nameof(ToastCalendarNavigateAction);
public const string ToastCalendarJoinOnlineAction = nameof(ToastCalendarJoinOnlineAction);
public const string ToastCalendarSnoozeAction = nameof(ToastCalendarSnoozeAction);
public const string ToastCalendarSnoozeDurationInputId = nameof(ToastCalendarSnoozeDurationInputId);
public const string ToastModeKey = nameof(ToastModeKey);
public const string ToastModeMail = nameof(ToastModeMail);
public const string ToastModeCalendar = nameof(ToastModeCalendar);
public const string ToastStoreUpdateActionKey = nameof(ToastStoreUpdateActionKey);
public const string ToastStoreUpdateActionInstall = nameof(ToastStoreUpdateActionInstall);
public const string ClientLogFile = "Client_.log";
public const string ServerLogFile = "Server_.log";
public const string LogArchiveFileName = "WinoLogs.zip";
@@ -33,4 +29,3 @@ public static class Constants
public const string WinoMailIdentiifer = nameof(WinoMailIdentiifer);
public const string WinoCalendarIdentifier = nameof(WinoCalendarIdentifier);
}
@@ -25,7 +25,6 @@ public class AccountCalendar : IAccountCalendar
/// </summary>
public string TextColorHex { get; set; }
public string BackgroundColorHex { get; set; }
public bool IsBackgroundColorUserOverridden { get; set; }
public string TimeZone { get; set; }
[Ignore]
@@ -1,10 +1,10 @@
using System;
using SQLite;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Calendar;
// TODO: Connect to Contact store with Wino People.
public class CalendarEventAttendee
{
[PrimaryKey]
@@ -16,11 +16,4 @@ public class CalendarEventAttendee
public bool IsOrganizer { get; set; }
public bool IsOptionalAttendee { get; set; }
public string Comment { get; set; }
/// <summary>
/// Resolved contact from the contact store. Populated at runtime via IContactService;
/// not persisted to the database.
/// </summary>
[Ignore]
public AccountContact ResolvedContact { get; set; }
}
@@ -154,24 +154,6 @@ public class CalendarItem : ICalendarItem
[Ignore]
public IAccountCalendar AssignedCalendar { get; set; }
[Ignore]
public bool CanChangeStartAndEndDate
{
get
{
if (IsLocked)
{
return false;
}
var accountAddress = AssignedCalendar?.MailAccount?.Address;
return string.IsNullOrWhiteSpace(OrganizerEmail) ||
string.IsNullOrWhiteSpace(accountAddress) ||
string.Equals(OrganizerEmail, accountAddress, StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>
/// Id to load information related to this event (attendees, reminders, etc.).
/// For child events, if they have their own data, use their own Id.
@@ -1,16 +0,0 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Mail;
public class EmailTemplate
{
[PrimaryKey]
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string HtmlContent { get; set; } = string.Empty;
}
@@ -2,8 +2,6 @@
using System.Collections.ObjectModel;
using System.Security.Cryptography.X509Certificates;
using SQLite;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Mail;
@@ -44,16 +42,6 @@ public class RemoteAccountAlias
/// Used for Gmail only.
/// </summary>
public string AliasSenderName { get; set; }
/// <summary>
/// Whether the alias was entered by the user or discovered from the provider.
/// </summary>
public AliasSource Source { get; set; } = AliasSource.Manual;
/// <summary>
/// Represents Wino's confidence that the alias can be used for sending.
/// </summary>
public AliasSendCapability SendCapability { get; set; } = AliasSendCapability.Unknown;
}
public class MailAccountAlias : RemoteAccountAlias
@@ -82,28 +70,4 @@ public class MailAccountAlias : RemoteAccountAlias
[Ignore]
public ObservableCollection<X509Certificate2> Certificates { get; set; } = [];
[Ignore]
public bool IsCapabilityConfirmed => SendCapability == AliasSendCapability.Confirmed;
[Ignore]
public bool IsCapabilityUnknown => SendCapability == AliasSendCapability.Unknown;
[Ignore]
public bool IsCapabilityDenied => SendCapability == AliasSendCapability.Denied;
[Ignore]
public string CapabilityDisplayName => SendCapability switch
{
AliasSendCapability.Confirmed => Translator.AccountAlias_Status_Confirmed,
AliasSendCapability.Denied => Translator.AccountAlias_Status_Denied,
_ => Translator.AccountAlias_Status_Unknown
};
[Ignore]
public string SourceDisplayName => Source switch
{
AliasSource.ProviderDiscovered => Translator.AccountAlias_Source_ProviderDiscovered,
_ => Translator.AccountAlias_Source_Manual
};
}
@@ -155,18 +155,6 @@ public class MailCopy
[Ignore]
public AccountContact SenderContact { get; set; }
[Ignore]
public bool IsReadReceiptRequested { get; set; }
[Ignore]
public SentMailReceiptStatus ReadReceiptStatus { get; set; }
[Ignore]
public DateTime? ReadReceiptAcknowledgedAtUtc { get; set; }
[Ignore]
public Guid? ReadReceiptMessageUniqueId { get; set; }
public IEnumerable<Guid> GetContainingIds() => [UniqueId];
public override string ToString() => $"{Subject} <-> {Id}";
}
@@ -1,25 +0,0 @@
using System;
using SQLite;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Mail;
public class SentMailReceiptState
{
[PrimaryKey]
public Guid MailUniqueId { get; set; }
public Guid AccountId { get; set; }
public string MessageId { get; set; }
public bool IsReceiptRequested { get; set; }
public DateTime RequestedAtUtc { get; set; }
public SentMailReceiptStatus Status { get; set; }
public DateTime? AcknowledgedAtUtc { get; set; }
public Guid? ReceiptMessageUniqueId { get; set; }
}
@@ -11,7 +11,7 @@ namespace Wino.Core.Domain.Entities.Shared;
// TODO: This can easily evolve to Contact store, just like People app in Windows 10/11.
// Do it.
public class AccountContact : IEquatable<AccountContact>, IContactDisplayItem
public class AccountContact : IEquatable<AccountContact>
{
/// <summary>
/// E-mail address of the contact.
@@ -25,10 +25,9 @@ public class AccountContact : IEquatable<AccountContact>, IContactDisplayItem
public string Name { get; set; }
/// <summary>
/// File ID for the contact picture stored on disk.
/// The actual file lives at {ApplicationDataFolderPath}/contacts/{ContactPictureFileId}.jpg.
/// Base64 encoded profile image of the contact.
/// </summary>
public Guid? ContactPictureFileId { get; set; }
public string Base64ContactPicture { get; set; }
/// <summary>
/// All registered accounts have their contacts registered as root.
@@ -43,9 +42,6 @@ public class AccountContact : IEquatable<AccountContact>, IContactDisplayItem
/// </summary>
public bool IsOverridden { get; set; } = false;
public string DisplayName => string.IsNullOrWhiteSpace(Name) ? Address : Name;
AccountContact IContactDisplayItem.PreviewContact => this;
public override bool Equals(object obj)
{
return Equals(obj as AccountContact);
@@ -1,19 +0,0 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Shared;
/// <summary>
/// A named group of contacts that can be expanded to individual addresses during mail composition.
/// </summary>
public class ContactGroup
{
[PrimaryKey]
public Guid Id { get; set; }
/// <summary>Display name of the group (e.g., "Team Alpha", "Family").</summary>
public string Name { get; set; }
/// <summary>Optional description for the group.</summary>
public string Description { get; set; }
}
@@ -1,21 +0,0 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Shared;
/// <summary>
/// Associates an e-mail address with a <see cref="ContactGroup"/>.
/// </summary>
public class ContactGroupMember
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
/// <summary>Group this member belongs to.</summary>
[Indexed]
public Guid GroupId { get; set; }
/// <summary>E-mail address of the member (FK to AccountContact.Address).</summary>
[Indexed]
public string MemberAddress { get; set; }
}
@@ -1,8 +0,0 @@
namespace Wino.Core.Domain.Entities.Shared;
public interface IContactDisplayItem
{
string DisplayName { get; }
string Address { get; }
AccountContact PreviewContact { get; }
}
@@ -12,11 +12,6 @@ public class KeyboardShortcut
[PrimaryKey]
public Guid Id { get; set; }
/// <summary>
/// The application mode this shortcut applies to.
/// </summary>
public WinoApplicationMode Mode { get; set; } = WinoApplicationMode.Mail;
/// <summary>
/// The key combination string (e.g., "D", "Delete", "F1").
/// </summary>
@@ -28,9 +23,9 @@ public class KeyboardShortcut
public ModifierKeys ModifierKeys { get; set; }
/// <summary>
/// The shortcut action this shortcut triggers.
/// The mail operation this shortcut triggers.
/// </summary>
public KeyboardShortcutAction Action { get; set; }
public MailOperation MailOperation { get; set; }
/// <summary>
/// Whether this shortcut is enabled.
@@ -60,6 +55,6 @@ public class KeyboardShortcut
modifierText += "Win+";
return modifierText + Key;
}
}
}
}
}
@@ -120,7 +120,7 @@ public class MailAccount
/// <summary>
/// Gets whether the account can perform AliasInformation sync type.
/// </summary>
public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail || ProviderType == MailProviderType.Outlook;
public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail;
public override string ToString() => Name;
}
@@ -1,30 +0,0 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Shared;
public class WinoAccount
{
[PrimaryKey]
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public string AccountStatus { get; set; } = string.Empty;
public bool HasPassword { get; set; }
public bool HasGoogleLogin { get; set; }
public bool HasFacebookLogin { get; set; }
public string AccessToken { get; set; } = string.Empty;
public DateTime AccessTokenExpiresAtUtc { get; set; }
public string RefreshToken { get; set; } = string.Empty;
public DateTime RefreshTokenExpiresAtUtc { get; set; }
public DateTime LastAuthenticatedUtc { get; set; }
}
@@ -1,9 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum AccountSetupStepStatus
{
Pending,
InProgress,
Succeeded,
Failed
}
-12
View File
@@ -1,12 +0,0 @@
using System;
namespace Wino.Core.Domain.Enums;
[Flags]
public enum AiActionType
{
None = 0,
Translate = 1,
Rewrite = 2,
Summarize = 4,
}
@@ -1,8 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum AliasSendCapability
{
Unknown = 0,
Confirmed = 1,
Denied = 2
}
-7
View File
@@ -1,7 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum AliasSource
{
Manual = 0,
ProviderDiscovered = 1
}
@@ -1,10 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum CalendarContextMenuActionType
{
Open,
JoinOnline,
Delete,
ShowAs,
Respond
}
@@ -0,0 +1,10 @@
namespace Wino.Core.Domain.Enums;
/// <summary>
/// Trigger to load more data.
/// </summary>
public enum CalendarInitInitiative
{
User,
App
}
@@ -1,17 +1,17 @@
namespace Wino.Core.Domain.Enums;
/// <summary>
/// Indicates the source of an entity update.
/// Indicates the source of a calendar item update.
/// </summary>
public enum EntityUpdateSource
public enum CalendarItemUpdateSource
{
/// <summary>
/// Update originated from client-side optimistic UI changes (ApplyUIChanges).
/// Update originated from client-side UI changes (ApplyUIChanges).
/// </summary>
ClientUpdated,
/// <summary>
/// Update originated from reverting client-side optimistic UI changes (RevertUIChanges).
/// Update originated from client-side UI revert (RevertUIChanges).
/// </summary>
ClientReverted,
@@ -5,7 +5,6 @@ public enum CalendarSynchronizationType
ExecuteRequests, // Execute all requests in the queue.
CalendarMetadata, // Sync calendar metadata.
CalendarEvents, // Sync all events for all calendars.
Strict, // Run metadata and event synchronization in sequence.
SingleCalendar, // Sync events for only specified calendars.
UpdateProfile // Update profile information only.
}
@@ -1,16 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum KeyboardShortcutAction
{
None,
NewMail,
ToggleReadUnread,
ToggleFlag,
ToggleArchive,
Delete,
Move,
Reply,
ReplyAll,
Send,
NewEvent
}
@@ -1,59 +0,0 @@
using System;
namespace Wino.Core.Domain.Enums;
[Flags]
public enum MailCopyChangeFlags
{
None = 0,
Id = 1 << 0,
FolderId = 1 << 1,
ThreadId = 1 << 2,
MessageId = 1 << 3,
References = 1 << 4,
InReplyTo = 1 << 5,
FromName = 1 << 6,
FromAddress = 1 << 7,
Subject = 1 << 8,
PreviewText = 1 << 9,
CreationDate = 1 << 10,
Importance = 1 << 11,
IsRead = 1 << 12,
IsFlagged = 1 << 13,
IsFocused = 1 << 14,
HasAttachments = 1 << 15,
ItemType = 1 << 16,
DraftId = 1 << 17,
IsDraft = 1 << 18,
FileId = 1 << 19,
AssignedFolder = 1 << 20,
AssignedAccount = 1 << 21,
SenderContact = 1 << 22,
UniqueId = 1 << 23,
ReadReceiptState = 1 << 24,
All = Id |
FolderId |
ThreadId |
MessageId |
References |
InReplyTo |
FromName |
FromAddress |
Subject |
PreviewText |
CreationDate |
Importance |
IsRead |
IsFlagged |
IsFocused |
HasAttachments |
ItemType |
DraftId |
IsDraft |
FileId |
AssignedFolder |
AssignedAccount |
SenderContact |
UniqueId |
ReadReceiptState
}
-2
View File
@@ -9,7 +9,6 @@ public enum MailSynchronizerOperation
CreateDraft,
Send,
ChangeFlag,
ChangeJunkState,
AlwaysMoveTo,
MoveToFocused,
Archive,
@@ -28,7 +27,6 @@ public enum CalendarSynchronizerOperation
{
CreateEvent,
UpdateEvent,
ChangeStartAndEndDate,
DeleteEvent,
AcceptEvent,
DeclineEvent,
@@ -0,0 +1,22 @@
namespace Wino.Core.Domain.Enums;
/// <summary>
/// Indicates the source of a mail update.
/// </summary>
public enum MailUpdateSource
{
/// <summary>
/// Update originated from client-side UI changes (ApplyUIChanges).
/// </summary>
ClientUpdated,
/// <summary>
/// Update originated from client-side UI revert (RevertUIChanges).
/// </summary>
ClientReverted,
/// <summary>
/// Update originated from server synchronization or database operations.
/// </summary>
Server
}
@@ -1,7 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum NewEventButtonBehavior
{
AskEachTime = 0,
AlwaysUseSpecificCalendar = 1
}
@@ -1,9 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum SentMailReceiptStatus
{
None = 0,
Requested = 1,
Acknowledged = 2,
FailedToCorrelate = 3
}
@@ -1,7 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum SynchronizationProgressCategory
{
Mail,
Calendar
}
@@ -1,7 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum WinoAddOnProductType
{
AI_PACK,
UNLIMITED_ACCOUNTS
}
@@ -3,7 +3,5 @@
public enum WinoApplicationMode
{
Mail,
Calendar,
Contacts,
Settings
Calendar
}
+4 -13
View File
@@ -11,6 +11,7 @@ public enum WinoPage
SettingsPage,
ContactsPage,
MailRenderingPage,
WelcomePage,
AccountDetailsPage,
MergedAccountDetailsPage,
ManageAccountsPage,
@@ -21,27 +22,17 @@ public enum WinoPage
MessageListPage,
MailListPage,
ReadComposePanePage,
LanguageTimePage,
AppPreferencesPage,
SettingOptionsPage,
AliasManagementPage,
EditAccountDetailsPage,
ImapCalDavSettingsPage,
KeyboardShortcutsPage,
CalendarPage,
CalendarSettingsPage,
CalendarRenderingSettingsPage,
CalendarNotificationSettingsPage,
CalendarPreferenceSettingsPage,
CalendarAccountSettingsPage,
EventDetailsPage,
CalendarEventComposePage,
SignatureAndEncryptionPage,
EmailTemplatesPage,
CreateEmailTemplatePage,
StoragePage,
WinoAccountManagementPage,
WelcomePageV2,
WelcomeHostPage,
ProviderSelectionPage,
AccountSetupProgressPage,
SpecialImapCredentialsPage
StoragePage
}
@@ -1,10 +0,0 @@
using System;
namespace Wino.Core.Domain.Exceptions;
public sealed class CalendarEventComposeValidationException : Exception
{
public CalendarEventComposeValidationException(string message) : base(message)
{
}
}
@@ -9,18 +9,22 @@ public class ImapClientPoolException : Exception
{
}
public ImapClientPoolException(string message, CustomServerInformation customServerInformation) : base(message)
public ImapClientPoolException(string message, CustomServerInformation customServerInformation, string protocolLog) : base(message)
{
CustomServerInformation = customServerInformation;
ProtocolLog = protocolLog;
}
public ImapClientPoolException(string message) : base(message)
public ImapClientPoolException(string message, string protocolLog) : base(message)
{
ProtocolLog = protocolLog;
}
public ImapClientPoolException(Exception innerException) : base(innerException.Message, innerException)
public ImapClientPoolException(Exception innerException, string protocolLog) : base(innerException.Message, innerException)
{
ProtocolLog = protocolLog;
}
public CustomServerInformation CustomServerInformation { get; }
public string ProtocolLog { get; }
}
@@ -0,0 +1,17 @@
using Wino.Core.Domain.Models.AutoDiscovery;
namespace Wino.Core.Domain.Exceptions;
public class ImapConnectionFailedPackage
{
public ImapConnectionFailedPackage(string errorMessage, string protocolLog, AutoDiscoverySettings settings)
{
ErrorMessage = errorMessage;
ProtocolLog = protocolLog;
Settings = settings;
}
public AutoDiscoverySettings Settings { get; }
public string ErrorMessage { get; set; }
public string ProtocolLog { get; }
}
@@ -1,67 +0,0 @@
using System;
namespace Wino.Core.Domain.Extensions;
public static class CalendarRemoteEventIdExtensions
{
private const string ClientTrackingSeparator = "::";
private const string CalDavClientTrackingPrefix = "caldav-";
private const string LocalClientTrackingPrefix = "local-";
public static string GetProviderRemoteEventId(this string remoteEventId)
{
if (string.IsNullOrWhiteSpace(remoteEventId))
return string.Empty;
var separatorIndex = remoteEventId.IndexOf(ClientTrackingSeparator, StringComparison.Ordinal);
return separatorIndex >= 0 ? remoteEventId[..separatorIndex] : remoteEventId;
}
public static Guid? GetClientTrackingId(this string remoteEventId)
{
if (string.IsNullOrWhiteSpace(remoteEventId))
return null;
if (remoteEventId.Contains(ClientTrackingSeparator, StringComparison.Ordinal))
{
var trackedPart = remoteEventId[(remoteEventId.LastIndexOf(ClientTrackingSeparator, StringComparison.Ordinal) + ClientTrackingSeparator.Length)..];
if (TryParseGuid(trackedPart, out var trackedId))
return trackedId;
}
if (TryParseGuid(remoteEventId, out var directId))
return directId;
if (remoteEventId.StartsWith(CalDavClientTrackingPrefix, StringComparison.OrdinalIgnoreCase) &&
TryParseGuid(remoteEventId[CalDavClientTrackingPrefix.Length..], out var calDavId))
{
return calDavId;
}
if (remoteEventId.StartsWith(LocalClientTrackingPrefix, StringComparison.OrdinalIgnoreCase) &&
TryParseGuid(remoteEventId[LocalClientTrackingPrefix.Length..], out var localId))
{
return localId;
}
return null;
}
public static string WithClientTrackingId(this string providerRemoteEventId, Guid? clientTrackingId)
{
if (string.IsNullOrWhiteSpace(providerRemoteEventId) || !clientTrackingId.HasValue)
return providerRemoteEventId ?? string.Empty;
return $"{providerRemoteEventId}{ClientTrackingSeparator}{clientTrackingId.Value:N}";
}
private static bool TryParseGuid(string value, out Guid parsedGuid)
{
parsedGuid = Guid.Empty;
if (string.IsNullOrWhiteSpace(value))
return false;
return Guid.TryParseExact(value, "N", out parsedGuid) || Guid.TryParse(value, out parsedGuid);
}
}
@@ -78,12 +78,8 @@ public static class DateTimeExtensions
}
public static DateTime GetLocalStartDate(this CalendarItem calendarItem)
=> calendarItem.IsAllDayEvent
? calendarItem.StartDate
: calendarItem.StartDate.ToLocalTimeFromTimeZone(calendarItem.StartTimeZone);
=> calendarItem.StartDate.ToLocalTimeFromTimeZone(calendarItem.StartTimeZone);
public static DateTime GetLocalEndDate(this CalendarItem calendarItem)
=> calendarItem.IsAllDayEvent
? calendarItem.EndDate
: calendarItem.EndDate.ToLocalTimeFromTimeZone(calendarItem.EndTimeZone);
=> calendarItem.EndDate.ToLocalTimeFromTimeZone(calendarItem.EndTimeZone);
}
@@ -1,26 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Wino.Core.Domain.Extensions;
public static class MailHeaderExtensions
{
public static string NormalizeMessageId(string value)
{
if (value == null)
return null;
var normalized = StripAngleBrackets(value)?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? string.Empty : normalized;
}
public static string ToHeaderMessageId(string value)
{
var normalized = NormalizeMessageId(value);
return string.IsNullOrEmpty(normalized) ? string.Empty : $"<{normalized}>";
}
/// <summary>
/// Strips angle brackets from a Message-ID or In-Reply-To value.
/// RFC 5322 Message-IDs are formatted as &lt;id@domain&gt;, but MimeKit
@@ -45,53 +29,14 @@ public static class MailHeaderExtensions
/// like "&lt;id1@domain&gt; &lt;id2@domain&gt;". This converts them to "id1@domain;id2@domain".
/// </summary>
public static string NormalizeReferences(string rawReferences)
=> JoinStoredReferences(SplitMessageIds(rawReferences));
public static IEnumerable<string> SplitMessageIds(string values)
{
if (string.IsNullOrWhiteSpace(values))
return [];
if (string.IsNullOrEmpty(rawReferences)) return rawReferences;
return values
var ids = rawReferences
.Split(new[] { ' ', '\t', '\r', '\n', ';', ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(NormalizeMessageId)
.Select(StripAngleBrackets)
.Where(id => !string.IsNullOrEmpty(id));
}
public static string JoinStoredReferences(IEnumerable<string> values)
=> string.Join(";", NormalizeDistinctMessageIds(values));
public static string BuildReferencesHeaderValue(IEnumerable<string> values)
=> string.Join(" ", NormalizeDistinctMessageIds(values).Select(ToHeaderMessageId));
public static List<string> BuildReferencesChain(IEnumerable<string> existingReferences, string parentMessageId)
{
var results = NormalizeDistinctMessageIds(existingReferences).ToList();
var normalizedParentMessageId = NormalizeMessageId(parentMessageId);
if (!string.IsNullOrEmpty(normalizedParentMessageId) &&
!results.Contains(normalizedParentMessageId, StringComparer.OrdinalIgnoreCase))
{
results.Add(normalizedParentMessageId);
}
return results;
}
private static IEnumerable<string> NormalizeDistinctMessageIds(IEnumerable<string> values)
{
if (values == null)
yield break;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in values)
{
var normalized = NormalizeMessageId(value);
if (string.IsNullOrEmpty(normalized) || !seen.Add(normalized))
continue;
yield return normalized;
}
return string.Join(";", ids);
}
}
@@ -1,111 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MimeKit;
namespace Wino.Core.Domain.Extensions;
public static class ReadReceiptExtensions
{
public static bool HasReadReceiptRequest(this MimeMessage mimeMessage)
=> mimeMessage?.Headers?.Contains(Constants.DispositionNotificationToHeader) == true
&& !string.IsNullOrWhiteSpace(mimeMessage.Headers[Constants.DispositionNotificationToHeader]);
public static void SetReadReceiptRequest(this MimeMessage mimeMessage, string address, bool isRequested)
{
if (mimeMessage == null)
return;
mimeMessage.Headers.Remove(Constants.DispositionNotificationToHeader);
if (isRequested && !string.IsNullOrWhiteSpace(address))
{
mimeMessage.Headers.Add(Constants.DispositionNotificationToHeader, address.Trim());
}
}
public static bool LooksLikeReadReceipt(this MimeMessage mimeMessage)
{
if (mimeMessage?.Body == null)
return false;
return mimeMessage.BodyParts.Any(IsReadReceiptEntity) || IsReadReceiptEntity(mimeMessage.Body);
}
public static ReadReceiptParseResult ParseReadReceipt(this MimeMessage mimeMessage)
{
if (!mimeMessage.LooksLikeReadReceipt())
return ReadReceiptParseResult.Empty;
var entity = mimeMessage.BodyParts.FirstOrDefault(IsReadReceiptEntity) ?? mimeMessage.Body;
var lines = ReadEntityLines(entity);
string originalMessageId = null;
foreach (var line in lines)
{
if (line.StartsWith(Constants.OriginalMessageIdHeader + ":", StringComparison.OrdinalIgnoreCase))
{
originalMessageId = line.Substring(line.IndexOf(':') + 1).Trim();
break;
}
}
var acknowledgedAtUtc = mimeMessage.Date != DateTimeOffset.MinValue
? mimeMessage.Date.UtcDateTime
: (DateTime?)null;
return new ReadReceiptParseResult(
true,
MailHeaderExtensions.NormalizeMessageId(originalMessageId),
acknowledgedAtUtc);
}
private static bool IsReadReceiptEntity(MimeEntity entity)
{
if (entity?.ContentType == null)
return false;
if (entity.ContentType.MimeType.Equals("message/disposition-notification", StringComparison.OrdinalIgnoreCase))
return true;
var reportType = entity.ContentType.Parameters["report-type"];
return entity.ContentType.MimeType.Equals("multipart/report", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(reportType)
&& reportType.Equals("disposition-notification", StringComparison.OrdinalIgnoreCase);
}
private static IEnumerable<string> ReadEntityLines(MimeEntity entity)
{
if (entity is TextPart textPart)
{
return SplitLines(textPart.Text);
}
if (entity is MimePart mimePart)
{
using var memoryStream = new MemoryStream();
mimePart.Content?.DecodeTo(memoryStream);
memoryStream.Position = 0;
using var reader = new StreamReader(memoryStream);
return SplitLines(reader.ReadToEnd());
}
using var serializedStream = new MemoryStream();
entity.WriteTo(serializedStream);
serializedStream.Position = 0;
using var serializedReader = new StreamReader(serializedStream);
return SplitLines(serializedReader.ReadToEnd());
}
private static IEnumerable<string> SplitLines(string content)
=> string.IsNullOrWhiteSpace(content)
? []
: content.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries);
}
public sealed record ReadReceiptParseResult(bool IsReadReceipt, string OriginalMessageId, DateTime? AcknowledgedAtUtc)
{
public static ReadReceiptParseResult Empty { get; } = new(false, string.Empty, null);
}
@@ -1,43 +1,35 @@
using System.Collections.Generic;
using System.Collections.Generic;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Domain.Interfaces;
public interface IAccountMenuItem : IMenuItem
{
bool IsEnabled { get; set; }
bool IsSynchronizationInProgress { get; set; }
/// <summary>
/// Calculated synchronization progress percentage (0-100).
/// Calculated synchronization progress percentage (0-100). -1 for indeterminate.
/// </summary>
double SynchronizationProgress { get; }
/// <summary>
/// Progress value clamped for XAML progress controls.
/// </summary>
double SynchronizationProgressValue { get; }
/// <summary>
/// Total items to sync. 0 for indeterminate progress.
/// </summary>
int TotalItemsToSync { get; set; }
/// <summary>
/// Remaining items to sync.
/// </summary>
int RemainingItemsToSync { get; set; }
/// <summary>
/// Current synchronization status message.
/// </summary>
string SynchronizationStatus { get; set; }
int UnreadItemCount { get; set; }
IEnumerable<MailAccount> HoldingAccounts { get; }
void ApplySynchronizationProgress(AccountSynchronizationProgress progress);
void UpdateAccount(MailAccount account);
}
@@ -147,7 +147,6 @@ public interface IAccountService
/// <param name="accountId">Account id.</param>
/// <returns>Primary alias for the account.</returns>
Task<MailAccountAlias> GetPrimaryAccountAliasAsync(Guid accountId);
Task UpdateAliasSendCapabilityAsync(Guid accountId, string aliasAddress, AliasSendCapability capability);
Task<bool> IsAccountFocusedEnabledAsync(Guid accountId);
/// <summary>
@@ -1,10 +0,0 @@
using System.Collections.Generic;
using Wino.Core.Domain.Models.Ai;
namespace Wino.Core.Domain.Interfaces;
public interface IAiActionOptionsService
{
IReadOnlyList<AiTranslateLanguageOption> GetTranslateLanguageOptions();
IReadOnlyList<AiRewriteModeOption> GetRewriteModeOptions();
}
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
@@ -31,22 +30,12 @@ public interface IBaseSynchronizer
/// <param name="mailUniqueId">Mail unique id to check.</param>
bool HasPendingOperation(Guid mailUniqueId);
/// <summary>
/// Returns mail unique ids that currently have queued or executing operations.
/// </summary>
IReadOnlyCollection<Guid> GetPendingOperationUniqueIds();
/// <summary>
/// Returns whether there is an in-progress (queued or currently executing) operation for the given calendar item id.
/// </summary>
/// <param name="calendarItemId">Calendar item id to check.</param>
bool HasPendingCalendarOperation(Guid calendarItemId);
/// <summary>
/// Returns calendar item ids that currently have queued or executing operations.
/// </summary>
IReadOnlyCollection<Guid> GetPendingCalendarOperationIds();
/// <summary>
/// Synchronizes profile information with the server.
/// Sender name and Profile picture are updated.
@@ -1,10 +0,0 @@
using System.Collections.Generic;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Core.Domain.Interfaces;
public interface ICalendarContextMenuItemService
{
IReadOnlyList<CalendarContextMenuItem> GetContextMenuItems(CalendarItem calendarItem);
}
@@ -18,7 +18,6 @@ public interface ICalendarService
Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar);
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
Task SetPrimaryCalendarAsync(Guid accountId, Guid accountCalendarId);
Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
/// <summary>
@@ -41,7 +40,6 @@ public interface ICalendarService
Task<List<CalendarEventAttendee>> GetAttendeesAsync(Guid calendarEventTrackingId);
Task<List<CalendarEventAttendee>> ManageEventAttendeesAsync(Guid calendarItemId, List<CalendarEventAttendee> allAttendees);
Task UpdateCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
Task<List<CalendarItem>> SearchCalendarItemsAsync(string searchQuery, int limit, CancellationToken cancellationToken = default);
Task<List<Reminder>> GetRemindersAsync(Guid calendarItemId);
Task SaveRemindersAsync(Guid calendarItemId, List<Reminder> reminders);
Task SnoozeCalendarItemAsync(Guid calendarItemId, DateTime snoozedUntilLocal);
@@ -1,26 +0,0 @@
using System;
using System.Threading.Tasks;
namespace Wino.Core.Domain.Interfaces;
/// <summary>
/// Manages contact picture files stored on disk instead of as base64 in SQLite,
/// eliminating DB bloat and enabling native WIC hardware-accelerated image loading.
/// </summary>
public interface IContactPictureFileService
{
/// <summary>
/// Returns the full file path for the given file ID, or null if the file does not exist on disk.
/// </summary>
string GetContactPicturePath(Guid fileId);
/// <summary>
/// Saves raw image bytes to disk and returns the new file ID.
/// </summary>
Task<Guid> SaveContactPictureAsync(byte[] imageData);
/// <summary>
/// Deletes the picture file for the given file ID if it exists.
/// </summary>
Task DeleteContactPictureAsync(Guid fileId);
}
+2 -18
View File
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MimeKit;
@@ -12,30 +11,15 @@ public interface IContactService
{
Task<List<AccountContact>> GetAddressInformationAsync(string queryText);
Task<AccountContact> GetAddressInformationByAddressAsync(string address);
Task<List<AccountContact>> GetContactsByAddressesAsync(IEnumerable<string> addresses);
Task SaveAddressInformationAsync(MimeMessage message);
Task SaveAddressInformationAsync(IEnumerable<AccountContact> contacts);
Task<AccountContact> CreateNewContactAsync(string address, string displayName);
// Paged contact queries for ContactsPage
// New methods for ContactsPage
Task<List<AccountContact>> GetAllContactsAsync();
Task<List<AccountContact>> SearchContactsAsync(string searchQuery);
Task<PagedContactsResult> GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false);
Task<AccountContact> UpdateContactAsync(AccountContact contact);
Task DeleteContactAsync(string address);
Task DeleteContactsAsync(IEnumerable<string> addresses);
// Group / distribution list support
Task<List<ContactGroup>> GetGroupsAsync();
Task<ContactGroup> CreateGroupAsync(string name, string description = null);
Task DeleteGroupAsync(Guid groupId);
Task<List<AccountContact>> GetGroupMembersAsync(Guid groupId);
Task AddGroupMemberAsync(Guid groupId, string memberAddress);
Task RemoveGroupMemberAsync(Guid groupId, string memberAddress);
/// <summary>
/// Expands a contact group to the individual <see cref="AccountContact"/> entries of its members.
/// Returns an empty list if the group does not exist or has no members.
/// </summary>
Task<List<AccountContact>> ExpandGroupAsync(Guid groupId);
}
@@ -1,9 +1,10 @@
using System;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Common;
using Wino.Core.Domain.Models.Printing;
namespace Wino.Core.Domain.Interfaces;
@@ -27,6 +28,6 @@ public interface IDialogServiceBase
Task<AccountCreationDialogResult> ShowAccountProviderSelectionDialogAsync(List<IProviderDetail> availableProviders);
IAccountCreationDialog GetAccountCreationDialog(AccountCreationDialogResult accountCreationDialogResult);
Task<List<SharedFile>> PickFilesAsync(params object[] typeFilters);
Task<List<PickedFileMetadata>> PickFilesMetadataAsync(params object[] typeFilters);
Task<string> PickFilePathAsync(string saveFileName);
Task<WebView2PrintSettingsModel> ShowPrintDialogAsync(WebView2PrintSettingsModel initialSettings = null);
}
@@ -1,15 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Mail;
namespace Wino.Core.Domain.Interfaces;
public interface IEmailTemplateService
{
Task<List<EmailTemplate>> GetEmailTemplatesAsync();
Task<EmailTemplate> GetEmailTemplateAsync(Guid templateId);
Task<EmailTemplate> CreateEmailTemplateAsync(EmailTemplate template);
Task<EmailTemplate> UpdateEmailTemplateAsync(EmailTemplate template);
Task<EmailTemplate> DeleteEmailTemplateAsync(EmailTemplate template);
}
@@ -37,23 +37,21 @@ public interface IKeyboardShortcutService
Task DeleteKeyboardShortcutAsync(Guid shortcutId);
/// <summary>
/// Gets the keyboard shortcut for the given key combination in a specific mode.
/// Gets the mail operation for the given key combination.
/// </summary>
/// <param name="mode">The application mode to search within.</param>
/// <param name="key">The pressed key.</param>
/// <param name="modifierKeys">The modifier keys pressed.</param>
/// <returns>The matching shortcut if found, otherwise null.</returns>
Task<KeyboardShortcut> GetShortcutForKeyAsync(WinoApplicationMode mode, string key, ModifierKeys modifierKeys);
/// <returns>The mail operation if found, otherwise null.</returns>
Task<MailOperation?> GetMailOperationForKeyAsync(string key, ModifierKeys modifierKeys);
/// <summary>
/// Checks if a key combination is already assigned to another shortcut.
/// </summary>
/// <param name="mode">The application mode to check within.</param>
/// <param name="key">The key to check.</param>
/// <param name="modifierKeys">The modifier keys to check.</param>
/// <param name="excludeShortcutId">Optional ID to exclude from the check (for updates).</param>
/// <returns>True if the combination is already used, false otherwise.</returns>
Task<bool> IsKeyCombinationInUseAsync(WinoApplicationMode mode, string key, ModifierKeys modifierKeys, Guid? excludeShortcutId = null);
Task<bool> IsKeyCombinationInUseAsync(string key, ModifierKeys modifierKeys, Guid? excludeShortcutId = null);
/// <summary>
/// Creates default keyboard shortcuts for common mail operations.
@@ -64,4 +62,4 @@ public interface IKeyboardShortcutService
/// Resets all shortcuts to defaults.
/// </summary>
Task ResetToDefaultShortcutsAsync();
}
}
@@ -1,5 +1,4 @@
using System;
using Wino.Core.Domain.Models.Launch;
using Wino.Core.Domain.Models.Launch;
namespace Wino.Core.Domain.Interfaces;
@@ -14,5 +13,4 @@ public interface ILaunchProtocolService
/// Used to handle mailto links.
/// </summary>
MailToUri MailToUri { get; set; }
}
@@ -1,15 +1,11 @@
#nullable enable
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders;
namespace Wino.Core.Domain.Interfaces;
@@ -22,7 +18,6 @@ public interface IMailDialogService : IDialogServiceBase
// Custom dialogs
Task<IMailItemFolder> ShowMoveMailFolderDialogAsync(List<IMailItemFolder> availableFolders);
Task<MailAccount> ShowAccountPickerDialogAsync(List<MailAccount> availableAccounts);
Task<AccountCalendarPickingResult> ShowSingleCalendarPickerDialogAsync(List<CalendarPickerAccountGroup> availableCalendarGroups);
/// <summary>
/// Displays a dialog to the user for reordering accounts.
@@ -43,7 +38,7 @@ public interface IMailDialogService : IDialogServiceBase
/// Presents a dialog to the user for signature creation/modification.
/// </summary>
/// <returns>Signature information. Null if canceled.</returns>
Task<AccountSignature> ShowSignatureEditorDialog(AccountSignature? signatureModel = null);
Task<AccountSignature> ShowSignatureEditorDialog(AccountSignature signatureModel = null);
/// <summary>
/// Presents a dialog to the user for account alias creation/modification.
@@ -61,7 +56,7 @@ public interface IMailDialogService : IDialogServiceBase
/// </summary>
/// <param name="existingShortcut">Existing shortcut to edit, or null for new shortcut.</param>
/// <returns>Dialog result with shortcut information.</returns>
#pragma warning disable CS8625
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
Task<KeyboardShortcutDialogResult> ShowKeyboardShortcutDialogAsync(KeyboardShortcut existingShortcut = null);
#pragma warning restore CS8625
@@ -70,11 +65,5 @@ public interface IMailDialogService : IDialogServiceBase
/// </summary>
/// <param name="contact">Existing contact to edit, or null for new contact.</param>
/// <returns>Contact information. Null if canceled.</returns>
Task<AccountContact?> ShowEditContactDialogAsync(AccountContact? contact = null);
Task<WinoAccount?> ShowWinoAccountRegistrationDialogAsync();
Task<WinoAccount?> ShowWinoAccountLoginDialogAsync();
Task<WinoAccountSyncExportResult?> ShowWinoAccountExportDialogAsync();
Task<AccountContact> ShowEditContactDialogAsync(AccountContact contact = null);
}
@@ -20,11 +20,9 @@ public interface IMailItemDisplayInformation : INotifyPropertyChanged
bool IsCalendarEvent { get; }
bool IsFlagged { get; }
DateTime CreationDate { get; }
Guid? ContactPictureFileId { get; }
string Base64ContactPicture { get; }
bool ThumbnailUpdatedEvent { get; }
bool IsBusy { get; }
bool IsThreadExpanded { get; }
AccountContact SenderContact { get; }
bool HasReadReceiptTracking { get; }
bool IsReadReceiptAcknowledged { get; }
string ReadReceiptDisplayText { get; }
}
@@ -4,5 +4,4 @@ public interface IMenuOperation
{
bool IsEnabled { get; }
string Identifier { get; }
bool IsSecondaryMenuPreferred { get; }
}
@@ -59,26 +59,6 @@ public interface IMimeFileService
/// </summary>
Task<bool> DeleteMimeMessageAsync(Guid accountId, Guid fileId);
/// <summary>
/// Returns cached translated html for the given mime resource if it exists.
/// </summary>
Task<string> GetTranslatedHtmlAsync(Guid accountId, Guid fileId, string targetLanguage, CancellationToken cancellationToken = default);
/// <summary>
/// Saves translated html for the given mime resource.
/// </summary>
Task SaveTranslatedHtmlAsync(Guid accountId, Guid fileId, string targetLanguage, string html, CancellationToken cancellationToken = default);
/// <summary>
/// Returns cached summary text for the given mime resource if it exists.
/// </summary>
Task<string> GetSummaryTextAsync(Guid accountId, Guid fileId, CancellationToken cancellationToken = default);
/// <summary>
/// Saves summary text for the given mime resource.
/// </summary>
Task SaveSummaryTextAsync(Guid accountId, Guid fileId, string summary, CancellationToken cancellationToken = default);
/// <summary>
/// Prepares the final model containing rendering details.
/// </summary>
@@ -37,10 +37,4 @@ public interface INewThemeService : IInitializeAsync
// Backdrop management
List<BackdropTypeWrapper> GetAvailableBackdropTypes();
/// <summary>
/// Re-applies the current theme (backdrop, root theme, accent, caption colors)
/// to the currently active window. Use after a window transition.
/// </summary>
Task ApplyThemeToActiveWindowAsync();
}
@@ -20,16 +20,6 @@ public interface INotificationBuilder
/// <returns></returns>
Task UpdateTaskbarIconBadgeAsync();
/// <summary>
/// Adds to the calendar app-entry badge count for newly downloaded events.
/// </summary>
Task AddCalendarTaskbarBadgeCountAsync(int newlyDownloadedCount);
/// <summary>
/// Clears the calendar app-entry badge.
/// </summary>
Task ClearCalendarTaskbarBadgeAsync();
/// <summary>
/// Removes the toast notification for a specific mail by unique id.
/// </summary>
@@ -46,14 +36,8 @@ public interface INotificationBuilder
/// </summary>
void CreateWebView2RuntimeMissingNotification();
/// <summary>
/// Shows a notification when a Microsoft Store update is available.
/// </summary>
void CreateStoreUpdateNotification();
/// <summary>
/// Creates a calendar reminder toast for the specified calendar item.
/// </summary>
Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds);
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.ComponentModel;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
@@ -57,47 +57,6 @@ public interface IPreferencesService : INotifyPropertyChanged
/// </summary>
WinoApplicationMode DefaultApplicationMode { get; set; }
/// <summary>
/// Setting: Whether Microsoft Store update notifications should be shown.
/// </summary>
bool IsStoreUpdateNotificationsEnabled { get; set; }
/// <summary>
/// Setting: Whether the Wino account profile button in the shell title bar should be hidden.
/// </summary>
bool IsWinoAccountButtonHidden { get; set; }
/// <summary>
/// Setting: Whether AI actions panels and their toggle buttons should be hidden.
/// </summary>
bool IsAiActionsPanelHidden { get; set; }
/// <summary>
/// Setting: Default target language code used for AI translation actions.
/// </summary>
string AiDefaultTranslationLanguageCode { get; set; }
/// <summary>
/// Setting: Preferred target language code for AI summarize actions.
/// </summary>
string AiSummarizeLanguageCode { get; set; }
/// <summary>
/// Setting: Preferred folder path used when saving AI summaries.
/// </summary>
string AiSummarySavePath { get; set; }
/// <summary>
/// Serializes the current syncable preferences snapshot.
/// </summary>
string ExportPreferences();
/// <summary>
/// Deserializes and applies a preferences snapshot.
/// Returns the applied and failed property counts.
/// </summary>
(int appliedCount, int failedCount) ImportPreferences(string settingsJson);
#endregion
#region Mail
@@ -118,7 +77,7 @@ public interface IPreferencesService : INotifyPropertyChanged
int MarkAsDelay { get; set; }
/// <summary>
/// Setting: Ask confirmation from the user during permanent delete.
/// Setting: Ask comfirmation from the user during permanent delete.
/// </summary>
bool IsHardDeleteProtectionEnabled { get; set; }
@@ -187,6 +146,11 @@ public interface IPreferencesService : INotifyPropertyChanged
/// </summary>
MailOperation RightHoverAction { get; set; }
/// <summary>
/// Setting: Whether Mailkit Protocol Logger is enabled for ImapTestService or not.
/// </summary>
bool IsMailkitProtocolLoggerEnabled { get; set; }
/// <summary>
/// Setting: Which entity id (merged account or folder) should be expanded automatically on startup.
/// </summary>
@@ -246,13 +210,11 @@ public interface IPreferencesService : INotifyPropertyChanged
#region Calendar
DayOfWeek FirstDayOfWeek { get; set; }
bool IsWorkingHoursEnabled { get; set; }
TimeSpan WorkingHourStart { get; set; }
TimeSpan WorkingHourEnd { get; set; }
DayOfWeek WorkingDayStart { get; set; }
DayOfWeek WorkingDayEnd { get; set; }
double HourHeight { get; set; }
string CalendarTimedDayHeaderDateFormat { get; set; }
/// <summary>
/// Setting: Default reminder duration in seconds for new calendar events.
@@ -260,21 +222,6 @@ public interface IPreferencesService : INotifyPropertyChanged
/// </summary>
long DefaultReminderDurationInSeconds { get; set; }
/// <summary>
/// Setting: Default snooze duration in minutes for calendar reminder notifications.
/// </summary>
int DefaultSnoozeDurationInMinutes { get; set; }
/// <summary>
/// Setting: How the New Event button chooses a calendar.
/// </summary>
NewEventButtonBehavior NewEventButtonBehavior { get; set; }
/// <summary>
/// Setting: Default calendar used when New Event is configured to always use a specific calendar.
/// </summary>
Guid? DefaultNewEventCalendarId { get; set; }
CalendarSettings GetCurrentCalendarSettings();
#endregion
+2 -5
View File
@@ -1,12 +1,9 @@
using System;
using System.IO;
using System.Threading.Tasks;
using System.Threading.Tasks;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Printing;
namespace Wino.Core.Domain.Interfaces;
public interface IPrintService
{
Task<PrintingResult> PrintAsync(nint windowHandle, string printTitle, Func<WebView2PrintSettingsModel, Task<Stream>> renderPdfStreamAsync);
Task<PrintingResult> PrintPdfFileAsync(string pdfFilePath, string printTitle);
}
@@ -1,5 +1,4 @@
using System;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
@@ -69,6 +68,5 @@ public interface IFolderActionRequest : IRequestBase
public interface ICalendarActionRequest : IRequestBase
{
CalendarItem Item { get; }
Guid? LocalCalendarItemId { get; }
CalendarSynchronizerOperation Operation { get; }
}
@@ -1,15 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MimeKit;
using Wino.Core.Domain.Entities.Mail;
namespace Wino.Core.Domain.Interfaces;
public interface ISentMailReceiptService
{
Task PopulateReceiptStateAsync(MailCopy mailCopy);
Task PopulateReceiptStatesAsync(IReadOnlyCollection<MailCopy> mailCopies);
Task TrackSentMailAsync(MailCopy mailCopy, MimeMessage mimeMessage = null);
Task ProcessIncomingReceiptAsync(MailCopy receiptMail, MimeMessage mimeMessage);
}
@@ -1,79 +0,0 @@
#nullable enable
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.MenuItems;
using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.Navigation;
namespace Wino.Core.Domain.Interfaces;
public interface IShellClient : INotifyPropertyChanged
{
WinoApplicationMode Mode { get; }
IDispatcher Dispatcher { get; set; }
MenuItemCollection? MenuItems { get; }
object? SelectedMenuItem { get; set; }
bool HandlesNavigationSelection { get; }
void Activate(ShellModeActivationContext activationContext);
void Deactivate();
Task HandleNavigationItemInvokedAsync(IMenuItem? menuItem);
Task HandleNavigationSelectionChangedAsync(IMenuItem? menuItem);
Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args);
}
public interface IMailShellClient : IShellClient
{
IMenuItem CreatePrimaryMenuItem { get; }
IEnumerable<FolderOperationMenuItem> GetFolderContextMenuActions(IBaseFolderMenuItem folder);
Task HandleAccountCreatedAsync(MailAccount createdAccount);
Task NavigateFolderAsync(IBaseFolderMenuItem baseFolderMenuItem, TaskCompletionSource<bool>? folderInitAwaitTask = null);
Task ChangeLoadedAccountAsync(IAccountMenuItem clickedBaseAccountMenuItem, bool navigateInbox = true);
Task PerformFolderOperationAsync(FolderOperation operation, IBaseFolderMenuItem folderMenuItem);
Task PerformMoveOperationAsync(IEnumerable<MailCopy> items, IBaseFolderMenuItem targetFolderMenuItem);
Task CreateNewMailForAsync(MailAccount account);
}
public interface ICalendarShellClient : IShellClient
{
IStatePersistanceService StatePersistenceService { get; }
IEnumerable DateNavigationHeaderItems { get; }
int SelectedDateNavigationHeaderIndex { get; }
VisibleDateRange? CurrentVisibleRange { get; }
string VisibleDateRangeText { get; }
bool CanSynchronizeCalendars { get; }
ICommand SyncCommand { get; }
ICommand TodayClickedCommand { get; }
ICommand DateClickedCommand { get; }
ICommand PreviousDateRangeCommand { get; }
ICommand NextDateRangeCommand { get; }
IEnumerable GroupedAccountCalendars { get; }
}
public interface IShellViewModel
{
WinoApplicationMode CurrentMode { get; }
IShellClient CurrentClient { get; }
MenuItemCollection? CurrentMenuItems { get; }
object? SelectedMenuItem { get; set; }
void SetCurrentMode(WinoApplicationMode mode);
IShellClient GetClient(WinoApplicationMode mode);
}
public interface IShellHost
{
bool HasShellContent { get; }
void ActivateMode(WinoApplicationMode mode, ShellModeActivationContext activationContext);
}
@@ -18,16 +18,16 @@ public interface IStatePersistanceService : INotifyPropertyChanged
/// </summary>
string CoreWindowTitle { get; set; }
/// <summary>
/// App mode title shown in the title bar.
/// </summary>
string AppModeTitle { get; set; }
/// <summary>
/// When only reader page is visible in small sized window.
/// </summary>
bool IsReaderNarrowed { get; set; }
/// <summary>
/// Should display back button on the shell title bar.
/// </summary>
bool IsBackButtonVisible { get; }
/// <summary>
/// Current application mode (Mail or Calendar).
/// Not persisted to configuration, only kept in memory.
@@ -39,6 +39,16 @@ public interface IStatePersistanceService : INotifyPropertyChanged
/// </summary>
bool IsEventDetailsVisible { get; set; }
/// <summary>
/// Whether ManageAccountsPage has navigated to a sub-page and can go back.
/// </summary>
bool IsManageAccountsNavigating { get; set; }
/// <summary>
/// Whether SettingsPage has navigated to a sub-page and can go back.
/// </summary>
bool IsSettingsNavigating { get; set; }
/// <summary>
/// Setting: Opened pane length for the navigation view.
/// </summary>
@@ -64,5 +74,4 @@ public interface IStatePersistanceService : INotifyPropertyChanged
/// Setting: Calendar display count for the day view.
/// </summary>
int DayDisplayCount { get; set; }
}
@@ -1,6 +1,6 @@
#nullable enable
using System.Threading.Tasks;
using System.Threading.Tasks;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Store;
namespace Wino.Core.Domain.Interfaces;
@@ -9,20 +9,10 @@ public interface IStoreManagementService
/// <summary>
/// Checks whether user has the type of an add-on purchased.
/// </summary>
Task<bool> HasProductAsync(WinoAddOnProductType productType);
Task<bool> HasProductAsync(StoreProductType productType);
/// <summary>
/// Attempts to purchase the given add-on.
/// </summary>
Task<StorePurchaseResult> PurchaseAsync(WinoAddOnProductType productType);
/// <summary>
/// Requests a Microsoft Store collections ID key for the current customer.
/// </summary>
Task<string?> GetCustomerCollectionsIdAsync(string serviceTicket, string publisherUserId);
/// <summary>
/// Requests a Microsoft Store purchase ID key for the current customer.
/// </summary>
Task<string?> GetCustomerPurchaseIdAsync(string serviceTicket, string publisherUserId);
Task<StorePurchaseResult> PurchaseAsync(StoreProductType productType);
}
@@ -1,12 +0,0 @@
using System.Threading.Tasks;
namespace Wino.Core.Domain.Interfaces;
public interface IStoreUpdateService
{
bool HasAvailableUpdate { get; }
Task<bool> RefreshAvailabilityAsync(bool showNotification = false);
Task<bool> StartUpdateAsync();
}
@@ -41,11 +41,6 @@ public interface ISynchronizationManager
/// </summary>
bool IsAccountSynchronizing(Guid accountId);
/// <summary>
/// Gets the latest centralized synchronization progress snapshot for the given account and category.
/// </summary>
AccountSynchronizationProgress GetSynchronizationProgress(Guid accountId, SynchronizationProgressCategory category);
/// <summary>
/// Queues a mail action request to the corresponding account's synchronizer with optional synchronization triggering.
/// </summary>
@@ -1,21 +0,0 @@
using System.Threading.Tasks;
using Wino.Core.Domain.Models.Updates;
using System.Collections.Generic;
namespace Wino.Core.Domain.Interfaces;
public interface IUpdateManager
{
/// <summary>Loads and parses the update notes for the current version from the bundled asset file.</summary>
Task<UpdateNotes> GetLatestUpdateNotesAsync();
/// <summary>Loads and parses the app feature highlights from the bundled asset file.</summary>
Task<List<UpdateNoteSection>> GetFeaturesAsync();
/// <summary>Returns true if the current version's update notes have not yet been shown to the user.</summary>
bool ShouldShowUpdateNotes();
/// <summary>Stores a flag in local settings indicating the update notes for the current version have been seen.</summary>
void MarkUpdateNotesAsSeen();
}
@@ -1,33 +0,0 @@
#nullable enable
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common;
using Wino.Mail.Api.Contracts.Users;
namespace Wino.Core.Domain.Interfaces;
public interface IWinoAccountApiClient
{
Task<WinoAccountApiResult<AuthResultDto>> RegisterAsync(string email, string password, CancellationToken cancellationToken = default);
Task<WinoAccountApiResult<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
Task<WinoAccountApiResult<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<ApiEnvelope<EmailConfirmationResendResultDto>> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default);
Task<ApiEnvelope<JsonElement>> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default);
Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiTextResultDto>> SummarizeAsync(string html, string targetLanguage, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiTextResultDto>> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiTextResultDto>> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default);
Task<ApiEnvelope<WinoStoreCollectionsIdTicketInfo>> CreateCollectionsIdTicketAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<WinoStoreCollectionsIdTicketInfo>> CreatePurchaseIdTicketAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<JsonElement>> SyncStoreEntitlementsAsync(string? storeIdKey, string? purchaseIdKey, CancellationToken cancellationToken = default);
Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default);
Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default);
Task<UserMailboxSyncListDto> GetMailboxesAsync(CancellationToken cancellationToken = default);
Task ReplaceMailboxesAsync(ReplaceUserMailboxesRequestDto request, CancellationToken cancellationToken = default);
}

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