Compare commits
538 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af4c9527b0 | |||
| 0b9bdc91fe | |||
| c1bda75d9f | |||
| 6f82cd4f26 | |||
| 81e28129b7 | |||
| 39cde10fab | |||
| ed54eb0284 | |||
| d66015bebd | |||
| 890bfc84f1 | |||
| 59505d6985 | |||
| 09820dda71 | |||
| c0023614ad | |||
| e0f517e993 | |||
| 66c556b587 | |||
| 877fb0dbd4 | |||
| 2ea65dc556 | |||
| 23dce29ff8 | |||
| 9292c963d5 | |||
| 2b1676a4f7 | |||
| d85812ed7b | |||
| 54148716bb | |||
| 3bd0b69429 | |||
| 496c7735f7 | |||
| bfbc3d40b3 | |||
| 9cc6b03f61 | |||
| 00437bae4e | |||
| 2a93600ede | |||
| 90cb5f86b3 | |||
| 3caecc5a9c | |||
| 9e735c911c | |||
| 37e60bdd6e | |||
| 85ac7e1330 | |||
| d287c22725 | |||
| 98eed39fe6 | |||
| 0b136b3d66 | |||
| e1f53c7f9f | |||
| 784144cd13 | |||
| 65f7e0236a | |||
| e13aaadc78 | |||
| 94675eee9a | |||
| b3360ecd76 | |||
| 4ca26cb131 | |||
| 7c0f8d4bb4 | |||
| 999d8cde73 | |||
| 1a1d69be56 | |||
| c2540926f4 | |||
| 9424fd9a16 | |||
| 89b48d3ac4 | |||
| 0bcc7a7647 | |||
| 260e1ab935 | |||
| ccf7c0607b | |||
| b8ce7e7422 | |||
| 1365e42fd7 | |||
| 0f160545ab | |||
| 8481a5c7cd | |||
| d32745fd67 | |||
| 470b2b8638 | |||
| 7e1731f4dc | |||
| aac9f9fec3 | |||
| cf8fff8ef1 | |||
| 0610096b78 | |||
| feff929333 | |||
| aa16609f89 | |||
| 4bea53a667 | |||
| b2ad4a1664 | |||
| dad3a51885 | |||
| 59ff0a1d7d | |||
| df19ab3196 | |||
| c622858d2d | |||
| 2e36772a4c | |||
| a2acad9ea4 | |||
| 40b15b4f08 | |||
| 6fd66810e9 | |||
| 758a186c26 | |||
| b6bf5f2cd1 | |||
| 3977401057 | |||
| 10c797fba4 | |||
| d922dd2f2e | |||
| 4d04595d0a | |||
| 4ac3ca3ee4 | |||
| 678245d1fa | |||
| c8ab214651 | |||
| 230039cb57 | |||
| 448ebd6036 | |||
| e206368801 | |||
| 24626d1c31 | |||
| 5cb49efeb4 | |||
| 40318ef99c | |||
| fdb340549d | |||
| 4cb08f0a98 | |||
| d5c121ce24 | |||
| be71ef3611 | |||
| 2a0d15ad69 | |||
| a8310f1dab | |||
| aaf0b7d069 | |||
| 832a4b0348 | |||
| d6049bbd88 | |||
| 3dc4ac03ec | |||
| a3c35dfae5 | |||
| cbdcfeae05 | |||
| 58a4e677e4 | |||
| 76f6ae0a1e | |||
| a855d8c8a8 | |||
| 1567d9fa5e | |||
| 71fc883e47 | |||
| 3db54023a4 | |||
| a9fd624742 | |||
| 9855170b2e | |||
| 12acff3bf8 | |||
| f693299304 | |||
| ff05195416 | |||
| c8265e75be | |||
| 81e476e699 | |||
| 3357f6273c | |||
| 323bbf7ea3 | |||
| ef85ce6947 | |||
| 748ac8377a | |||
| 08bb9ede2a | |||
| 32d677025d | |||
| ac78cf2b78 | |||
| 5f64cca518 | |||
| ca19297b92 | |||
| c1ab49fb1d | |||
| 6013865fca | |||
| 1d0fcfb5b0 | |||
| 1667aa34db | |||
| 1211e9b28a | |||
| 27e91316d3 | |||
| 8f16f553f5 | |||
| 7b369201b0 | |||
| 6f61605c12 | |||
| 686446937b | |||
| fb8a3d8f90 | |||
| 3712041689 | |||
| 37afb990f1 | |||
| a465545fcb | |||
| 022ffc567b | |||
| e3c3b341e5 | |||
| 8c492bb094 | |||
| 0056f372b9 | |||
| 7aad6b0157 | |||
| fd81ee31ce | |||
| 27c90d2f89 | |||
| d699818c6f | |||
| 317cad2459 | |||
| ff84d62196 | |||
| 5c7f6aa734 | |||
| 1adba271e2 | |||
| 8586d0ef54 | |||
| 8d143e3b08 | |||
| e6a38a3e77 | |||
| 51fef043ee | |||
| 01f7a09cb7 | |||
| eb8cd7651d | |||
| c88c875fb8 | |||
| a00af1da3f | |||
| 1fe569e0ac | |||
| 4a20ea2577 | |||
| d38317f0be | |||
| c2320de5c4 | |||
| 873a7eca12 | |||
| c3e1991942 | |||
| b0ee5c9974 | |||
| f306f6eb1c | |||
| a3b43fd079 | |||
| bac291587d | |||
| aee32228c2 | |||
| 0d6da30a29 | |||
| 289d0c8eeb | |||
| 4a94dfb10c | |||
| ea204fef21 | |||
| e2b9216f8f | |||
| 5f519f6ae1 | |||
| 5b2a99ffe5 | |||
| 4519b77444 | |||
| 59d9cf4eea | |||
| 0ee3a0c3bc | |||
| 37c1bd3f62 | |||
| 921c3bef93 | |||
| 45142e6953 | |||
| 642f6efbfb | |||
| 56b0f79edc | |||
| 4ba7d5fd07 | |||
| 7f0b671b62 | |||
| de5309ea56 | |||
| fd13f2eba5 | |||
| 861b991eee | |||
| 9dd68fd62e | |||
| 2b523d64e8 | |||
| bf331dfeb3 | |||
| 9b567c4bac | |||
| 859a5bb117 | |||
| 0d898d3de0 | |||
| 44be3eb4f7 | |||
| 3e731967cd | |||
| 8548257878 | |||
| d9da326f0a | |||
| d43e2b269a | |||
| 9d94badb95 | |||
| e4a224bd68 | |||
| 15400d4096 | |||
| c1568d33e6 | |||
| a8f9b2d126 | |||
| 1da34080d1 | |||
| ebc35c3de8 | |||
| d1f8163d72 | |||
| 09f1cee3a5 | |||
| 8e8b123aa6 | |||
| 9ec7b32762 | |||
| e94cce451f | |||
| 6608baed69 | |||
| 59042729c1 | |||
| e1be644631 | |||
| 51f64466c2 | |||
| 24f7c26d60 | |||
| 1aaf4e8a7e | |||
| 3d6763770e | |||
| aaa6e8a2c9 | |||
| db5ecd60e4 | |||
| 5b3739c6cf | |||
| d45d3faa89 | |||
| e816e87f61 | |||
| bdd32786d6 | |||
| f35a4333f9 | |||
| 2c9351f551 | |||
| 211faff750 | |||
| 11158fe737 | |||
| 76e3b7289e | |||
| 2040d4abce | |||
| 0e742c7a8f | |||
| d2fce5eee1 | |||
| 5c510fd7b0 | |||
| e1ce85698c | |||
| 4b22608bc5 | |||
| 3a39266121 | |||
| 5d46ea73db | |||
| d51f4a7a23 | |||
| 79a81710f0 | |||
| c5a631da6f | |||
| 33672ab0aa | |||
| 311b3c77c8 | |||
| 17ca32c537 | |||
| 9d3f0bddde | |||
| 7f198bad92 | |||
| a912ada890 | |||
| 317113a1b3 | |||
| 564cb0b16f | |||
| ab0810f710 | |||
| 7a13ae0ac8 | |||
| c8e1678e55 | |||
| f49d276f5a | |||
| 05112d6a35 | |||
| fec49ce6f8 | |||
| 31a7faeef9 | |||
| dae7d046c4 | |||
| d428a6ce7a | |||
| ff25db3fea | |||
| 2baa87daeb | |||
| 42e51571a8 | |||
| acf0f649e8 | |||
| 64b9bfc392 | |||
| 744145be06 | |||
| 4a0dcd2899 | |||
| 92df726f34 | |||
| dbd5812c45 | |||
| 884f000058 | |||
| e936c431a2 | |||
| b01fa4e4ba | |||
| 96dcdc8e03 | |||
| 96d2efb3f0 | |||
| 37199d84cb | |||
| 52ee5f1d8a | |||
| 870a5e2bf6 | |||
| 10dd42b63f | |||
| 0999c71578 | |||
| e559a79506 | |||
| 1747ed84a8 | |||
| 22c6452227 | |||
| ad9b94d407 | |||
| 9f13bcd991 | |||
| 5bfa61a218 | |||
| 2cd03d5fec | |||
| c7fb648387 | |||
| 331b966556 | |||
| d28de50ec6 | |||
| 1ec8d5bbf2 | |||
| 4374d19ac2 | |||
| 071f1c9786 | |||
| d1425ca9ca | |||
| 2fd600d47d | |||
| 0eba778158 | |||
| b343152f14 | |||
| 4f65502c95 | |||
| 31097e42a9 | |||
| 319b0af305 | |||
| f105c2f8f0 | |||
| 7cc201f423 | |||
| a23a99cc8d | |||
| be6b23c47b | |||
| f8333aab10 | |||
| d279c0a8dd | |||
| bd8867dba6 | |||
| 3d07328f47 | |||
| 0b0f6b8d8e | |||
| 4603b1fb14 | |||
| c8ef031e7d | |||
| 9877656eea | |||
| a64627e7d6 | |||
| 3b485dc1fe | |||
| e71c050724 | |||
| d54a9f6279 | |||
| f917e4a721 | |||
| 61fb10a951 | |||
| d3704a0f09 | |||
| c584929db5 | |||
| ea4cf20746 | |||
| 2056a2d783 | |||
| b81ab0ca15 | |||
| 70ac2d2bea | |||
| 07f3dabff6 | |||
| 72e43e4b7a | |||
| 0519bf86b3 | |||
| 6ba2f1f3e2 | |||
| 8613e92b31 | |||
| f79305f0a6 | |||
| 0f6aa66b21 | |||
| 51540c89d1 | |||
| a5227abd40 | |||
| 014b5aa671 | |||
| 10b85ea135 | |||
| f6e94e89c9 | |||
| 8a68fafedf | |||
| 7f8c6776fc | |||
| 6e5efa69c9 | |||
| 9fbbd00dc5 | |||
| a8a5d3c3d6 | |||
| beb3bf9d1d | |||
| 1a2590e2c3 | |||
| 8858ef08c2 | |||
| 4520e16048 | |||
| 56cd29429e | |||
| 07aeaf8c8f | |||
| a2c7e5f29a | |||
| b3130d9441 | |||
| 0dd907e314 | |||
| 12a39064dc | |||
| b356af8eb4 | |||
| ae64094feb | |||
| 472cc3d7f2 | |||
| dbaed6094b | |||
| 8cb8f27e00 | |||
| d9ef81729f | |||
| d592d1c235 | |||
| e185301277 | |||
| 249a950dc1 | |||
| 540a4e5117 | |||
| 3d5da92c74 | |||
| 88fe141b16 | |||
| 87d2ffdb71 | |||
| 13cb3a1042 | |||
| 6be271565e | |||
| 8482171bf2 | |||
| c277893145 | |||
| 9a0290d7a6 | |||
| 777219ab87 | |||
| 16e06af76f | |||
| 3b776ec1bd | |||
| 175ed24a66 | |||
| 5f9b51e4db | |||
| ae9e35e091 | |||
| b60832a270 | |||
| 5186b14905 | |||
| 2a67a1e961 | |||
| 4d0d2ff099 | |||
| 37b8a382a8 | |||
| f06273aa77 | |||
| 600d1b7d38 | |||
| 9e74fa9578 | |||
| 282655dca8 | |||
| 3cc1d10b87 | |||
| 4bf8f8b3d3 | |||
| 2d81d07c0a | |||
| b0ac6e4e55 | |||
| 3db1fd0dde | |||
| df0eae256c | |||
| 9c348f79d7 | |||
| 525950a4da | |||
| 394af3ba0a | |||
| 27177acff7 | |||
| 864d68b6ac | |||
| c2e6c68f50 | |||
| b44fb5c45a | |||
| abaab18eb7 | |||
| d02205fba3 | |||
| c461a4daed | |||
| 4f85fa6ba9 | |||
| 4eea21c4f5 | |||
| 7816400c01 | |||
| 103841c364 | |||
| 54ac07f6fb | |||
| d9fc365aeb | |||
| 79d5b6ed40 | |||
| d4c8ae6cb7 | |||
| 6c37c9e786 | |||
| ff1c3dece3 | |||
| 449c1d3f4d | |||
| ae7d576967 | |||
| 3b3c878d0e | |||
| 057edb5488 | |||
| 4191b7314f | |||
| baf4141773 | |||
| 7a7281f2fa | |||
| 8e16908fce | |||
| 5e0a0456c4 | |||
| fb56001a52 | |||
| ecff97419b | |||
| ad135c5e32 | |||
| 89f4b4c05d | |||
| 70a1f1325f | |||
| 522a2da114 | |||
| 7ca6a65559 | |||
| 309e891594 | |||
| 9623c2e6d2 | |||
| 3b1eff1702 | |||
| a00ff3df46 | |||
| 2f5d4dad9a | |||
| 20ee4c3567 | |||
| accffe8ef6 | |||
| e42ebb49ae | |||
| 1c49b69332 | |||
| 229006c51d | |||
| 15b6f5f6fb | |||
| ec7ac44b87 | |||
| 7b41f558d4 | |||
| 2bec513d2c | |||
| f6bf080c9e | |||
| 734a3d75db | |||
| e67b893ae4 | |||
| f9c53ca2c9 | |||
| 21f9c7cf6d | |||
| 43283b7218 | |||
| c2bb07ff3d | |||
| 8cd7f68c30 | |||
| 3e889d8c08 | |||
| a01395aed3 | |||
| 7b3459abff | |||
| 9a88f798fc | |||
| 256fd1cce2 | |||
| a8cb332232 | |||
| 89ea2b23a2 | |||
| 9b214a66c8 | |||
| 4c4689ec8d | |||
| c4e561dee6 | |||
| 69bfe5b750 | |||
| 137b3dc2ea | |||
| ea5f879181 | |||
| 25d5f34f68 | |||
| c8a6df77ac | |||
| 7b6ac46b6a | |||
| d77c648d54 | |||
| c3f47c5fa1 | |||
| f37a51b46f | |||
| 9feb3f35c3 | |||
| 5b44cf03ce | |||
| 86a6382463 | |||
| df991a3829 | |||
| f243c86b50 | |||
| b77be0a5e9 | |||
| 83be587c1a | |||
| c6048aea80 | |||
| 13b495b0f6 | |||
| ac64c35efa | |||
| 127b58601f | |||
| 1f795b45e9 | |||
| d26e35ee9a | |||
| 70e69e9dac | |||
| 3d88f4212d | |||
| ad90a9c8f3 | |||
| b43176764b | |||
| 77f24282e0 | |||
| 533f1f1102 | |||
| 92c5d8bd44 | |||
| d754ecb486 | |||
| b18987a95c | |||
| 0daec61f31 | |||
| 8ecf301eb8 | |||
| 6080646e89 | |||
| 970a521b66 | |||
| 9b5a92f942 | |||
| c4e0f13d67 | |||
| b6821746d0 | |||
| b98fc91a99 | |||
| bd7f7b867e | |||
| 32a3fea8d7 | |||
| 3561beab1d | |||
| 1d1fd52cae | |||
| c4ba438150 | |||
| 37f0ee08b1 | |||
| 240b02c94e | |||
| e8142ff3df | |||
| 832b363da7 | |||
| cf8f1ecd67 | |||
| ee5129830c | |||
| 9facfaffa8 | |||
| 31b859ba1a | |||
| b0f5a24c30 | |||
| b60b594e44 | |||
| a8cee1016b | |||
| b551af01fa | |||
| b178869a8e | |||
| 8e1c60d5f0 | |||
| 71ea49439e | |||
| 9d0a2f6535 | |||
| c091fffe90 | |||
| 7e05d05f94 | |||
| bd5b51c62f | |||
| 1d5eb2eced | |||
| 5073ead8fe | |||
| f61bcb621b | |||
| 42b695854b | |||
| 496ae8b1b2 | |||
| 4215a2592f | |||
| bca62033a1 | |||
| 18a91f9223 | |||
| 474d7c7a26 | |||
| 3f9a51ff46 | |||
| df3b5c41f9 | |||
| 8800d11ab0 | |||
| f021834ceb | |||
| f54a39a549 | |||
| c312ff3faf | |||
| db833594f4 | |||
| d36cf59829 | |||
| caae751698 | |||
| f7836eedce | |||
| 3ddc1a6229 | |||
| cf9869b71e | |||
| d31d8f574e |
@@ -8,6 +8,9 @@ dotnet_style_operator_placement_when_wrapping = beginning_of_line
|
|||||||
tab_width = 4
|
tab_width = 4
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
end_of_line = crlf
|
end_of_line = crlf
|
||||||
|
[XamlTypeInfo.g.cs]
|
||||||
|
dotnet_diagnostic.CS0612.severity = none
|
||||||
|
dotnet_diagnostic.CS0618.severity = none
|
||||||
dotnet_style_coalesce_expression = true:suggestion
|
dotnet_style_coalesce_expression = true:suggestion
|
||||||
dotnet_style_null_propagation = true:suggestion
|
dotnet_style_null_propagation = true:suggestion
|
||||||
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
|
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
|
||||||
@@ -149,7 +152,7 @@ csharp_preferred_modifier_order = public,private,protected,internal,static,exter
|
|||||||
# Code-block preferences
|
# Code-block preferences
|
||||||
csharp_prefer_braces = true:silent
|
csharp_prefer_braces = true:silent
|
||||||
csharp_prefer_simple_using_statement = true:suggestion
|
csharp_prefer_simple_using_statement = true:suggestion
|
||||||
csharp_style_namespace_declarations = block_scoped:silent
|
csharp_style_namespace_declarations = file_scoped:error
|
||||||
|
|
||||||
# Expression-level preferences
|
# Expression-level preferences
|
||||||
csharp_prefer_simple_default_expression = true:suggestion
|
csharp_prefer_simple_default_expression = true:suggestion
|
||||||
@@ -287,4 +290,6 @@ csharp_style_prefer_top_level_statements = true:silent
|
|||||||
csharp_style_prefer_utf8_string_literals = true:suggestion
|
csharp_style_prefer_utf8_string_literals = true:suggestion
|
||||||
csharp_style_prefer_readonly_struct = true:suggestion
|
csharp_style_prefer_readonly_struct = true:suggestion
|
||||||
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
|
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
|
||||||
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
|
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
|
||||||
|
csharp_style_prefer_primary_constructors = true:silent
|
||||||
|
csharp_prefer_system_threading_lock = true:suggestion
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Copilot Instructions for Wino-Mail Project
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Wino Mail is a native Windows mail client targeting Windows 10 1809+ and Windows 11. The project is **transitioning from UWP to WinUI 3** - always work with WinUI projects (Wino.Mail.WinUI, Wino.Core.WinUI), never edit the old Wino.Mail UWP project.
|
||||||
|
|
||||||
|
### Key Technologies
|
||||||
|
- **WinUI 3** for UI (previously UWP/WinUI 2)
|
||||||
|
- **MVVM Toolkit** (CommunityToolkit.Mvvm) for ViewModels with source generators
|
||||||
|
- **Messenger** pattern (WeakReferenceMessenger.Default) for event pub-sub throughout the codebase
|
||||||
|
- **SQLite** database stored in publisher cache folder (not local storage)
|
||||||
|
- **WebView2** for mail rendering/composition with custom HTML/JavaScript editors
|
||||||
|
- **MimeKit/MailKit** for IMAP/SMTP operations
|
||||||
|
- **Microsoft Graph SDK** for Outlook synchronization
|
||||||
|
- **Gmail API** for Gmail synchronization
|
||||||
|
|
||||||
|
### Solution Structure
|
||||||
|
```
|
||||||
|
Wino.Core.Domain → Entities, interfaces, translations, enums (shared contracts)
|
||||||
|
Wino.Core → Synchronization engine, authenticators, request processing
|
||||||
|
Wino.Services → Database, mail, folder, account services
|
||||||
|
Wino.Mail.ViewModels → Mail-specific ViewModels
|
||||||
|
Wino.Core.ViewModels → Shared ViewModels (settings, personalization)
|
||||||
|
Wino.Mail.WinUI → **Active WinUI 3 UI project** (use this)
|
||||||
|
Wino.Mail → **Deprecated UWP project** (DO NOT EDIT)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Mail Synchronization Flow
|
||||||
|
1. **WinoRequestDelegator** → Validates and delegates user actions (mark read, delete, move)
|
||||||
|
2. **WinoRequestProcessor** → Batches requests using RequestComparer, queues to synchronizers
|
||||||
|
3. **Synchronizers** (OutlookSynchronizer, GmailSynchronizer, ImapSynchronizer) → Execute batched operations
|
||||||
|
4. **ChangeProcessors** (OutlookChangeProcessor, etc.) → Apply changes to local database
|
||||||
|
5. Database updates trigger **Messenger** events (MailAddedMessage, MailUpdatedMessage, etc.)
|
||||||
|
|
||||||
|
### Queue-Based Sync (New Pattern - See QUEUE_SYNC_IMPLEMENTATION.md)
|
||||||
|
- Initial sync now queues mail IDs first (MailItemQueue table), downloads metadata only (no MIME)
|
||||||
|
- MIME content downloaded on-demand when user opens mail
|
||||||
|
- Synchronizers override `QueueMailIdsForInitialSyncAsync()`, `DownloadMailsFromQueueAsync()`, `CreateMinimalMailCopyAsync()`
|
||||||
|
- Check `MailItemFolder.IsInitialSyncCompleted` to determine sync state
|
||||||
|
|
||||||
|
### Dependency Injection Setup
|
||||||
|
Services registered in extension methods across projects:
|
||||||
|
- `RegisterCoreServices()` in Wino.Core/CoreContainerSetup.cs
|
||||||
|
- `RegisterSharedServices()` in Wino.Services/ServicesContainerSetup.cs
|
||||||
|
- `RegisterCoreUWPServices()` in CoreUWPContainerSetup.cs
|
||||||
|
- ViewModels registered in App.xaml.cs with AddTransient/AddSingleton
|
||||||
|
|
||||||
|
### Messenger Pattern (Event Pub-Sub)
|
||||||
|
- All ViewModels inherit from CoreBaseViewModel or MailBaseViewModel which implement IRecipient<T>
|
||||||
|
- Register/unregister message handlers in `RegisterRecipients()` / `UnregisterRecipients()`
|
||||||
|
- Send messages via `WeakReferenceMessenger.Default.Send(new MessageType(...))`
|
||||||
|
- Common messages: MailAddedMessage, MailUpdatedMessage, NavigationRequested, ThemeChanged
|
||||||
|
|
||||||
|
## ViewModels Development Guidelines
|
||||||
|
|
||||||
|
### Observable Properties - Critical Pattern
|
||||||
|
- **ALWAYS** use `public partial` observable properties with MVVM Toolkit source generators
|
||||||
|
- **NEVER** use private fields with `[ObservableProperty]` attribute
|
||||||
|
- **Correct:**
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string SearchQuery { get; set; } = string.Empty;
|
||||||
|
```
|
||||||
|
- **Incorrect:**
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty]
|
||||||
|
private string searchQuery = string.Empty; // WRONG - will not work
|
||||||
|
```
|
||||||
|
|
||||||
|
### ViewModels Structure
|
||||||
|
- Inherit from MailBaseViewModel (for mail features) or CoreBaseViewModel (for shared features)
|
||||||
|
- Use `[RelayCommand]` for command methods - source generator creates Command properties
|
||||||
|
- Implement IRecipient<TMessage> for message handlers
|
||||||
|
- Use `IMailDialogService` for Mail-related dialogs, `IDialogServiceBase` for core dialogs
|
||||||
|
- Call `RegisterRecipients()` in constructor/OnNavigatedTo, `UnregisterRecipients()` in OnNavigatedFrom
|
||||||
|
|
||||||
|
## Localization System
|
||||||
|
|
||||||
|
### Translation Workflow (Custom T4-based System)
|
||||||
|
1. Add English strings ONLY to `Wino.Core.Domain/Translations/en_US/resources.json`
|
||||||
|
2. Build the project - source generators automatically create Translator properties
|
||||||
|
3. Use `Translator.{PropertyName}` in ViewModels, XAML (with x:Bind, OneTime mode)
|
||||||
|
4. **NEVER** edit other language files - Crowdin manages translations automatically
|
||||||
|
5. **NEVER** hardcode user-facing strings
|
||||||
|
|
||||||
|
### Usage Examples
|
||||||
|
```csharp
|
||||||
|
// ViewModel
|
||||||
|
_dialogService.InfoBarMessage(Translator.Info_MissingFolderTitle, message);
|
||||||
|
|
||||||
|
// XAML
|
||||||
|
<TextBlock Text="{x:Bind Translator.Settings_Title, Mode=OneTime}" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Data Binding and Converters
|
||||||
|
|
||||||
|
### WinUI 3 Automatic Conversions
|
||||||
|
- **NEVER** create IValueConverter classes or add them to Converters.xaml
|
||||||
|
- **NEVER** use BoolToVisibilityConverter - WinUI 3 SDK automatically converts bool to Visibility
|
||||||
|
- Direct binding: `Visibility="{x:Bind IsVisible, Mode=OneWay}"`
|
||||||
|
- Register control events (for example `Loaded`, `Unloaded`, `SizeChanged`, `PointerEntered`) in XAML markup, not with `+=` in `.xaml.cs`.
|
||||||
|
|
||||||
|
### XamlHelpers for Complex Conversions
|
||||||
|
- **ALWAYS** use XamlHelpers static methods instead of converters
|
||||||
|
- Add xmlns: `xmlns:helpers="using:Wino.Helpers"`
|
||||||
|
- Usage: `{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(PropertyName), Mode=OneWay}`
|
||||||
|
- Available methods: ReverseBoolToVisibilityConverter, CountToBooleanConverter, BoolToSelectionMode, Base64ToBitmapImage
|
||||||
|
- Add new methods to XamlHelpers.cs when needed, don't create converters
|
||||||
|
|
||||||
|
## WebView2 Mail Rendering
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **reader.html** (Wino.Mail.WinUI/JS/) for reading mails
|
||||||
|
- **editor.html** for composing mails (uses Jodit editor, not Quill as originally planned)
|
||||||
|
- WebView2 uses virtual host mapping: `https://wino.mail/reader.html`
|
||||||
|
- JavaScript interop via `ExecuteScriptFunctionAsync()` to call functions like `RenderHTML()`
|
||||||
|
- MIME content downloaded on-demand, not during sync
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
- Set environment variables for WebView2 before initialization (overlay scrollbars, cache)
|
||||||
|
- Wait for DOMContentLoaded event before script execution
|
||||||
|
- Handle theme changes by updating editor CSS dynamically
|
||||||
|
- Cancel external navigation, open in browser via Launcher.LaunchUriAsync()
|
||||||
|
|
||||||
|
## File Structure and Project Organization
|
||||||
|
|
||||||
|
### Critical Rules
|
||||||
|
- **NEVER** edit files in Wino.Mail (UWP) project - it's deprecated
|
||||||
|
- **ALWAYS** work with Wino.Mail.WinUI for UI components
|
||||||
|
- Place ViewModels in Wino.Mail.ViewModels (mail-specific) or Wino.Core.ViewModels (shared)
|
||||||
|
- Create abstract base classes in Views/Abstract folders
|
||||||
|
- Mail-specific dialog services go in Wino.Mail.WinUI/Services
|
||||||
|
|
||||||
|
### Database and Storage
|
||||||
|
- SQLite database in publisher cache folder (not app local storage)
|
||||||
|
- EML files stored in app local storage, referenced by MailCopy.FileId
|
||||||
|
- Paths resolved via MimeFileService.GetMimeMessagePath()
|
||||||
|
- Database entities in Wino.Core.Domain/Entities
|
||||||
|
|
||||||
|
## Error Handling and User Feedback
|
||||||
|
|
||||||
|
### Exception Handling Patterns
|
||||||
|
```csharp
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
} catch (UnavailableSpecialFolderException ex) {
|
||||||
|
_dialogService.InfoBarMessage(title, message, InfoBarMessageType.Warning, buttonText, action);
|
||||||
|
} catch (NotImplementedException) {
|
||||||
|
_dialogService.ShowNotSupportedMessage();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dialog Service Methods
|
||||||
|
- `InfoBarMessage()` - simple notifications with optional action button
|
||||||
|
- `ShowConfirmationDialogAsync()` - yes/no dialogs
|
||||||
|
- `PickFilesAsync()` - file selection
|
||||||
|
- Always check for null/empty results from dialog operations
|
||||||
|
|
||||||
|
## Code Style and Best Practices
|
||||||
|
|
||||||
|
- Use `var` where type is obvious from right side
|
||||||
|
- String interpolation over string.Format for simple cases
|
||||||
|
- Keep methods focused and single-responsibility
|
||||||
|
- Add XML documentation for public APIs
|
||||||
|
- Avoid introducing new NuGet packages - maximize use of existing libraries
|
||||||
|
- Wrap async operations in try-catch blocks
|
||||||
|
- Log errors via IWinoLogger but don't expose technical details to users
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Building and Running
|
||||||
|
- Open WinoMail.slnx in Visual Studio 2022+
|
||||||
|
- Target platforms: x86, x64, ARM64 (ARM32 being phased out)
|
||||||
|
- Minimum: Windows 10 1809, Target: Windows 11 22H2
|
||||||
|
- Set Wino.Mail.WinUI as startup project
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Test suite in Wino.Core.Tests
|
||||||
|
- Manual testing required for UI/WebView2 interactions
|
||||||
|
- Test synchronization with real accounts when modifying synchronizers
|
||||||
|
|
||||||
|
### Common Pitfalls
|
||||||
|
- Forgetting to register ViewModels in App.xaml.cs RegisterViewModels()
|
||||||
|
- Not calling RegisterRecipients() for message handlers
|
||||||
|
- Using private fields with [ObservableProperty] (won't work - must be public partial)
|
||||||
|
- Creating IValueConverter classes instead of using XamlHelpers
|
||||||
|
- Editing UWP project files instead of WinUI equivalents
|
||||||
|
- Hardcoding strings instead of using Translator
|
||||||
|
- Forgetting to unregister Messenger recipients (memory leaks)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
name: Manual Beta Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_title:
|
||||||
|
description: Optional GitHub release title override
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-beta:
|
||||||
|
name: Build and publish beta release
|
||||||
|
runs-on: windows-latest
|
||||||
|
env:
|
||||||
|
PROJECT_PATH: Wino.Mail.WinUI/Wino.Mail.WinUI.csproj
|
||||||
|
MANIFEST_PATH: Wino.Mail.WinUI/Package.appxmanifest
|
||||||
|
CHANGELOG_PATH: CHANGELOG.md
|
||||||
|
NUGET_CONFIG_PATH: ${{ github.workspace }}\nuget.config
|
||||||
|
PACKAGE_OUTPUT_DIR: ${{ github.workspace }}\artifacts\package
|
||||||
|
RELEASE_OUTPUT_DIR: ${{ github.workspace }}\artifacts\release
|
||||||
|
CERTIFICATE_PFX_PATH: ${{ github.workspace }}\artifacts\signing\beta-signing-cert.pfx
|
||||||
|
CERTIFICATE_CER_PATH: ${{ github.workspace }}\artifacts\release\Wino-Mail-Beta.cer
|
||||||
|
steps:
|
||||||
|
- name: Checkout selected branch
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.ref }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch tags from origin
|
||||||
|
shell: pwsh
|
||||||
|
run: git fetch origin --force --tags
|
||||||
|
|
||||||
|
- name: Validate release secrets
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
BETA_SIGNING_CERT_PFX_BASE64: ${{ secrets.BETA_SIGNING_CERT_PFX_BASE64 }}
|
||||||
|
run: |
|
||||||
|
if ([string]::IsNullOrWhiteSpace($env:BETA_SIGNING_CERT_PFX_BASE64)) {
|
||||||
|
throw "Missing required secret: BETA_SIGNING_CERT_PFX_BASE64"
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Setup .NET SDK
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.x
|
||||||
|
env:
|
||||||
|
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Compute beta version and release metadata
|
||||||
|
id: metadata
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
RELEASE_TITLE_INPUT: ${{ github.event.inputs.release_title }}
|
||||||
|
run: |
|
||||||
|
$manifestPath = Join-Path $env:GITHUB_WORKSPACE $env:MANIFEST_PATH
|
||||||
|
if (-not (Test-Path $manifestPath)) {
|
||||||
|
throw "Package manifest not found: $manifestPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$changelogPath = Join-Path $env:GITHUB_WORKSPACE $env:CHANGELOG_PATH
|
||||||
|
if (-not (Test-Path $changelogPath)) {
|
||||||
|
throw "Release notes file not found: $changelogPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
[xml]$manifest = Get-Content -LiteralPath $manifestPath
|
||||||
|
$identityNode = $manifest.Package.Identity
|
||||||
|
if (-not $identityNode) {
|
||||||
|
throw "Could not locate the Package/Identity node in $manifestPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentVersionText = [string]$identityNode.Version
|
||||||
|
if ($currentVersionText -notmatch '^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)\.(?<revision>\d+)$') {
|
||||||
|
throw "Manifest version '$currentVersionText' is not a four-part numeric version."
|
||||||
|
}
|
||||||
|
|
||||||
|
$packageVersion = $currentVersionText
|
||||||
|
$releaseTag = "v$packageVersion"
|
||||||
|
$releaseTitleInput = $env:RELEASE_TITLE_INPUT
|
||||||
|
$releaseTitle = if ([string]::IsNullOrWhiteSpace($releaseTitleInput)) { $releaseTag } else { $releaseTitleInput.Trim() }
|
||||||
|
|
||||||
|
$headSha = (git rev-parse HEAD).Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($headSha)) {
|
||||||
|
throw "Failed to resolve the checked out commit SHA."
|
||||||
|
}
|
||||||
|
|
||||||
|
$notesInput = Get-Content -LiteralPath $changelogPath -Raw
|
||||||
|
if ([string]::IsNullOrWhiteSpace($notesInput)) {
|
||||||
|
throw "Release notes file is empty: $changelogPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$notesInput = $notesInput.Trim()
|
||||||
|
New-Item -ItemType Directory -Path $env:RELEASE_OUTPUT_DIR -Force | Out-Null
|
||||||
|
$releaseNotesPath = Join-Path $env:RELEASE_OUTPUT_DIR 'beta-release-notes.md'
|
||||||
|
$notesInput | Set-Content -LiteralPath $releaseNotesPath -Encoding utf8
|
||||||
|
|
||||||
|
"package_version=$packageVersion" >> $env:GITHUB_OUTPUT
|
||||||
|
"release_tag=$releaseTag" >> $env:GITHUB_OUTPUT
|
||||||
|
"release_title=$releaseTitle" >> $env:GITHUB_OUTPUT
|
||||||
|
"release_notes_path=$releaseNotesPath" >> $env:GITHUB_OUTPUT
|
||||||
|
"head_sha=$headSha" >> $env:GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Materialize signing certificate
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
BETA_SIGNING_CERT_PFX_BASE64: ${{ secrets.BETA_SIGNING_CERT_PFX_BASE64 }}
|
||||||
|
run: |
|
||||||
|
$signingDir = Split-Path -Parent $env:CERTIFICATE_PFX_PATH
|
||||||
|
New-Item -ItemType Directory -Path $signingDir -Force | Out-Null
|
||||||
|
[IO.File]::WriteAllBytes($env:CERTIFICATE_PFX_PATH, [Convert]::FromBase64String($env:BETA_SIGNING_CERT_PFX_BASE64))
|
||||||
|
|
||||||
|
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($env:CERTIFICATE_PFX_PATH, $null, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable)
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path (Split-Path -Parent $env:CERTIFICATE_CER_PATH) -Force | Out-Null
|
||||||
|
[IO.File]::WriteAllBytes($env:CERTIFICATE_CER_PATH, $certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
|
||||||
|
|
||||||
|
- name: Restore WinUI project dependencies
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
if (-not (Test-Path $env:NUGET_CONFIG_PATH)) {
|
||||||
|
throw "NuGet config file not found: $env:NUGET_CONFIG_PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
dotnet restore $env:PROJECT_PATH `
|
||||||
|
--configfile $env:NUGET_CONFIG_PATH `
|
||||||
|
-p:Platform=x64 `
|
||||||
|
/p:RestoreConfigFile="$env:NUGET_CONFIG_PATH"
|
||||||
|
|
||||||
|
- name: Build MSIX bundle
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Path $env:PACKAGE_OUTPUT_DIR -Force | Out-Null
|
||||||
|
|
||||||
|
dotnet build $env:PROJECT_PATH `
|
||||||
|
--configuration Release `
|
||||||
|
--no-restore `
|
||||||
|
--configfile $env:NUGET_CONFIG_PATH `
|
||||||
|
/p:Platform=x64 `
|
||||||
|
/p:RestoreConfigFile="$env:NUGET_CONFIG_PATH" `
|
||||||
|
/p:GenerateAppxPackageOnBuild=true `
|
||||||
|
/p:UapAppxPackageBuildMode=SideloadOnly `
|
||||||
|
/p:AppxBundle=Always `
|
||||||
|
/p:AppxBundlePlatforms="x86|x64|arm64" `
|
||||||
|
/p:AppxPackageDir="$env:PACKAGE_OUTPUT_DIR\\" `
|
||||||
|
/p:AppxPackageVersion=${{ steps.metadata.outputs.package_version }} `
|
||||||
|
/p:PackageCertificateKeyFile="$env:CERTIFICATE_PFX_PATH" `
|
||||||
|
/p:PackageCertificatePassword= `
|
||||||
|
/p:PackageCertificateThumbprint= `
|
||||||
|
/p:AppxPackageSigningEnabled=true
|
||||||
|
|
||||||
|
- name: Collect packaged artifacts
|
||||||
|
id: package
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$bundle = Get-ChildItem -Path $env:PACKAGE_OUTPUT_DIR -Recurse -Filter *.msixbundle | Select-Object -First 1
|
||||||
|
if (-not $bundle) {
|
||||||
|
throw "No .msixbundle file was generated under $env:PACKAGE_OUTPUT_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseAssetPath = Join-Path $env:RELEASE_OUTPUT_DIR "Wino_${{ steps.metadata.outputs.package_version }}.zip"
|
||||||
|
if (Test-Path $releaseAssetPath) {
|
||||||
|
Remove-Item -LiteralPath $releaseAssetPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Compress-Archive -LiteralPath @($bundle.FullName, $env:CERTIFICATE_CER_PATH) -DestinationPath $releaseAssetPath -Force
|
||||||
|
|
||||||
|
"bundle_path=$($bundle.FullName)" >> $env:GITHUB_OUTPUT
|
||||||
|
"bundle_name=$($bundle.Name)" >> $env:GITHUB_OUTPUT
|
||||||
|
"release_asset_path=$releaseAssetPath" >> $env:GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create GitHub prerelease
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh release create "${{ steps.metadata.outputs.release_tag }}" `
|
||||||
|
"${{ steps.package.outputs.release_asset_path }}" `
|
||||||
|
--repo "${{ github.repository }}" `
|
||||||
|
--target "${{ steps.metadata.outputs.head_sha }}" `
|
||||||
|
--title "${{ steps.metadata.outputs.release_title }}" `
|
||||||
|
--notes-file "${{ steps.metadata.outputs.release_notes_path }}" `
|
||||||
|
--prerelease
|
||||||
@@ -399,3 +399,5 @@ Wino/obj/x86/Debug/XamlSaveStateFile.xml
|
|||||||
Wino/obj/x86/Debug/XamlSaveStateFile.xml
|
Wino/obj/x86/Debug/XamlSaveStateFile.xml
|
||||||
*.cache
|
*.cache
|
||||||
.vs/Wino/v16/.suo
|
.vs/Wino/v16/.suo
|
||||||
|
/.claude/settings.local.json
|
||||||
|
scripts/translate_resources.local.bat
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance to AI agent when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Wino Mail is a native Windows mail client (Windows 10 1809+ / Windows 11) replacing the deprecated Windows Mail & Calendar. It's **transitioning from UWP to WinUI 3** - always work with WinUI projects (Wino.Mail.WinUI), never edit the deprecated Wino.Mail UWP project.
|
||||||
|
|
||||||
|
## Build and Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open solution
|
||||||
|
# WinoMail.slnx is the main solution file (VS 2022+)
|
||||||
|
|
||||||
|
# Build WinUI project (Debug x64)
|
||||||
|
dotnet restore Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configfile nuget.config -p:Platform=x64 -p:RuntimeIdentifier=win-x64 && dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj -c Debug --no-restore /p:Platform=x64 /p:RuntimeIdentifier=win-x64 /p:GenerateAppxPackageOnBuild=false /p:AppxPackageSigningEnabled=false
|
||||||
|
|
||||||
|
# Build WinUI project with diagnostic XAML/compiler logging (use when plain build only shows "XamlCompiler.exe exited with code 1")
|
||||||
|
dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj -c Debug --no-restore /p:Platform=x64 /p:RuntimeIdentifier=win-x64 /p:GenerateAppxPackageOnBuild=false /p:AppxPackageSigningEnabled=false "/flp:logfile=winui-build.log;verbosity=diagnostic" /bl:winui-build.binlog
|
||||||
|
|
||||||
|
# Run tests (Debug x64)
|
||||||
|
dotnet test Wino.Core.Tests/Wino.Core.Tests.csproj -c Debug /p:Platform=x64
|
||||||
|
|
||||||
|
# Copilot CLI build command (Debug x64)
|
||||||
|
dotnet restore Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configfile nuget.config -p:Platform=x64 -p:RuntimeIdentifier=win-x64 && dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj -c Debug --no-restore /p:Platform=x64 /p:RuntimeIdentifier=win-x64 /p:GenerateAppxPackageOnBuild=false /p:AppxPackageSigningEnabled=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prerequisites:** Visual Studio 2022+ with ".NET desktop development" workload, .NET SDK 10+
|
||||||
|
|
||||||
|
**Startup project:** Wino.Mail.WinUI
|
||||||
|
|
||||||
|
**Platforms:** x86, x64, ARM64
|
||||||
|
|
||||||
|
## Efficient Workflow
|
||||||
|
|
||||||
|
- Start with targeted symbol or file search before reading full files
|
||||||
|
- Prefer one focused task per thread; use a new thread for unrelated follow-up work
|
||||||
|
- Keep verification narrow: build only the affected project, not the full solution, unless cross-project changes require it
|
||||||
|
- After the first restore, prefer `--no-restore` builds unless package or project references changed
|
||||||
|
- Summarize long build logs and inspect only the files named in diagnostics instead of loading large logs into context
|
||||||
|
- When the prompt already names likely files, types, or symbols, start there instead of re-mapping the repository
|
||||||
|
- If a WinUI build only reports `XamlCompiler.exe exited with code 1`, rerun with the diagnostic logging command above and inspect the terminal output plus `winui-build.log` for real `WMC`/`WMC1121`/binding diagnostics before guessing
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Solution Structure
|
||||||
|
```
|
||||||
|
Wino.Core.Domain → Entities, interfaces, translations, enums (shared contracts)
|
||||||
|
Wino.Core → Synchronization engine, authenticators, request processing
|
||||||
|
Wino.Services → Database, mail, folder, account services
|
||||||
|
Wino.Authentication → OAuth2 authenticators (Outlook, Gmail)
|
||||||
|
Wino.Mail.ViewModels → Mail-specific ViewModels
|
||||||
|
Wino.Core.ViewModels → Shared ViewModels (settings, personalization)
|
||||||
|
Wino.Messaging → Pub-sub message definitions
|
||||||
|
Wino.Mail.WinUI → **Active WinUI 3 UI project** (use this)
|
||||||
|
Wino.Mail → **Deprecated UWP project** (DO NOT EDIT)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail Synchronization Flow
|
||||||
|
1. **WinoRequestDelegator** → Validates and delegates user actions (mark read, delete, move)
|
||||||
|
2. **WinoRequestProcessor** → Batches requests using RequestComparer, queues to synchronizers
|
||||||
|
3. **Synchronizers** (OutlookSynchronizer, GmailSynchronizer, ImapSynchronizer) → Execute batched operations
|
||||||
|
4. **ChangeProcessors** → Apply changes to local SQLite database
|
||||||
|
5. Database updates trigger **Messenger** events (MailAddedMessage, MailUpdatedMessage, etc.)
|
||||||
|
|
||||||
|
### Synchronizer Types
|
||||||
|
- **OutlookSynchronizer** - Microsoft Graph SDK for Office 365
|
||||||
|
- **GmailSynchronizer** - Gmail API
|
||||||
|
- **ImapSynchronizer** - MimeKit/MailKit for IMAP/SMTP
|
||||||
|
|
||||||
|
### Queue-Based Sync Pattern
|
||||||
|
- Initial sync queues mail IDs first (MailItemQueue table), downloads metadata only
|
||||||
|
- MIME content downloaded on-demand when user opens mail
|
||||||
|
- Check `MailItemFolder.IsInitialSyncCompleted` for sync state
|
||||||
|
- See QUEUE_SYNC_IMPLEMENTATION.md for details
|
||||||
|
|
||||||
|
### Dependency Injection
|
||||||
|
- `RegisterCoreServices()` in Wino.Core/CoreContainerSetup.cs
|
||||||
|
- `RegisterSharedServices()` in Wino.Services/ServicesContainerSetup.cs
|
||||||
|
- ViewModels registered in App.xaml.cs
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### MVVM with Source Generators
|
||||||
|
**CORRECT - use public partial properties:**
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string SearchQuery { get; set; } = string.Empty;
|
||||||
|
```
|
||||||
|
|
||||||
|
**WRONG - will not work:**
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty]
|
||||||
|
private string searchQuery = string.Empty;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Messenger Pattern
|
||||||
|
- ViewModels inherit from CoreBaseViewModel or MailBaseViewModel
|
||||||
|
- Register handlers in `RegisterRecipients()`, unregister in `UnregisterRecipients()`
|
||||||
|
- Send via `WeakReferenceMessenger.Default.Send(new MessageType(...))`
|
||||||
|
|
||||||
|
### Data Binding - No Converters
|
||||||
|
- **NEVER** create IValueConverter classes
|
||||||
|
- WinUI 3 auto-converts bool to Visibility: `Visibility="{x:Bind IsVisible, Mode=OneWay}"`
|
||||||
|
- Use XamlHelpers for complex conversions: `{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(Prop)}`
|
||||||
|
- `x:Bind` does not implicitly convert `double` to `GridLength`; when binding `RowDefinition.Height` or `ColumnDefinition.Width`, use a `XamlHelpers` method such as `DoubleToGridLength(...)`
|
||||||
|
- For `ComboBox` controls in XAML, never use `DisplayMemberPath` or `SelectedValuePath`; use a typed `ItemTemplate` and bind `SelectedItem` explicitly, preferably with `x:Bind`
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
1. Add English strings ONLY to Wino.Core.Domain/Translations/en_US/resources.json
|
||||||
|
2. Build project - source generators create Translator properties
|
||||||
|
3. Use Translator.{PropertyName} in code/XAML
|
||||||
|
4. NEVER edit any resources.json file outside Wino.Core.Domain/Translations/en_US/resources.json
|
||||||
|
5. Treat all non-en_US translation files as managed externally and leave them untouched, even when adding new localization keys
|
||||||
|
6. In XAML, translation bindings must use `Mode=OneTime` because `Wino.Core.Domain/Translator.cs` does not implement `INotifyPropertyChanged`
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
- **SQLite database** in publisher cache folder (shared with future Wino Calendar)
|
||||||
|
- **EML files** in app local storage, referenced by `MailCopy.FileId`
|
||||||
|
- Paths resolved via `MimeFileService.GetMimeMessagePath()`
|
||||||
|
|
||||||
|
## WebView2 Mail Rendering
|
||||||
|
|
||||||
|
- `reader.html` for reading mails, `editor.html` for composing (Jodit editor)
|
||||||
|
- Virtual host mapping: `https://wino.mail/reader.html`
|
||||||
|
- JavaScript interop via `ExecuteScriptFunctionAsync()`
|
||||||
|
- MIME content downloaded on-demand, not during sync
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Forgetting to register ViewModels in App.xaml.cs `RegisterViewModels()`
|
||||||
|
- Not calling `RegisterRecipients()` for message handlers
|
||||||
|
- Using private fields with `[ObservableProperty]` instead of public partial
|
||||||
|
- Creating IValueConverter classes instead of using XamlHelpers
|
||||||
|
- Editing UWP project files instead of WinUI equivalents
|
||||||
|
- Hardcoding strings instead of using Translator
|
||||||
|
- Forgetting to unregister Messenger recipients (memory leaks)
|
||||||
|
- Putting authentication validation, token refresh, account API calls, settings serialization/deserialization, or preference-application logic into ViewModels instead of the corresponding service
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Avoid introducing new NuGet packages when possible
|
||||||
|
- Use existing libraries (MimeKit, MailKit, Microsoft Graph, Gmail API)
|
||||||
|
- Use `var` where type is obvious
|
||||||
|
- String interpolation over string.Format
|
||||||
|
- Wrap async operations in try-catch
|
||||||
|
- Log errors via IWinoLogger
|
||||||
|
- For dependency properties in WinUI code, always prefer `[GeneratedDependencyProperty]` from CommunityToolkit over manual `DependencyProperty.Register(...)` declarations.
|
||||||
|
- When a `[RelayCommand]` needs enable/disable logic, prefer the command's `CanExecute` over binding `Button.IsEnabled` in XAML; use `[NotifyCanExecuteChangedFor]` on dependent properties and call `NotifyCanExecuteChanged()` explicitly when non-generated state affects the command.
|
||||||
|
- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`).
|
||||||
|
- `ConfigureAwait(false)` continues execution on a background thread. Any UI-bound property change, `INotifyPropertyChanged` notification, collection mutation, or similar UI-facing state update after that point must be marshaled back with `ExecuteUIThread(...)` or the appropriate dispatcher call, otherwise the app can crash.
|
||||||
|
- Messenger messages are raised from a background thread by default, while UI control event handlers such as `Button.Click` start on the UI thread. Be deliberate when combining dispatcher usage with `ConfigureAwait(false)` so post-await UI updates always return to the UI thread.
|
||||||
|
- ViewModels should only handle UI interaction/state and delegate business logic to services; account-management work belongs in `WinoAccountProfileService`, and preferences import/export/apply logic belongs in `PreferencesService`.
|
||||||
|
- In `EventDetailsPageViewModel.LoadAttendeesAsync`, never mutate `CurrentEvent.Attendees` outside `ExecuteUIThread(...)`.
|
||||||
|
- Never create pure C# controls or controls that heavily manipulate UI structure from `.cs` files. Define controls in XAML and keep UI composition in XAML.
|
||||||
|
- Never add XAML-backed UI controls to `.xaml.cs`. If a view has XAML, all control declarations, flyouts, templates, and visual composition belong in the `.xaml` file; keep `.xaml.cs` limited to event handling and view glue.
|
||||||
|
- Never subscribe to framework events like `Loaded`, `Unloaded`, or input events from constructors in `.xaml.cs` for XAML-backed controls and pages; wire them directly in XAML instead.
|
||||||
|
- If you use `x:Load` in XAML, always give that `UIElement` an `x:Name`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,64 +1,80 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="ColorHashSharp" Version="1.0.0" />
|
<PackageVersion Include="ColorHashSharp" Version="1.1.0" />
|
||||||
<PackageVersion Include="CommunityToolkit.Common" Version="8.4.0" />
|
<PackageVersion Include="CommunityToolkit.Common" Version="8.4.2" />
|
||||||
<PackageVersion Include="CommunityToolkit.Diagnostics" Version="8.4.0" />
|
<PackageVersion Include="CommunityToolkit.Diagnostics" Version="8.4.2" />
|
||||||
<PackageVersion Include="CommunityToolkit.Labs.Uwp.Controls.MarkdownTextBlock" Version="0.1.250206-build.2040" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
<PackageVersion Include="CommunityToolkit.WinUI.Animations" Version="8.2.251219" />
|
||||||
<PackageVersion Include="CommunityToolkit.Uwp.Animations" Version="8.2.250129-preview2" />
|
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Segmented" Version="8.2.251219" />
|
||||||
<PackageVersion Include="CommunityToolkit.Uwp.Behaviors" Version="8.2.250129-preview2" />
|
<PackageVersion Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.251219" />
|
||||||
<PackageVersion Include="CommunityToolkit.Uwp.Controls.Segmented" Version="8.2.250129-preview2" />
|
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
|
||||||
<PackageVersion Include="CommunityToolkit.Uwp.Controls.SettingsControls" Version="8.2.250129-preview2" />
|
<PackageVersion Include="CommunityToolkit.WinUI.Controls.TabbedCommandBar" Version="8.2.251219" />
|
||||||
<PackageVersion Include="CommunityToolkit.Uwp.Controls.Sizers" Version="8.2.250129-preview2" />
|
<PackageVersion Include="CommunityToolkit.WinUI.Controls.TokenizingTextBox" Version="8.2.251219" />
|
||||||
<PackageVersion Include="CommunityToolkit.Uwp.Controls.TabbedCommandBar" Version="8.2.250129-preview2" />
|
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
|
||||||
<PackageVersion Include="CommunityToolkit.Uwp.Controls.TokenizingTextBox" Version="8.2.250129-preview2" />
|
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.250926-build.2293" />
|
||||||
<PackageVersion Include="CommunityToolkit.Uwp.Extensions" Version="8.2.250129-preview2" />
|
<PackageVersion Include="CommunityToolkit.WinUI.Lottie" Version="8.2.250604" />
|
||||||
<PackageVersion Include="EmailValidation" Version="1.2.0" />
|
<PackageVersion Include="CommunityToolkit.Labs.WinUI.DependencyPropertyGenerator" Version="0.1.250926-build.2293" />
|
||||||
<PackageVersion Include="HtmlAgilityPack" Version="1.11.72" />
|
<PackageVersion Include="EmailValidation" Version="1.3.0" />
|
||||||
<PackageVersion Include="Ical.Net" Version="4.3.1" />
|
<PackageVersion Include="gravatar-dotnet" Version="0.1.3" />
|
||||||
<PackageVersion Include="IsExternalInit" Version="1.0.3" />
|
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
<PackageVersion Include="Ical.Net" Version="5.2.1" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
|
<PackageVersion Include="IsExternalInit" Version="1.0.3" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.2" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
|
||||||
<PackageVersion Include="Microsoft.Graph" Version="5.69.0" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" />
|
||||||
<PackageVersion Include="Microsoft.Identity.Client" Version="4.68.0" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
|
||||||
<PackageVersion Include="Microsoft.Identity.Client.Broker" Version="4.68.0" />
|
<PackageVersion Include="Microsoft.Graph" Version="5.104.0" />
|
||||||
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.68.0" />
|
<PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.4.0" />
|
||||||
<PackageVersion Include="Microsoft.NETCore.UniversalWindowsPlatform" Version="6.2.14" />
|
<PackageVersion Include="Microsoft.Identity.Client" Version="4.83.3" />
|
||||||
<PackageVersion Include="Microsoft.UI.Xaml" Version="2.8.7" />
|
<PackageVersion Include="Microsoft.Identity.Client.Broker" Version="4.83.3" />
|
||||||
<PackageVersion Include="Microsoft.Xaml.Behaviors.Uwp.Managed" Version="3.0.0" />
|
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.3" />
|
||||||
<PackageVersion Include="MimeKit" Version="4.10.0" />
|
<PackageVersion Include="Microsoft.NETCore.UniversalWindowsPlatform" Version="6.2.14" />
|
||||||
<PackageVersion Include="morelinq" Version="4.4.0" />
|
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.260316102" />
|
||||||
<PackageVersion Include="Nito.AsyncEx" Version="5.1.2" />
|
<PackageVersion Include="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="3.0.1" />
|
||||||
<PackageVersion Include="Nito.AsyncEx.Tasks" Version="5.1.2" />
|
<PackageVersion Include="Wino.Mail.Contracts" Version="1.0.18" />
|
||||||
<PackageVersion Include="NodaTime" Version="3.2.1" />
|
<PackageVersion Include="MimeKit" Version="4.16.0" />
|
||||||
<PackageVersion Include="Serilog" Version="4.2.0" />
|
<PackageVersion Include="morelinq" Version="4.4.0" />
|
||||||
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
|
<PackageVersion Include="Nito.AsyncEx" Version="5.1.2" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
<PackageVersion Include="Nito.AsyncEx.Tasks" Version="5.1.2" />
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageVersion Include="NodaTime" Version="3.3.1" />
|
||||||
<PackageVersion Include="Serilog.Sinks.ApplicationInsights" Version="4.0.0" />
|
<PackageVersion Include="Sentry.Serilog" Version="6.4.0" />
|
||||||
<PackageVersion Include="SkiaSharp" Version="3.116.1" />
|
<PackageVersion Include="Serilog" Version="4.3.1" />
|
||||||
<PackageVersion Include="sqlite-net-pcl" Version="1.9.172" />
|
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
|
||||||
<PackageVersion Include="SqlKata" Version="4.0.1" />
|
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||||
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.2" />
|
<PackageVersion Include="Serilog.Sinks.ApplicationInsights" Version="4.0.0" />
|
||||||
<PackageVersion Include="System.Text.Json" Version="9.0.2" />
|
<PackageVersion Include="SkiaSharp" Version="3.119.2" />
|
||||||
<PackageVersion Include="Win2D.uwp" Version="1.28.2" />
|
<PackageVersion Include="SkiaSharp.Views.WinUI" Version="3.119.2" />
|
||||||
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.2.0" />
|
<PackageVersion Include="sqlite-net-pcl" Version="1.10.196-beta" />
|
||||||
<PackageVersion Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" />
|
<PackageVersion Include="System.Drawing.Common" Version="10.0.5" />
|
||||||
<PackageVersion Include="Google.Apis.Auth" Version="1.69.0" />
|
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
|
||||||
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.69.0.3667" />
|
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
|
||||||
<PackageVersion Include="Google.Apis.Gmail.v1" Version="1.68.0.3427" />
|
<PackageVersion Include="System.Text.Json" Version="10.0.6" />
|
||||||
<PackageVersion Include="Google.Apis.PeopleService.v1" Version="1.68.0.3359" />
|
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.3.0" />
|
||||||
<PackageVersion Include="HtmlKit" Version="1.2.0" />
|
<PackageVersion Include="H.NotifyIcon.WinUI" Version="2.4.1" />
|
||||||
<PackageVersion Include="MailKit" Version="4.10.0" />
|
<PackageVersion Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" />
|
||||||
<PackageVersion Include="TimePeriodLibrary.NET" Version="2.1.5" />
|
<PackageVersion Include="Google.Apis.Auth" Version="1.73.0" />
|
||||||
<PackageVersion Include="System.Reactive" Version="6.0.1" />
|
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.73.0.4073" />
|
||||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.2" />
|
<PackageVersion Include="Google.Apis.Drive.v3" Version="1.73.0.4112" />
|
||||||
<PackageVersion Include="System.Text.Encodings.Web" Version="9.0.2" />
|
<PackageVersion Include="Google.Apis.Gmail.v1" Version="1.73.0.4029" />
|
||||||
</ItemGroup>
|
<PackageVersion Include="Google.Apis.PeopleService.v1" Version="1.72.0.3973" />
|
||||||
</Project>
|
<PackageVersion Include="HtmlKit" Version="1.2.0" />
|
||||||
|
<PackageVersion Include="MailKit" Version="4.16.0" />
|
||||||
|
<PackageVersion Include="TimePeriodLibrary.NET" Version="2.1.6" />
|
||||||
|
<PackageVersion Include="System.Reactive" Version="6.1.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
|
||||||
|
<PackageVersion Include="System.Text.Encodings.Web" Version="10.0.6" />
|
||||||
|
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
|
||||||
|
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.250930001-experimental1" />
|
||||||
|
<PackageVersion Include="WinUIEx" Version="2.9.0" />
|
||||||
|
<!-- Testing packages -->
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
|
<PackageVersion Include="FluentAssertions" Version="8.9.0" />
|
||||||
|
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,45 +1,54 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true
|
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true&mode=full">
|
||||||
&mode=full">
|
<img src="https://www.winomail.app/images/v2/Logo.png" width="90" height="90" alt="Wino Mail logo">
|
||||||
<img src="https://www.winomail.app/images/wino_logo.png" width=90 height=90>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h3 align="center">Wino Mail</h3>
|
<h3 align="center">Wino Mail</h3>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Native mail client for Windows device families.
|
Native mail and calendar client for Windows.
|
||||||
</p>
|
</p>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
|
|
||||||
I'm a big fan of Windows Mail & Calendars due to its simplicity. Personally, I find it more intuitive for daily use cases compared to Outlook desktop and the new WebView2 powered Outlook version. Seeing [Microsoft deprecating it](https://support.microsoft.com/en-us/office/outlook-for-windows-the-future-of-mail-calendar-and-people-on-windows-11-715fc27c-e0f4-4652-9174-47faa751b199#:~:text=The%20Mail%20and%20Calendar%20applications,will%20no%20longer%20be%20supported.) dragged me into starting to work on Wino a couple of years ago. Wino's main motivation is to bring all the existing functionality from Mail & Calendars over time without changing the user experience that millions have loved since the Windows 8 days in Mail & Calendars
|
I'm a big fan of Windows Mail & Calendars due to its simplicity. Personally, I find it more intuitive for daily use cases compared to Outlook desktop and the new WebView2 powered Outlook version. Seeing [Microsoft deprecating it](https://support.microsoft.com/en-us/office/outlook-for-windows-the-future-of-mail-calendar-and-people-on-windows-11-715fc27c-e0f4-4652-9174-47faa751b199#:~:text=The%20Mail%20and%20Calendar%20applications,will%20no%20longer%20be%20supported.) dragged me into starting to work on Wino a couple of years ago. Wino's main motivation is to bring all the existing functionality from Mail & Calendars over time without changing the user experience that millions have loved since the Windows 8 days in Mail & Calendars.
|
||||||
|
|
||||||
|
## vNext Release Highlights
|
||||||
|
|
||||||
|
Wino vNext focuses on making Mail, Calendar, and Contacts feel like one cohesive native Windows experience while improving sync reliability and startup responsiveness.
|
||||||
|
|
||||||
|
- 📅 **Calendar management:** Event compose/create flow, calendar-mail mapping, reminder snooze support, occurrence and detail-page improvements, and CalDAV correctness fixes.
|
||||||
|
- 👥 **Contact management:** Improved contact workflows, account/settings integration, and contact data-model cleanup.
|
||||||
|
- 🔄 **Synchronization reliability:** Refactored synchronizers, better state handling, 404 + 429 error handling, and duplicate-operation prevention.
|
||||||
|
- ✉️ **Compose and drafts:** Refined editor/toolbar architecture, better rendering pipeline, Gmail draft support, and large Outlook attachment upload sessions.
|
||||||
|
- ⚡ **Performance and quality:** Faster mail fetching with batched DB queries and caching, SQLite indexing/foreign key enforcement, and broader test + CI coverage.
|
||||||
|
- 🎨 **WinUI polish:** Improved onboarding/startup, settings and dialogs refresh, notification routing fixes, and keyboard/navigation quality-of-life improvements.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- API integration for Outlook and Gmail
|
- 📨 Outlook and Gmail API integration
|
||||||
- IMAP/SMTP support for custom mail servers
|
- 🌐 IMAP/SMTP support for custom mail servers
|
||||||
- Send, receive, mark as (read,important,spam etc), move mails.
|
- 📅 Calendar support with event creation/compose and reminders
|
||||||
- Linked/Merged Accounts
|
- 👥 Contact management and people-centric account experience
|
||||||
- Toast notifications with background sync.
|
- ✅ Core mail actions: send, receive, read/unread, move, spam, and more
|
||||||
- Instant startup performance
|
- 🔗 Linked/Merged accounts
|
||||||
- Offline use / search.
|
- 🔔 Toast notifications with background sync
|
||||||
- Modern and responsive UI
|
- ⚡ Instant startup-oriented architecture
|
||||||
- Lots of personalization options
|
- 🔎 Offline-capable workflows and search improvements
|
||||||
- Dark / Light mode for mail reader
|
- 🎛️ Modern responsive WinUI interface with personalization options
|
||||||
|
- 🌗 Dark/Light mode for mail reader and app surfaces
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Download latest version of Wino Mail from Microsoft Store for free.
|
Download latest version of Wino Mail from Microsoft Store for free.
|
||||||
|
|
||||||
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true
|
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true&mode=full">
|
||||||
&mode=full">
|
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200" alt="Get Wino Mail from Microsoft Store"/>
|
||||||
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200"/>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Beta Releases
|
## Beta Releases
|
||||||
@@ -48,7 +57,6 @@ Stable releases will always be distributed on Microsoft Store. However, beta rel
|
|||||||
|
|
||||||
These releases are distributed as side-loaded packages. To install them, download the **.msixbundle** file in GitHub releases and [follow the steps explained here.](https://learn.microsoft.com/en-us/windows/application-management/sideload-apps-in-windows)
|
These releases are distributed as side-loaded packages. To install them, download the **.msixbundle** file in GitHub releases and [follow the steps explained here.](https://learn.microsoft.com/en-us/windows/application-management/sideload-apps-in-windows)
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Check out the [contribution guidelines](/CONTRIBUTING.md) before diving into the source code or opening an issue. There are multiple ways to contribute and all of them are explained in detail there.
|
Check out the [contribution guidelines](/CONTRIBUTING.md) before diving into the source code or opening an issue. There are multiple ways to contribute and all of them are explained in detail there.
|
||||||
@@ -59,3 +67,4 @@ Your donations will motivate me more to work on Wino in my spare time and cover
|
|||||||
|
|
||||||
- You can [donate via Paypal by clicking here](https://www.paypal.com/donate/?hosted_button_id=LGPERGGXFMQ7U)
|
- You can [donate via Paypal by clicking here](https://www.paypal.com/donate/?hosted_button_id=LGPERGGXFMQ7U)
|
||||||
- You can buy Unlimited Accounts add-on in the application. It's a one-time payment for lifetime, not a monthly recurring payment.
|
- You can buy Unlimited Accounts add-on in the application. It's a one-time payment for lifetime, not a monthly recurring payment.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Wino Mail vNext Improvements
|
||||||
|
|
||||||
|
This document summarizes the major improvements on `feature/vNext` compared to `main`, based on the commit history between the current branch and the merge-base with `main`.
|
||||||
|
|
||||||
|
## Wino Calendar
|
||||||
|
|
||||||
|
Calendar has grown from an early implementation into a much more complete product area on this branch.
|
||||||
|
|
||||||
|
### A full Wino Calendar experience
|
||||||
|
|
||||||
|
- Added a dedicated Wino Calendar app entry, making calendar a first-class experience instead of a secondary add-on.
|
||||||
|
- Built out the calendar rendering experience with multiple rounds of rendering improvements, updated calendar view styling, calendar buttons, and better event visuals.
|
||||||
|
- Added event creation and full event compose flows, including follow-up improvements for attachments, attendees, recurrence summaries, RSVP actions, reminders, and event details.
|
||||||
|
- Improved support for all-day events, better display dates, occurrence handling, and mail-to-calendar mapping so calendar actions connect more naturally with messages and invitations.
|
||||||
|
|
||||||
|
### Local calendar support
|
||||||
|
|
||||||
|
- Added local calendar operation coverage and supporting behavior for IMAP-backed/local calendar scenarios.
|
||||||
|
- Prevented duplicate operations by ignoring local calendar apply-changes in the wrong paths.
|
||||||
|
- Added busy-state support and metadata fetch flows so newly created accounts can initialize calendar data more reliably.
|
||||||
|
|
||||||
|
### CalDAV sync
|
||||||
|
|
||||||
|
- Introduced a dedicated CalDAV synchronizer and supporting service/client work.
|
||||||
|
- Fixed CalDAV delta sync issues.
|
||||||
|
- Fixed CalDAV timezone issues.
|
||||||
|
- Added manual live CalDAV workflow tests to validate real-world sync behavior.
|
||||||
|
|
||||||
|
This means local and self-hosted calendar scenarios are much better represented on this branch than on `main`.
|
||||||
|
|
||||||
|
### API calendar sync for Outlook and Gmail
|
||||||
|
|
||||||
|
- Expanded Outlook calendar sync behavior, including broader sync windows and fixes around date/time handling.
|
||||||
|
- Improved Gmail drafting and mail/calendar integration so event-related actions work better across providers.
|
||||||
|
- Added mail and calendar synchronizer state tracking to make sync progress and error handling more reliable.
|
||||||
|
- Added auto calendar sync on account creation and broader auto-sync trigger and cancellation support.
|
||||||
|
|
||||||
|
### Calendar polish and reliability
|
||||||
|
|
||||||
|
- Fixed calendar crashes and null-handling issues in calendar view date range updates.
|
||||||
|
- Fixed double initialization in calendar day views.
|
||||||
|
- Improved reaction to calendar changes and calendar item update-source handling.
|
||||||
|
- Added reminder snooze support across toast UI, services, and database storage.
|
||||||
|
|
||||||
|
Overall, Wino Calendar is one of the biggest themes of this branch: richer UI, more complete event workflows, and real sync support across local, CalDAV, Outlook, and Gmail-backed scenarios.
|
||||||
|
|
||||||
|
## Wino Accounts
|
||||||
|
|
||||||
|
Wino Accounts was significantly expanded and polished on this branch.
|
||||||
|
|
||||||
|
### Account flows and identity
|
||||||
|
|
||||||
|
- Added sign in, sign out, and registration flows.
|
||||||
|
- Redesigned login and registration dialogs.
|
||||||
|
- Added privacy policy presentation during registration.
|
||||||
|
- Added forgot password and email confirmation flows.
|
||||||
|
- Pointed the app to the real API and improved profile caching.
|
||||||
|
|
||||||
|
### Account management and settings
|
||||||
|
|
||||||
|
- Added Wino account settings and a dedicated management page.
|
||||||
|
- Added a special navigation item for Wino Accounts.
|
||||||
|
- Added import functionality for Wino Accounts.
|
||||||
|
- Added a preference to hide the title bar Wino account button.
|
||||||
|
- Improved the top-shell account icon and signed-out identity visuals.
|
||||||
|
|
||||||
|
### Purchases and add-ons
|
||||||
|
|
||||||
|
- Added handling for Paddle purchases and add-ons.
|
||||||
|
- Added purchase-success deep linking.
|
||||||
|
- Added support for AI pack handling through the Microsoft Store.
|
||||||
|
|
||||||
|
### User-facing polish
|
||||||
|
|
||||||
|
- Redesigned the Wino Account flyout and menu with a more polished Fluent-style presentation.
|
||||||
|
- Improved account cleanup behavior when an account is deleted.
|
||||||
|
- Added account attention handling and better account details/settings behavior.
|
||||||
|
|
||||||
|
Compared to `main`, this branch turns Wino Accounts into a much more complete platform feature rather than a minimal sign-in surface.
|
||||||
|
|
||||||
|
## Improved Stability and Reliability
|
||||||
|
|
||||||
|
A large part of this branch is about making the app more dependable in everyday use.
|
||||||
|
|
||||||
|
### Synchronization stability
|
||||||
|
|
||||||
|
- Refactored synchronizers to address long-standing reliability issues.
|
||||||
|
- Improved thread mapping across synchronizers.
|
||||||
|
- Added generic 404 handling for synchronizers.
|
||||||
|
- Added specific Outlook 429 handling for rate-limit scenarios.
|
||||||
|
- Improved Outlook authentication and Outlook sync reliability.
|
||||||
|
- Improved Gmail synchronizer behavior.
|
||||||
|
- Added explicit mail and calendar synchronizer state support.
|
||||||
|
|
||||||
|
### Mail and data reliability
|
||||||
|
|
||||||
|
- Optimized mail fetching with batched database queries and in-memory caching.
|
||||||
|
- Added SQLite indexes and enabled foreign key enforcement.
|
||||||
|
- Switched away from the old mail item queue approach and returned to a simpler initial sync strategy.
|
||||||
|
- Improved local draft resend behavior and added grace-period handling for local drafts.
|
||||||
|
- Added better handling for large Outlook attachments via upload sessions.
|
||||||
|
- Fixed issues with sent/draft placement, loading mails with infinite scroll, selection cleanup, and deleted-object scenarios.
|
||||||
|
|
||||||
|
### UI and lifecycle stability
|
||||||
|
|
||||||
|
- Fixed mail rendering page disposal issues.
|
||||||
|
- Fixed WebView2 runtime toast dispatching on the UI thread.
|
||||||
|
- Fixed startup mode issues, single-instancing problems, and shell/navigation regressions.
|
||||||
|
- Fixed multiple thread selection, container, flicker, and context-menu issues.
|
||||||
|
- Fixed crashes and null-reference style issues in several calendar and shell flows.
|
||||||
|
|
||||||
|
### Engineering quality
|
||||||
|
|
||||||
|
- Added more tests across calendar, CalDAV, IMAP, view-model, sanitization, and account sync scenarios.
|
||||||
|
- Added a GitHub Actions workflow to build WinUI and run Core tests on pull requests.
|
||||||
|
- Resolved warnings and moved the WinUI project toward warnings-as-errors discipline.
|
||||||
|
- Added AOT compatibility work and related cleanup across the app.
|
||||||
|
|
||||||
|
The branch is not just adding features; it is also clearly reducing failure points throughout sync, rendering, navigation, and storage.
|
||||||
|
|
||||||
|
## Contacts, Settings, and General UX
|
||||||
|
|
||||||
|
This branch also improves the everyday product experience outside mail and calendar core flows.
|
||||||
|
|
||||||
|
### Contacts
|
||||||
|
|
||||||
|
- Added contacts management.
|
||||||
|
- Improved contacts UI and related thread/image preview behavior.
|
||||||
|
- Removed legacy SQLite base64 contact storage from `AccountContact`.
|
||||||
|
- Added contact picture handling support and supporting contact service improvements.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
- Added a dedicated settings shell and refactored settings home/navigation.
|
||||||
|
- Expanded settings UI and introduced new setting options.
|
||||||
|
- Added calendar settings into the settings experience.
|
||||||
|
- Improved account details/settings pages and storage settings navigation.
|
||||||
|
- Refined settings visuals, shell integration, and menu behavior.
|
||||||
|
|
||||||
|
### Onboarding and app experience
|
||||||
|
|
||||||
|
- Added a new startup window and a more guided onboarding flow with wizard-like steps.
|
||||||
|
- Added a "What's New" implementation for feature communication.
|
||||||
|
- Improved dialogs, title bar behavior, shell content, navigation, and shell polish across multiple iterations.
|
||||||
|
- Added live store update notifications.
|
||||||
|
- Improved keyboard shortcuts and related dialogs.
|
||||||
|
- Added tray icon support and better toast routing between mail and calendar app entries.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Compared to `main`, `feature/vNext` delivers four major leaps:
|
||||||
|
|
||||||
|
1. Wino Calendar becomes a substantially more complete feature set, including local calendar support, CalDAV sync, and stronger Outlook and Gmail calendar integration.
|
||||||
|
2. Wino Accounts becomes a real product surface with better authentication flows, management, imports, purchases, and polish.
|
||||||
|
3. The app is more stable thanks to synchronization refactors, storage improvements, test expansion, and many crash and lifecycle fixes.
|
||||||
|
4. Contacts, settings, onboarding, and shell/navigation experience all feel more mature and more consistent.
|
||||||
|
|
||||||
|
In short, this branch is a broad product maturation release rather than a narrow feature drop.
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
namespace Wino.Authentication
|
namespace Wino.Authentication;
|
||||||
|
|
||||||
|
public abstract class BaseAuthenticator
|
||||||
{
|
{
|
||||||
public abstract class BaseAuthenticator
|
public abstract MailProviderType ProviderType { get; }
|
||||||
|
protected IAuthenticatorConfig AuthenticatorConfig { get; }
|
||||||
|
|
||||||
|
protected BaseAuthenticator(IAuthenticatorConfig authenticatorConfig)
|
||||||
{
|
{
|
||||||
public abstract MailProviderType ProviderType { get; }
|
|
||||||
protected IAuthenticatorConfig AuthenticatorConfig { get; }
|
|
||||||
|
|
||||||
protected BaseAuthenticator(IAuthenticatorConfig authenticatorConfig)
|
AuthenticatorConfig = authenticatorConfig;
|
||||||
{
|
|
||||||
|
|
||||||
AuthenticatorConfig = authenticatorConfig;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,45 +7,44 @@ using Wino.Core.Domain.Enums;
|
|||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Authentication;
|
using Wino.Core.Domain.Models.Authentication;
|
||||||
|
|
||||||
namespace Wino.Authentication
|
namespace Wino.Authentication;
|
||||||
|
|
||||||
|
public class GmailAuthenticator : BaseAuthenticator, IGmailAuthenticator
|
||||||
{
|
{
|
||||||
public class GmailAuthenticator : BaseAuthenticator, IGmailAuthenticator
|
public GmailAuthenticator(IAuthenticatorConfig authConfig) : base(authConfig)
|
||||||
{
|
{
|
||||||
public GmailAuthenticator(IAuthenticatorConfig authConfig) : base(authConfig)
|
}
|
||||||
|
|
||||||
|
public string ClientId => AuthenticatorConfig.GmailAuthenticatorClientId;
|
||||||
|
public bool ProposeCopyAuthURL { get; set; }
|
||||||
|
|
||||||
|
public override MailProviderType ProviderType => MailProviderType.Gmail;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates the token information for the given account.
|
||||||
|
/// For gmail, interactivity is automatically handled when you get the token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account">Account to get token for.</param>
|
||||||
|
public Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
|
||||||
|
=> GetTokenInformationAsync(account);
|
||||||
|
|
||||||
|
public async Task<TokenInformationEx> GetTokenInformationAsync(MailAccount account)
|
||||||
|
{
|
||||||
|
var userCredential = await GetGoogleUserCredentialAsync(account);
|
||||||
|
|
||||||
|
if (userCredential.Token.IsStale)
|
||||||
{
|
{
|
||||||
|
await userCredential.RefreshTokenAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ClientId => AuthenticatorConfig.GmailAuthenticatorClientId;
|
return new TokenInformationEx(userCredential.Token.AccessToken, account.Address);
|
||||||
public bool ProposeCopyAuthURL { get; set; }
|
}
|
||||||
|
|
||||||
public override MailProviderType ProviderType => MailProviderType.Gmail;
|
private Task<UserCredential> GetGoogleUserCredentialAsync(MailAccount account)
|
||||||
|
{
|
||||||
/// <summary>
|
return GoogleWebAuthorizationBroker.AuthorizeAsync(new ClientSecrets()
|
||||||
/// Generates the token information for the given account.
|
|
||||||
/// For gmail, interactivity is automatically handled when you get the token.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="account">Account to get token for.</param>
|
|
||||||
public Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
|
|
||||||
=> GetTokenInformationAsync(account);
|
|
||||||
|
|
||||||
public async Task<TokenInformationEx> GetTokenInformationAsync(MailAccount account)
|
|
||||||
{
|
{
|
||||||
var userCredential = await GetGoogleUserCredentialAsync(account);
|
ClientId = ClientId
|
||||||
|
}, AuthenticatorConfig.GetGmailScope(account?.IsMailAccessGranted != false, account?.IsCalendarAccessGranted == true), account.Id.ToString(), CancellationToken.None, new FileDataStore(AuthenticatorConfig.GmailTokenStoreIdentifier));
|
||||||
if (userCredential.Token.IsStale)
|
|
||||||
{
|
|
||||||
await userCredential.RefreshTokenAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TokenInformationEx(userCredential.Token.AccessToken, account.Address);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task<UserCredential> GetGoogleUserCredentialAsync(MailAccount account)
|
|
||||||
{
|
|
||||||
return GoogleWebAuthorizationBroker.AuthorizeAsync(new ClientSecrets()
|
|
||||||
{
|
|
||||||
ClientId = ClientId
|
|
||||||
}, AuthenticatorConfig.GmailScope, account.Id.ToString(), CancellationToken.None, new FileDataStore(AuthenticatorConfig.GmailTokenStoreIdentifier));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,116 +11,132 @@ using Wino.Core.Domain.Exceptions;
|
|||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Authentication;
|
using Wino.Core.Domain.Models.Authentication;
|
||||||
|
|
||||||
namespace Wino.Authentication
|
namespace Wino.Authentication;
|
||||||
|
|
||||||
|
public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
|
||||||
{
|
{
|
||||||
public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
|
private const string TokenCacheFileName = "OutlookCache.bin";
|
||||||
|
private bool isTokenCacheAttached = false;
|
||||||
|
|
||||||
|
// Outlook
|
||||||
|
private const string Authority = "https://login.microsoftonline.com/common";
|
||||||
|
|
||||||
|
public override MailProviderType ProviderType => MailProviderType.Outlook;
|
||||||
|
|
||||||
|
private readonly IPublicClientApplication _publicClientApplication;
|
||||||
|
private readonly INativeAppService _nativeAppService;
|
||||||
|
private readonly IApplicationConfiguration _applicationConfiguration;
|
||||||
|
|
||||||
|
public OutlookAuthenticator(INativeAppService nativeAppService,
|
||||||
|
IApplicationConfiguration applicationConfiguration,
|
||||||
|
IAuthenticatorConfig authenticatorConfig) : base(authenticatorConfig)
|
||||||
{
|
{
|
||||||
private const string TokenCacheFileName = "OutlookCache.bin";
|
_nativeAppService = nativeAppService;
|
||||||
private bool isTokenCacheAttached = false;
|
_applicationConfiguration = applicationConfiguration;
|
||||||
|
|
||||||
// Outlook
|
var authenticationRedirectUri = nativeAppService.GetWebAuthenticationBrokerUri();
|
||||||
private const string Authority = "https://login.microsoftonline.com/common";
|
|
||||||
|
|
||||||
public override MailProviderType ProviderType => MailProviderType.Outlook;
|
var options = new BrokerOptions(BrokerOptions.OperatingSystems.Windows)
|
||||||
|
|
||||||
private readonly IPublicClientApplication _publicClientApplication;
|
|
||||||
private readonly IApplicationConfiguration _applicationConfiguration;
|
|
||||||
|
|
||||||
public OutlookAuthenticator(INativeAppService nativeAppService,
|
|
||||||
IApplicationConfiguration applicationConfiguration,
|
|
||||||
IAuthenticatorConfig authenticatorConfig) : base(authenticatorConfig)
|
|
||||||
{
|
{
|
||||||
_applicationConfiguration = applicationConfiguration;
|
Title = "Wino Mail",
|
||||||
|
ListOperatingSystemAccounts = true,
|
||||||
|
};
|
||||||
|
|
||||||
var authenticationRedirectUri = nativeAppService.GetWebAuthenticationBrokerUri();
|
PublicClientApplicationBuilder outlookAppBuilder = null;
|
||||||
|
|
||||||
var options = new BrokerOptions(BrokerOptions.OperatingSystems.Windows)
|
// Being created from an app notification.
|
||||||
{
|
// This is where we avoid all interactive shit for authentication.
|
||||||
Title = "Wino Mail",
|
if (nativeAppService.GetCoreWindowHwnd == null)
|
||||||
ListOperatingSystemAccounts = true,
|
{
|
||||||
};
|
outlookAppBuilder = PublicClientApplicationBuilder.Create(AuthenticatorConfig.OutlookAuthenticatorClientId)
|
||||||
|
.WithDefaultRedirectUri()
|
||||||
var outlookAppBuilder = PublicClientApplicationBuilder.Create(AuthenticatorConfig.OutlookAuthenticatorClientId)
|
|
||||||
.WithParentActivityOrWindow(nativeAppService.GetCoreWindowHwnd)
|
|
||||||
.WithBroker(options)
|
.WithBroker(options)
|
||||||
|
.WithAuthority(Authority);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
outlookAppBuilder = PublicClientApplicationBuilder.Create(AuthenticatorConfig.OutlookAuthenticatorClientId)
|
||||||
|
.WithBroker(options)
|
||||||
|
.WithParentActivityOrWindow(_nativeAppService.GetCoreWindowHwnd)
|
||||||
.WithDefaultRedirectUri()
|
.WithDefaultRedirectUri()
|
||||||
.WithAuthority(Authority);
|
.WithAuthority(Authority);
|
||||||
|
|
||||||
_publicClientApplication = outlookAppBuilder.Build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string[] Scope => AuthenticatorConfig.OutlookScope;
|
_publicClientApplication = outlookAppBuilder.Build();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task EnsureTokenCacheAttachedAsync()
|
private string[] GetScope(MailAccount account)
|
||||||
|
=> AuthenticatorConfig.GetOutlookScope(
|
||||||
|
account?.IsMailAccessGranted != false,
|
||||||
|
account?.IsCalendarAccessGranted == true);
|
||||||
|
|
||||||
|
private async Task EnsureTokenCacheAttachedAsync()
|
||||||
|
{
|
||||||
|
if (!isTokenCacheAttached)
|
||||||
{
|
{
|
||||||
if (!isTokenCacheAttached)
|
var storageProperties = new StorageCreationPropertiesBuilder(TokenCacheFileName, _applicationConfiguration.PublisherSharedFolderPath).Build();
|
||||||
{
|
var msalcachehelper = await MsalCacheHelper.CreateAsync(storageProperties);
|
||||||
var storageProperties = new StorageCreationPropertiesBuilder(TokenCacheFileName, _applicationConfiguration.PublisherSharedFolderPath).Build();
|
msalcachehelper.RegisterCache(_publicClientApplication.UserTokenCache);
|
||||||
var msalcachehelper = await MsalCacheHelper.CreateAsync(storageProperties);
|
|
||||||
msalcachehelper.RegisterCache(_publicClientApplication.UserTokenCache);
|
|
||||||
|
|
||||||
isTokenCacheAttached = true;
|
isTokenCacheAttached = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<TokenInformationEx> GetTokenInformationAsync(MailAccount account)
|
public async Task<TokenInformationEx> GetTokenInformationAsync(MailAccount account)
|
||||||
|
{
|
||||||
|
await EnsureTokenCacheAttachedAsync();
|
||||||
|
|
||||||
|
var storedAccount = (await _publicClientApplication.GetAccountsAsync()).FirstOrDefault(
|
||||||
|
a => string.Equals(a.Username?.Trim(), account.Address?.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (storedAccount == null)
|
||||||
|
return await GenerateTokenInformationAsync(account);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var authResult = await _publicClientApplication.AcquireTokenSilent(GetScope(account), storedAccount).ExecuteAsync();
|
||||||
|
|
||||||
|
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
|
||||||
|
}
|
||||||
|
catch (MsalUiRequiredException)
|
||||||
|
{
|
||||||
|
// Somehow MSAL is not able to refresh the token silently.
|
||||||
|
// Force interactive login which will include calendar scopes.
|
||||||
|
// The calling code should update account.IsCalendarAccessGranted = true after successful authentication.
|
||||||
|
|
||||||
|
return await GenerateTokenInformationAsync(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
await EnsureTokenCacheAttachedAsync();
|
await EnsureTokenCacheAttachedAsync();
|
||||||
|
|
||||||
var storedAccount = (await _publicClientApplication.GetAccountsAsync()).FirstOrDefault(a => a.Username == account.Address);
|
// Interactive authentication required but window doesn't exist.
|
||||||
|
// This can happen when being called from a notification background task and the token is expired.
|
||||||
|
// Force account attention;
|
||||||
|
|
||||||
if (storedAccount == null)
|
if (_nativeAppService.GetCoreWindowHwnd == null) throw new AuthenticationAttentionException(account);
|
||||||
return await GenerateTokenInformationAsync(account);
|
|
||||||
|
|
||||||
try
|
AuthenticationResult authResult = await _publicClientApplication
|
||||||
{
|
.AcquireTokenInteractive(GetScope(account))
|
||||||
var authResult = await _publicClientApplication.AcquireTokenSilent(Scope, storedAccount).ExecuteAsync();
|
.ExecuteAsync();
|
||||||
|
|
||||||
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
|
// Microsoft 365 work/school tenants can use a sign-in UPN that differs from
|
||||||
}
|
// the mailbox primary SMTP address, so interactive reauth must not reject them.
|
||||||
catch (MsalUiRequiredException)
|
|
||||||
{
|
|
||||||
// Somehow MSAL is not able to refresh the token silently.
|
|
||||||
// Force interactive login.
|
|
||||||
|
|
||||||
return await GenerateTokenInformationAsync(account);
|
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
catch (MsalClientException msalClientException)
|
||||||
public async Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
|
|
||||||
{
|
{
|
||||||
try
|
if (msalClientException.ErrorCode == "authentication_canceled" || msalClientException.ErrorCode == "access_denied")
|
||||||
{
|
throw new AccountSetupCanceledException();
|
||||||
await EnsureTokenCacheAttachedAsync();
|
|
||||||
|
|
||||||
var authResult = await _publicClientApplication
|
throw;
|
||||||
.AcquireTokenInteractive(Scope)
|
|
||||||
.ExecuteAsync();
|
|
||||||
|
|
||||||
// If the account is null, it means it's the initial creation of it.
|
|
||||||
// If not, make sure the authenticated user address matches the username.
|
|
||||||
// When people refresh their token, accounts must match.
|
|
||||||
|
|
||||||
if (account?.Address != null && !account.Address.Equals(authResult.Account.Username, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
throw new AuthenticationException("Authenticated address does not match with your account address.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
|
|
||||||
}
|
|
||||||
catch (MsalClientException msalClientException)
|
|
||||||
{
|
|
||||||
if (msalClientException.ErrorCode == "authentication_canceled" || msalClientException.ErrorCode == "access_denied")
|
|
||||||
throw new AccountSetupCanceledException();
|
|
||||||
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AuthenticationException(Translator.Exception_UnknowErrorDuringAuthentication, new Exception(Translator.Exception_TokenGenerationFailed));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new AuthenticationException(Translator.Exception_UnknowErrorDuringAuthentication, new Exception(Translator.Exception_TokenGenerationFailed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||||
<RootNamespace>Wino.Authentication</RootNamespace>
|
<RootNamespace>Wino.Authentication</RootNamespace>
|
||||||
<Platforms>x86;x64;arm64</Platforms>
|
<Platforms>x86;x64;arm64</Platforms>
|
||||||
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
|
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
|
||||||
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
|
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
|
||||||
|
<IsTrimmable>true</IsTrimmable>
|
||||||
|
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CommunityToolkit.Diagnostics" />
|
<PackageReference Include="CommunityToolkit.Diagnostics" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
<PackageReference Include="Google.Apis.Auth" />
|
<PackageReference Include="Google.Apis.Auth" />
|
||||||
<PackageReference Include="Microsoft.Identity.Client" />
|
<PackageReference Include="Microsoft.Identity.Client" />
|
||||||
<PackageReference Include="Microsoft.Identity.Client.Broker" />
|
<PackageReference Include="Microsoft.Identity.Client.Broker" />
|
||||||
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" />
|
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" />
|
||||||
|
<PackageReference Include="Sentry.Serilog" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
|
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
using Microsoft.Toolkit.Uwp.Notifications;
|
|
||||||
using Windows.ApplicationModel;
|
|
||||||
using Windows.ApplicationModel.Background;
|
|
||||||
|
|
||||||
namespace Wino.BackgroundTasks
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a toast notification to notify user when the Store update happens.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AppUpdatedTask : IBackgroundTask
|
|
||||||
{
|
|
||||||
public void Run(IBackgroundTaskInstance taskInstance)
|
|
||||||
{
|
|
||||||
var def = taskInstance.GetDeferral();
|
|
||||||
|
|
||||||
var builder = new ToastContentBuilder();
|
|
||||||
builder.SetToastScenario(ToastScenario.Default);
|
|
||||||
|
|
||||||
Package package = Package.Current;
|
|
||||||
PackageId packageId = package.Id;
|
|
||||||
PackageVersion version = packageId.Version;
|
|
||||||
|
|
||||||
var versionText = string.Format("{0}.{1}.{2}.{3}", version.Major, version.Minor, version.Build, version.Revision);
|
|
||||||
|
|
||||||
// TODO: Handle with Translator, but it's not initialized here yet.
|
|
||||||
builder.AddText("Wino Mail is updated!");
|
|
||||||
builder.AddText(string.Format("New version {0} is ready.", versionText));
|
|
||||||
|
|
||||||
builder.Show();
|
|
||||||
|
|
||||||
def.Complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using System.Reflection;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
// General Information about an assembly is controlled through the following
|
|
||||||
// set of attributes. Change these attribute values to modify the information
|
|
||||||
// associated with an assembly.
|
|
||||||
[assembly: AssemblyTitle("Wino.BackgroundTasks")]
|
|
||||||
[assembly: AssemblyDescription("")]
|
|
||||||
[assembly: AssemblyConfiguration("")]
|
|
||||||
[assembly: AssemblyCompany("")]
|
|
||||||
[assembly: AssemblyProduct("Wino.BackgroundTasks")]
|
|
||||||
[assembly: AssemblyCopyright("Copyright © 2023")]
|
|
||||||
[assembly: AssemblyTrademark("")]
|
|
||||||
[assembly: AssemblyCulture("")]
|
|
||||||
|
|
||||||
// Version information for an assembly consists of the following four values:
|
|
||||||
//
|
|
||||||
// Major Version
|
|
||||||
// Minor Version
|
|
||||||
// Build Number
|
|
||||||
// Revision
|
|
||||||
//
|
|
||||||
// You can specify all the values or you can default the Build and Revision Numbers
|
|
||||||
// by using the '*' as shown below:
|
|
||||||
// [assembly: AssemblyVersion("1.0.*")]
|
|
||||||
[assembly: AssemblyVersion("1.0.0.0")]
|
|
||||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
|
||||||
[assembly: ComVisible(false)]
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
|
||||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
|
||||||
<PropertyGroup>
|
|
||||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
|
||||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
|
||||||
<ProjectGuid>{D9EF0F59-F5F2-4D6C-A5BA-84043D8F3E08}</ProjectGuid>
|
|
||||||
<OutputType>winmdobj</OutputType>
|
|
||||||
<AppDesignerFolder>Properties</AppDesignerFolder>
|
|
||||||
<RootNamespace>Wino.BackgroundTasks</RootNamespace>
|
|
||||||
<AssemblyName>Wino.BackgroundTasks</AssemblyName>
|
|
||||||
<DefaultLanguage>en-US</DefaultLanguage>
|
|
||||||
<TargetPlatformIdentifier>UAP</TargetPlatformIdentifier>
|
|
||||||
<TargetPlatformVersion Condition=" '$(TargetPlatformVersion)' == '' ">10.0.22621.0</TargetPlatformVersion>
|
|
||||||
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
|
||||||
<MinimumVisualStudioVersion>14</MinimumVisualStudioVersion>
|
|
||||||
<FileAlignment>512</FileAlignment>
|
|
||||||
<ProjectTypeGuids>{A5A43C5B-DE2A-4C0C-9213-0A381AF9435A};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
|
||||||
<AllowCrossPlatformRetargeting>false</AllowCrossPlatformRetargeting>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'">
|
|
||||||
<PlatformTarget>x86</PlatformTarget>
|
|
||||||
<DebugSymbols>true</DebugSymbols>
|
|
||||||
<OutputPath>bin\x86\Debug\</OutputPath>
|
|
||||||
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
|
||||||
<NoWarn>;2008</NoWarn>
|
|
||||||
<DebugType>full</DebugType>
|
|
||||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
|
||||||
<ErrorReport>prompt</ErrorReport>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'">
|
|
||||||
<PlatformTarget>x86</PlatformTarget>
|
|
||||||
<OutputPath>bin\x86\Release\</OutputPath>
|
|
||||||
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
|
||||||
<Optimize>true</Optimize>
|
|
||||||
<NoWarn>;2008</NoWarn>
|
|
||||||
<DebugType>pdbonly</DebugType>
|
|
||||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
|
||||||
<ErrorReport>prompt</ErrorReport>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|ARM64'">
|
|
||||||
<PlatformTarget>ARM64</PlatformTarget>
|
|
||||||
<DebugSymbols>true</DebugSymbols>
|
|
||||||
<OutputPath>bin\ARM64\Debug\</OutputPath>
|
|
||||||
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
|
||||||
<NoWarn>;2008</NoWarn>
|
|
||||||
<DebugType>full</DebugType>
|
|
||||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
|
||||||
<ErrorReport>prompt</ErrorReport>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|ARM64'">
|
|
||||||
<PlatformTarget>ARM64</PlatformTarget>
|
|
||||||
<OutputPath>bin\ARM64\Release\</OutputPath>
|
|
||||||
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
|
||||||
<Optimize>true</Optimize>
|
|
||||||
<NoWarn>;2008</NoWarn>
|
|
||||||
<DebugType>pdbonly</DebugType>
|
|
||||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
|
||||||
<ErrorReport>prompt</ErrorReport>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'">
|
|
||||||
<PlatformTarget>x64</PlatformTarget>
|
|
||||||
<DebugSymbols>true</DebugSymbols>
|
|
||||||
<OutputPath>bin\x64\Debug\</OutputPath>
|
|
||||||
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
|
||||||
<NoWarn>;2008</NoWarn>
|
|
||||||
<DebugType>full</DebugType>
|
|
||||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
|
||||||
<ErrorReport>prompt</ErrorReport>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'">
|
|
||||||
<PlatformTarget>x64</PlatformTarget>
|
|
||||||
<OutputPath>bin\x64\Release\</OutputPath>
|
|
||||||
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
|
||||||
<Optimize>true</Optimize>
|
|
||||||
<NoWarn>;2008</NoWarn>
|
|
||||||
<DebugType>pdbonly</DebugType>
|
|
||||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
|
||||||
<ErrorReport>prompt</ErrorReport>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup>
|
|
||||||
<RestoreProjectStyle>PackageReference</RestoreProjectStyle>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="AppUpdatedTask.cs" />
|
|
||||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Identity.Client">
|
|
||||||
<Version>4.66.2</Version>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.NETCore.UniversalWindowsPlatform">
|
|
||||||
<Version>6.2.14</Version>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications">
|
|
||||||
<Version>7.1.3</Version>
|
|
||||||
</PackageReference>
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj">
|
|
||||||
<Project>{CF3312E5-5DA0-4867-9945-49EA7598AF1F}</Project>
|
|
||||||
<Name>Wino.Core.Domain</Name>
|
|
||||||
</ProjectReference>
|
|
||||||
<ProjectReference Include="..\Wino.Core.UWP\Wino.Core.UWP.csproj">
|
|
||||||
<Project>{395f19ba-1e42-495c-9db5-1a6f537fccb8}</Project>
|
|
||||||
<Name>Wino.Core.UWP</Name>
|
|
||||||
</ProjectReference>
|
|
||||||
<ProjectReference Include="..\Wino.Core\Wino.Core.csproj">
|
|
||||||
<Project>{e6b1632a-8901-41e8-9ddf-6793c7698b0b}</Project>
|
|
||||||
<Name>Wino.Core</Name>
|
|
||||||
</ProjectReference>
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<SDKReference Include="WindowsDesktop, Version=10.0.22621.0">
|
|
||||||
<Name>Windows Desktop Extensions for the UWP</Name>
|
|
||||||
</SDKReference>
|
|
||||||
</ItemGroup>
|
|
||||||
<PropertyGroup Condition=" '$(VisualStudioVersion)' == '' or '$(VisualStudioVersion)' < '14.0' ">
|
|
||||||
<VisualStudioVersion>14.0</VisualStudioVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
<Import Project="$(MSBuildExtensionsPath)\Microsoft\WindowsXaml\v$(VisualStudioVersion)\Microsoft.Windows.UI.Xaml.CSharp.targets" />
|
|
||||||
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
|
|
||||||
Other similar extension points exist, see Microsoft.Common.targets.
|
|
||||||
<Target Name="BeforeBuild">
|
|
||||||
</Target>
|
|
||||||
<Target Name="AfterBuild">
|
|
||||||
</Target>
|
|
||||||
-->
|
|
||||||
</Project>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -1,90 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
|
|
||||||
<Package
|
|
||||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
|
||||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
|
||||||
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
|
|
||||||
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
|
|
||||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
|
||||||
IgnorableNamespaces="uap rescap">
|
|
||||||
|
|
||||||
<Identity
|
|
||||||
Name="58272BurakKSE.WinoCalendar"
|
|
||||||
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
|
|
||||||
Version="1.0.0.0" />
|
|
||||||
|
|
||||||
<Properties>
|
|
||||||
<DisplayName>Wino Calendar</DisplayName>
|
|
||||||
<PublisherDisplayName>Burak KÖSE</PublisherDisplayName>
|
|
||||||
<Logo>Images\StoreLogo.png</Logo>
|
|
||||||
</Properties>
|
|
||||||
|
|
||||||
<Dependencies>
|
|
||||||
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.0.0" MaxVersionTested="10.0.0.0" />
|
|
||||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.14393.0" MaxVersionTested="10.0.14393.0" />
|
|
||||||
</Dependencies>
|
|
||||||
|
|
||||||
<Resources>
|
|
||||||
<Resource Language="x-generate"/>
|
|
||||||
</Resources>
|
|
||||||
|
|
||||||
<Applications>
|
|
||||||
<Application Id="App"
|
|
||||||
Executable="$targetnametoken$.exe"
|
|
||||||
EntryPoint="$targetentrypoint$">
|
|
||||||
<uap:VisualElements
|
|
||||||
DisplayName="Wino Calendar"
|
|
||||||
Description="Wino.Calendar.Packaging"
|
|
||||||
BackgroundColor="transparent"
|
|
||||||
Square150x150Logo="Images\Square150x150Logo.png"
|
|
||||||
Square44x44Logo="Images\Square44x44Logo.png">
|
|
||||||
<uap:DefaultTile Wide310x150Logo="Images\Wide310x150Logo.png" />
|
|
||||||
<uap:SplashScreen Image="Images\SplashScreen.png" />
|
|
||||||
</uap:VisualElements>
|
|
||||||
|
|
||||||
<Extensions>
|
|
||||||
<!-- Registration of full trust backend application. -->
|
|
||||||
<uap:Extension Category="windows.appService">
|
|
||||||
<uap:AppService Name="WinoInteropService" />
|
|
||||||
</uap:Extension>
|
|
||||||
|
|
||||||
<!-- Protocol activation: Google OAuth -->
|
|
||||||
<uap:Extension Category="windows.protocol">
|
|
||||||
<uap:Protocol Name="google.pw.oauth2">
|
|
||||||
<uap:DisplayName>Wino Google Authentication Protocol</uap:DisplayName>
|
|
||||||
</uap:Protocol>
|
|
||||||
</uap:Extension>
|
|
||||||
|
|
||||||
<!-- Protocol activation: Launch UWP app from Full Trust Process -->
|
|
||||||
<uap:Extension Category="windows.protocol">
|
|
||||||
<uap:Protocol Name="wino.calendar.launch">
|
|
||||||
<uap:DisplayName>Wino Calendara Launcher Protocol</uap:DisplayName>
|
|
||||||
</uap:Protocol>
|
|
||||||
</uap:Extension>
|
|
||||||
|
|
||||||
<!-- Startup Task -->
|
|
||||||
<uap5:Extension
|
|
||||||
Category="windows.startupTask"
|
|
||||||
Executable="Wino.Server\Wino.Server.exe"
|
|
||||||
EntryPoint="Windows.FullTrustApplication">
|
|
||||||
<uap5:StartupTask
|
|
||||||
TaskId="WinoServer"
|
|
||||||
Enabled="false"
|
|
||||||
DisplayName="Wino Mail" />
|
|
||||||
</uap5:Extension>
|
|
||||||
|
|
||||||
<desktop:Extension Category="windows.fullTrustProcess" Executable="Wino.Server\Wino.Server.exe">
|
|
||||||
<desktop:FullTrustProcess>
|
|
||||||
<desktop:ParameterGroup GroupId="WinoServer" Parameters="Calendar" />
|
|
||||||
</desktop:FullTrustProcess>
|
|
||||||
</desktop:Extension>
|
|
||||||
</Extensions>
|
|
||||||
</Application>
|
|
||||||
</Applications>
|
|
||||||
|
|
||||||
<Capabilities>
|
|
||||||
<Capability Name="internetClient" />
|
|
||||||
<rescap:Capability Name="runFullTrust" />
|
|
||||||
<rescap:Capability Name="confirmAppClose" />
|
|
||||||
</Capabilities>
|
|
||||||
</Package>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
|
||||||
<PropertyGroup Condition="'$(VisualStudioVersion)' == '' or '$(VisualStudioVersion)' < '15.0'">
|
|
||||||
<VisualStudioVersion>15.0</VisualStudioVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup Label="ProjectConfigurations">
|
|
||||||
<ProjectConfiguration Include="Debug|x86">
|
|
||||||
<Configuration>Debug</Configuration>
|
|
||||||
<Platform>x86</Platform>
|
|
||||||
</ProjectConfiguration>
|
|
||||||
<ProjectConfiguration Include="Release|x86">
|
|
||||||
<Configuration>Release</Configuration>
|
|
||||||
<Platform>x86</Platform>
|
|
||||||
</ProjectConfiguration>
|
|
||||||
<ProjectConfiguration Include="Debug|x64">
|
|
||||||
<Configuration>Debug</Configuration>
|
|
||||||
<Platform>x64</Platform>
|
|
||||||
</ProjectConfiguration>
|
|
||||||
<ProjectConfiguration Include="Release|x64">
|
|
||||||
<Configuration>Release</Configuration>
|
|
||||||
<Platform>x64</Platform>
|
|
||||||
</ProjectConfiguration>
|
|
||||||
<ProjectConfiguration Include="Debug|ARM64">
|
|
||||||
<Configuration>Debug</Configuration>
|
|
||||||
<Platform>ARM64</Platform>
|
|
||||||
</ProjectConfiguration>
|
|
||||||
<ProjectConfiguration Include="Release|ARM64">
|
|
||||||
<Configuration>Release</Configuration>
|
|
||||||
<Platform>ARM64</Platform>
|
|
||||||
</ProjectConfiguration>
|
|
||||||
<ProjectConfiguration Include="Debug|AnyCPU">
|
|
||||||
<Configuration>Debug</Configuration>
|
|
||||||
<Platform>AnyCPU</Platform>
|
|
||||||
</ProjectConfiguration>
|
|
||||||
<ProjectConfiguration Include="Release|AnyCPU">
|
|
||||||
<Configuration>Release</Configuration>
|
|
||||||
<Platform>AnyCPU</Platform>
|
|
||||||
</ProjectConfiguration>
|
|
||||||
</ItemGroup>
|
|
||||||
<PropertyGroup>
|
|
||||||
<WapProjPath Condition="'$(WapProjPath)'==''">$(MSBuildExtensionsPath)\Microsoft\DesktopBridge\</WapProjPath>
|
|
||||||
</PropertyGroup>
|
|
||||||
<Import Project="$(WapProjPath)\Microsoft.DesktopBridge.props" />
|
|
||||||
<PropertyGroup>
|
|
||||||
<ProjectGuid>7485b18c-f5ab-4abe-ba7f-05b6623c67c8</ProjectGuid>
|
|
||||||
<TargetPlatformVersion>10.0.22621.0</TargetPlatformVersion>
|
|
||||||
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
|
||||||
<DefaultLanguage>en-US</DefaultLanguage>
|
|
||||||
<AppxPackageSigningEnabled>false</AppxPackageSigningEnabled>
|
|
||||||
<NoWarn>$(NoWarn);NU1702</NoWarn>
|
|
||||||
<EntryPointProjectUniqueName>..\Wino.Calendar\Wino.Calendar.csproj</EntryPointProjectUniqueName>
|
|
||||||
<GenerateTemporaryStoreCertificate>True</GenerateTemporaryStoreCertificate>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<AppxManifest Include="Package.appxmanifest">
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
</AppxManifest>
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="Images\SplashScreen.scale-200.png" />
|
|
||||||
<Content Include="Images\LockScreenLogo.scale-200.png" />
|
|
||||||
<Content Include="Images\Square150x150Logo.scale-200.png" />
|
|
||||||
<Content Include="Images\Square44x44Logo.scale-200.png" />
|
|
||||||
<Content Include="Images\Square44x44Logo.targetsize-24_altform-unplated.png" />
|
|
||||||
<Content Include="Images\StoreLogo.png" />
|
|
||||||
<Content Include="Images\Wide310x150Logo.scale-200.png" />
|
|
||||||
<None Include="Package.StoreAssociation.xml" />
|
|
||||||
</ItemGroup>
|
|
||||||
<Import Project="$(WapProjPath)\Microsoft.DesktopBridge.targets" />
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.1742" PrivateAssets="all" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Wino.Calendar\Wino.Calendar.csproj" />
|
|
||||||
<ProjectReference Include="..\Wino.Server\Wino.Server.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
using System.Threading.Tasks;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using Wino.Calendar.ViewModels.Interfaces;
|
|
||||||
using Wino.Core.Domain.Interfaces;
|
|
||||||
using Wino.Core.Domain.Models.Navigation;
|
|
||||||
using Wino.Core.ViewModels;
|
|
||||||
using Wino.Mail.ViewModels.Data;
|
|
||||||
using Wino.Messaging.UI;
|
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels
|
|
||||||
{
|
|
||||||
public partial class AccountDetailsPageViewModel : CalendarBaseViewModel
|
|
||||||
{
|
|
||||||
private readonly IAccountService _accountService;
|
|
||||||
|
|
||||||
public AccountProviderDetailViewModel Account { get; private set; }
|
|
||||||
public ICalendarDialogService CalendarDialogService { get; }
|
|
||||||
public IAccountCalendarStateService AccountCalendarStateService { get; }
|
|
||||||
|
|
||||||
public AccountDetailsPageViewModel(ICalendarDialogService calendarDialogService, IAccountService accountService, IAccountCalendarStateService accountCalendarStateService)
|
|
||||||
{
|
|
||||||
CalendarDialogService = calendarDialogService;
|
|
||||||
_accountService = accountService;
|
|
||||||
AccountCalendarStateService = accountCalendarStateService;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task RenameAccount()
|
|
||||||
{
|
|
||||||
if (Account == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var updatedAccount = await CalendarDialogService.ShowEditAccountDialogAsync(Account.Account);
|
|
||||||
|
|
||||||
if (updatedAccount != null)
|
|
||||||
{
|
|
||||||
await _accountService.UpdateAccountAsync(updatedAccount);
|
|
||||||
|
|
||||||
ReportUIChange(new AccountUpdatedMessage(updatedAccount));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnNavigatedTo(NavigationMode mode, object parameters)
|
|
||||||
{
|
|
||||||
base.OnNavigatedTo(mode, parameters);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using Wino.Core.Domain;
|
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
|
||||||
using Wino.Core.Domain.Enums;
|
|
||||||
using Wino.Core.Domain.Exceptions;
|
|
||||||
using Wino.Core.Domain.Interfaces;
|
|
||||||
using Wino.Core.Domain.Models.Authentication;
|
|
||||||
using Wino.Core.Domain.Models.Navigation;
|
|
||||||
using Wino.Core.Domain.Models.Synchronization;
|
|
||||||
using Wino.Core.ViewModels;
|
|
||||||
using Wino.Messaging.Server;
|
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels
|
|
||||||
{
|
|
||||||
public partial class AccountManagementViewModel : AccountManagementPageViewModelBase
|
|
||||||
{
|
|
||||||
private readonly IProviderService _providerService;
|
|
||||||
|
|
||||||
public AccountManagementViewModel(ICalendarDialogService dialogService,
|
|
||||||
IWinoServerConnectionManager winoServerConnectionManager,
|
|
||||||
INavigationService navigationService,
|
|
||||||
IAccountService accountService,
|
|
||||||
IProviderService providerService,
|
|
||||||
IStoreManagementService storeManagementService,
|
|
||||||
IAuthenticationProvider authenticationProvider,
|
|
||||||
IPreferencesService preferencesService) : base(dialogService, winoServerConnectionManager, navigationService, accountService, providerService, storeManagementService, authenticationProvider, preferencesService)
|
|
||||||
{
|
|
||||||
CalendarDialogService = dialogService;
|
|
||||||
_providerService = providerService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ICalendarDialogService CalendarDialogService { get; }
|
|
||||||
|
|
||||||
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
|
||||||
{
|
|
||||||
base.OnNavigatedTo(mode, parameters);
|
|
||||||
|
|
||||||
await InitializeAccountsAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task InitializeAccountsAsync()
|
|
||||||
{
|
|
||||||
Accounts.Clear();
|
|
||||||
|
|
||||||
var accounts = await AccountService.GetAccountsAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
await ExecuteUIThread(() =>
|
|
||||||
{
|
|
||||||
foreach (var account in accounts)
|
|
||||||
{
|
|
||||||
var accountDetails = GetAccountProviderDetails(account);
|
|
||||||
|
|
||||||
Accounts.Add(accountDetails);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await ManageStorePurchasesAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task AddNewAccountAsync()
|
|
||||||
{
|
|
||||||
if (IsAccountCreationBlocked)
|
|
||||||
{
|
|
||||||
var isPurchaseClicked = await DialogService.ShowConfirmationDialogAsync(Translator.DialogMessage_AccountLimitMessage, Translator.DialogMessage_AccountLimitTitle, Translator.Buttons_Purchase);
|
|
||||||
|
|
||||||
if (!isPurchaseClicked) return;
|
|
||||||
|
|
||||||
await PurchaseUnlimitedAccountAsync();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var availableProviders = _providerService.GetAvailableProviders();
|
|
||||||
|
|
||||||
var accountCreationDialogResult = await DialogService.ShowAccountProviderSelectionDialogAsync(availableProviders);
|
|
||||||
|
|
||||||
if (accountCreationDialogResult == null) return;
|
|
||||||
|
|
||||||
var accountCreationCancellationTokenSource = new CancellationTokenSource();
|
|
||||||
var accountCreationDialog = CalendarDialogService.GetAccountCreationDialog(accountCreationDialogResult);
|
|
||||||
|
|
||||||
accountCreationDialog.ShowDialog(accountCreationCancellationTokenSource);
|
|
||||||
accountCreationDialog.State = AccountCreationDialogState.SigningIn;
|
|
||||||
|
|
||||||
// For OAuth authentications, we just generate token and assign it to the MailAccount.
|
|
||||||
|
|
||||||
var createdAccount = new MailAccount()
|
|
||||||
{
|
|
||||||
ProviderType = accountCreationDialogResult.ProviderType,
|
|
||||||
Name = accountCreationDialogResult.AccountName,
|
|
||||||
Id = Guid.NewGuid()
|
|
||||||
};
|
|
||||||
|
|
||||||
var tokenInformationResponse = await WinoServerConnectionManager
|
|
||||||
.GetResponseAsync<TokenInformationEx, AuthorizationRequested>(new AuthorizationRequested(accountCreationDialogResult.ProviderType,
|
|
||||||
createdAccount,
|
|
||||||
createdAccount.ProviderType == MailProviderType.Gmail), accountCreationCancellationTokenSource.Token);
|
|
||||||
|
|
||||||
if (accountCreationDialog.State == AccountCreationDialogState.Canceled)
|
|
||||||
throw new AccountSetupCanceledException();
|
|
||||||
|
|
||||||
tokenInformationResponse.ThrowIfFailed();
|
|
||||||
|
|
||||||
await AccountService.CreateAccountAsync(createdAccount, null);
|
|
||||||
|
|
||||||
// Sync profile information if supported.
|
|
||||||
if (createdAccount.IsProfileInfoSyncSupported)
|
|
||||||
{
|
|
||||||
// Start profile information synchronization.
|
|
||||||
// It's only available for Outlook and Gmail synchronizers.
|
|
||||||
|
|
||||||
var profileSyncOptions = new MailSynchronizationOptions()
|
|
||||||
{
|
|
||||||
AccountId = createdAccount.Id,
|
|
||||||
Type = MailSynchronizationType.UpdateProfile
|
|
||||||
};
|
|
||||||
|
|
||||||
var profileSynchronizationResponse = await WinoServerConnectionManager.GetResponseAsync<MailSynchronizationResult, NewMailSynchronizationRequested>(new NewMailSynchronizationRequested(profileSyncOptions, SynchronizationSource.Client));
|
|
||||||
|
|
||||||
var profileSynchronizationResult = profileSynchronizationResponse.Data;
|
|
||||||
|
|
||||||
if (profileSynchronizationResult.CompletedState != SynchronizationCompletedState.Success)
|
|
||||||
throw new Exception(Translator.Exception_FailedToSynchronizeProfileInformation);
|
|
||||||
|
|
||||||
createdAccount.SenderName = profileSynchronizationResult.ProfileInformation.SenderName;
|
|
||||||
createdAccount.Base64ProfilePictureData = profileSynchronizationResult.ProfileInformation.Base64ProfilePictureData;
|
|
||||||
|
|
||||||
await AccountService.UpdateProfileInformationAsync(createdAccount.Id, profileSynchronizationResult.ProfileInformation);
|
|
||||||
}
|
|
||||||
|
|
||||||
accountCreationDialog.State = AccountCreationDialogState.FetchingEvents;
|
|
||||||
|
|
||||||
// Start synchronizing events.
|
|
||||||
var synchronizationOptions = new CalendarSynchronizationOptions()
|
|
||||||
{
|
|
||||||
AccountId = createdAccount.Id,
|
|
||||||
Type = CalendarSynchronizationType.CalendarMetadata
|
|
||||||
};
|
|
||||||
|
|
||||||
var synchronizationResponse = await WinoServerConnectionManager.GetResponseAsync<CalendarSynchronizationResult, NewCalendarSynchronizationRequested>(new NewCalendarSynchronizationRequested(synchronizationOptions, SynchronizationSource.Client));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
|
||||||
using Serilog;
|
|
||||||
using Wino.Calendar.ViewModels.Data;
|
|
||||||
using Wino.Calendar.ViewModels.Interfaces;
|
|
||||||
using Wino.Core.Domain.Collections;
|
|
||||||
using Wino.Core.Domain.Enums;
|
|
||||||
using Wino.Core.Domain.Extensions;
|
|
||||||
using Wino.Core.Domain.Interfaces;
|
|
||||||
using Wino.Core.Domain.Models.Calendar;
|
|
||||||
using Wino.Core.Domain.Models.Navigation;
|
|
||||||
using Wino.Core.Domain.Models.Synchronization;
|
|
||||||
using Wino.Core.ViewModels;
|
|
||||||
using Wino.Messaging.Client.Calendar;
|
|
||||||
using Wino.Messaging.Client.Navigation;
|
|
||||||
using Wino.Messaging.Server;
|
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels
|
|
||||||
{
|
|
||||||
public partial class AppShellViewModel : CalendarBaseViewModel,
|
|
||||||
IRecipient<VisibleDateRangeChangedMessage>,
|
|
||||||
IRecipient<CalendarEnableStatusChangedMessage>,
|
|
||||||
IRecipient<NavigateManageAccountsRequested>,
|
|
||||||
IRecipient<CalendarDisplayTypeChangedMessage>,
|
|
||||||
IRecipient<DetailsPageStateChangedMessage>
|
|
||||||
{
|
|
||||||
public IPreferencesService PreferencesService { get; }
|
|
||||||
public IStatePersistanceService StatePersistenceService { get; }
|
|
||||||
public IAccountCalendarStateService AccountCalendarStateService { get; }
|
|
||||||
public INavigationService NavigationService { get; }
|
|
||||||
public IWinoServerConnectionManager ServerConnectionManager { get; }
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool _isEventDetailsPageActive;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private int _selectedMenuItemIndex = -1;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool isCalendarEnabled;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the active connection status of the Wino server.
|
|
||||||
/// </summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
private WinoServerConnectionStatus activeConnectionStatus;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the display date of the calendar.
|
|
||||||
/// </summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
private DateTimeOffset _displayDate;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the highlighted range in the CalendarView and displayed date range in FlipView.
|
|
||||||
/// </summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
private DateRange highlightedDateRange;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private ObservableRangeCollection<string> dateNavigationHeaderItems = [];
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private int _selectedDateNavigationHeaderIndex;
|
|
||||||
|
|
||||||
public bool IsVerticalCalendar => StatePersistenceService.CalendarDisplayType == CalendarDisplayType.Month;
|
|
||||||
|
|
||||||
// For updating account calendars asynchronously.
|
|
||||||
private SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
|
|
||||||
|
|
||||||
public AppShellViewModel(IPreferencesService preferencesService,
|
|
||||||
IStatePersistanceService statePersistanceService,
|
|
||||||
IAccountService accountService,
|
|
||||||
ICalendarService calendarService,
|
|
||||||
IAccountCalendarStateService accountCalendarStateService,
|
|
||||||
INavigationService navigationService,
|
|
||||||
IWinoServerConnectionManager serverConnectionManager)
|
|
||||||
{
|
|
||||||
_accountService = accountService;
|
|
||||||
_calendarService = calendarService;
|
|
||||||
|
|
||||||
AccountCalendarStateService = accountCalendarStateService;
|
|
||||||
AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested;
|
|
||||||
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged += AccountCalendarStateCollectivelyChanged;
|
|
||||||
|
|
||||||
NavigationService = navigationService;
|
|
||||||
ServerConnectionManager = serverConnectionManager;
|
|
||||||
PreferencesService = preferencesService;
|
|
||||||
|
|
||||||
StatePersistenceService = statePersistanceService;
|
|
||||||
StatePersistenceService.StatePropertyChanged += PrefefencesChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SelectedCalendarItemsChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PrefefencesChanged(object sender, string e)
|
|
||||||
{
|
|
||||||
if (e == nameof(StatePersistenceService.CalendarDisplayType))
|
|
||||||
{
|
|
||||||
Messenger.Send(new CalendarDisplayTypeChangedMessage(StatePersistenceService.CalendarDisplayType));
|
|
||||||
|
|
||||||
|
|
||||||
// Change the calendar.
|
|
||||||
DateClicked(new CalendarViewDayClickedEventArgs(GetDisplayTypeSwitchDate()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
|
||||||
{
|
|
||||||
base.OnNavigatedTo(mode, parameters);
|
|
||||||
|
|
||||||
UpdateDateNavigationHeaderItems();
|
|
||||||
|
|
||||||
await InitializeAccountCalendarsAsync();
|
|
||||||
|
|
||||||
TodayClicked();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e)
|
|
||||||
{
|
|
||||||
// When using three-state checkbox, multiple accounts will be selected/unselected at the same time.
|
|
||||||
// Reporting all these changes one by one to the UI is not efficient and may cause problems in the future.
|
|
||||||
|
|
||||||
// Update all calendar states at once.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _accountCalendarUpdateSemaphoreSlim.WaitAsync();
|
|
||||||
|
|
||||||
foreach (var calendar in e.AccountCalendars)
|
|
||||||
{
|
|
||||||
await _calendarService.UpdateAccountCalendarAsync(calendar.AccountCalendar).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Error while waiting for account calendar update semaphore.");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_accountCalendarUpdateSemaphoreSlim.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void UpdateAccountCalendarRequested(object sender, AccountCalendarViewModel e)
|
|
||||||
=> await _calendarService.UpdateAccountCalendarAsync(e.AccountCalendar).ConfigureAwait(false);
|
|
||||||
|
|
||||||
private async Task InitializeAccountCalendarsAsync()
|
|
||||||
{
|
|
||||||
await Dispatcher.ExecuteOnUIThread(() => AccountCalendarStateService.ClearGroupedAccountCalendar());
|
|
||||||
|
|
||||||
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
foreach (var account in accounts)
|
|
||||||
{
|
|
||||||
var accountCalendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
|
|
||||||
var calendarViewModels = new List<AccountCalendarViewModel>();
|
|
||||||
|
|
||||||
foreach (var calendar in accountCalendars)
|
|
||||||
{
|
|
||||||
var calendarViewModel = new AccountCalendarViewModel(account, calendar);
|
|
||||||
|
|
||||||
calendarViewModels.Add(calendarViewModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
var groupedAccountCalendarViewModel = new GroupedAccountCalendarViewModel(account, calendarViewModels);
|
|
||||||
|
|
||||||
await Dispatcher.ExecuteOnUIThread(() =>
|
|
||||||
{
|
|
||||||
AccountCalendarStateService.AddGroupedAccountCalendar(groupedAccountCalendarViewModel);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ForceNavigateCalendarDate()
|
|
||||||
{
|
|
||||||
if (SelectedMenuItemIndex == -1)
|
|
||||||
{
|
|
||||||
var args = new CalendarPageNavigationArgs()
|
|
||||||
{
|
|
||||||
NavigationDate = _navigationDate ?? DateTime.Now.Date
|
|
||||||
};
|
|
||||||
|
|
||||||
// Already on calendar. Just navigate.
|
|
||||||
NavigationService.Navigate(WinoPage.CalendarPage, args);
|
|
||||||
|
|
||||||
_navigationDate = null;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
SelectedMenuItemIndex = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnSelectedMenuItemIndexChanged(int oldValue, int newValue)
|
|
||||||
{
|
|
||||||
switch (newValue)
|
|
||||||
{
|
|
||||||
case -1:
|
|
||||||
ForceNavigateCalendarDate();
|
|
||||||
break;
|
|
||||||
case 0:
|
|
||||||
NavigationService.Navigate(WinoPage.ManageAccountsPage);
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
NavigationService.Navigate(WinoPage.SettingsPage);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task Sync()
|
|
||||||
{
|
|
||||||
// Sync all calendars.
|
|
||||||
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
foreach (var account in accounts)
|
|
||||||
{
|
|
||||||
var t = new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions()
|
|
||||||
{
|
|
||||||
AccountId = account.Id,
|
|
||||||
Type = CalendarSynchronizationType.CalendarMetadata
|
|
||||||
}, SynchronizationSource.Client);
|
|
||||||
|
|
||||||
Messenger.Send(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When calendar type switches, we need to navigate to the most ideal date.
|
|
||||||
/// This method returns that date.
|
|
||||||
/// </summary>
|
|
||||||
private DateTime GetDisplayTypeSwitchDate()
|
|
||||||
{
|
|
||||||
var settings = PreferencesService.GetCurrentCalendarSettings();
|
|
||||||
switch (StatePersistenceService.CalendarDisplayType)
|
|
||||||
{
|
|
||||||
case CalendarDisplayType.Day:
|
|
||||||
if (HighlightedDateRange.IsInRange(DateTime.Now)) return DateTime.Now.Date;
|
|
||||||
|
|
||||||
return HighlightedDateRange.StartDate;
|
|
||||||
case CalendarDisplayType.Week:
|
|
||||||
if (HighlightedDateRange == null || HighlightedDateRange.IsInRange(DateTime.Now))
|
|
||||||
{
|
|
||||||
return DateTime.Now.Date.GetWeekStartDateForDate(settings.FirstDayOfWeek);
|
|
||||||
}
|
|
||||||
|
|
||||||
return HighlightedDateRange.StartDate.GetWeekStartDateForDate(settings.FirstDayOfWeek);
|
|
||||||
case CalendarDisplayType.WorkWeek:
|
|
||||||
break;
|
|
||||||
case CalendarDisplayType.Month:
|
|
||||||
break;
|
|
||||||
case CalendarDisplayType.Year:
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DateTime.Today.Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
private DateTime? _navigationDate;
|
|
||||||
private readonly IAccountService _accountService;
|
|
||||||
private readonly ICalendarService _calendarService;
|
|
||||||
|
|
||||||
#region Commands
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void TodayClicked()
|
|
||||||
{
|
|
||||||
_navigationDate = DateTime.Now.Date;
|
|
||||||
|
|
||||||
ForceNavigateCalendarDate();
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
public void ManageAccounts() => NavigationService.Navigate(WinoPage.AccountManagementPage);
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private Task ReconnectServerAsync() => ServerConnectionManager.ConnectAsync();
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void DateClicked(CalendarViewDayClickedEventArgs clickedDateArgs)
|
|
||||||
{
|
|
||||||
_navigationDate = clickedDateArgs.ClickedDate;
|
|
||||||
|
|
||||||
ForceNavigateCalendarDate();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
public void Receive(VisibleDateRangeChangedMessage message) => HighlightedDateRange = message.DateRange;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the header navigation items based on visible date range and calendar type.
|
|
||||||
/// </summary>
|
|
||||||
private void UpdateDateNavigationHeaderItems()
|
|
||||||
{
|
|
||||||
DateNavigationHeaderItems.Clear();
|
|
||||||
|
|
||||||
// TODO: From settings
|
|
||||||
var testInfo = new CultureInfo("en-US");
|
|
||||||
|
|
||||||
switch (StatePersistenceService.CalendarDisplayType)
|
|
||||||
{
|
|
||||||
case CalendarDisplayType.Day:
|
|
||||||
case CalendarDisplayType.Week:
|
|
||||||
case CalendarDisplayType.WorkWeek:
|
|
||||||
case CalendarDisplayType.Month:
|
|
||||||
DateNavigationHeaderItems.ReplaceRange(testInfo.DateTimeFormat.MonthNames);
|
|
||||||
break;
|
|
||||||
case CalendarDisplayType.Year:
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
SetDateNavigationHeaderItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnHighlightedDateRangeChanged(DateRange value) => SetDateNavigationHeaderItems();
|
|
||||||
|
|
||||||
private void SetDateNavigationHeaderItems()
|
|
||||||
{
|
|
||||||
if (HighlightedDateRange == null) return;
|
|
||||||
|
|
||||||
if (DateNavigationHeaderItems.Count == 0)
|
|
||||||
{
|
|
||||||
UpdateDateNavigationHeaderItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Year view
|
|
||||||
var monthIndex = HighlightedDateRange.GetMostVisibleMonthIndex();
|
|
||||||
|
|
||||||
SelectedDateNavigationHeaderIndex = Math.Max(monthIndex - 1, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void Receive(CalendarEnableStatusChangedMessage message)
|
|
||||||
=> await ExecuteUIThread(() => IsCalendarEnabled = message.IsEnabled);
|
|
||||||
|
|
||||||
public void Receive(NavigateManageAccountsRequested message) => SelectedMenuItemIndex = 1;
|
|
||||||
|
|
||||||
public void Receive(CalendarDisplayTypeChangedMessage message) => OnPropertyChanged(nameof(IsVerticalCalendar));
|
|
||||||
|
|
||||||
public async void Receive(DetailsPageStateChangedMessage message)
|
|
||||||
{
|
|
||||||
await ExecuteUIThread(() =>
|
|
||||||
{
|
|
||||||
IsEventDetailsPageActive = message.IsActivated;
|
|
||||||
|
|
||||||
// TODO: This is for Wino Mail. Generalize this later on.
|
|
||||||
StatePersistenceService.IsReaderNarrowed = message.IsActivated;
|
|
||||||
StatePersistenceService.IsReadingMail = message.IsActivated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
|
using Wino.Core.Domain;
|
||||||
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.Navigation;
|
||||||
|
using Wino.Core.ViewModels;
|
||||||
|
using Wino.Messaging.Client.Calendar;
|
||||||
|
|
||||||
|
namespace Wino.Calendar.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ViewModel for managing calendar account settings.
|
||||||
|
/// </summary>
|
||||||
|
public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewModel
|
||||||
|
{
|
||||||
|
private readonly ICalendarService _calendarService;
|
||||||
|
private readonly IAccountService _accountService;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial MailAccount Account { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial AccountCalendar AccountCalendar { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string AccountColorHex { get; set; } = "#0078D4";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsSyncEnabled { get; set; }
|
||||||
|
|
||||||
|
public ObservableCollection<ShowAsOption> ShowAsOptions { get; } = new ObservableCollection<ShowAsOption>();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ShowAsOption SelectedDefaultShowAsOption { get; set; }
|
||||||
|
|
||||||
|
public CalendarAccountSettingsPageViewModel(ICalendarService calendarService, IAccountService accountService)
|
||||||
|
{
|
||||||
|
_calendarService = calendarService;
|
||||||
|
_accountService = accountService;
|
||||||
|
|
||||||
|
// Initialize ShowAs options
|
||||||
|
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Free));
|
||||||
|
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Tentative));
|
||||||
|
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Busy));
|
||||||
|
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.OutOfOffice));
|
||||||
|
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.WorkingElsewhere));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||||
|
{
|
||||||
|
base.OnNavigatedTo(mode, parameters);
|
||||||
|
|
||||||
|
if (parameters is AccountCalendar selectedCalendar)
|
||||||
|
{
|
||||||
|
Account = await _accountService.GetAccountAsync(selectedCalendar.AccountId);
|
||||||
|
AccountCalendar = await _calendarService.GetAccountCalendarAsync(selectedCalendar.Id) ?? selectedCalendar;
|
||||||
|
}
|
||||||
|
else if (parameters is Guid accountId)
|
||||||
|
{
|
||||||
|
Account = await _accountService.GetAccountAsync(accountId);
|
||||||
|
var calendars = await _calendarService.GetAccountCalendarsAsync(accountId);
|
||||||
|
AccountCalendar = calendars.FirstOrDefault(c => c.IsPrimary) ?? calendars.FirstOrDefault();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Account == null || AccountCalendar == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Initialize properties from AccountCalendar
|
||||||
|
AccountColorHex = AccountCalendar.BackgroundColorHex ?? "#0078D4";
|
||||||
|
IsSyncEnabled = AccountCalendar.IsSynchronizationEnabled;
|
||||||
|
SelectedDefaultShowAsOption = ShowAsOptions.FirstOrDefault(o => o.ShowAs == AccountCalendar.DefaultShowAs) ?? ShowAsOptions[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnAccountColorHexChanged(string value)
|
||||||
|
{
|
||||||
|
if (AccountCalendar != null && !string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
AccountCalendar.BackgroundColorHex = value;
|
||||||
|
AccountCalendar.IsBackgroundColorUserOverridden = true;
|
||||||
|
SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsSyncEnabledChanged(bool value)
|
||||||
|
{
|
||||||
|
if (AccountCalendar != null)
|
||||||
|
{
|
||||||
|
AccountCalendar.IsSynchronizationEnabled = value;
|
||||||
|
SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedDefaultShowAsOptionChanged(ShowAsOption value)
|
||||||
|
{
|
||||||
|
if (AccountCalendar != null && value != null)
|
||||||
|
{
|
||||||
|
AccountCalendar.DefaultShowAs = value.ShowAs;
|
||||||
|
SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void SaveChangesAsync()
|
||||||
|
{
|
||||||
|
if (AccountCalendar == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _calendarService.UpdateAccountCalendarAsync(AccountCalendar);
|
||||||
|
|
||||||
|
// Send message to update UI
|
||||||
|
Messenger.Send(new CalendarListUpdated(AccountCalendar));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
|
using Serilog;
|
||||||
|
using Wino.Calendar.ViewModels.Data;
|
||||||
|
using Wino.Calendar.ViewModels.Interfaces;
|
||||||
|
using Wino.Core.Domain;
|
||||||
|
using Wino.Core.Domain.Collections;
|
||||||
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.MenuItems;
|
||||||
|
using Wino.Core.Domain.Models;
|
||||||
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
|
using Wino.Core.Domain.Models.Navigation;
|
||||||
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
|
using Wino.Core.ViewModels;
|
||||||
|
using Wino.Messaging.Client.Calendar;
|
||||||
|
using Wino.Messaging.Server;
|
||||||
|
using Wino.Messaging.UI;
|
||||||
|
|
||||||
|
namespace Wino.Calendar.ViewModels;
|
||||||
|
|
||||||
|
public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
|
||||||
|
ICalendarShellClient,
|
||||||
|
IRecipient<CalendarDisplayTypeChangedMessage>,
|
||||||
|
IRecipient<AccountRemovedMessage>
|
||||||
|
{
|
||||||
|
public IPreferencesService PreferencesService { get; }
|
||||||
|
public IStatePersistanceService StatePersistenceService { get; }
|
||||||
|
public IAccountCalendarStateService AccountCalendarStateService { get; }
|
||||||
|
public INavigationService NavigationService { get; }
|
||||||
|
public WinoApplicationMode Mode => WinoApplicationMode.Calendar;
|
||||||
|
public bool HandlesNavigationSelection => false;
|
||||||
|
public VisibleDateRange CurrentVisibleRange => _calendarPageViewModel.CurrentVisibleRange;
|
||||||
|
public string VisibleDateRangeText => _calendarPageViewModel.VisibleDateRangeText;
|
||||||
|
System.Collections.IEnumerable ICalendarShellClient.GroupedAccountCalendars => AccountCalendarStateService.GroupedAccountCalendars;
|
||||||
|
System.Collections.IEnumerable ICalendarShellClient.DateNavigationHeaderItems => DateNavigationHeaderItems;
|
||||||
|
object IShellClient.SelectedMenuItem
|
||||||
|
{
|
||||||
|
get => null;
|
||||||
|
set { }
|
||||||
|
}
|
||||||
|
System.Windows.Input.ICommand ICalendarShellClient.TodayClickedCommand => TodayClickedCommand;
|
||||||
|
System.Windows.Input.ICommand ICalendarShellClient.DateClickedCommand => DateClickedCommand;
|
||||||
|
System.Windows.Input.ICommand ICalendarShellClient.PreviousDateRangeCommand => PreviousDateRangeCommand;
|
||||||
|
System.Windows.Input.ICommand ICalendarShellClient.NextDateRangeCommand => NextDateRangeCommand;
|
||||||
|
System.Windows.Input.ICommand ICalendarShellClient.SyncCommand => SyncCommand;
|
||||||
|
|
||||||
|
public bool CanSynchronizeCalendars => !AccountCalendarStateService.IsAnySynchronizationInProgress;
|
||||||
|
|
||||||
|
public MenuItemCollection MenuItems { get; private set; }
|
||||||
|
public MenuItemCollection FooterItems { get; private set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int _selectedMenuItemIndex = -1;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ObservableRangeCollection<string> dateNavigationHeaderItems = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int _selectedDateNavigationHeaderIndex;
|
||||||
|
|
||||||
|
public bool IsVerticalCalendar => StatePersistenceService.CalendarDisplayType == CalendarDisplayType.Month;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isStoreUpdateItemVisible;
|
||||||
|
|
||||||
|
private readonly SettingsItem _settingsItem = new();
|
||||||
|
private readonly StoreUpdateMenuItem _storeUpdateMenuItem = new();
|
||||||
|
private readonly SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
|
||||||
|
private readonly CalendarPageViewModel _calendarPageViewModel;
|
||||||
|
private readonly IMailDialogService _dialogService;
|
||||||
|
private readonly IStoreUpdateService _storeUpdateService;
|
||||||
|
private readonly IAccountService _accountService;
|
||||||
|
private readonly ICalendarService _calendarService;
|
||||||
|
private readonly IDateContextProvider _dateContextProvider;
|
||||||
|
private bool _runtimeSubscriptionsAttached;
|
||||||
|
private bool _hasRegisteredPersistentRecipients;
|
||||||
|
private DateTime? _navigationDate;
|
||||||
|
|
||||||
|
public CalendarAppShellViewModel(
|
||||||
|
IPreferencesService preferencesService,
|
||||||
|
IStatePersistanceService statePersistanceService,
|
||||||
|
IAccountService accountService,
|
||||||
|
ICalendarService calendarService,
|
||||||
|
IAccountCalendarStateService accountCalendarStateService,
|
||||||
|
INavigationService navigationService,
|
||||||
|
CalendarPageViewModel calendarPageViewModel,
|
||||||
|
IMailDialogService dialogService,
|
||||||
|
IStoreUpdateService storeUpdateService,
|
||||||
|
IDateContextProvider dateContextProvider)
|
||||||
|
{
|
||||||
|
PreferencesService = preferencesService;
|
||||||
|
StatePersistenceService = statePersistanceService;
|
||||||
|
AccountCalendarStateService = accountCalendarStateService;
|
||||||
|
NavigationService = navigationService;
|
||||||
|
_accountService = accountService;
|
||||||
|
_calendarService = calendarService;
|
||||||
|
_calendarPageViewModel = calendarPageViewModel;
|
||||||
|
_dialogService = dialogService;
|
||||||
|
_storeUpdateService = storeUpdateService;
|
||||||
|
_dateContextProvider = dateContextProvider;
|
||||||
|
|
||||||
|
_calendarPageViewModel.PropertyChanged += CalendarPageViewModelPropertyChanged;
|
||||||
|
AccountCalendarStateService.PropertyChanged += AccountCalendarStateServicePropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDispatcherAssigned()
|
||||||
|
{
|
||||||
|
base.OnDispatcherAssigned();
|
||||||
|
|
||||||
|
AccountCalendarStateService.Dispatcher = Dispatcher;
|
||||||
|
MenuItems = new MenuItemCollection(Dispatcher);
|
||||||
|
FooterItems = new MenuItemCollection(Dispatcher);
|
||||||
|
_ = RefreshFooterItemsAsync(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CalendarPageViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(CalendarPageViewModel.CurrentVisibleRange))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CurrentVisibleRange));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.PropertyName == nameof(CalendarPageViewModel.CurrentVisibleRange) ||
|
||||||
|
e.PropertyName == nameof(CalendarPageViewModel.VisibleDateRangeText))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(VisibleDateRangeText));
|
||||||
|
UpdateDateNavigationHeaderItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PrefefencesChanged(object sender, string e)
|
||||||
|
{
|
||||||
|
if (e != nameof(StatePersistenceService.CalendarDisplayType))
|
||||||
|
return;
|
||||||
|
|
||||||
|
Messenger.Send(new CalendarDisplayTypeChangedMessage(StatePersistenceService.CalendarDisplayType));
|
||||||
|
OnPropertyChanged(nameof(IsVerticalCalendar));
|
||||||
|
UpdateDateNavigationHeaderItems();
|
||||||
|
NavigateCalendarDate(GetDisplayTypeSwitchDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void PreferencesServiceChanged(object sender, string e)
|
||||||
|
{
|
||||||
|
if (e == nameof(IPreferencesService.IsStoreUpdateNotificationsEnabled))
|
||||||
|
{
|
||||||
|
await RefreshFooterItemsAsync(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||||
|
{
|
||||||
|
if (!_hasRegisteredPersistentRecipients)
|
||||||
|
{
|
||||||
|
RegisterRecipients();
|
||||||
|
_hasRegisteredPersistentRecipients = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
AttachRuntimeSubscriptions();
|
||||||
|
|
||||||
|
var activationContext = parameters as ShellModeActivationContext;
|
||||||
|
var shouldRunStartupFlows = activationContext?.IsInitialActivation ?? true;
|
||||||
|
var navigationArgs = activationContext?.Parameter as CalendarPageNavigationArgs;
|
||||||
|
|
||||||
|
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
|
||||||
|
PreferencesService.PreferenceChanged += PreferencesServiceChanged;
|
||||||
|
|
||||||
|
await RefreshFooterItemsAsync(mode == NavigationMode.New);
|
||||||
|
UpdateDateNavigationHeaderItems();
|
||||||
|
await InitializeAccountCalendarsAsync();
|
||||||
|
ValidateConfiguredNewEventCalendar();
|
||||||
|
|
||||||
|
if (navigationArgs != null)
|
||||||
|
{
|
||||||
|
NavigationService.Navigate(WinoPage.CalendarPage, navigationArgs);
|
||||||
|
}
|
||||||
|
else if (shouldRunStartupFlows || _calendarPageViewModel.CurrentVisibleRange == null)
|
||||||
|
{
|
||||||
|
TodayClicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
|
||||||
|
{
|
||||||
|
DetachRuntimeSubscriptions();
|
||||||
|
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
|
||||||
|
_ = ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
DateNavigationHeaderItems.Clear();
|
||||||
|
AccountCalendarStateService.ClearGroupedAccountCalendars();
|
||||||
|
SelectedDateNavigationHeaderIndex = -1;
|
||||||
|
});
|
||||||
|
_calendarPageViewModel.CleanupForShellDeactivation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PrepareForShellShutdown()
|
||||||
|
{
|
||||||
|
DetachRuntimeSubscriptions();
|
||||||
|
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
|
||||||
|
|
||||||
|
if (_hasRegisteredPersistentRecipients)
|
||||||
|
{
|
||||||
|
UnregisterRecipients();
|
||||||
|
_hasRegisteredPersistentRecipients = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateNavigationHeaderItems.Clear();
|
||||||
|
SelectedDateNavigationHeaderIndex = -1;
|
||||||
|
SelectedMenuItemIndex = -1;
|
||||||
|
MenuItems?.Clear();
|
||||||
|
FooterItems?.Clear();
|
||||||
|
AccountCalendarStateService.ClearGroupedAccountCalendars();
|
||||||
|
_calendarPageViewModel.CleanupForShellDeactivation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AccountCalendarStateServicePropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName != nameof(IAccountCalendarStateService.IsAnySynchronizationInProgress))
|
||||||
|
return;
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(CanSynchronizeCalendars));
|
||||||
|
SyncCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AttachRuntimeSubscriptions()
|
||||||
|
{
|
||||||
|
if (_runtimeSubscriptionsAttached)
|
||||||
|
return;
|
||||||
|
|
||||||
|
AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested;
|
||||||
|
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged += AccountCalendarStateCollectivelyChanged;
|
||||||
|
StatePersistenceService.StatePropertyChanged += PrefefencesChanged;
|
||||||
|
_runtimeSubscriptionsAttached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DetachRuntimeSubscriptions()
|
||||||
|
{
|
||||||
|
if (!_runtimeSubscriptionsAttached)
|
||||||
|
return;
|
||||||
|
|
||||||
|
AccountCalendarStateService.AccountCalendarSelectionStateChanged -= UpdateAccountCalendarRequested;
|
||||||
|
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged -= AccountCalendarStateCollectivelyChanged;
|
||||||
|
StatePersistenceService.StatePropertyChanged -= PrefefencesChanged;
|
||||||
|
_runtimeSubscriptionsAttached = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshFooterItemsAsync(bool showNotification)
|
||||||
|
{
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
FooterItems.Clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartStoreUpdateAsync()
|
||||||
|
{
|
||||||
|
await _storeUpdateService.StartUpdateAsync().ConfigureAwait(false);
|
||||||
|
await RefreshFooterItemsAsync(false).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _accountCalendarUpdateSemaphoreSlim.WaitAsync();
|
||||||
|
|
||||||
|
foreach (var calendar in e.AccountCalendars)
|
||||||
|
{
|
||||||
|
await _calendarService.UpdateAccountCalendarAsync(calendar.AccountCalendar).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Error while waiting for account calendar update semaphore.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_accountCalendarUpdateSemaphoreSlim.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void UpdateAccountCalendarRequested(object sender, AccountCalendarViewModel e)
|
||||||
|
=> await _calendarService.UpdateAccountCalendarAsync(e.AccountCalendar).ConfigureAwait(false);
|
||||||
|
|
||||||
|
private async Task InitializeAccountCalendarsAsync()
|
||||||
|
{
|
||||||
|
await Dispatcher.ExecuteOnUIThread(() => AccountCalendarStateService.ClearGroupedAccountCalendars());
|
||||||
|
|
||||||
|
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
if (!GroupedAccountCalendarViewModel.SupportsCalendar(account))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var accountCalendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
|
||||||
|
var calendarViewModels = accountCalendars.Select(calendar => new AccountCalendarViewModel(account, calendar)).ToList();
|
||||||
|
var groupedAccountCalendarViewModel = new GroupedAccountCalendarViewModel(account, calendarViewModels);
|
||||||
|
|
||||||
|
await Dispatcher.ExecuteOnUIThread(() =>
|
||||||
|
{
|
||||||
|
AccountCalendarStateService.AddGroupedAccountCalendar(groupedAccountCalendarViewModel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NavigateCalendarDate(DateTime date)
|
||||||
|
{
|
||||||
|
_navigationDate = date.Date;
|
||||||
|
ForceNavigateCalendarDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ForceNavigateCalendarDate()
|
||||||
|
{
|
||||||
|
var args = new CalendarPageNavigationArgs
|
||||||
|
{
|
||||||
|
NavigationDate = _navigationDate ?? DateTime.Now.Date
|
||||||
|
};
|
||||||
|
|
||||||
|
NavigationService.Navigate(WinoPage.CalendarPage, args);
|
||||||
|
_navigationDate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanSynchronizeCalendars))]
|
||||||
|
private async Task Sync()
|
||||||
|
{
|
||||||
|
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
|
||||||
|
foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions
|
||||||
|
{
|
||||||
|
AccountId = account.Id,
|
||||||
|
Type = CalendarSynchronizationType.Strict
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime GetDisplayTypeSwitchDate()
|
||||||
|
{
|
||||||
|
var today = _dateContextProvider.GetToday();
|
||||||
|
var settings = PreferencesService.GetCurrentCalendarSettings();
|
||||||
|
var referenceRange = CurrentVisibleRange
|
||||||
|
?? CalendarRangeResolver.Resolve(new CalendarDisplayRequest(StatePersistenceService.CalendarDisplayType, today), settings, today);
|
||||||
|
var targetRange = CalendarRangeResolver.ChangeDisplayType(referenceRange, StatePersistenceService.CalendarDisplayType, settings, today);
|
||||||
|
return targetRange.AnchorDate.ToDateTime(TimeOnly.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void TodayClicked()
|
||||||
|
{
|
||||||
|
NavigateCalendarDate(_dateContextProvider.GetToday().ToDateTime(TimeOnly.MinValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void PreviousDateRange()
|
||||||
|
{
|
||||||
|
NavigateRelativePeriod(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void NextDateRange()
|
||||||
|
{
|
||||||
|
NavigateRelativePeriod(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NavigateRelativePeriod(int direction)
|
||||||
|
{
|
||||||
|
var today = _dateContextProvider.GetToday();
|
||||||
|
var settings = PreferencesService.GetCurrentCalendarSettings();
|
||||||
|
var referenceRange = CurrentVisibleRange
|
||||||
|
?? CalendarRangeResolver.Resolve(new CalendarDisplayRequest(StatePersistenceService.CalendarDisplayType, today), settings, today);
|
||||||
|
var targetRange = CalendarRangeResolver.Navigate(referenceRange, direction, settings, today);
|
||||||
|
NavigateCalendarDate(targetRange.AnchorDate.ToDateTime(TimeOnly.MinValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandleNavigationItemInvokedAsync(IMenuItem menuItem)
|
||||||
|
{
|
||||||
|
switch (menuItem)
|
||||||
|
{
|
||||||
|
case NewMailMenuItem:
|
||||||
|
await NewEventAsync().ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case SettingsItem:
|
||||||
|
NavigationService.Navigate(WinoPage.SettingsPage);
|
||||||
|
break;
|
||||||
|
case StoreUpdateMenuItem:
|
||||||
|
await StartStoreUpdateAsync().ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task NewEventAsync()
|
||||||
|
{
|
||||||
|
var pickedCalendar = TryResolveConfiguredNewEventCalendar();
|
||||||
|
|
||||||
|
if (pickedCalendar == null)
|
||||||
|
{
|
||||||
|
var availableGroups = AccountCalendarStateService.GroupedAccountCalendars
|
||||||
|
.Where(group => group.AccountCalendars.Count > 0)
|
||||||
|
.Select(group => new CalendarPickerAccountGroup
|
||||||
|
{
|
||||||
|
Account = group.Account,
|
||||||
|
Calendars = group.AccountCalendars.Select(calendar => calendar.AccountCalendar).ToList()
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (availableGroups.Count == 0)
|
||||||
|
{
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.CalendarEventCompose_NoCalendarsTitle,
|
||||||
|
Translator.CalendarEventCompose_NoCalendarsMessage,
|
||||||
|
InfoBarMessageType.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pickingResult = await _dialogService.ShowSingleCalendarPickerDialogAsync(availableGroups);
|
||||||
|
if (pickingResult.ShouldNavigateToCalendarSettings)
|
||||||
|
{
|
||||||
|
NavigationService.Navigate(WinoPage.CalendarPreferenceSettingsPage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pickedCalendar = pickingResult.PickedCalendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pickedCalendar == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var (startDate, endDate) = GetDefaultComposeDateRange();
|
||||||
|
|
||||||
|
NavigationService.Navigate(WinoPage.CalendarEventComposePage, new CalendarEventComposeNavigationArgs
|
||||||
|
{
|
||||||
|
SelectedCalendarId = pickedCalendar.Id,
|
||||||
|
StartDate = startDate,
|
||||||
|
EndDate = endDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args)
|
||||||
|
{
|
||||||
|
if (args.Handled || args.Mode != WinoApplicationMode.Calendar)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (args.Action == KeyboardShortcutAction.NewEvent)
|
||||||
|
{
|
||||||
|
await NewEventAsync();
|
||||||
|
args.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void DateClicked(CalendarViewDayClickedEventArgs clickedDateArgs)
|
||||||
|
=> NavigateCalendarDate(clickedDateArgs.ClickedDate);
|
||||||
|
|
||||||
|
protected override void RegisterRecipients()
|
||||||
|
{
|
||||||
|
base.RegisterRecipients();
|
||||||
|
|
||||||
|
UnregisterRecipients();
|
||||||
|
|
||||||
|
Messenger.Register<CalendarDisplayTypeChangedMessage>(this);
|
||||||
|
Messenger.Register<AccountRemovedMessage>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void UnregisterRecipients()
|
||||||
|
{
|
||||||
|
base.UnregisterRecipients();
|
||||||
|
|
||||||
|
Messenger.Unregister<CalendarDisplayTypeChangedMessage>(this);
|
||||||
|
Messenger.Unregister<AccountRemovedMessage>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDateNavigationHeaderItems()
|
||||||
|
{
|
||||||
|
var headerText = VisibleDateRangeText;
|
||||||
|
DateNavigationHeaderItems.ReplaceRange(string.IsNullOrWhiteSpace(headerText) ? [] : [headerText]);
|
||||||
|
SelectedDateNavigationHeaderIndex = DateNavigationHeaderItems.Count > 0 ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Receive(CalendarDisplayTypeChangedMessage message)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsVerticalCalendar));
|
||||||
|
UpdateDateNavigationHeaderItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void Receive(AccountRemovedMessage message)
|
||||||
|
{
|
||||||
|
await InitializeAccountCalendarsAsync();
|
||||||
|
ValidateConfiguredNewEventCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AccountCalendar TryResolveConfiguredNewEventCalendar()
|
||||||
|
{
|
||||||
|
ValidateConfiguredNewEventCalendar();
|
||||||
|
|
||||||
|
if (PreferencesService.NewEventButtonBehavior != NewEventButtonBehavior.AlwaysUseSpecificCalendar
|
||||||
|
|| !PreferencesService.DefaultNewEventCalendarId.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AccountCalendarStateService.AllCalendars
|
||||||
|
.FirstOrDefault(calendar => calendar.Id == PreferencesService.DefaultNewEventCalendarId.Value)?
|
||||||
|
.AccountCalendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateConfiguredNewEventCalendar()
|
||||||
|
{
|
||||||
|
if (PreferencesService.NewEventButtonBehavior != NewEventButtonBehavior.AlwaysUseSpecificCalendar
|
||||||
|
|| !PreferencesService.DefaultNewEventCalendarId.HasValue)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists = AccountCalendarStateService.AllCalendars
|
||||||
|
.Any(calendar => calendar.Id == PreferencesService.DefaultNewEventCalendarId.Value);
|
||||||
|
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AskEachTime;
|
||||||
|
PreferencesService.DefaultNewEventCalendarId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DateTime StartDate, DateTime EndDate) GetDefaultComposeDateRange()
|
||||||
|
{
|
||||||
|
var localNow = DateTime.Now;
|
||||||
|
var roundedMinutes = localNow.Minute switch
|
||||||
|
{
|
||||||
|
< 30 => 30,
|
||||||
|
30 when localNow.Second == 0 && localNow.Millisecond == 0 => 30,
|
||||||
|
_ => 60
|
||||||
|
};
|
||||||
|
|
||||||
|
var startDate = new DateTime(localNow.Year, localNow.Month, localNow.Day, localNow.Hour, 0, 0);
|
||||||
|
startDate = roundedMinutes == 60 ? startDate.AddHours(1) : startDate.AddMinutes(roundedMinutes);
|
||||||
|
|
||||||
|
return (startDate, startDate.AddMinutes(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
void IShellClient.Activate(ShellModeActivationContext activationContext)
|
||||||
|
=> OnNavigatedTo(NavigationMode.New, activationContext);
|
||||||
|
|
||||||
|
void IShellClient.Deactivate()
|
||||||
|
=> OnNavigatedFrom(NavigationMode.New, null!);
|
||||||
|
|
||||||
|
Task IShellClient.HandleNavigationItemInvokedAsync(IMenuItem menuItem)
|
||||||
|
=> menuItem == null ? Task.CompletedTask : HandleNavigationItemInvokedAsync(menuItem);
|
||||||
|
|
||||||
|
Task IShellClient.HandleNavigationSelectionChangedAsync(IMenuItem menuItem)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using EmailValidation;
|
||||||
|
using Wino.Calendar.ViewModels.Data;
|
||||||
|
using Wino.Core.Domain;
|
||||||
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Exceptions;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
|
using Wino.Core.Domain.Models.Navigation;
|
||||||
|
using Wino.Core.Domain.Validation;
|
||||||
|
using Wino.Core.ViewModels;
|
||||||
|
|
||||||
|
namespace Wino.Calendar.ViewModels;
|
||||||
|
|
||||||
|
public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel
|
||||||
|
{
|
||||||
|
private readonly IAccountService _accountService;
|
||||||
|
private readonly ICalendarService _calendarService;
|
||||||
|
private readonly INavigationService _navigationService;
|
||||||
|
private readonly IMailDialogService _dialogService;
|
||||||
|
private readonly IContactService _contactService;
|
||||||
|
private readonly IPreferencesService _preferencesService;
|
||||||
|
private readonly IUnderlyingThemeService _underlyingThemeService;
|
||||||
|
private readonly IWinoRequestDelegator _winoRequestDelegator;
|
||||||
|
private readonly CalendarEventComposeResultValidator _composeResultValidator = new();
|
||||||
|
|
||||||
|
public Func<Task<string>> GetHtmlNotesAsync { get; set; }
|
||||||
|
|
||||||
|
public ObservableCollection<AccountCalendarViewModel> AvailableCalendars { get; } = [];
|
||||||
|
public ObservableCollection<GroupedAccountCalendarViewModel> AvailableCalendarGroups { get; } = [];
|
||||||
|
public ObservableCollection<CalendarComposeAttendeeViewModel> Attendees { get; } = [];
|
||||||
|
public ObservableCollection<CalendarComposeAttachmentViewModel> Attachments { get; } = [];
|
||||||
|
public ObservableCollection<ShowAsOption> ShowAsOptions { get; } = [];
|
||||||
|
public ObservableCollection<ReminderOption> ReminderOptions { get; } = [];
|
||||||
|
public ObservableCollection<int> RecurrenceIntervalOptions { get; } = [];
|
||||||
|
public ObservableCollection<CalendarComposeFrequencyOption> RecurrenceFrequencyOptions { get; } = [];
|
||||||
|
public ObservableCollection<CalendarComposeWeekdayOption> WeekdayOptions { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial AccountCalendarViewModel SelectedCalendar { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string Location { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsAllDay { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial DateTimeOffset StartDate { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial TimeSpan StartTime { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial TimeSpan EndTime { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial DateTimeOffset AllDayEndDate { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsRecurring { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial int SelectedRecurrenceInterval { get; set; } = 1;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial CalendarComposeFrequencyOption SelectedRecurrenceFrequencyOption { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial DateTimeOffset? RecurrenceEndDate { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string RecurrenceSummary { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ReminderOption SelectedReminderOption { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ShowAsOption SelectedShowAsOption { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsDarkWebviewRenderer { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial CalendarEventComposeResult LastCreatedResult { get; set; }
|
||||||
|
|
||||||
|
public CalendarSettings CurrentSettings { get; }
|
||||||
|
public string TimePickerClockIdentifier => CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "24HourClock" : "12HourClock";
|
||||||
|
public bool HasAttachments => Attachments.Count > 0;
|
||||||
|
public bool IsSelectedCalendarCalDav => SelectedCalendar?.Account?.ProviderType == MailProviderType.IMAP4 &&
|
||||||
|
SelectedCalendar.Account.ServerInformation?.CalendarSupportMode == ImapCalendarSupportMode.CalDav;
|
||||||
|
public bool CanAddAttachments => !IsSelectedCalendarCalDav;
|
||||||
|
public string AttachmentsDisabledTooltipText => IsSelectedCalendarCalDav
|
||||||
|
? Translator.CalendarEventCompose_AttachmentsNotSupportedForCalDav
|
||||||
|
: string.Empty;
|
||||||
|
public string SelectedCalendarDisplayText => SelectedCalendar?.Name ?? Translator.CalendarEventCompose_SelectCalendar;
|
||||||
|
public string SelectedCalendarAccountText => SelectedCalendar?.Account?.Address ?? string.Empty;
|
||||||
|
public bool IsDailyRecurrenceSelected => SelectedRecurrenceFrequencyOption?.Frequency == CalendarItemRecurrenceFrequency.Daily;
|
||||||
|
|
||||||
|
public CalendarEventComposePageViewModel(IAccountService accountService,
|
||||||
|
ICalendarService calendarService,
|
||||||
|
INavigationService navigationService,
|
||||||
|
IMailDialogService dialogService,
|
||||||
|
IContactService contactService,
|
||||||
|
IPreferencesService preferencesService,
|
||||||
|
IUnderlyingThemeService underlyingThemeService,
|
||||||
|
IWinoRequestDelegator winoRequestDelegator)
|
||||||
|
{
|
||||||
|
_accountService = accountService;
|
||||||
|
_calendarService = calendarService;
|
||||||
|
_navigationService = navigationService;
|
||||||
|
_dialogService = dialogService;
|
||||||
|
_contactService = contactService;
|
||||||
|
_preferencesService = preferencesService;
|
||||||
|
_underlyingThemeService = underlyingThemeService;
|
||||||
|
_winoRequestDelegator = winoRequestDelegator;
|
||||||
|
|
||||||
|
CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
|
||||||
|
IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark();
|
||||||
|
|
||||||
|
Attachments.CollectionChanged += AttachmentsCollectionChanged;
|
||||||
|
|
||||||
|
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Free));
|
||||||
|
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Tentative));
|
||||||
|
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Busy));
|
||||||
|
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.OutOfOffice));
|
||||||
|
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.WorkingElsewhere));
|
||||||
|
|
||||||
|
foreach (var reminderMinutes in _calendarService.GetPredefinedReminderMinutes().OrderByDescending(x => x))
|
||||||
|
{
|
||||||
|
ReminderOptions.Add(new ReminderOption(reminderMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var interval in Enumerable.Range(1, 99))
|
||||||
|
{
|
||||||
|
RecurrenceIntervalOptions.Add(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Daily, Translator.CalendarEventCompose_FrequencyDay));
|
||||||
|
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Weekly, Translator.CalendarEventCompose_FrequencyWeek));
|
||||||
|
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Monthly, Translator.CalendarEventCompose_FrequencyMonth));
|
||||||
|
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Yearly, Translator.CalendarEventCompose_FrequencyYear));
|
||||||
|
SelectedRecurrenceFrequencyOption = RecurrenceFrequencyOptions.FirstOrDefault();
|
||||||
|
|
||||||
|
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Monday, "MO", Translator.CalendarEventCompose_Weekday_Monday));
|
||||||
|
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Tuesday, "TU", Translator.CalendarEventCompose_Weekday_Tuesday));
|
||||||
|
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Wednesday, "WE", Translator.CalendarEventCompose_Weekday_Wednesday));
|
||||||
|
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Thursday, "TH", Translator.CalendarEventCompose_Weekday_Thursday));
|
||||||
|
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Friday, "FR", Translator.CalendarEventCompose_Weekday_Friday));
|
||||||
|
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Saturday, "SA", Translator.CalendarEventCompose_Weekday_Saturday));
|
||||||
|
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Sunday, "SU", Translator.CalendarEventCompose_Weekday_Sunday));
|
||||||
|
|
||||||
|
SelectedReminderOption = GetDefaultReminderOption();
|
||||||
|
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == CalendarItemShowAs.Busy);
|
||||||
|
|
||||||
|
var (defaultStart, defaultEnd) = GetDefaultComposeDateRange();
|
||||||
|
ApplyDateRange(defaultStart, defaultEnd, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||||
|
{
|
||||||
|
base.OnNavigatedTo(mode, parameters);
|
||||||
|
|
||||||
|
await LoadAvailableCalendarsAsync();
|
||||||
|
|
||||||
|
var args = parameters as CalendarEventComposeNavigationArgs;
|
||||||
|
ApplyNavigationArgs(args);
|
||||||
|
UpdateRecurrenceSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedCalendarChanged(AccountCalendarViewModel value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == value.DefaultShowAs)
|
||||||
|
?? ShowAsOptions.FirstOrDefault();
|
||||||
|
|
||||||
|
if (IsSelectedCalendarCalDav && Attachments.Count > 0)
|
||||||
|
{
|
||||||
|
Attachments.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(IsSelectedCalendarCalDav));
|
||||||
|
OnPropertyChanged(nameof(CanAddAttachments));
|
||||||
|
OnPropertyChanged(nameof(AttachmentsDisabledTooltipText));
|
||||||
|
OnPropertyChanged(nameof(SelectedCalendarDisplayText));
|
||||||
|
OnPropertyChanged(nameof(SelectedCalendarAccountText));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsAllDayChanged(bool value)
|
||||||
|
{
|
||||||
|
if (value)
|
||||||
|
{
|
||||||
|
if (AllDayEndDate.Date <= StartDate.Date)
|
||||||
|
{
|
||||||
|
AllDayEndDate = StartDate.AddDays(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateRecurrenceSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnStartDateChanged(DateTimeOffset value)
|
||||||
|
{
|
||||||
|
if (IsAllDay && AllDayEndDate.Date <= value.Date)
|
||||||
|
{
|
||||||
|
AllDayEndDate = value.AddDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsRecurring && WeekdayOptions.All(option => !option.IsSelected))
|
||||||
|
{
|
||||||
|
SelectSingleWeekday(value.DayOfWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateRecurrenceSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnStartTimeChanged(TimeSpan value) => UpdateRecurrenceSummary();
|
||||||
|
partial void OnEndTimeChanged(TimeSpan value) => UpdateRecurrenceSummary();
|
||||||
|
partial void OnAllDayEndDateChanged(DateTimeOffset value) => UpdateRecurrenceSummary();
|
||||||
|
partial void OnIsRecurringChanged(bool value)
|
||||||
|
{
|
||||||
|
if (value && WeekdayOptions.All(option => !option.IsSelected))
|
||||||
|
{
|
||||||
|
SelectSingleWeekday(StartDate.DayOfWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateRecurrenceSummary();
|
||||||
|
}
|
||||||
|
partial void OnSelectedRecurrenceIntervalChanged(int value) => UpdateRecurrenceSummary();
|
||||||
|
partial void OnSelectedRecurrenceFrequencyOptionChanged(CalendarComposeFrequencyOption value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsDailyRecurrenceSelected));
|
||||||
|
UpdateRecurrenceSummary();
|
||||||
|
}
|
||||||
|
partial void OnRecurrenceEndDateChanged(DateTimeOffset? value) => UpdateRecurrenceSummary();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddAttachmentsAsync()
|
||||||
|
{
|
||||||
|
if (!CanAddAttachments)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var pickedFiles = await _dialogService.PickFilesMetadataAsync("*");
|
||||||
|
if (pickedFiles.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
foreach (var file in pickedFiles)
|
||||||
|
{
|
||||||
|
TryAddAttachment(file.FileName, file.FullFilePath, file.FileExtension, file.Size);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAddAttachment(string filePath, long size)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(filePath))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var fileName = Path.GetFileName(filePath);
|
||||||
|
var fileExtension = Path.GetExtension(filePath);
|
||||||
|
return TryAddAttachment(fileName, filePath, fileExtension, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RemoveAttachment(CalendarComposeAttachmentViewModel attachment)
|
||||||
|
{
|
||||||
|
if (attachment == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Attachments.Remove(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ClearRecurrenceEndDate()
|
||||||
|
{
|
||||||
|
RecurrenceEndDate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
_navigationService.GoBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CreateAsync()
|
||||||
|
{
|
||||||
|
var uniqueAttendees = Attendees
|
||||||
|
.GroupBy(attendee => attendee.Email, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var createdResult = await BuildResultAsync(uniqueAttendees);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_composeResultValidator.Validate(createdResult);
|
||||||
|
}
|
||||||
|
catch (CalendarEventComposeValidationException ex)
|
||||||
|
{
|
||||||
|
ShowValidationMessage(ex.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LastCreatedResult = createdResult;
|
||||||
|
|
||||||
|
await _winoRequestDelegator.ExecuteAsync(new CalendarOperationPreparationRequest(
|
||||||
|
CalendarSynchronizerOperation.CreateEvent,
|
||||||
|
ComposeResult: createdResult));
|
||||||
|
|
||||||
|
NavigateBackToCalendar(createdResult.StartDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NavigateBackToCalendar(DateTime targetDate)
|
||||||
|
{
|
||||||
|
_navigationService.Navigate(
|
||||||
|
WinoPage.CalendarPage,
|
||||||
|
new CalendarPageNavigationArgs
|
||||||
|
{
|
||||||
|
NavigationDate = targetDate,
|
||||||
|
ForceReload = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<AccountContact>> SearchContactsAsync(string queryText)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(queryText) || queryText.Length < 2)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return await _contactService.GetAddressInformationAsync(queryText).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CalendarComposeAttendeeViewModel> GetAttendeeAsync(string tokenText)
|
||||||
|
{
|
||||||
|
if (!EmailValidator.Validate(tokenText))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var existing = Attendees.Any(attendee => attendee.Email.Equals(tokenText, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (existing)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var info = await _contactService.GetAddressInformationByAddressAsync(tokenText).ConfigureAwait(false);
|
||||||
|
if (info != null)
|
||||||
|
{
|
||||||
|
return CalendarComposeAttendeeViewModel.FromContact(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CalendarComposeAttendeeViewModel(string.Empty, tokenText);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddAttendee(CalendarComposeAttendeeViewModel attendee)
|
||||||
|
{
|
||||||
|
if (Attendees.Any(existing => existing.Email.Equals(attendee.Email, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
Attendees.Add(attendee);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RemoveAttendee(CalendarComposeAttendeeViewModel attendee)
|
||||||
|
{
|
||||||
|
if (attendee == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Attendees.Remove(attendee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void NotifyAddressExists()
|
||||||
|
{
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.Info_ContactExistsTitle,
|
||||||
|
Translator.Info_ContactExistsMessage,
|
||||||
|
InfoBarMessageType.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void NotifyInvalidEmail(string address)
|
||||||
|
{
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.Info_InvalidAddressTitle,
|
||||||
|
string.Format(Translator.Info_InvalidAddressMessage, address),
|
||||||
|
InfoBarMessageType.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAvailableCalendarsAsync()
|
||||||
|
{
|
||||||
|
var accountCalendars = new List<AccountCalendarViewModel>();
|
||||||
|
var groupedCalendars = new List<GroupedAccountCalendarViewModel>();
|
||||||
|
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
if (!GroupedAccountCalendarViewModel.SupportsCalendar(account))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var calendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
|
||||||
|
var viewModels = calendars
|
||||||
|
.Select(calendar => new AccountCalendarViewModel(account, calendar))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
accountCalendars.AddRange(viewModels);
|
||||||
|
|
||||||
|
if (viewModels.Count > 0)
|
||||||
|
{
|
||||||
|
groupedCalendars.Add(new GroupedAccountCalendarViewModel(account, viewModels));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
AvailableCalendars.Clear();
|
||||||
|
AvailableCalendarGroups.Clear();
|
||||||
|
|
||||||
|
foreach (var calendar in accountCalendars.OrderBy(calendar => calendar.Account.Name).ThenBy(calendar => calendar.Name))
|
||||||
|
{
|
||||||
|
AvailableCalendars.Add(calendar);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in groupedCalendars.OrderBy(group => group.Account.Name))
|
||||||
|
{
|
||||||
|
AvailableCalendarGroups.Add(group);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyNavigationArgs(CalendarEventComposeNavigationArgs args)
|
||||||
|
{
|
||||||
|
var (defaultStart, defaultEnd) = GetDefaultComposeDateRange();
|
||||||
|
var startDate = args?.StartDate != default ? args!.StartDate : defaultStart;
|
||||||
|
var endDate = args?.EndDate != default ? args!.EndDate : defaultEnd;
|
||||||
|
var isAllDay = args?.IsAllDay ?? false;
|
||||||
|
|
||||||
|
Title = args?.Title ?? string.Empty;
|
||||||
|
Location = args?.Location ?? string.Empty;
|
||||||
|
|
||||||
|
ApplyDateRange(startDate, endDate, isAllDay);
|
||||||
|
|
||||||
|
SelectedCalendar = ResolveSelectedCalendar(args?.SelectedCalendarId);
|
||||||
|
if (SelectedCalendar != null)
|
||||||
|
{
|
||||||
|
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == SelectedCalendar.DefaultShowAs)
|
||||||
|
?? SelectedShowAsOption
|
||||||
|
?? ShowAsOptions.FirstOrDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AccountCalendarViewModel ResolveSelectedCalendar(Guid? selectedCalendarId)
|
||||||
|
{
|
||||||
|
if (selectedCalendarId.HasValue)
|
||||||
|
{
|
||||||
|
var selectedCalendar = AvailableCalendars.FirstOrDefault(calendar => calendar.Id == selectedCalendarId.Value);
|
||||||
|
if (selectedCalendar != null)
|
||||||
|
return selectedCalendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AvailableCalendars.FirstOrDefault(calendar => calendar.IsPrimary) ?? AvailableCalendars.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyDateRange(DateTime startDate, DateTime endDate, bool isAllDay)
|
||||||
|
{
|
||||||
|
IsAllDay = isAllDay;
|
||||||
|
StartDate = new DateTimeOffset(startDate.Date);
|
||||||
|
StartTime = startDate.TimeOfDay;
|
||||||
|
EndTime = endDate.TimeOfDay;
|
||||||
|
AllDayEndDate = new DateTimeOffset((isAllDay ? endDate.Date : startDate.Date.AddDays(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CalendarEventComposeResult> BuildResultAsync(List<CalendarComposeAttendeeViewModel> uniqueAttendees)
|
||||||
|
{
|
||||||
|
if (RecurrenceEndDate.HasValue && RecurrenceEndDate.Value.Date < StartDate.Date)
|
||||||
|
{
|
||||||
|
throw new CalendarEventComposeValidationException(Translator.CalendarEventCompose_ValidationInvalidRecurrenceEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
var htmlNotes = GetHtmlNotesAsync == null ? string.Empty : await GetHtmlNotesAsync();
|
||||||
|
var effectiveStart = GetEffectiveStartDateTime();
|
||||||
|
var effectiveEnd = GetEffectiveEndDateTime();
|
||||||
|
|
||||||
|
return new CalendarEventComposeResult
|
||||||
|
{
|
||||||
|
CalendarId = SelectedCalendar?.Id ?? Guid.Empty,
|
||||||
|
AccountId = SelectedCalendar?.Account.Id ?? Guid.Empty,
|
||||||
|
Title = Title.Trim(),
|
||||||
|
Location = Location?.Trim() ?? string.Empty,
|
||||||
|
HtmlNotes = htmlNotes,
|
||||||
|
StartDate = effectiveStart,
|
||||||
|
EndDate = effectiveEnd,
|
||||||
|
IsAllDay = IsAllDay,
|
||||||
|
TimeZoneId = TimeZoneInfo.Local.Id,
|
||||||
|
ShowAs = SelectedShowAsOption?.ShowAs ?? SelectedCalendar?.DefaultShowAs ?? CalendarItemShowAs.Busy,
|
||||||
|
SelectedReminders = BuildSelectedReminders(),
|
||||||
|
Attendees = BuildAttendees(uniqueAttendees),
|
||||||
|
Attachments = CanAddAttachments
|
||||||
|
? Attachments.Select(attachment => attachment.ToDraftModel()).ToList()
|
||||||
|
: [],
|
||||||
|
Recurrence = BuildRecurrenceRule(),
|
||||||
|
RecurrenceSummary = RecurrenceSummary
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Reminder> BuildSelectedReminders()
|
||||||
|
{
|
||||||
|
if (SelectedReminderOption == null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new Reminder
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CalendarItemId = Guid.Empty,
|
||||||
|
DurationInSeconds = SelectedReminderOption.Minutes * 60L,
|
||||||
|
ReminderType = CalendarItemReminderType.Popup
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CalendarEventAttendee> BuildAttendees(IEnumerable<CalendarComposeAttendeeViewModel> attendees)
|
||||||
|
{
|
||||||
|
return attendees
|
||||||
|
.Select(attendee => new CalendarEventAttendee
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CalendarItemId = Guid.Empty,
|
||||||
|
Name = attendee.HasDistinctDisplayName ? attendee.DisplayName : string.Empty,
|
||||||
|
Email = attendee.Email,
|
||||||
|
AttendenceStatus = AttendeeStatus.NeedsAction,
|
||||||
|
IsOrganizer = false,
|
||||||
|
ResolvedContact = attendee.ResolvedContact
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReminderOption GetDefaultReminderOption()
|
||||||
|
{
|
||||||
|
var reminderMinutes = Math.Max(1, _preferencesService.DefaultReminderDurationInSeconds / 60);
|
||||||
|
return ReminderOptions.FirstOrDefault(option => option.Minutes == reminderMinutes)
|
||||||
|
?? ReminderOptions.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateRecurrenceSummary()
|
||||||
|
{
|
||||||
|
if (!HasInitializedComposeDateRange())
|
||||||
|
{
|
||||||
|
RecurrenceSummary = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var effectiveStart = GetEffectiveStartDateTime();
|
||||||
|
var effectiveEnd = GetEffectiveEndDateTime();
|
||||||
|
var selectedDays = IsDailyRecurrenceSelected
|
||||||
|
? WeekdayOptions
|
||||||
|
.Where(option => option.IsSelected)
|
||||||
|
.Select(option => option.DayOfWeek)
|
||||||
|
.ToList()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
RecurrenceSummary = CalendarRecurrenceSummaryFormatter.BuildSummary(
|
||||||
|
IsRecurring,
|
||||||
|
effectiveStart,
|
||||||
|
effectiveEnd,
|
||||||
|
IsAllDay,
|
||||||
|
CurrentSettings,
|
||||||
|
SelectedRecurrenceInterval,
|
||||||
|
SelectedRecurrenceFrequencyOption?.Frequency ?? CalendarItemRecurrenceFrequency.Weekly,
|
||||||
|
selectedDays,
|
||||||
|
RecurrenceEndDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasInitializedComposeDateRange()
|
||||||
|
{
|
||||||
|
if (StartDate == default)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !IsAllDay || AllDayEndDate != default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildRecurrenceRule()
|
||||||
|
{
|
||||||
|
if (!IsRecurring || SelectedRecurrenceFrequencyOption == null)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var parts = new List<string>
|
||||||
|
{
|
||||||
|
$"FREQ={SelectedRecurrenceFrequencyOption.Frequency.ToString().ToUpperInvariant()}",
|
||||||
|
$"INTERVAL={SelectedRecurrenceInterval}"
|
||||||
|
};
|
||||||
|
|
||||||
|
var selectedDays = IsDailyRecurrenceSelected
|
||||||
|
? WeekdayOptions
|
||||||
|
.Where(option => option.IsSelected)
|
||||||
|
.Select(option => option.RuleValue)
|
||||||
|
.ToList()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (selectedDays.Count > 0)
|
||||||
|
{
|
||||||
|
parts.Add($"BYDAY={string.Join(",", selectedDays)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RecurrenceEndDate.HasValue)
|
||||||
|
{
|
||||||
|
var untilValue = IsAllDay
|
||||||
|
? RecurrenceEndDate.Value.ToString("yyyyMMdd", CultureInfo.InvariantCulture)
|
||||||
|
: RecurrenceEndDate.Value.Date.AddDays(1).AddSeconds(-1).ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
parts.Add($"UNTIL={untilValue}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"RRULE:{string.Join(";", parts)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime GetEffectiveStartDateTime()
|
||||||
|
=> StartDate.Date.Add(IsAllDay ? TimeSpan.Zero : StartTime);
|
||||||
|
|
||||||
|
private DateTime GetEffectiveEndDateTime()
|
||||||
|
=> IsAllDay
|
||||||
|
? AllDayEndDate.Date
|
||||||
|
: StartDate.Date.Add(EndTime);
|
||||||
|
|
||||||
|
private static (DateTime StartDate, DateTime EndDate) GetDefaultComposeDateRange()
|
||||||
|
{
|
||||||
|
var localNow = DateTime.Now;
|
||||||
|
var roundedMinutes = localNow.Minute switch
|
||||||
|
{
|
||||||
|
< 30 => 30,
|
||||||
|
30 when localNow.Second == 0 && localNow.Millisecond == 0 => 30,
|
||||||
|
_ => 60
|
||||||
|
};
|
||||||
|
|
||||||
|
var startDate = new DateTime(localNow.Year, localNow.Month, localNow.Day, localNow.Hour, 0, 0);
|
||||||
|
startDate = roundedMinutes == 60 ? startDate.AddHours(1) : startDate.AddMinutes(roundedMinutes);
|
||||||
|
|
||||||
|
return (startDate, startDate.AddMinutes(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CalendarComposeWeekdayOption CreateWeekdayOption(DayOfWeek dayOfWeek, string ruleValue, string label)
|
||||||
|
{
|
||||||
|
var option = new CalendarComposeWeekdayOption(dayOfWeek, ruleValue, label);
|
||||||
|
option.PropertyChanged += WeekdayOptionPropertyChanged;
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WeekdayOptionPropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(CalendarComposeWeekdayOption.IsSelected))
|
||||||
|
{
|
||||||
|
UpdateRecurrenceSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectSingleWeekday(DayOfWeek dayOfWeek)
|
||||||
|
{
|
||||||
|
foreach (var option in WeekdayOptions)
|
||||||
|
{
|
||||||
|
option.IsSelected = option.DayOfWeek == dayOfWeek;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowValidationMessage(string message)
|
||||||
|
{
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.CalendarEventCompose_ValidationTitle,
|
||||||
|
message,
|
||||||
|
InfoBarMessageType.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AttachmentsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasAttachments));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryAddAttachment(string fileName, string filePath, string fileExtension, long size)
|
||||||
|
{
|
||||||
|
if (!CanAddAttachments ||
|
||||||
|
string.IsNullOrWhiteSpace(filePath) ||
|
||||||
|
Attachments.Any(existing => existing.FilePath.Equals(filePath, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Attachments.Add(new CalendarComposeAttachmentViewModel(fileName, filePath, fileExtension, size));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class CalendarComposeFrequencyOption : ObservableObject
|
||||||
|
{
|
||||||
|
public CalendarItemRecurrenceFrequency Frequency { get; }
|
||||||
|
public string DisplayText { get; }
|
||||||
|
|
||||||
|
public CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency frequency, string displayText)
|
||||||
|
{
|
||||||
|
Frequency = frequency;
|
||||||
|
DisplayText = displayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PluralLabel(int interval)
|
||||||
|
{
|
||||||
|
if (interval == 1)
|
||||||
|
return DisplayText;
|
||||||
|
|
||||||
|
return Frequency switch
|
||||||
|
{
|
||||||
|
CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDayPlural,
|
||||||
|
CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeekPlural,
|
||||||
|
CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonthPlural,
|
||||||
|
CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYearPlural,
|
||||||
|
_ => DisplayText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class CalendarComposeWeekdayOption : ObservableObject
|
||||||
|
{
|
||||||
|
public DayOfWeek DayOfWeek { get; }
|
||||||
|
public string RuleValue { get; }
|
||||||
|
public string Label { get; }
|
||||||
|
public string FullDayName => DayOfWeek switch
|
||||||
|
{
|
||||||
|
DayOfWeek.Monday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[1],
|
||||||
|
DayOfWeek.Tuesday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[2],
|
||||||
|
DayOfWeek.Wednesday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[3],
|
||||||
|
DayOfWeek.Thursday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[4],
|
||||||
|
DayOfWeek.Friday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[5],
|
||||||
|
DayOfWeek.Saturday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[6],
|
||||||
|
DayOfWeek.Sunday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[0],
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsSelected { get; set; }
|
||||||
|
|
||||||
|
public CalendarComposeWeekdayOption(DayOfWeek dayOfWeek, string ruleValue, string label)
|
||||||
|
{
|
||||||
|
DayOfWeek = dayOfWeek;
|
||||||
|
RuleValue = ruleValue;
|
||||||
|
Label = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
|
||||||
using Wino.Core.Domain.Interfaces;
|
|
||||||
using Wino.Core.Domain.Translations;
|
|
||||||
using Wino.Core.ViewModels;
|
|
||||||
using Wino.Messaging.Client.Calendar;
|
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels
|
|
||||||
{
|
|
||||||
public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
|
|
||||||
{
|
|
||||||
[ObservableProperty]
|
|
||||||
private double _cellHourHeight;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private int _selectedFirstDayOfWeekIndex;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool _is24HourHeaders;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private TimeSpan _workingHourStart;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private TimeSpan _workingHourEnd;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private List<string> _dayNames = [];
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private int _workingDayStartIndex;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private int _workingDayEndIndex;
|
|
||||||
|
|
||||||
public IPreferencesService PreferencesService { get; }
|
|
||||||
|
|
||||||
private readonly bool _isLoaded = false;
|
|
||||||
|
|
||||||
public CalendarSettingsPageViewModel(IPreferencesService preferencesService)
|
|
||||||
{
|
|
||||||
PreferencesService = preferencesService;
|
|
||||||
|
|
||||||
var currentLanguageLanguageCode = WinoTranslationDictionary.GetLanguageFileNameRelativePath(preferencesService.CurrentLanguage);
|
|
||||||
|
|
||||||
var cultureInfo = new CultureInfo(currentLanguageLanguageCode);
|
|
||||||
|
|
||||||
// Populate the day names list
|
|
||||||
for (var i = 0; i < 7; i++)
|
|
||||||
{
|
|
||||||
_dayNames.Add(cultureInfo.DateTimeFormat.DayNames[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
var cultureFirstDayName = cultureInfo.DateTimeFormat.GetDayName(preferencesService.FirstDayOfWeek);
|
|
||||||
|
|
||||||
_selectedFirstDayOfWeekIndex = _dayNames.IndexOf(cultureFirstDayName);
|
|
||||||
_is24HourHeaders = preferencesService.Prefer24HourTimeFormat;
|
|
||||||
_workingHourStart = preferencesService.WorkingHourStart;
|
|
||||||
_workingHourEnd = preferencesService.WorkingHourEnd;
|
|
||||||
_cellHourHeight = preferencesService.HourHeight;
|
|
||||||
|
|
||||||
_workingDayStartIndex = _dayNames.IndexOf(cultureInfo.DateTimeFormat.GetDayName(preferencesService.WorkingDayStart));
|
|
||||||
_workingDayEndIndex = _dayNames.IndexOf(cultureInfo.DateTimeFormat.GetDayName(preferencesService.WorkingDayEnd));
|
|
||||||
|
|
||||||
_isLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnCellHourHeightChanged(double oldValue, double newValue) => SaveSettings();
|
|
||||||
partial void OnIs24HourHeadersChanged(bool value) => SaveSettings();
|
|
||||||
partial void OnSelectedFirstDayOfWeekIndexChanged(int value) => SaveSettings();
|
|
||||||
partial void OnWorkingHourStartChanged(TimeSpan value) => SaveSettings();
|
|
||||||
partial void OnWorkingHourEndChanged(TimeSpan value) => SaveSettings();
|
|
||||||
partial void OnWorkingDayStartIndexChanged(int value) => SaveSettings();
|
|
||||||
partial void OnWorkingDayEndIndexChanged(int value) => SaveSettings();
|
|
||||||
|
|
||||||
public void SaveSettings()
|
|
||||||
{
|
|
||||||
if (!_isLoaded) return;
|
|
||||||
|
|
||||||
PreferencesService.FirstDayOfWeek = SelectedFirstDayOfWeekIndex switch
|
|
||||||
{
|
|
||||||
0 => DayOfWeek.Sunday,
|
|
||||||
1 => DayOfWeek.Monday,
|
|
||||||
2 => DayOfWeek.Tuesday,
|
|
||||||
3 => DayOfWeek.Wednesday,
|
|
||||||
4 => DayOfWeek.Thursday,
|
|
||||||
5 => DayOfWeek.Friday,
|
|
||||||
6 => DayOfWeek.Saturday,
|
|
||||||
_ => throw new ArgumentOutOfRangeException()
|
|
||||||
};
|
|
||||||
|
|
||||||
PreferencesService.WorkingDayStart = WorkingDayStartIndex switch
|
|
||||||
{
|
|
||||||
0 => DayOfWeek.Sunday,
|
|
||||||
1 => DayOfWeek.Monday,
|
|
||||||
2 => DayOfWeek.Tuesday,
|
|
||||||
3 => DayOfWeek.Wednesday,
|
|
||||||
4 => DayOfWeek.Thursday,
|
|
||||||
5 => DayOfWeek.Friday,
|
|
||||||
6 => DayOfWeek.Saturday,
|
|
||||||
_ => throw new ArgumentOutOfRangeException()
|
|
||||||
};
|
|
||||||
|
|
||||||
PreferencesService.WorkingDayEnd = WorkingDayEndIndex switch
|
|
||||||
{
|
|
||||||
0 => DayOfWeek.Sunday,
|
|
||||||
1 => DayOfWeek.Monday,
|
|
||||||
2 => DayOfWeek.Tuesday,
|
|
||||||
3 => DayOfWeek.Wednesday,
|
|
||||||
4 => DayOfWeek.Thursday,
|
|
||||||
5 => DayOfWeek.Friday,
|
|
||||||
6 => DayOfWeek.Saturday,
|
|
||||||
_ => throw new ArgumentOutOfRangeException()
|
|
||||||
};
|
|
||||||
|
|
||||||
PreferencesService.Prefer24HourTimeFormat = Is24HourHeaders;
|
|
||||||
PreferencesService.WorkingHourStart = WorkingHourStart;
|
|
||||||
PreferencesService.WorkingHourEnd = WorkingHourEnd;
|
|
||||||
PreferencesService.HourHeight = CellHourHeight;
|
|
||||||
|
|
||||||
Messenger.Send(new CalendarSettingsUpdatedMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Wino.Core;
|
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels
|
|
||||||
{
|
|
||||||
public static class CalendarViewModelContainerSetup
|
|
||||||
{
|
|
||||||
public static void RegisterCalendarViewModelServices(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.RegisterCoreServices();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,69 +2,92 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Wino.Core.Domain.Entities.Calendar;
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels.Data
|
namespace Wino.Calendar.ViewModels.Data;
|
||||||
|
|
||||||
|
public partial class AccountCalendarViewModel : ObservableObject, IAccountCalendar
|
||||||
{
|
{
|
||||||
public partial class AccountCalendarViewModel : ObservableObject, IAccountCalendar
|
public MailAccount Account { get; }
|
||||||
|
public AccountCalendar AccountCalendar { get; }
|
||||||
|
|
||||||
|
public AccountCalendarViewModel(MailAccount account, AccountCalendar accountCalendar)
|
||||||
{
|
{
|
||||||
public MailAccount Account { get; }
|
Account = account;
|
||||||
public AccountCalendar AccountCalendar { get; }
|
AccountCalendar = accountCalendar;
|
||||||
|
|
||||||
public AccountCalendarViewModel(MailAccount account, AccountCalendar accountCalendar)
|
IsChecked = accountCalendar.IsExtended;
|
||||||
{
|
}
|
||||||
Account = account;
|
|
||||||
AccountCalendar = accountCalendar;
|
|
||||||
|
|
||||||
IsChecked = accountCalendar.IsExtended;
|
[ObservableProperty]
|
||||||
}
|
private bool _isChecked;
|
||||||
|
|
||||||
[ObservableProperty]
|
partial void OnIsCheckedChanged(bool value) => IsExtended = value;
|
||||||
private bool _isChecked;
|
|
||||||
|
|
||||||
partial void OnIsCheckedChanged(bool value) => IsExtended = value;
|
public string Name
|
||||||
|
{
|
||||||
|
get => AccountCalendar.Name;
|
||||||
|
set => SetProperty(AccountCalendar.Name, value, AccountCalendar, (u, n) => u.Name = n);
|
||||||
|
}
|
||||||
|
|
||||||
public string Name
|
public string TextColorHex
|
||||||
{
|
{
|
||||||
get => AccountCalendar.Name;
|
get => AccountCalendar.TextColorHex;
|
||||||
set => SetProperty(AccountCalendar.Name, value, AccountCalendar, (u, n) => u.Name = n);
|
set => SetProperty(AccountCalendar.TextColorHex, value, AccountCalendar, (u, t) => u.TextColorHex = t);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string TextColorHex
|
public string BackgroundColorHex
|
||||||
{
|
{
|
||||||
get => AccountCalendar.TextColorHex;
|
get => AccountCalendar.BackgroundColorHex;
|
||||||
set => SetProperty(AccountCalendar.TextColorHex, value, AccountCalendar, (u, t) => u.TextColorHex = t);
|
set => SetProperty(AccountCalendar.BackgroundColorHex, value, AccountCalendar, (u, b) => u.BackgroundColorHex = b);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string BackgroundColorHex
|
public bool IsExtended
|
||||||
{
|
{
|
||||||
get => AccountCalendar.BackgroundColorHex;
|
get => AccountCalendar.IsExtended;
|
||||||
set => SetProperty(AccountCalendar.BackgroundColorHex, value, AccountCalendar, (u, b) => u.BackgroundColorHex = b);
|
set => SetProperty(AccountCalendar.IsExtended, value, AccountCalendar, (u, i) => u.IsExtended = i);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsExtended
|
public bool IsPrimary
|
||||||
{
|
{
|
||||||
get => AccountCalendar.IsExtended;
|
get => AccountCalendar.IsPrimary;
|
||||||
set => SetProperty(AccountCalendar.IsExtended, value, AccountCalendar, (u, i) => u.IsExtended = i);
|
set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsPrimary
|
public bool IsReadOnly
|
||||||
{
|
{
|
||||||
get => AccountCalendar.IsPrimary;
|
get => AccountCalendar.IsReadOnly;
|
||||||
set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i);
|
set => SetProperty(AccountCalendar.IsReadOnly, value, AccountCalendar, (u, i) => u.IsReadOnly = i);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid AccountId
|
public bool IsSynchronizationEnabled
|
||||||
{
|
{
|
||||||
get => AccountCalendar.AccountId;
|
get => AccountCalendar.IsSynchronizationEnabled;
|
||||||
set => SetProperty(AccountCalendar.AccountId, value, AccountCalendar, (u, a) => u.AccountId = a);
|
set => SetProperty(AccountCalendar.IsSynchronizationEnabled, value, AccountCalendar, (u, i) => u.IsSynchronizationEnabled = i);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string RemoteCalendarId
|
public Guid AccountId
|
||||||
{
|
{
|
||||||
get => AccountCalendar.RemoteCalendarId;
|
get => AccountCalendar.AccountId;
|
||||||
set => SetProperty(AccountCalendar.RemoteCalendarId, value, AccountCalendar, (u, r) => u.RemoteCalendarId = r);
|
set => SetProperty(AccountCalendar.AccountId, value, AccountCalendar, (u, a) => u.AccountId = a);
|
||||||
}
|
}
|
||||||
public Guid Id { get => ((IAccountCalendar)AccountCalendar).Id; set => ((IAccountCalendar)AccountCalendar).Id = value; }
|
|
||||||
|
public string RemoteCalendarId
|
||||||
|
{
|
||||||
|
get => AccountCalendar.RemoteCalendarId;
|
||||||
|
set => SetProperty(AccountCalendar.RemoteCalendarId, value, AccountCalendar, (u, r) => u.RemoteCalendarId = r);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CalendarItemShowAs DefaultShowAs
|
||||||
|
{
|
||||||
|
get => AccountCalendar.DefaultShowAs;
|
||||||
|
set => SetProperty(AccountCalendar.DefaultShowAs, value, AccountCalendar, (u, s) => u.DefaultShowAs = s);
|
||||||
|
}
|
||||||
|
public Guid Id { get => ((IAccountCalendar)AccountCalendar).Id; set => ((IAccountCalendar)AccountCalendar).Id = value; }
|
||||||
|
public MailAccount MailAccount
|
||||||
|
{
|
||||||
|
get => AccountCalendar.MailAccount ?? Account;
|
||||||
|
set => AccountCalendar.MailAccount = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Extensions;
|
||||||
|
|
||||||
|
namespace Wino.Calendar.ViewModels.Data;
|
||||||
|
|
||||||
|
public partial class CalendarAttachmentViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
public CalendarAttachment Attachment { get; }
|
||||||
|
|
||||||
|
public Guid Id => Attachment.Id;
|
||||||
|
public string FileName => Attachment.FileName;
|
||||||
|
public string ReadableSize { get; }
|
||||||
|
public MailAttachmentType AttachmentType { get; }
|
||||||
|
public bool IsDownloaded => Attachment.IsDownloaded;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
public CalendarAttachmentViewModel(CalendarAttachment attachment)
|
||||||
|
{
|
||||||
|
Attachment = attachment;
|
||||||
|
ReadableSize = attachment.Size.GetBytesReadable();
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(FileName);
|
||||||
|
AttachmentType = GetAttachmentType(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MailAttachmentType GetAttachmentType(string extension)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(extension))
|
||||||
|
return MailAttachmentType.None;
|
||||||
|
|
||||||
|
switch (extension.ToLower())
|
||||||
|
{
|
||||||
|
case ".exe":
|
||||||
|
return MailAttachmentType.Executable;
|
||||||
|
case ".rar":
|
||||||
|
return MailAttachmentType.RarArchive;
|
||||||
|
case ".zip":
|
||||||
|
return MailAttachmentType.Archive;
|
||||||
|
case ".ogg":
|
||||||
|
case ".mp3":
|
||||||
|
case ".wav":
|
||||||
|
case ".aac":
|
||||||
|
case ".alac":
|
||||||
|
return MailAttachmentType.Audio;
|
||||||
|
case ".mp4":
|
||||||
|
case ".wmv":
|
||||||
|
case ".avi":
|
||||||
|
case ".flv":
|
||||||
|
return MailAttachmentType.Video;
|
||||||
|
case ".pdf":
|
||||||
|
return MailAttachmentType.PDF;
|
||||||
|
case ".htm":
|
||||||
|
case ".html":
|
||||||
|
return MailAttachmentType.HTML;
|
||||||
|
case ".png":
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg":
|
||||||
|
case ".gif":
|
||||||
|
case ".jiff":
|
||||||
|
return MailAttachmentType.Image;
|
||||||
|
default:
|
||||||
|
return MailAttachmentType.Other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
|
||||||
|
namespace Wino.Calendar.ViewModels.Data;
|
||||||
|
|
||||||
|
public class CalendarComposeAttendeeViewModel : IContactDisplayItem
|
||||||
|
{
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public string Email { get; }
|
||||||
|
public AccountContact ResolvedContact { get; }
|
||||||
|
public string Address => Email;
|
||||||
|
public AccountContact PreviewContact => ResolvedContact;
|
||||||
|
public bool HasDistinctDisplayName => !string.IsNullOrWhiteSpace(DisplayName) && !DisplayName.Equals(Email, System.StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public CalendarComposeAttendeeViewModel(string displayName, string email, AccountContact resolvedContact = null)
|
||||||
|
{
|
||||||
|
DisplayName = string.IsNullOrWhiteSpace(displayName) ? email : displayName;
|
||||||
|
Email = email;
|
||||||
|
ResolvedContact = resolvedContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CalendarComposeAttendeeViewModel FromContact(AccountContact contact)
|
||||||
|
=> new(contact.Name, contact.Address, contact);
|
||||||
|
}
|
||||||
@@ -2,45 +2,197 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Itenso.TimePeriod;
|
using Itenso.TimePeriod;
|
||||||
|
using Wino.Core.Domain;
|
||||||
using Wino.Core.Domain.Entities.Calendar;
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
|
using Wino.Core.Domain.Extensions;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels.Data
|
namespace Wino.Calendar.ViewModels.Data;
|
||||||
|
|
||||||
|
public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, ICalendarItemViewModel
|
||||||
{
|
{
|
||||||
public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, ICalendarItemViewModel
|
public CalendarItem CalendarItem { get; }
|
||||||
|
|
||||||
|
public string Title => CalendarItem.Title;
|
||||||
|
|
||||||
|
public Guid Id => CalendarItem.Id;
|
||||||
|
|
||||||
|
public IAccountCalendar AssignedCalendar => CalendarItem.AssignedCalendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the start date converted to user's local timezone for display.
|
||||||
|
/// The underlying CalendarItem stores dates according to their timezone.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime StartDate
|
||||||
{
|
{
|
||||||
public CalendarItem CalendarItem { get; }
|
get
|
||||||
|
|
||||||
public string Title => CalendarItem.Title;
|
|
||||||
|
|
||||||
public Guid Id => CalendarItem.Id;
|
|
||||||
|
|
||||||
public IAccountCalendar AssignedCalendar => CalendarItem.AssignedCalendar;
|
|
||||||
|
|
||||||
public DateTime StartDate { get => CalendarItem.StartDate; set => CalendarItem.StartDate = value; }
|
|
||||||
|
|
||||||
public DateTime EndDate => CalendarItem.EndDate;
|
|
||||||
|
|
||||||
public double DurationInSeconds { get => CalendarItem.DurationInSeconds; set => CalendarItem.DurationInSeconds = value; }
|
|
||||||
|
|
||||||
public ITimePeriod Period => CalendarItem.Period;
|
|
||||||
|
|
||||||
public bool IsAllDayEvent => CalendarItem.IsAllDayEvent;
|
|
||||||
public bool IsMultiDayEvent => CalendarItem.IsMultiDayEvent;
|
|
||||||
public bool IsRecurringEvent => CalendarItem.IsRecurringEvent;
|
|
||||||
public bool IsRecurringChild => CalendarItem.IsRecurringChild;
|
|
||||||
public bool IsRecurringParent => CalendarItem.IsRecurringParent;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool _isSelected;
|
|
||||||
|
|
||||||
public ObservableCollection<CalendarEventAttendee> Attendees { get; } = new ObservableCollection<CalendarEventAttendee>();
|
|
||||||
|
|
||||||
public CalendarItemViewModel(CalendarItem calendarItem)
|
|
||||||
{
|
{
|
||||||
CalendarItem = calendarItem;
|
// Get start date in user's local timezone
|
||||||
|
return CalendarItem.LocalStartDate;
|
||||||
|
}
|
||||||
|
set
|
||||||
|
{
|
||||||
|
// All-day events use floating dates and should not shift across timezones.
|
||||||
|
CalendarItem.StartDate = CalendarItem.IsAllDayEvent
|
||||||
|
? value.Date
|
||||||
|
: value.ToTimeZoneFromLocal(CalendarItem.StartTimeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => CalendarItem.Title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the end date converted to user's local timezone for display.
|
||||||
|
/// The underlying CalendarItem stores dates according to their timezone.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime EndDate
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// Get end date in user's local timezone
|
||||||
|
return CalendarItem.LocalEndDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double DurationInSeconds { get => CalendarItem.DurationInSeconds; set => CalendarItem.DurationInSeconds = value; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the time period in local time.
|
||||||
|
/// </summary>
|
||||||
|
public ITimePeriod Period
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// Return a period using local times for UI display
|
||||||
|
return new TimeRange(StartDate, EndDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsAllDayEvent => CalendarItem.IsAllDayEvent;
|
||||||
|
public bool IsMultiDayEvent => CalendarItem.IsMultiDayEvent;
|
||||||
|
public bool IsRecurringEvent => CalendarItem.IsRecurringEvent;
|
||||||
|
public bool IsRecurringChild => CalendarItem.IsRecurringChild;
|
||||||
|
public bool IsRecurringParent => CalendarItem.IsRecurringParent;
|
||||||
|
public bool CanDragDrop => CalendarItem.CanChangeStartAndEndDate;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsSelected { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The period of the day where this item is currently being displayed.
|
||||||
|
/// Used for multi-day event title formatting.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(DisplayTitle))]
|
||||||
|
public partial ITimePeriod DisplayingPeriod { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calendar settings for time formatting.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(DisplayTitle))]
|
||||||
|
public partial CalendarSettings CalendarSettings { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the display title based on the current displaying period.
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayTitle
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (DisplayingPeriod == null || CalendarSettings == null)
|
||||||
|
return Title;
|
||||||
|
|
||||||
|
return GetDisplayTitle(DisplayingPeriod, CalendarSettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<CalendarEventAttendee> Attendees { get; } = new ObservableCollection<CalendarEventAttendee>();
|
||||||
|
|
||||||
|
public CalendarItemViewModel(CalendarItem calendarItem)
|
||||||
|
{
|
||||||
|
CalendarItem = calendarItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the underlying CalendarItem with new data and raises property change notifications.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="calendarItem">The updated calendar item data.</param>
|
||||||
|
public void UpdateFrom(CalendarItem calendarItem)
|
||||||
|
{
|
||||||
|
if (calendarItem == null || calendarItem.Id != CalendarItem.Id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Update all mutable properties
|
||||||
|
CalendarItem.Title = calendarItem.Title;
|
||||||
|
CalendarItem.Description = calendarItem.Description;
|
||||||
|
CalendarItem.Location = calendarItem.Location;
|
||||||
|
CalendarItem.StartDate = calendarItem.StartDate;
|
||||||
|
CalendarItem.StartTimeZone = calendarItem.StartTimeZone;
|
||||||
|
CalendarItem.EndTimeZone = calendarItem.EndTimeZone;
|
||||||
|
CalendarItem.DurationInSeconds = calendarItem.DurationInSeconds;
|
||||||
|
CalendarItem.Recurrence = calendarItem.Recurrence;
|
||||||
|
CalendarItem.RecurringCalendarItemId = calendarItem.RecurringCalendarItemId;
|
||||||
|
CalendarItem.OrganizerDisplayName = calendarItem.OrganizerDisplayName;
|
||||||
|
CalendarItem.OrganizerEmail = calendarItem.OrganizerEmail;
|
||||||
|
CalendarItem.IsLocked = calendarItem.IsLocked;
|
||||||
|
CalendarItem.IsHidden = calendarItem.IsHidden;
|
||||||
|
CalendarItem.CustomEventColorHex = calendarItem.CustomEventColorHex;
|
||||||
|
CalendarItem.HtmlLink = calendarItem.HtmlLink;
|
||||||
|
CalendarItem.Status = calendarItem.Status;
|
||||||
|
CalendarItem.Visibility = calendarItem.Visibility;
|
||||||
|
CalendarItem.ShowAs = calendarItem.ShowAs;
|
||||||
|
CalendarItem.UpdatedAt = calendarItem.UpdatedAt;
|
||||||
|
CalendarItem.AssignedCalendar = calendarItem.AssignedCalendar;
|
||||||
|
|
||||||
|
// Raise property changed for all bindable properties
|
||||||
|
OnPropertyChanged(nameof(Title));
|
||||||
|
OnPropertyChanged(nameof(StartDate));
|
||||||
|
OnPropertyChanged(nameof(EndDate));
|
||||||
|
OnPropertyChanged(nameof(DurationInSeconds));
|
||||||
|
OnPropertyChanged(nameof(Period));
|
||||||
|
OnPropertyChanged(nameof(IsAllDayEvent));
|
||||||
|
OnPropertyChanged(nameof(IsMultiDayEvent));
|
||||||
|
OnPropertyChanged(nameof(IsRecurringEvent));
|
||||||
|
OnPropertyChanged(nameof(IsRecurringChild));
|
||||||
|
OnPropertyChanged(nameof(IsRecurringParent));
|
||||||
|
OnPropertyChanged(nameof(CanDragDrop));
|
||||||
|
OnPropertyChanged(nameof(AssignedCalendar));
|
||||||
|
OnPropertyChanged(nameof(DisplayTitle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the display title for this calendar item when rendered in a specific day.
|
||||||
|
/// </summary>
|
||||||
|
public string GetDisplayTitle(ITimePeriod displayingPeriod, CalendarSettings calendarSettings)
|
||||||
|
{
|
||||||
|
if (!IsMultiDayEvent)
|
||||||
|
return Title;
|
||||||
|
|
||||||
|
var periodRelation = Period.GetRelation(displayingPeriod);
|
||||||
|
|
||||||
|
if (periodRelation == PeriodRelation.StartInside || periodRelation == PeriodRelation.EnclosingStartTouching)
|
||||||
|
{
|
||||||
|
// Event starts within this day: "HH:mm -> Title"
|
||||||
|
return $"{calendarSettings.GetTimeString(StartDate.TimeOfDay)} -> {Title}";
|
||||||
|
}
|
||||||
|
else if (periodRelation == PeriodRelation.EndInside || periodRelation == PeriodRelation.EnclosingEndTouching)
|
||||||
|
{
|
||||||
|
// Event ends within this day: "Title <- HH:mm"
|
||||||
|
return $"{Title} <- {calendarSettings.GetTimeString(EndDate.TimeOfDay)}";
|
||||||
|
}
|
||||||
|
else if (periodRelation == PeriodRelation.Enclosing)
|
||||||
|
{
|
||||||
|
// Event spans the entire day
|
||||||
|
return $"{Translator.CalendarItemAllDay} {Title}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return Title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => CalendarItem.Title;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,146 +1,207 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels.Data
|
namespace Wino.Calendar.ViewModels.Data;
|
||||||
|
|
||||||
|
public partial class GroupedAccountCalendarViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
public partial class GroupedAccountCalendarViewModel : ObservableObject
|
public event EventHandler CollectiveSelectionStateChanged;
|
||||||
|
public event EventHandler<AccountCalendarViewModel> CalendarSelectionStateChanged;
|
||||||
|
|
||||||
|
public MailAccount Account { get; }
|
||||||
|
public ObservableCollection<AccountCalendarViewModel> AccountCalendars { get; }
|
||||||
|
|
||||||
|
public static bool SupportsCalendar(MailAccount account)
|
||||||
|
=> account?.IsCalendarAccessGranted == true;
|
||||||
|
|
||||||
|
public GroupedAccountCalendarViewModel(MailAccount account, IEnumerable<AccountCalendarViewModel> calendarViewModels)
|
||||||
{
|
{
|
||||||
public event EventHandler CollectiveSelectionStateChanged;
|
Account = account;
|
||||||
public event EventHandler<AccountCalendarViewModel> CalendarSelectionStateChanged;
|
AccountCalendars = new ObservableCollection<AccountCalendarViewModel>(calendarViewModels);
|
||||||
|
AccountColorHex = account.AccountColorHex;
|
||||||
|
|
||||||
public MailAccount Account { get; }
|
ManageIsCheckedState();
|
||||||
public ObservableCollection<AccountCalendarViewModel> AccountCalendars { get; }
|
|
||||||
|
|
||||||
public GroupedAccountCalendarViewModel(MailAccount account, IEnumerable<AccountCalendarViewModel> calendarViewModels)
|
foreach (var calendarViewModel in calendarViewModels)
|
||||||
{
|
{
|
||||||
Account = account;
|
calendarViewModel.PropertyChanged += CalendarPropertyChanged;
|
||||||
AccountCalendars = new ObservableCollection<AccountCalendarViewModel>(calendarViewModels);
|
|
||||||
|
|
||||||
ManageIsCheckedState();
|
|
||||||
|
|
||||||
foreach (var calendarViewModel in calendarViewModels)
|
|
||||||
{
|
|
||||||
calendarViewModel.PropertyChanged += CalendarPropertyChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
AccountCalendars.CollectionChanged += CalendarListUpdated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CalendarListUpdated(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
AccountCalendars.CollectionChanged += CalendarListUpdated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CalendarListUpdated(object sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Action == NotifyCollectionChangedAction.Add)
|
||||||
{
|
{
|
||||||
if (e.Action == NotifyCollectionChangedAction.Add)
|
foreach (AccountCalendarViewModel calendar in e.NewItems)
|
||||||
{
|
{
|
||||||
foreach (AccountCalendarViewModel calendar in e.NewItems)
|
calendar.PropertyChanged += CalendarPropertyChanged;
|
||||||
{
|
|
||||||
calendar.PropertyChanged += CalendarPropertyChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (e.Action == NotifyCollectionChangedAction.Remove)
|
|
||||||
{
|
|
||||||
foreach (AccountCalendarViewModel calendar in e.OldItems)
|
|
||||||
{
|
|
||||||
calendar.PropertyChanged -= CalendarPropertyChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (e.Action == NotifyCollectionChangedAction.Reset)
|
|
||||||
{
|
|
||||||
foreach (AccountCalendarViewModel calendar in e.OldItems)
|
|
||||||
{
|
|
||||||
calendar.PropertyChanged -= CalendarPropertyChanged;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (e.Action == NotifyCollectionChangedAction.Remove)
|
||||||
private void CalendarPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
||||||
{
|
{
|
||||||
if (sender is AccountCalendarViewModel viewModel)
|
foreach (AccountCalendarViewModel calendar in e.OldItems)
|
||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(AccountCalendarViewModel.IsChecked))
|
calendar.PropertyChanged -= CalendarPropertyChanged;
|
||||||
{
|
|
||||||
ManageIsCheckedState();
|
|
||||||
UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (e.Action == NotifyCollectionChangedAction.Reset)
|
||||||
[ObservableProperty]
|
|
||||||
private bool _isExpanded = true;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool? isCheckedState = true;
|
|
||||||
|
|
||||||
private bool _isExternalPropChangeBlocked = false;
|
|
||||||
|
|
||||||
private void ManageIsCheckedState()
|
|
||||||
{
|
{
|
||||||
if (_isExternalPropChangeBlocked) return;
|
foreach (AccountCalendarViewModel calendar in e.OldItems)
|
||||||
|
|
||||||
_isExternalPropChangeBlocked = true;
|
|
||||||
|
|
||||||
if (AccountCalendars.All(c => c.IsChecked))
|
|
||||||
{
|
{
|
||||||
IsCheckedState = true;
|
calendar.PropertyChanged -= CalendarPropertyChanged;
|
||||||
}
|
}
|
||||||
else if (AccountCalendars.All(c => !c.IsChecked))
|
|
||||||
{
|
|
||||||
IsCheckedState = false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
IsCheckedState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isExternalPropChangeBlocked = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnIsCheckedStateChanged(bool? newValue)
|
|
||||||
{
|
|
||||||
if (_isExternalPropChangeBlocked) return;
|
|
||||||
|
|
||||||
// Update is triggered by user on the three-state checkbox.
|
|
||||||
// We should not report all changes one by one.
|
|
||||||
|
|
||||||
_isExternalPropChangeBlocked = true;
|
|
||||||
|
|
||||||
if (newValue == null)
|
|
||||||
{
|
|
||||||
// Only primary calendars must be checked.
|
|
||||||
|
|
||||||
foreach (var calendar in AccountCalendars)
|
|
||||||
{
|
|
||||||
UpdateCalendarCheckedState(calendar, calendar.IsPrimary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
foreach (var calendar in AccountCalendars)
|
|
||||||
{
|
|
||||||
UpdateCalendarCheckedState(calendar, newValue.GetValueOrDefault());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_isExternalPropChangeBlocked = false;
|
|
||||||
|
|
||||||
CollectiveSelectionStateChanged?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateCalendarCheckedState(AccountCalendarViewModel accountCalendarViewModel, bool newValue, bool ignoreValueCheck = false)
|
|
||||||
{
|
|
||||||
var currentValue = accountCalendarViewModel.IsChecked;
|
|
||||||
|
|
||||||
if (currentValue == newValue && !ignoreValueCheck) return;
|
|
||||||
|
|
||||||
accountCalendarViewModel.IsChecked = newValue;
|
|
||||||
|
|
||||||
// No need to report.
|
|
||||||
if (_isExternalPropChangeBlocked == true) return;
|
|
||||||
|
|
||||||
CalendarSelectionStateChanged?.Invoke(this, accountCalendarViewModel);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void CalendarPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is AccountCalendarViewModel viewModel &&
|
||||||
|
e.PropertyName == nameof(AccountCalendarViewModel.IsChecked))
|
||||||
|
{
|
||||||
|
ManageIsCheckedState();
|
||||||
|
UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsExpanded { get; set; } = true;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool? IsCheckedState { get; set; } = true;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string AccountColorHex { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CanSynchronize), nameof(IsSynchronizationProgressVisible), nameof(IsProgressIndeterminate))]
|
||||||
|
public partial bool IsSynchronizationInProgress { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))]
|
||||||
|
public partial int TotalItemsToSync { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))]
|
||||||
|
public partial int RemainingItemsToSync { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string SynchronizationStatus { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool CanSynchronize => !IsSynchronizationInProgress;
|
||||||
|
public bool IsSynchronizationProgressVisible => IsSynchronizationInProgress;
|
||||||
|
public bool IsProgressIndeterminate => IsSynchronizationInProgress && TotalItemsToSync <= 0;
|
||||||
|
public string AccountAddressDisplay => string.IsNullOrWhiteSpace(Account?.Address) ? string.Empty : $" ({Account.Address})";
|
||||||
|
|
||||||
|
public double SynchronizationProgress
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (TotalItemsToSync <= 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double SynchronizationProgressValue => SynchronizationProgress;
|
||||||
|
|
||||||
|
private bool _isExternalPropChangeBlocked;
|
||||||
|
|
||||||
|
public void ApplySynchronizationProgress(AccountSynchronizationProgress progress)
|
||||||
|
{
|
||||||
|
if (progress == null || progress.AccountId != Account.Id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IsSynchronizationInProgress = progress.IsInProgress;
|
||||||
|
TotalItemsToSync = progress.TotalUnits;
|
||||||
|
RemainingItemsToSync = progress.RemainingUnits;
|
||||||
|
SynchronizationStatus = progress.Status ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ManageIsCheckedState()
|
||||||
|
{
|
||||||
|
if (_isExternalPropChangeBlocked)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isExternalPropChangeBlocked = true;
|
||||||
|
|
||||||
|
if (AccountCalendars.All(c => c.IsChecked))
|
||||||
|
{
|
||||||
|
IsCheckedState = true;
|
||||||
|
}
|
||||||
|
else if (AccountCalendars.All(c => !c.IsChecked))
|
||||||
|
{
|
||||||
|
IsCheckedState = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IsCheckedState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isExternalPropChangeBlocked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsCheckedStateChanged(bool? oldValue, bool? newValue)
|
||||||
|
{
|
||||||
|
if (_isExternalPropChangeBlocked)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isExternalPropChangeBlocked = true;
|
||||||
|
|
||||||
|
if (newValue == null)
|
||||||
|
{
|
||||||
|
foreach (var calendar in AccountCalendars)
|
||||||
|
{
|
||||||
|
UpdateCalendarCheckedState(calendar, calendar.IsPrimary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var calendar in AccountCalendars)
|
||||||
|
{
|
||||||
|
UpdateCalendarCheckedState(calendar, newValue.GetValueOrDefault());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isExternalPropChangeBlocked = false;
|
||||||
|
CollectiveSelectionStateChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCalendarCheckedState(AccountCalendarViewModel accountCalendarViewModel, bool newValue, bool ignoreValueCheck = false)
|
||||||
|
{
|
||||||
|
var currentValue = accountCalendarViewModel.IsChecked;
|
||||||
|
|
||||||
|
if (currentValue == newValue && !ignoreValueCheck)
|
||||||
|
return;
|
||||||
|
|
||||||
|
accountCalendarViewModel.IsChecked = newValue;
|
||||||
|
|
||||||
|
if (_isExternalPropChangeBlocked)
|
||||||
|
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));
|
||||||
|
OnPropertyChanged(nameof(AccountAddressDisplay));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +1,884 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
|
using Serilog;
|
||||||
using Wino.Calendar.ViewModels.Data;
|
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.Enums;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Calendar;
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
|
using Wino.Core.Domain.Models;
|
||||||
using Wino.Core.Domain.Models.Navigation;
|
using Wino.Core.Domain.Models.Navigation;
|
||||||
|
using Wino.Core.Services;
|
||||||
using Wino.Core.ViewModels;
|
using Wino.Core.ViewModels;
|
||||||
using Wino.Messaging.Client.Calendar;
|
using Wino.Messaging.Client.Calendar;
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels
|
namespace Wino.Calendar.ViewModels;
|
||||||
|
|
||||||
|
public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||||
{
|
{
|
||||||
public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
private readonly ICalendarService _calendarService;
|
||||||
|
private readonly INativeAppService _nativeAppService;
|
||||||
|
private readonly IPreferencesService _preferencesService;
|
||||||
|
private readonly IMailDialogService _dialogService;
|
||||||
|
private readonly IWinoRequestDelegator _winoRequestDelegator;
|
||||||
|
private readonly INavigationService _navigationService;
|
||||||
|
private readonly IUnderlyingThemeService _underlyingThemeService;
|
||||||
|
private readonly INotificationBuilder _notificationBuilder;
|
||||||
|
private readonly IContactService _contactService;
|
||||||
|
|
||||||
|
public CalendarSettings CurrentSettings { get; }
|
||||||
|
public INativeAppService NativeAppService => _nativeAppService;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsDarkWebviewRenderer { get; set; }
|
||||||
|
|
||||||
|
public ObservableCollection<CalendarAttachmentViewModel> Attachments { get; } = new ObservableCollection<CalendarAttachmentViewModel>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the current event has attachments.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasAttachments => Attachments.Count > 0;
|
||||||
|
|
||||||
|
#region Details
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CanViewSeries))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CanEditSeries))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsCurrentUserOrganizer))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CurrentRsvpText))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CurrentRsvpStatus))]
|
||||||
|
public partial CalendarItemViewModel CurrentEvent { get; set; }
|
||||||
|
|
||||||
|
partial void OnCurrentEventChanged(CalendarItemViewModel value)
|
||||||
{
|
{
|
||||||
private readonly ICalendarService _calendarService;
|
// Notify the view to re-render the description
|
||||||
private readonly INativeAppService _nativeAppService;
|
Messenger.Send(new CalendarDescriptionRenderingRequested());
|
||||||
private readonly IPreferencesService _preferencesService;
|
}
|
||||||
|
|
||||||
public CalendarSettings CurrentSettings { get; }
|
[ObservableProperty]
|
||||||
|
public partial CalendarItemViewModel SeriesParent { get; set; }
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial List<Reminder> Reminders { get; set; }
|
||||||
|
|
||||||
#region Details
|
public ObservableCollection<ReminderOption> ReminderOptions { get; } = new ObservableCollection<ReminderOption>();
|
||||||
|
|
||||||
[ObservableProperty]
|
/// <summary>
|
||||||
[NotifyPropertyChangedFor(nameof(CanViewSeries))]
|
/// Returns true if the event is part of a recurring series (as a child occurrence).
|
||||||
private CalendarItemViewModel _currentEvent;
|
/// Used to enable "View Series" functionality.
|
||||||
|
/// </summary>
|
||||||
|
public bool CanViewSeries => CurrentEvent?.IsRecurringChild ?? false;
|
||||||
|
|
||||||
[ObservableProperty]
|
/// <summary>
|
||||||
private CalendarItemViewModel _seriesParent;
|
/// Returns true if the "Edit Series" button should be visible.
|
||||||
|
/// Only visible for child occurrences of recurring events, not for master events or single events.
|
||||||
|
/// </summary>
|
||||||
|
public bool CanEditSeries => CurrentEvent?.IsRecurringChild ?? false;
|
||||||
|
|
||||||
public bool CanViewSeries => CurrentEvent?.IsRecurringChild ?? false;
|
/// <summary>
|
||||||
|
/// Returns true if the current user is the organizer of the event.
|
||||||
|
/// Used to determine if the user can invite attendees or modify the event.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsCurrentUserOrganizer => CurrentEvent?.Attendees?.Any(a => a.IsOrganizer) ?? true;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
public EventDetailsPageViewModel(ICalendarService calendarService, INativeAppService nativeAppService, IPreferencesService preferencesService)
|
#region Show As Options
|
||||||
|
|
||||||
|
public ObservableCollection<ShowAsOption> ShowAsOptions { get; } = new ObservableCollection<ShowAsOption>();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ShowAsOption SelectedShowAsOption { get; set; }
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region RSVP Panel
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsRsvpPanelVisible { get; set; }
|
||||||
|
|
||||||
|
public bool IncludeRsvpMessage => !string.IsNullOrEmpty(RsvpMessage);
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IncludeRsvpMessage))]
|
||||||
|
public partial string RsvpMessage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public ObservableCollection<RsvpStatusOption> RsvpStatusOptions { get; } = new ObservableCollection<RsvpStatusOption>();
|
||||||
|
|
||||||
|
public CalendarItemStatus CurrentRsvpStatus
|
||||||
|
{
|
||||||
|
get
|
||||||
{
|
{
|
||||||
_calendarService = calendarService;
|
return CurrentEvent?.CalendarItem?.Status ?? CalendarItemStatus.NotResponded;
|
||||||
_nativeAppService = nativeAppService;
|
|
||||||
_preferencesService = preferencesService;
|
|
||||||
|
|
||||||
CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
|
||||||
{
|
|
||||||
base.OnNavigatedTo(mode, parameters);
|
|
||||||
|
|
||||||
Messenger.Send(new DetailsPageStateChangedMessage(true));
|
|
||||||
|
|
||||||
if (parameters == null || parameters is not CalendarItemTarget args)
|
|
||||||
return;
|
|
||||||
|
|
||||||
await LoadCalendarItemTargetAsync(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadCalendarItemTargetAsync(CalendarItemTarget target)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var currentEventItem = await _calendarService.GetCalendarItemTargetAsync(target);
|
|
||||||
|
|
||||||
if (currentEventItem == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
CurrentEvent = new CalendarItemViewModel(currentEventItem);
|
|
||||||
|
|
||||||
var attendees = await _calendarService.GetAttendeesAsync(currentEventItem.EventTrackingId);
|
|
||||||
|
|
||||||
foreach (var item in attendees)
|
|
||||||
{
|
|
||||||
CurrentEvent.Attendees.Add(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
|
|
||||||
{
|
|
||||||
base.OnNavigatedFrom(mode, parameters);
|
|
||||||
|
|
||||||
Messenger.Send(new DetailsPageStateChangedMessage(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task SaveAsync()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task DeleteAsync()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private Task JoinOnline()
|
|
||||||
{
|
|
||||||
if (CurrentEvent == null || string.IsNullOrEmpty(CurrentEvent.CalendarItem.HtmlLink)) return Task.CompletedTask;
|
|
||||||
|
|
||||||
return _nativeAppService.LaunchUriAsync(new Uri(CurrentEvent.CalendarItem.HtmlLink));
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task Respond(CalendarItemStatus status)
|
|
||||||
{
|
|
||||||
if (CurrentEvent == null) return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string CurrentRsvpText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (CurrentEvent?.CalendarItem == null) return Translator.CalendarEventResponse_Accept;
|
||||||
|
|
||||||
|
return CurrentEvent.CalendarItem.Status switch
|
||||||
|
{
|
||||||
|
CalendarItemStatus.Accepted => Translator.CalendarEventResponse_AcceptedResponse,
|
||||||
|
CalendarItemStatus.Tentative => Translator.CalendarEventResponse_TentativeResponse,
|
||||||
|
CalendarItemStatus.Cancelled => Translator.CalendarEventResponse_DeclinedResponse,
|
||||||
|
CalendarItemStatus.NotResponded => Translator.CalendarEventResponse_NotResponded,
|
||||||
|
_ => Translator.CalendarEventResponse_NotResponded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public EventDetailsPageViewModel(ICalendarService calendarService,
|
||||||
|
INativeAppService nativeAppService,
|
||||||
|
IPreferencesService preferencesService,
|
||||||
|
IMailDialogService dialogService,
|
||||||
|
IWinoRequestDelegator winoRequestDelegator,
|
||||||
|
INavigationService navigationService,
|
||||||
|
INotificationBuilder notificationBuilder,
|
||||||
|
IUnderlyingThemeService underlyingThemeService,
|
||||||
|
IContactService contactService)
|
||||||
|
{
|
||||||
|
_calendarService = calendarService;
|
||||||
|
_nativeAppService = nativeAppService;
|
||||||
|
_preferencesService = preferencesService;
|
||||||
|
_dialogService = dialogService;
|
||||||
|
_winoRequestDelegator = winoRequestDelegator;
|
||||||
|
_navigationService = navigationService;
|
||||||
|
_underlyingThemeService = underlyingThemeService;
|
||||||
|
_notificationBuilder = notificationBuilder;
|
||||||
|
_contactService = contactService;
|
||||||
|
|
||||||
|
CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
|
||||||
|
IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark();
|
||||||
|
|
||||||
|
foreach (var showAs in CalendarItemActionOptions.ShowAsOptions)
|
||||||
|
{
|
||||||
|
ShowAsOptions.Add(new ShowAsOption(showAs));
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == CalendarItemShowAs.Busy) ?? ShowAsOptions.FirstOrDefault();
|
||||||
|
|
||||||
|
foreach (var responseStatus in CalendarItemActionOptions.ResponseOptions)
|
||||||
|
{
|
||||||
|
RsvpStatusOptions.Add(new RsvpStatusOption(responseStatus));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||||
|
{
|
||||||
|
base.OnNavigatedTo(mode, parameters);
|
||||||
|
|
||||||
|
if (parameters == null || parameters is not CalendarItemTarget args)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await LoadCalendarItemTargetAsync(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnCalendarItemUpdated(CalendarItem calendarItem, EntityUpdateSource source)
|
||||||
|
{
|
||||||
|
base.OnCalendarItemUpdated(calendarItem, source);
|
||||||
|
|
||||||
|
// If the current event was updated, reload it
|
||||||
|
if (IsCurrentEventMatch(calendarItem))
|
||||||
|
{
|
||||||
|
// Reflect client-side optimistic changes immediately; fallback to DB for server updates.
|
||||||
|
if (source == EntityUpdateSource.ClientUpdated || source == EntityUpdateSource.ClientReverted)
|
||||||
|
{
|
||||||
|
var previousAttendees = CurrentEvent?.Attendees?.ToList() ?? [];
|
||||||
|
CurrentEvent = new CalendarItemViewModel(calendarItem)
|
||||||
|
{
|
||||||
|
IsBusy = source == EntityUpdateSource.ClientUpdated
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var attendee in previousAttendees)
|
||||||
|
{
|
||||||
|
CurrentEvent.Attendees.Add(attendee);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh from DB when update comes from server sync.
|
||||||
|
var refreshedEvent = await _calendarService.GetCalendarItemAsync(calendarItem.Id);
|
||||||
|
if (refreshedEvent != null)
|
||||||
|
{
|
||||||
|
CurrentEvent = new CalendarItemViewModel(refreshedEvent);
|
||||||
|
await LoadAttendeesAsync(refreshedEvent.Id, refreshedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnCalendarItemAdded(CalendarItem calendarItem, EntityUpdateSource source)
|
||||||
|
{
|
||||||
|
base.OnCalendarItemAdded(calendarItem, source);
|
||||||
|
|
||||||
|
if (!IsCurrentEventMatch(calendarItem))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (source == EntityUpdateSource.ClientUpdated || source == EntityUpdateSource.ClientReverted)
|
||||||
|
{
|
||||||
|
CurrentEvent = new CalendarItemViewModel(calendarItem)
|
||||||
|
{
|
||||||
|
IsBusy = source == EntityUpdateSource.ClientUpdated
|
||||||
|
};
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshedEvent = await _calendarService.GetCalendarItemAsync(calendarItem.Id);
|
||||||
|
if (refreshedEvent != null)
|
||||||
|
{
|
||||||
|
CurrentEvent = new CalendarItemViewModel(refreshedEvent);
|
||||||
|
await LoadAttendeesAsync(refreshedEvent.Id, refreshedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnCalendarItemDeleted(CalendarItem calendarItem, EntityUpdateSource source)
|
||||||
|
{
|
||||||
|
base.OnCalendarItemDeleted(calendarItem, source);
|
||||||
|
|
||||||
|
// If the current event was deleted, navigate back
|
||||||
|
if (IsCurrentEventMatch(calendarItem))
|
||||||
|
{
|
||||||
|
NavigateBackToCalendar(forceReload: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsCurrentEventMatch(CalendarItem calendarItem)
|
||||||
|
{
|
||||||
|
if (CurrentEvent?.CalendarItem == null || calendarItem == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var trackedLocalItemId = calendarItem.RemoteEventId.GetClientTrackingId();
|
||||||
|
|
||||||
|
return CurrentEvent.CalendarItem.Id == calendarItem.Id ||
|
||||||
|
(trackedLocalItemId.HasValue && CurrentEvent.CalendarItem.Id == trackedLocalItemId.Value) ||
|
||||||
|
CurrentEvent.CalendarItem.RecurringCalendarItemId == calendarItem.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadCalendarItemTargetAsync(CalendarItemTarget target)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var currentEventItem = await _calendarService.GetCalendarItemTargetAsync(target);
|
||||||
|
|
||||||
|
if (currentEventItem == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CurrentEvent = new CalendarItemViewModel(currentEventItem);
|
||||||
|
|
||||||
|
await LoadAttendeesAsync(currentEventItem.Id, currentEventItem);
|
||||||
|
|
||||||
|
// Initialize SelectedShowAsOption based on current event's ShowAs
|
||||||
|
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(o => o.ShowAs == currentEventItem.ShowAs) ?? ShowAsOptions[2];
|
||||||
|
|
||||||
|
// Load reminders for this calendar item
|
||||||
|
Reminders = await _calendarService.GetRemindersAsync(currentEventItem.Id);
|
||||||
|
InitializeReminderOptions();
|
||||||
|
|
||||||
|
// Load attachments
|
||||||
|
await LoadAttachmentsAsync(currentEventItem.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAttendeesAsync(Guid calendarItemId, CalendarItem calendarItem)
|
||||||
|
{
|
||||||
|
var attendees = await _calendarService.GetAttendeesAsync(calendarItemId);
|
||||||
|
|
||||||
|
// Resolve contacts for all attendees in a single batch DB query.
|
||||||
|
var emails = attendees
|
||||||
|
.Where(a => !string.IsNullOrEmpty(a.Email))
|
||||||
|
.Select(a => a.Email)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail))
|
||||||
|
emails.Add(calendarItem.OrganizerEmail);
|
||||||
|
|
||||||
|
var contacts = await _contactService.GetContactsByAddressesAsync(emails).ConfigureAwait(false);
|
||||||
|
var contactLookup = contacts.ToDictionary(c => c.Address, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var attendee in attendees)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(attendee.Email) && contactLookup.TryGetValue(attendee.Email, out var contact))
|
||||||
|
attendee.ResolvedContact = contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail))
|
||||||
|
{
|
||||||
|
// If the organizer is not in the attendees list, create and add them first
|
||||||
|
var organizerAttendee = new CalendarEventAttendee
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CalendarItemId = calendarItem.Id,
|
||||||
|
Name = calendarItem.OrganizerDisplayName ?? calendarItem.OrganizerEmail,
|
||||||
|
Email = calendarItem.OrganizerEmail,
|
||||||
|
IsOrganizer = true,
|
||||||
|
AttendenceStatus = AttendeeStatus.Accepted
|
||||||
|
};
|
||||||
|
|
||||||
|
if (contactLookup.TryGetValue(calendarItem.OrganizerEmail, out var organizerContact))
|
||||||
|
organizerAttendee.ResolvedContact = organizerContact;
|
||||||
|
|
||||||
|
attendeesForUi.Add(organizerAttendee);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all other attendees after the organizer
|
||||||
|
foreach (var item in nonOrganizerAttendees)
|
||||||
|
{
|
||||||
|
attendeesForUi.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)
|
||||||
|
{
|
||||||
|
Attachments.Clear();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var attachments = await _calendarService.GetAttachmentsAsync(calendarItemId);
|
||||||
|
|
||||||
|
foreach (var attachment in attachments)
|
||||||
|
{
|
||||||
|
Attachments.Add(new CalendarAttachmentViewModel(attachment));
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(HasAttachments));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Error loading attachments: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeReminderOptions()
|
||||||
|
{
|
||||||
|
ReminderOptions.Clear();
|
||||||
|
|
||||||
|
// Add predefined options from service
|
||||||
|
var predefinedMinutes = _calendarService.GetPredefinedReminderMinutes();
|
||||||
|
var predefinedOptions = predefinedMinutes.Select(m => new ReminderOption(m)).ToList();
|
||||||
|
|
||||||
|
// Add custom reminders from synced data
|
||||||
|
if (Reminders != null)
|
||||||
|
{
|
||||||
|
foreach (var reminder in Reminders)
|
||||||
|
{
|
||||||
|
// Convert seconds to minutes
|
||||||
|
var minutesDiff = (int)(reminder.DurationInSeconds / 60);
|
||||||
|
|
||||||
|
// Check if this is a custom value not in predefined list
|
||||||
|
if (!predefinedMinutes.Contains(minutesDiff))
|
||||||
|
{
|
||||||
|
predefinedOptions.Add(new ReminderOption(minutesDiff, isCustom: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by minutes descending and add to collection
|
||||||
|
foreach (var option in predefinedOptions.OrderByDescending(o => o.Minutes))
|
||||||
|
{
|
||||||
|
ReminderOptions.Add(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set selected state based on current reminders
|
||||||
|
if (Reminders != null)
|
||||||
|
{
|
||||||
|
foreach (var reminder in Reminders)
|
||||||
|
{
|
||||||
|
// Convert seconds to minutes
|
||||||
|
var minutesDiff = (int)(reminder.DurationInSeconds / 60);
|
||||||
|
|
||||||
|
var matchingOption = ReminderOptions.FirstOrDefault(o => o.Minutes == minutesDiff);
|
||||||
|
matchingOption?.IsSelected = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (CurrentEvent == null) return;
|
||||||
|
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Capture original state BEFORE making any changes for potential revert
|
||||||
|
var originalItem = await _calendarService.GetCalendarItemAsync(CurrentEvent.CalendarItem.Id);
|
||||||
|
var originalAttendees = await _calendarService.GetAttendeesAsync(CurrentEvent.CalendarItem.Id);
|
||||||
|
|
||||||
|
// Get selected reminder options
|
||||||
|
var selectedOptions = ReminderOptions.Where(o => o.IsSelected).ToList();
|
||||||
|
|
||||||
|
// Create separate Reminder entities for each selected option
|
||||||
|
var newReminders = new List<Reminder>();
|
||||||
|
|
||||||
|
foreach (var option in selectedOptions)
|
||||||
|
{
|
||||||
|
var durationInSeconds = option.Minutes * 60; // Convert minutes to seconds
|
||||||
|
|
||||||
|
newReminders.Add(new Reminder
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CalendarItemId = CurrentEvent.Id,
|
||||||
|
DurationInSeconds = durationInSeconds,
|
||||||
|
ReminderType = CalendarItemReminderType.Popup
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save reminders to database
|
||||||
|
await _calendarService.SaveRemindersAsync(CurrentEvent.CalendarItem.Id, newReminders);
|
||||||
|
Reminders = newReminders;
|
||||||
|
|
||||||
|
// Update ShowAs if changed
|
||||||
|
if (SelectedShowAsOption != null)
|
||||||
|
{
|
||||||
|
CurrentEvent.CalendarItem.ShowAs = SelectedShowAsOption.ShowAs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the calendar item and attendees in database
|
||||||
|
await _calendarService.UpdateCalendarItemAsync(CurrentEvent.CalendarItem, CurrentEvent.Attendees.ToList());
|
||||||
|
|
||||||
|
// Queue the update request to synchronizer with original state for revert capability
|
||||||
|
var preparationRequest = new CalendarOperationPreparationRequest(
|
||||||
|
CalendarSynchronizerOperation.UpdateEvent,
|
||||||
|
CurrentEvent.CalendarItem,
|
||||||
|
CurrentEvent.Attendees.ToList(),
|
||||||
|
ResponseMessage: null,
|
||||||
|
OriginalItem: originalItem,
|
||||||
|
OriginalAttendees: originalAttendees);
|
||||||
|
|
||||||
|
await _winoRequestDelegator.ExecuteAsync(preparationRequest);
|
||||||
|
|
||||||
|
NavigateBackToCalendar(forceReload: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Error saving event: {ex.Message}");
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.Info_AttachmentSaveFailedTitle,
|
||||||
|
ex.Message,
|
||||||
|
InfoBarMessageType.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DeleteAsync()
|
||||||
|
{
|
||||||
|
if (CurrentEvent == null) return;
|
||||||
|
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the event is a master recurring event, ask for confirmation
|
||||||
|
if (CurrentEvent.IsRecurringParent)
|
||||||
|
{
|
||||||
|
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
|
||||||
|
Translator.DialogMessage_DeleteRecurringSeriesMessage,
|
||||||
|
Translator.DialogMessage_DeleteRecurringSeriesTitle,
|
||||||
|
Translator.Buttons_Delete);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var preparationRequest = new CalendarOperationPreparationRequest(
|
||||||
|
CalendarSynchronizerOperation.DeleteEvent,
|
||||||
|
CurrentEvent.CalendarItem,
|
||||||
|
null);
|
||||||
|
|
||||||
|
await _winoRequestDelegator.ExecuteAsync(preparationRequest);
|
||||||
|
|
||||||
|
NavigateBackToCalendar(forceReload: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Error deleting calendar event: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NavigateBackToCalendar(bool forceReload)
|
||||||
|
{
|
||||||
|
var navigationDate = CurrentEvent?.CalendarItem.LocalStartDate ?? DateTime.Now;
|
||||||
|
|
||||||
|
_navigationService.Navigate(
|
||||||
|
WinoPage.CalendarPage,
|
||||||
|
new CalendarPageNavigationArgs
|
||||||
|
{
|
||||||
|
NavigationDate = navigationDate,
|
||||||
|
ForceReload = forceReload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args)
|
||||||
|
{
|
||||||
|
if (args.Handled || args.Mode != WinoApplicationMode.Calendar || args.Action != KeyboardShortcutAction.Delete)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await DeleteAsync();
|
||||||
|
args.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task JoinOnlineAsync()
|
||||||
|
{
|
||||||
|
if (CurrentEvent == null || string.IsNullOrEmpty(CurrentEvent.CalendarItem.HtmlLink))
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
return _nativeAppService.LaunchUriAsync(new Uri(CurrentEvent.CalendarItem.HtmlLink));
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task CreateTestNotificationAsync()
|
||||||
|
{
|
||||||
|
if (CurrentEvent?.CalendarItem == null)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
var reminderDurationInSeconds = Reminders?
|
||||||
|
.Where(x => x.DurationInSeconds > 0)
|
||||||
|
.OrderByDescending(x => x.DurationInSeconds)
|
||||||
|
.Select(x => x.DurationInSeconds)
|
||||||
|
.FirstOrDefault() ?? 0;
|
||||||
|
|
||||||
|
if (reminderDurationInSeconds <= 0)
|
||||||
|
reminderDurationInSeconds = Math.Max(_preferencesService.DefaultReminderDurationInSeconds, 30 * 60);
|
||||||
|
|
||||||
|
return _notificationBuilder.CreateCalendarReminderNotificationAsync(CurrentEvent.CalendarItem, reminderDurationInSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleRsvpPanel()
|
||||||
|
{
|
||||||
|
IsRsvpPanelVisible = !IsRsvpPanelVisible;
|
||||||
|
|
||||||
|
if (IsRsvpPanelVisible && CurrentEvent?.CalendarItem != null)
|
||||||
|
{
|
||||||
|
// Initialize selection based on current status
|
||||||
|
foreach (var item in RsvpStatusOptions)
|
||||||
|
{
|
||||||
|
item.IsSelected = CurrentEvent?.CalendarItem?.Status == item.Status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CloseRsvpPanel()
|
||||||
|
{
|
||||||
|
IsRsvpPanelVisible = false;
|
||||||
|
RsvpMessage = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SendRsvpResponse(AttendeeStatus status)
|
||||||
|
{
|
||||||
|
if (CurrentEvent == null) return;
|
||||||
|
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get the optional response message if user wants to include it
|
||||||
|
var responseMessage = IncludeRsvpMessage ? RsvpMessage : null;
|
||||||
|
|
||||||
|
// Map status to operation
|
||||||
|
CalendarSynchronizerOperation operation = status switch
|
||||||
|
{
|
||||||
|
AttendeeStatus.Accepted => CalendarSynchronizerOperation.AcceptEvent,
|
||||||
|
AttendeeStatus.Tentative => CalendarSynchronizerOperation.TentativeEvent,
|
||||||
|
AttendeeStatus.Declined => CalendarSynchronizerOperation.DeclineEvent,
|
||||||
|
_ => throw new InvalidOperationException($"Invalid RSVP status: {status}")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create preparation request with the optional message
|
||||||
|
var preparationRequest = new CalendarOperationPreparationRequest(
|
||||||
|
operation,
|
||||||
|
CurrentEvent.CalendarItem,
|
||||||
|
null,
|
||||||
|
responseMessage);
|
||||||
|
|
||||||
|
await _winoRequestDelegator.ExecuteAsync(preparationRequest);
|
||||||
|
|
||||||
|
// Reload attendees to get the updated status from the server
|
||||||
|
await LoadAttendeesAsync(CurrentEvent.CalendarItem.Id, CurrentEvent.CalendarItem);
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(CurrentRsvpText));
|
||||||
|
OnPropertyChanged(nameof(CurrentRsvpStatus));
|
||||||
|
|
||||||
|
CloseRsvpPanel();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Error sending RSVP response: {ex.Message}");
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.Info_AttachmentSaveFailedTitle,
|
||||||
|
ex.Message,
|
||||||
|
InfoBarMessageType.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ViewSeriesAsync()
|
||||||
|
{
|
||||||
|
if (CurrentEvent == null || !CurrentEvent.IsRecurringChild) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get the master event from the recurring series
|
||||||
|
var masterEventId = CurrentEvent.CalendarItem.RecurringCalendarItemId.Value;
|
||||||
|
var masterEvent = await _calendarService.GetCalendarItemAsync(masterEventId);
|
||||||
|
|
||||||
|
if (masterEvent == null) return;
|
||||||
|
|
||||||
|
// Load the master event without navigation
|
||||||
|
var target = new CalendarItemTarget(masterEvent, CalendarEventTargetType.Series);
|
||||||
|
await LoadCalendarItemTargetAsync(target);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Error loading series: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel)
|
||||||
|
{
|
||||||
|
if (attachmentViewModel == null || CurrentEvent?.CalendarItem == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
attachmentViewModel.IsBusy = true;
|
||||||
|
|
||||||
|
// If not downloaded, download it first
|
||||||
|
if (!attachmentViewModel.IsDownloaded)
|
||||||
|
{
|
||||||
|
await DownloadAttachmentAsync(attachmentViewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch the file
|
||||||
|
if (!string.IsNullOrEmpty(attachmentViewModel.Attachment.LocalFilePath) &&
|
||||||
|
File.Exists(attachmentViewModel.Attachment.LocalFilePath))
|
||||||
|
{
|
||||||
|
await _nativeAppService.LaunchFileAsync(attachmentViewModel.Attachment.LocalFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to open calendar attachment.");
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.Info_AttachmentOpenFailedTitle,
|
||||||
|
Translator.Info_AttachmentOpenFailedMessage,
|
||||||
|
InfoBarMessageType.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
attachmentViewModel.IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SaveAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel)
|
||||||
|
{
|
||||||
|
if (attachmentViewModel == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
attachmentViewModel.IsBusy = true;
|
||||||
|
|
||||||
|
var pickedPath = await _dialogService.PickWindowsFolderAsync();
|
||||||
|
if (string.IsNullOrEmpty(pickedPath)) return;
|
||||||
|
|
||||||
|
// Download if not already downloaded
|
||||||
|
if (!attachmentViewModel.IsDownloaded)
|
||||||
|
{
|
||||||
|
await DownloadAttachmentAsync(attachmentViewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to selected location
|
||||||
|
if (!string.IsNullOrEmpty(attachmentViewModel.Attachment.LocalFilePath) &&
|
||||||
|
File.Exists(attachmentViewModel.Attachment.LocalFilePath))
|
||||||
|
{
|
||||||
|
var destinationPath = Path.Combine(pickedPath, attachmentViewModel.FileName);
|
||||||
|
File.Copy(attachmentViewModel.Attachment.LocalFilePath, destinationPath, overwrite: true);
|
||||||
|
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.Info_AttachmentSaveSuccessTitle,
|
||||||
|
Translator.Info_AttachmentSaveSuccessMessage,
|
||||||
|
InfoBarMessageType.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to save calendar attachment.");
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.Info_AttachmentSaveFailedTitle,
|
||||||
|
Translator.Info_AttachmentSaveFailedMessage,
|
||||||
|
InfoBarMessageType.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
attachmentViewModel.IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel)
|
||||||
|
{
|
||||||
|
if (CurrentEvent?.CalendarItem == null) return;
|
||||||
|
|
||||||
|
// Create attachments folder for this calendar item
|
||||||
|
var attachmentsFolder = Path.Combine(
|
||||||
|
_nativeAppService.GetCalendarAttachmentsFolderPath(),
|
||||||
|
CurrentEvent.CalendarItem.Id.ToString());
|
||||||
|
|
||||||
|
Directory.CreateDirectory(attachmentsFolder);
|
||||||
|
|
||||||
|
var localFilePath = Path.Combine(attachmentsFolder, attachmentViewModel.FileName);
|
||||||
|
|
||||||
|
// Download attachment using synchronizer
|
||||||
|
await SynchronizationManager.Instance.DownloadCalendarAttachmentAsync(
|
||||||
|
CurrentEvent.CalendarItem,
|
||||||
|
attachmentViewModel.Attachment,
|
||||||
|
localFilePath);
|
||||||
|
|
||||||
|
// Mark as downloaded
|
||||||
|
await _calendarService.MarkAttachmentDownloadedAsync(
|
||||||
|
attachmentViewModel.Id,
|
||||||
|
localFilePath);
|
||||||
|
|
||||||
|
// Update view model
|
||||||
|
attachmentViewModel.Attachment.IsDownloaded = true;
|
||||||
|
attachmentViewModel.Attachment.LocalFilePath = localFilePath;
|
||||||
|
OnPropertyChanged(nameof(attachmentViewModel.IsDownloaded));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class ReminderOption : ObservableObject
|
||||||
|
{
|
||||||
|
public int Minutes { get; }
|
||||||
|
public bool IsCustom { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsSelected { get; set; }
|
||||||
|
|
||||||
|
public string DisplayText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (Minutes >= 60)
|
||||||
|
{
|
||||||
|
var hours = Minutes / 60;
|
||||||
|
return hours == 1 ? "1 Hour" : $"{hours} Hours";
|
||||||
|
}
|
||||||
|
return Minutes == 1 ? "1 Minute" : $"{Minutes} Minutes";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReminderOption(int minutes, bool isCustom = false)
|
||||||
|
{
|
||||||
|
Minutes = minutes;
|
||||||
|
IsCustom = isCustom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class ShowAsOption : ObservableObject
|
||||||
|
{
|
||||||
|
public CalendarItemShowAs ShowAs { get; }
|
||||||
|
|
||||||
|
public string DisplayText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return ShowAs switch
|
||||||
|
{
|
||||||
|
CalendarItemShowAs.Free => Translator.CalendarShowAs_Free,
|
||||||
|
CalendarItemShowAs.Tentative => Translator.CalendarShowAs_Tentative,
|
||||||
|
CalendarItemShowAs.Busy => Translator.CalendarShowAs_Busy,
|
||||||
|
CalendarItemShowAs.OutOfOffice => Translator.CalendarShowAs_OutOfOffice,
|
||||||
|
CalendarItemShowAs.WorkingElsewhere => Translator.CalendarShowAs_WorkingElsewhere,
|
||||||
|
_ => Translator.CalendarShowAs_Busy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShowAsOption(CalendarItemShowAs showAs)
|
||||||
|
{
|
||||||
|
ShowAs = showAs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class RsvpStatusOption : ObservableObject
|
||||||
|
{
|
||||||
|
public CalendarItemStatus Status { get; }
|
||||||
|
|
||||||
|
public string StatusText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return Status switch
|
||||||
|
{
|
||||||
|
CalendarItemStatus.Accepted => Translator.CalendarEventResponse_Accept,
|
||||||
|
CalendarItemStatus.Tentative => Translator.CalendarEventResponse_Tentative,
|
||||||
|
CalendarItemStatus.Cancelled => Translator.CalendarEventResponse_Decline,
|
||||||
|
_ => Translator.CalendarEventResponse_Accept
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsSelected { get; set; }
|
||||||
|
|
||||||
|
public RsvpStatusOption(CalendarItemStatus status)
|
||||||
|
{
|
||||||
|
Status = status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,30 +2,33 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using CommunityToolkit.Mvvm.Collections;
|
||||||
using Wino.Calendar.ViewModels.Data;
|
using Wino.Calendar.ViewModels.Data;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels.Interfaces
|
namespace Wino.Calendar.ViewModels.Interfaces;
|
||||||
|
|
||||||
|
public interface IAccountCalendarStateService : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
public interface IAccountCalendarStateService : INotifyPropertyChanged
|
IDispatcher Dispatcher { get; set; }
|
||||||
{
|
ReadOnlyObservableCollection<GroupedAccountCalendarViewModel> GroupedAccountCalendars { get; }
|
||||||
ReadOnlyObservableCollection<GroupedAccountCalendarViewModel> GroupedAccountCalendars { get; }
|
|
||||||
|
|
||||||
event EventHandler<GroupedAccountCalendarViewModel> CollectiveAccountGroupSelectionStateChanged;
|
event EventHandler<GroupedAccountCalendarViewModel> CollectiveAccountGroupSelectionStateChanged;
|
||||||
event EventHandler<AccountCalendarViewModel> AccountCalendarSelectionStateChanged;
|
event EventHandler<AccountCalendarViewModel> AccountCalendarSelectionStateChanged;
|
||||||
|
|
||||||
public void AddGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
|
public void AddGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
|
||||||
public void RemoveGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
|
public void RemoveGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
|
||||||
public void ClearGroupedAccountCalendar();
|
public void ClearGroupedAccountCalendars();
|
||||||
|
|
||||||
public void AddAccountCalendar(AccountCalendarViewModel accountCalendar);
|
public void AddAccountCalendar(AccountCalendarViewModel accountCalendar);
|
||||||
public void RemoveAccountCalendar(AccountCalendarViewModel accountCalendar);
|
public void RemoveAccountCalendar(AccountCalendarViewModel accountCalendar);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enumeration of currently selected calendars.
|
/// Enumeration of currently selected calendars.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; }
|
IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; }
|
||||||
IEnumerable<IGrouping<MailAccount, AccountCalendarViewModel>> GroupedAccountCalendarsEnumerable { get; }
|
IEnumerable<AccountCalendarViewModel> AllCalendars { get; }
|
||||||
}
|
bool IsAnySynchronizationInProgress { get; }
|
||||||
|
ReadOnlyObservableGroupedCollection<MailAccount, AccountCalendarViewModel> GroupedCalendars { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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,14 +1,13 @@
|
|||||||
using Wino.Calendar.ViewModels.Data;
|
using Wino.Calendar.ViewModels.Data;
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels.Messages
|
namespace Wino.Calendar.ViewModels.Messages;
|
||||||
{
|
|
||||||
public class CalendarItemDoubleTappedMessage
|
|
||||||
{
|
|
||||||
public CalendarItemDoubleTappedMessage(CalendarItemViewModel calendarItemViewModel)
|
|
||||||
{
|
|
||||||
CalendarItemViewModel = calendarItemViewModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CalendarItemViewModel CalendarItemViewModel { get; }
|
public class CalendarItemDoubleTappedMessage
|
||||||
|
{
|
||||||
|
public CalendarItemDoubleTappedMessage(CalendarItemViewModel calendarItemViewModel)
|
||||||
|
{
|
||||||
|
CalendarItemViewModel = calendarItemViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CalendarItemViewModel CalendarItemViewModel { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
using Wino.Calendar.ViewModels.Data;
|
using Wino.Calendar.ViewModels.Data;
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels.Messages
|
namespace Wino.Calendar.ViewModels.Messages;
|
||||||
{
|
|
||||||
public class CalendarItemRightTappedMessage
|
|
||||||
{
|
|
||||||
public CalendarItemRightTappedMessage(CalendarItemViewModel calendarItemViewModel)
|
|
||||||
{
|
|
||||||
CalendarItemViewModel = calendarItemViewModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CalendarItemViewModel CalendarItemViewModel { get; }
|
public class CalendarItemRightTappedMessage
|
||||||
|
{
|
||||||
|
public CalendarItemRightTappedMessage(CalendarItemViewModel calendarItemViewModel)
|
||||||
|
{
|
||||||
|
CalendarItemViewModel = calendarItemViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CalendarItemViewModel CalendarItemViewModel { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
using Wino.Calendar.ViewModels.Data;
|
using Wino.Calendar.ViewModels.Data;
|
||||||
using Wino.Core.Domain.Models.Calendar;
|
namespace Wino.Calendar.ViewModels.Messages;
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels.Messages
|
public class CalendarItemTappedMessage
|
||||||
{
|
{
|
||||||
public class CalendarItemTappedMessage
|
public CalendarItemTappedMessage(CalendarItemViewModel calendarItemViewModel)
|
||||||
{
|
{
|
||||||
public CalendarItemTappedMessage(CalendarItemViewModel calendarItemViewModel, CalendarDayModel clickedPeriod)
|
CalendarItemViewModel = calendarItemViewModel;
|
||||||
{
|
|
||||||
CalendarItemViewModel = calendarItemViewModel;
|
|
||||||
ClickedPeriod = clickedPeriod;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CalendarItemViewModel CalendarItemViewModel { get; }
|
|
||||||
public CalendarDayModel ClickedPeriod { get; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CalendarItemViewModel CalendarItemViewModel { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>12</LangVersion>
|
<Platforms>x86;x64;arm64</Platforms>
|
||||||
<Platforms>AnyCPU;x64;x86</Platforms>
|
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||||
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
|
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
|
||||||
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
|
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
|
||||||
|
<IsTrimmable>true</IsTrimmable>
|
||||||
|
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="TimePeriodLibrary.NET" Version="2.1.5" />
|
<PackageReference Include="TimePeriodLibrary.NET" />
|
||||||
|
<PackageReference Include="EmailValidation" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 17
|
|
||||||
VisualStudioVersion = 17.12.35424.110
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Core.Domain", "Wino.Core.Domain\Wino.Core.Domain.csproj", "{814400B6-5A05-4596-B451-3A116A147DC1}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wino.Core.UWP", "Wino.Core.UWP\Wino.Core.UWP.csproj", "{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Core.ViewModels", "Wino.Core.ViewModels\Wino.Core.ViewModels.csproj", "{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Messaging", "Wino.Messages\Wino.Messaging.csproj", "{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Server", "Wino.Server\Wino.Server.csproj", "{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Core", "Wino.Core\Wino.Core.csproj", "{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wino.Calendar", "Wino.Calendar\Wino.Calendar.csproj", "{600F4979-DB7E-409D-B7DA-B60BE4C55C35}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.SourceGenerators", "Wino.SourceGenerators\Wino.SourceGenerators.csproj", "{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}"
|
|
||||||
EndProject
|
|
||||||
Project("{C7167F0D-BC9F-4E6E-AFE1-012C56B48DB5}") = "Wino.Calendar.Packaging", "Wino.Calendar.Packaging\Wino.Calendar.Packaging.wapproj", "{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wino.Calendar.ViewModels", "Wino.Calendar.ViewModels\Wino.Calendar.ViewModels.csproj", "{CF850F8C-5042-4376-9CBA-C8F2BB554083}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wino.Services", "Wino.Services\Wino.Services.csproj", "{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wino.Authentication", "Wino.Authentication\Wino.Authentication.csproj", "{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Debug|ARM = Debug|ARM
|
|
||||||
Debug|ARM64 = Debug|ARM64
|
|
||||||
Debug|x64 = Debug|x64
|
|
||||||
Debug|x86 = Debug|x86
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
Release|ARM = Release|ARM
|
|
||||||
Release|ARM64 = Release|ARM64
|
|
||||||
Release|x64 = Release|x64
|
|
||||||
Release|x86 = Release|x86
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|ARM.ActiveCfg = Debug|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|ARM.Build.0 = Debug|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|ARM64.ActiveCfg = Debug|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|ARM64.Build.0 = Debug|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|x64.ActiveCfg = Debug|x64
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|x64.Build.0 = Debug|x64
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|x86.ActiveCfg = Debug|x86
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Debug|x86.Build.0 = Debug|x86
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|ARM.ActiveCfg = Release|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|ARM.Build.0 = Release|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|ARM64.ActiveCfg = Release|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|ARM64.Build.0 = Release|Any CPU
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|x64.ActiveCfg = Release|x64
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|x64.Build.0 = Release|x64
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|x86.ActiveCfg = Release|x86
|
|
||||||
{814400B6-5A05-4596-B451-3A116A147DC1}.Release|x86.Build.0 = Release|x86
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|ARM.ActiveCfg = Debug|Any CPU
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|ARM.Build.0 = Debug|Any CPU
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|ARM64.ActiveCfg = Debug|ARM64
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|ARM64.Build.0 = Debug|ARM64
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|x64.ActiveCfg = Debug|x64
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|x64.Build.0 = Debug|x64
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|x86.ActiveCfg = Debug|x86
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Debug|x86.Build.0 = Debug|x86
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|ARM.ActiveCfg = Release|Any CPU
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|ARM.Build.0 = Release|Any CPU
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|ARM64.ActiveCfg = Release|ARM64
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|ARM64.Build.0 = Release|ARM64
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|x64.ActiveCfg = Release|x64
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|x64.Build.0 = Release|x64
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|x86.ActiveCfg = Release|x86
|
|
||||||
{395F19BA-1E42-495C-9DB5-1A6F537FCCB8}.Release|x86.Build.0 = Release|x86
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|ARM.ActiveCfg = Debug|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|ARM.Build.0 = Debug|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|ARM64.ActiveCfg = Debug|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|ARM64.Build.0 = Debug|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|x64.ActiveCfg = Debug|x64
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|x64.Build.0 = Debug|x64
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|x86.ActiveCfg = Debug|x86
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Debug|x86.Build.0 = Debug|x86
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|ARM.ActiveCfg = Release|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|ARM.Build.0 = Release|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|ARM64.ActiveCfg = Release|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|ARM64.Build.0 = Release|Any CPU
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|x64.ActiveCfg = Release|x64
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|x64.Build.0 = Release|x64
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|x86.ActiveCfg = Release|x86
|
|
||||||
{510CD96C-B3FF-4EC9-A67B-845C842E6BEC}.Release|x86.Build.0 = Release|x86
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|ARM.ActiveCfg = Debug|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|ARM.Build.0 = Debug|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|ARM64.ActiveCfg = Debug|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|ARM64.Build.0 = Debug|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|x64.ActiveCfg = Debug|x64
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|x64.Build.0 = Debug|x64
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|x86.ActiveCfg = Debug|x86
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Debug|x86.Build.0 = Debug|x86
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|ARM.ActiveCfg = Release|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|ARM.Build.0 = Release|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|ARM64.ActiveCfg = Release|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|ARM64.Build.0 = Release|Any CPU
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|x64.ActiveCfg = Release|x64
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|x64.Build.0 = Release|x64
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|x86.ActiveCfg = Release|x86
|
|
||||||
{AB588CFD-4B0C-4A1F-B711-1999E3D092D0}.Release|x86.Build.0 = Release|x86
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|Any CPU.ActiveCfg = Debug|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|Any CPU.Build.0 = Debug|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|ARM.ActiveCfg = Debug|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|ARM.Build.0 = Debug|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|ARM64.ActiveCfg = Debug|ARM64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|ARM64.Build.0 = Debug|ARM64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|x64.ActiveCfg = Debug|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|x64.Build.0 = Debug|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|x86.ActiveCfg = Debug|x86
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Debug|x86.Build.0 = Debug|x86
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|Any CPU.ActiveCfg = Release|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|Any CPU.Build.0 = Release|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|ARM.ActiveCfg = Release|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|ARM.Build.0 = Release|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|ARM64.ActiveCfg = Release|ARM64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|ARM64.Build.0 = Release|ARM64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|x64.ActiveCfg = Release|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|x64.Build.0 = Release|x64
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|x86.ActiveCfg = Release|x86
|
|
||||||
{92DA33FC-9252-40C5-BF71-67ACB0B56F2B}.Release|x86.Build.0 = Release|x86
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|ARM.ActiveCfg = Debug|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|ARM.Build.0 = Debug|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|ARM64.ActiveCfg = Debug|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|ARM64.Build.0 = Debug|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|x64.ActiveCfg = Debug|x64
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|x64.Build.0 = Debug|x64
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|x86.ActiveCfg = Debug|x86
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Debug|x86.Build.0 = Debug|x86
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|ARM.ActiveCfg = Release|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|ARM.Build.0 = Release|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|ARM64.ActiveCfg = Release|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|ARM64.Build.0 = Release|Any CPU
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|x64.ActiveCfg = Release|x64
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|x64.Build.0 = Release|x64
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|x86.ActiveCfg = Release|x86
|
|
||||||
{87FFCBF4-DC17-4F09-90D6-102CF4C72BAF}.Release|x86.Build.0 = Release|x86
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|Any CPU.ActiveCfg = Debug|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|Any CPU.Build.0 = Debug|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|Any CPU.Deploy.0 = Debug|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM.ActiveCfg = Debug|ARM
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM.Build.0 = Debug|ARM
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM.Deploy.0 = Debug|ARM
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM64.ActiveCfg = Debug|ARM64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM64.Build.0 = Debug|ARM64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|ARM64.Deploy.0 = Debug|ARM64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x64.ActiveCfg = Debug|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x64.Build.0 = Debug|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x64.Deploy.0 = Debug|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x86.ActiveCfg = Debug|x86
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x86.Build.0 = Debug|x86
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Debug|x86.Deploy.0 = Debug|x86
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|Any CPU.ActiveCfg = Release|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|Any CPU.Build.0 = Release|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|Any CPU.Deploy.0 = Release|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM.ActiveCfg = Release|ARM
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM.Build.0 = Release|ARM
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM.Deploy.0 = Release|ARM
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM64.ActiveCfg = Release|ARM64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM64.Build.0 = Release|ARM64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|ARM64.Deploy.0 = Release|ARM64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x64.ActiveCfg = Release|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x64.Build.0 = Release|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x64.Deploy.0 = Release|x64
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x86.ActiveCfg = Release|x86
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x86.Build.0 = Release|x86
|
|
||||||
{600F4979-DB7E-409D-B7DA-B60BE4C55C35}.Release|x86.Deploy.0 = Release|x86
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|ARM.ActiveCfg = Debug|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|ARM.Build.0 = Debug|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|ARM64.ActiveCfg = Debug|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|ARM64.Build.0 = Debug|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|ARM.ActiveCfg = Release|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|ARM.Build.0 = Release|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|ARM64.ActiveCfg = Release|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|ARM64.Build.0 = Release|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{8A7EB697-D722-4E0F-B20E-9FC88373ADB5}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM.ActiveCfg = Debug|ARM
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM.Build.0 = Debug|ARM
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM.Deploy.0 = Debug|ARM
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM64.ActiveCfg = Debug|ARM64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM64.Build.0 = Debug|ARM64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|ARM64.Deploy.0 = Debug|ARM64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x64.ActiveCfg = Debug|x64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x64.Build.0 = Debug|x64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x64.Deploy.0 = Debug|x64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x86.ActiveCfg = Debug|x86
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x86.Build.0 = Debug|x86
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Debug|x86.Deploy.0 = Debug|x86
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|Any CPU.Deploy.0 = Release|Any CPU
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM.ActiveCfg = Release|ARM
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM.Build.0 = Release|ARM
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM.Deploy.0 = Release|ARM
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM64.ActiveCfg = Release|ARM64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM64.Build.0 = Release|ARM64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|ARM64.Deploy.0 = Release|ARM64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x64.ActiveCfg = Release|x64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x64.Build.0 = Release|x64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x64.Deploy.0 = Release|x64
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x86.ActiveCfg = Release|x86
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x86.Build.0 = Release|x86
|
|
||||||
{7485B18C-F5AB-4ABE-BA7F-05B6623C67C8}.Release|x86.Deploy.0 = Release|x86
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|ARM.ActiveCfg = Debug|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|ARM.Build.0 = Debug|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|ARM64.ActiveCfg = Debug|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|ARM64.Build.0 = Debug|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|x64.ActiveCfg = Debug|x64
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|x64.Build.0 = Debug|x64
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|x86.ActiveCfg = Debug|x86
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Debug|x86.Build.0 = Debug|x86
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|ARM.ActiveCfg = Release|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|ARM.Build.0 = Release|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|ARM64.ActiveCfg = Release|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|ARM64.Build.0 = Release|Any CPU
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|x64.ActiveCfg = Release|x64
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|x64.Build.0 = Release|x64
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|x86.ActiveCfg = Release|x86
|
|
||||||
{CF850F8C-5042-4376-9CBA-C8F2BB554083}.Release|x86.Build.0 = Release|x86
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|ARM.ActiveCfg = Debug|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|ARM.Build.0 = Debug|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|ARM64.ActiveCfg = Debug|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|ARM64.Build.0 = Debug|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|ARM.ActiveCfg = Release|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|ARM.Build.0 = Release|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|ARM64.ActiveCfg = Release|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|ARM64.Build.0 = Release|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{BBA49030-7277-48CF-B2FE-3D01CB6B6C81}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|ARM.ActiveCfg = Debug|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|ARM.Build.0 = Debug|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|ARM64.ActiveCfg = Debug|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|ARM64.Build.0 = Debug|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|x64.ActiveCfg = Debug|x64
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|x64.Build.0 = Debug|x64
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|x86.ActiveCfg = Debug|x86
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Debug|x86.Build.0 = Debug|x86
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|ARM.ActiveCfg = Release|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|ARM.Build.0 = Release|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|ARM64.ActiveCfg = Release|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|ARM64.Build.0 = Release|Any CPU
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|x64.ActiveCfg = Release|x64
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|x64.Build.0 = Release|x64
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|x86.ActiveCfg = Release|x86
|
|
||||||
{16A979C2-F308-464F-9B2A-0AF8ED5EDB43}.Release|x86.Build.0 = Release|x86
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
using System.Threading.Tasks;
|
|
||||||
using Windows.ApplicationModel.Activation;
|
|
||||||
using Windows.UI.Xaml;
|
|
||||||
using Windows.UI.Xaml.Controls;
|
|
||||||
using Windows.UI.Xaml.Media.Animation;
|
|
||||||
using Wino.Activation;
|
|
||||||
using Wino.Calendar.Views;
|
|
||||||
|
|
||||||
namespace Wino.Calendar.Activation
|
|
||||||
{
|
|
||||||
public class DefaultActivationHandler : ActivationHandler<IActivatedEventArgs>
|
|
||||||
{
|
|
||||||
protected override Task HandleInternalAsync(IActivatedEventArgs args)
|
|
||||||
{
|
|
||||||
(Window.Current.Content as Frame).Navigate(typeof(AppShell), null, new DrillInNavigationTransitionInfo());
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only navigate if Frame content doesn't exist.
|
|
||||||
protected override bool CanHandleInternal(IActivatedEventArgs args)
|
|
||||||
=> (Window.Current?.Content as Frame)?.Content == null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<core:WinoApplication
|
|
||||||
x:Class="Wino.Calendar.App"
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:controls="using:Microsoft.UI.Xaml.Controls"
|
|
||||||
xmlns:core="using:Wino.Core.UWP"
|
|
||||||
xmlns:coreStyles="using:Wino.Core.UWP.Styles"
|
|
||||||
xmlns:local="using:Wino.Calendar"
|
|
||||||
xmlns:styles="using:Wino.Calendar.Styles">
|
|
||||||
<Application.Resources>
|
|
||||||
<ResourceDictionary>
|
|
||||||
<ResourceDictionary.MergedDictionaries>
|
|
||||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
|
||||||
|
|
||||||
<core:CoreGeneric />
|
|
||||||
|
|
||||||
<styles:WinoCalendarResources />
|
|
||||||
|
|
||||||
<ResourceDictionary Source="Styles/CalendarThemeResources.xaml" />
|
|
||||||
|
|
||||||
<ResourceDictionary Source="Styles/WinoDayTimelineCanvas.xaml" />
|
|
||||||
<ResourceDictionary Source="Styles/WinoCalendarView.xaml" />
|
|
||||||
<ResourceDictionary Source="Styles/WinoCalendarTypeSelectorControl.xaml" />
|
|
||||||
|
|
||||||
<!-- Last item must always be the default theme. -->
|
|
||||||
<ResourceDictionary Source="ms-appx:///Wino.Core.UWP/AppThemes/Mica.xaml" />
|
|
||||||
</ResourceDictionary.MergedDictionaries>
|
|
||||||
</ResourceDictionary>
|
|
||||||
</Application.Resources>
|
|
||||||
</core:WinoApplication>
|
|
||||||
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Serilog;
|
|
||||||
using Windows.ApplicationModel;
|
|
||||||
using Windows.ApplicationModel.Activation;
|
|
||||||
using Windows.ApplicationModel.AppService;
|
|
||||||
using Windows.ApplicationModel.Background;
|
|
||||||
using Windows.UI.Core.Preview;
|
|
||||||
using Wino.Activation;
|
|
||||||
using Wino.Calendar.Activation;
|
|
||||||
using Wino.Calendar.Services;
|
|
||||||
using Wino.Calendar.ViewModels;
|
|
||||||
using Wino.Calendar.ViewModels.Interfaces;
|
|
||||||
using Wino.Core.Domain;
|
|
||||||
using Wino.Core.Domain.Enums;
|
|
||||||
using Wino.Core.Domain.Exceptions;
|
|
||||||
using Wino.Core.Domain.Interfaces;
|
|
||||||
using Wino.Core.Domain.Models.Synchronization;
|
|
||||||
using Wino.Core.UWP;
|
|
||||||
using Wino.Core.ViewModels;
|
|
||||||
using Wino.Messaging.Client.Connection;
|
|
||||||
using Wino.Messaging.Server;
|
|
||||||
using Wino.Services;
|
|
||||||
|
|
||||||
namespace Wino.Calendar
|
|
||||||
{
|
|
||||||
public sealed partial class App : WinoApplication, IRecipient<NewCalendarSynchronizationRequested>
|
|
||||||
{
|
|
||||||
public override string AppCenterKey => "dfdad6ab-95f9-44cc-9112-45ec6730c49e";
|
|
||||||
|
|
||||||
private BackgroundTaskDeferral connectionBackgroundTaskDeferral;
|
|
||||||
private BackgroundTaskDeferral toastActionBackgroundTaskDeferral;
|
|
||||||
|
|
||||||
public App()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
WeakReferenceMessenger.Default.Register(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override IServiceProvider ConfigureServices()
|
|
||||||
{
|
|
||||||
var services = new ServiceCollection();
|
|
||||||
|
|
||||||
services.RegisterSharedServices();
|
|
||||||
services.RegisterCalendarViewModelServices();
|
|
||||||
services.RegisterCoreUWPServices();
|
|
||||||
services.RegisterCoreViewModels();
|
|
||||||
|
|
||||||
RegisterUWPServices(services);
|
|
||||||
RegisterViewModels(services);
|
|
||||||
RegisterActivationHandlers(services);
|
|
||||||
|
|
||||||
return services.BuildServiceProvider();
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Dependency Injection
|
|
||||||
|
|
||||||
private void RegisterActivationHandlers(IServiceCollection services)
|
|
||||||
{
|
|
||||||
//services.AddTransient<ProtocolActivationHandler>();
|
|
||||||
//services.AddTransient<ToastNotificationActivationHandler>();
|
|
||||||
//services.AddTransient<FileActivationHandler>();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RegisterUWPServices(IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddSingleton<INavigationService, NavigationService>();
|
|
||||||
services.AddSingleton<ICalendarDialogService, DialogService>();
|
|
||||||
services.AddTransient<ISettingsBuilderService, SettingsBuilderService>();
|
|
||||||
services.AddTransient<IProviderService, ProviderService>();
|
|
||||||
services.AddSingleton<IAuthenticatorConfig, CalendarAuthenticatorConfig>();
|
|
||||||
services.AddSingleton<IAccountCalendarStateService, AccountCalendarStateService>();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RegisterViewModels(IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddSingleton(typeof(AppShellViewModel));
|
|
||||||
services.AddSingleton(typeof(CalendarPageViewModel));
|
|
||||||
services.AddTransient(typeof(CalendarSettingsPageViewModel));
|
|
||||||
services.AddTransient(typeof(AccountManagementViewModel));
|
|
||||||
services.AddTransient(typeof(PersonalizationPageViewModel));
|
|
||||||
services.AddTransient(typeof(AccountDetailsPageViewModel));
|
|
||||||
services.AddTransient(typeof(EventDetailsPageViewModel));
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
protected override void OnApplicationCloseRequested(object sender, SystemNavigationCloseRequestedPreviewEventArgs e)
|
|
||||||
{
|
|
||||||
// TODO: Check server running.
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async void OnLaunched(LaunchActivatedEventArgs args)
|
|
||||||
{
|
|
||||||
LogActivation($"OnLaunched -> {args.GetType().Name}, Kind -> {args.Kind}, PreviousExecutionState -> {args.PreviousExecutionState}, IsPrelaunch -> {args.PrelaunchActivated}");
|
|
||||||
|
|
||||||
if (!args.PrelaunchActivated)
|
|
||||||
{
|
|
||||||
await ActivateWinoAsync(args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override IEnumerable<ActivationHandler> GetActivationHandlers()
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override ActivationHandler<IActivatedEventArgs> GetDefaultActivationHandler()
|
|
||||||
=> new DefaultActivationHandler();
|
|
||||||
|
|
||||||
protected override void OnBackgroundActivated(BackgroundActivatedEventArgs args)
|
|
||||||
{
|
|
||||||
base.OnBackgroundActivated(args);
|
|
||||||
|
|
||||||
if (args.TaskInstance.TriggerDetails is AppServiceTriggerDetails appServiceTriggerDetails)
|
|
||||||
{
|
|
||||||
LogActivation("OnBackgroundActivated -> AppServiceTriggerDetails received.");
|
|
||||||
|
|
||||||
// Only accept connections from callers in the same package
|
|
||||||
if (appServiceTriggerDetails.CallerPackageFamilyName == Package.Current.Id.FamilyName)
|
|
||||||
{
|
|
||||||
// Connection established from the fulltrust process
|
|
||||||
|
|
||||||
connectionBackgroundTaskDeferral = args.TaskInstance.GetDeferral();
|
|
||||||
args.TaskInstance.Canceled += OnConnectionBackgroundTaskCanceled;
|
|
||||||
|
|
||||||
AppServiceConnectionManager.Connection = appServiceTriggerDetails.AppServiceConnection;
|
|
||||||
|
|
||||||
WeakReferenceMessenger.Default.Send(new WinoServerConnectionEstablished());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnConnectionBackgroundTaskCanceled(IBackgroundTaskInstance sender, BackgroundTaskCancellationReason reason)
|
|
||||||
{
|
|
||||||
sender.Canceled -= OnConnectionBackgroundTaskCanceled;
|
|
||||||
|
|
||||||
Log.Information($"Server connection background task was canceled. Reason: {reason}");
|
|
||||||
|
|
||||||
connectionBackgroundTaskDeferral?.Complete();
|
|
||||||
connectionBackgroundTaskDeferral = null;
|
|
||||||
|
|
||||||
AppServiceConnectionManager.Connection = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void Receive(NewCalendarSynchronizationRequested message)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var synchronizationResultResponse = await AppServiceConnectionManager.GetResponseAsync<CalendarSynchronizationResult, NewCalendarSynchronizationRequested>(message);
|
|
||||||
synchronizationResultResponse.ThrowIfFailed();
|
|
||||||
}
|
|
||||||
catch (WinoServerException serverException)
|
|
||||||
{
|
|
||||||
var dialogService = Services.GetService<ICalendarDialogService>();
|
|
||||||
|
|
||||||
dialogService.InfoBarMessage(Translator.Info_SyncFailedTitle, serverException.Message, InfoBarMessageType.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Windows.Foundation;
|
|
||||||
|
|
||||||
namespace Wino.Calendar.Args
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// When a new timeline cell is selected.
|
|
||||||
/// </summary>
|
|
||||||
public class TimelineCellSelectedArgs : EventArgs
|
|
||||||
{
|
|
||||||
public TimelineCellSelectedArgs(DateTime clickedDate, Point canvasPoint, Point positionerPoint, Size cellSize)
|
|
||||||
{
|
|
||||||
ClickedDate = clickedDate;
|
|
||||||
CanvasPoint = canvasPoint;
|
|
||||||
PositionerPoint = positionerPoint;
|
|
||||||
CellSize = cellSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clicked date and time information for the cell.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime ClickedDate { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Position relative to the cell drawing part of the canvas.
|
|
||||||
/// Used to detect clicked cell from the position.
|
|
||||||
/// </summary>
|
|
||||||
public Point CanvasPoint { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Position relative to the main root positioner element of the drawing canvas.
|
|
||||||
/// Used to show the create event dialog teaching tip in correct position.
|
|
||||||
/// </summary>
|
|
||||||
public Point PositionerPoint { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Size of the cell.
|
|
||||||
/// </summary>
|
|
||||||
public Size CellSize { get; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace Wino.Calendar.Args
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// When selected timeline cell is unselected.
|
|
||||||
/// </summary>
|
|
||||||
public class TimelineCellUnselectedArgs : EventArgs { }
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 809 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 809 B |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 596 B |
|
Before Width: | Height: | Size: 920 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |