Compare commits
1113 Commits
v1.7.1
...
beta/2.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| eb2335893c | |||
| 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 | |||
| c1336428dc | |||
| f0e513bf0d | |||
| ee9e41c5a7 | |||
| 30f1257983 | |||
| f007cef208 | |||
| 19b5852098 | |||
| 2ec05ea7cc | |||
| e8dd8bff44 | |||
| ab3f65edfa | |||
| fcaf62ecf7 | |||
| 7cfa5a57f5 | |||
| 56d6c22d53 | |||
| 10b0b1e96e | |||
| 6ef1e9c86c | |||
| a0d0f2651b | |||
| b6edbad744 | |||
| 05748e23b1 | |||
| 12d87be106 | |||
| dfb83cc1f7 | |||
| 1840ae80c2 | |||
| 2e9d1d83a4 | |||
| 1ef83a3089 | |||
| b95c06a5dc | |||
| 8f789841a6 | |||
| 0f57a4dfd7 | |||
| 125c277c88 | |||
| a7674d436d | |||
| 48ba4cdf42 | |||
| 8a9265eb79 | |||
| 215dc6ea6d | |||
| 428dcb2348 | |||
| 1c79d14260 | |||
| a82b487b92 | |||
| 068369fca2 | |||
| d524143a53 | |||
| 05ebfa68c9 | |||
| 57d8fd7e10 | |||
| 64c556a337 | |||
| de268d1168 | |||
| 8fd09bcad4 | |||
| 8cc7d46d7b | |||
| bb56815210 | |||
| 332f2ab89f | |||
| d3780244cd | |||
| f7bfbd5080 | |||
| eef2ee1baa | |||
| 8d8d7d0f8c | |||
| 979a3d8f1f | |||
| 95b8f54b27 | |||
| 5f1d411b28 | |||
| 6e3fcf363a | |||
| 8e129c561d | |||
| fbc3ca4517 | |||
| 1668dfcce6 | |||
| da2a58a88b | |||
| 8390a868ba | |||
| 296c498464 | |||
| a92ff89221 | |||
| e3b2f41a1c | |||
| 9b3424fa90 | |||
| 678d947f16 | |||
| 0cd1568c64 | |||
| 4e25dbf5e3 | |||
| 14e10c038c | |||
| 2bc5be2105 | |||
| 36b8de470a | |||
| 9abe3dd7b3 | |||
| 96c98a6987 | |||
| e586145f50 | |||
| 611fbfa6df | |||
| 54eb4f78b2 | |||
| 1ce7bb8c02 | |||
| ef17e86465 | |||
| cd3e0492f5 | |||
| c3f98e327c | |||
| 20fc34a6fd | |||
| 87af67c36c | |||
| f33335a768 | |||
| a9fffd44d2 | |||
| e81b7e2e61 | |||
| 7fad15524f | |||
| 8367efa174 | |||
| aa86b7efff | |||
| b490450107 | |||
| a4a7ff46c5 | |||
| 418eeb7317 | |||
| 5b0fcd77e5 | |||
| 757a73ca6b | |||
| faab29cab7 | |||
| d1d6f12f05 | |||
| a979e8430f | |||
| f5f045e8f1 | |||
| 90e291ac8a | |||
| b49e1b3a97 | |||
| 5245feb739 | |||
| 550c8fb899 | |||
| 89ffc5d246 | |||
| 3330873ce0 | |||
| ff998a8bd1 | |||
| a1517f82bc | |||
| d8885b089a | |||
| beba06b8ba | |||
| 762be492bb | |||
| c00efff554 | |||
| 5258ae4b34 | |||
| a8b19e73fe | |||
| 92944c7adc | |||
| 0042173ddc | |||
| a438b5ba17 | |||
| b86643c052 | |||
| e897182b23 | |||
| 56329f02b6 | |||
| 11ab579de9 | |||
| 9aa1de11af | |||
| 939b395dcd | |||
| 118e9bf50b | |||
| 67828365ca | |||
| e0d99257fe | |||
| e628a98cb8 | |||
| a4f9284970 | |||
| c403a716dd | |||
| 4278c8bacb | |||
| 56bfbeca58 | |||
| b6f9eae7b5 | |||
| cad9250cb7 | |||
| 4ac8095554 | |||
| 05d16983eb | |||
| 12f821fd6b | |||
| 13c8bf5f19 | |||
| 9a44e30e0f | |||
| bf77572041 | |||
| 18ba4851d1 | |||
| 6620034d98 | |||
| e93ecc7e4a | |||
| f85085de41 | |||
| 310943590b | |||
| c1dcd52a28 | |||
| 9bee5e449f | |||
| 02e99066ca | |||
| 7761cf6dbe | |||
| 7715d4bdda | |||
| f3c4906f88 | |||
| 3dc16fa07b | |||
| be58bdf24f | |||
| ac170c67bf | |||
| 169cd9d743 | |||
| a1931f08a8 | |||
| 5617206c6d | |||
| cee00b8b2b | |||
| 323a8acbd5 | |||
| d488b10848 | |||
| 24c99364ef | |||
| f8b6975e70 | |||
| c416e8c1fb | |||
| 51626dfd04 | |||
| cb05e58f1e | |||
| 24c8cfd402 | |||
| 8257b0b582 | |||
| e612f2c281 | |||
| 155df59b1d | |||
| 6efab9f386 | |||
| 422105a507 | |||
| d58438ab1d | |||
| 209aa1a89f | |||
| 07ac81583e | |||
| ee6249bb17 | |||
| b8ca3f8604 | |||
| 85b5469d96 | |||
| d3ddf7b191 | |||
| ebf196ec73 | |||
| 85c3833452 | |||
| 8fb4735fc2 | |||
| 6bb09f10d2 | |||
| a4ff67e8f4 | |||
| b9a1756f90 | |||
| 72ff8e67ed | |||
| d7006365eb | |||
| 86ef78b296 | |||
| 0d84e409c5 | |||
| 17c7b33167 | |||
| 856e1613a0 | |||
| 8db34289a7 | |||
| 3016f70349 | |||
| bdf212fdb3 | |||
| c6216f54f8 | |||
| 945c747e3e | |||
| 552fca8df7 | |||
| 4dac160619 | |||
| fc0e746e1b | |||
| 8374b5fc0c | |||
| 52923ed35b | |||
| f002ccfa3a | |||
| b64cc44531 | |||
| 1b51982551 | |||
| f4bbf6eb73 | |||
| 10c94efa57 | |||
| c84316e974 | |||
| 7e4d1fbf49 | |||
| 31c7c8b46f | |||
| 8cdb6646c4 | |||
| 43a51e5f2f | |||
| d0b54ea44b | |||
| c8fce82dc1 | |||
| 3ffccaa7e5 | |||
| 3f7e7a1474 | |||
| d30c15464b | |||
| 2a1f748469 | |||
| 74b429b1bf | |||
| 7afe1b517c | |||
| fcdcf5692f | |||
| 735baa67ed | |||
| ac00caf83e | |||
| 2ccda353e9 | |||
| 4257ca54b7 | |||
| 20dd2ef98d | |||
| 8be52c9ddd | |||
| 3bea6619fa | |||
| 0e5fb11c52 | |||
| fc47f7701d | |||
| 3e4ccf8de4 | |||
| 8abb3c709b | |||
| 5263900620 | |||
| 54ee9e5072 | |||
| 20f4857405 | |||
| 55110dd39d | |||
| 07d8111df9 | |||
| a701b97f1e | |||
| b025537d62 | |||
| e68bc2de65 | |||
| d0b1c93382 | |||
| a08fa9eabf | |||
| 65ef130bda | |||
| 32471a71e5 | |||
| ca80f01907 | |||
| b1fae57922 | |||
| dea01dda2d | |||
| 9777619259 | |||
| 6db0f84f8f | |||
| 84e382fcc5 | |||
| eceed1b934 | |||
| e7b5cd74a4 | |||
| a98930791c | |||
| 67b0389097 | |||
| ff30595fb4 | |||
| d272b62c45 | |||
| c1973023d0 | |||
| ef4689619e | |||
| 9ed297a49d | |||
| 9950729080 | |||
| 36eec9d061 | |||
| fd3a977009 | |||
| ff88832cca | |||
| d69b72b77d | |||
| d9bd9e996b | |||
| f45580be70 | |||
| 0fbeb11304 | |||
| 6a70c13b57 | |||
| f797520e56 | |||
| d060db3c96 | |||
| 298344c2ab | |||
| 53dbeadabb | |||
| 93087d7aa7 | |||
| c304517fc2 | |||
| af13e034c3 | |||
| e6b9d59160 | |||
| bd9cbe30c5 | |||
| f627226da9 | |||
| bab3272970 | |||
| 003085db7e | |||
| 8f98bd37c7 | |||
| 6971ef1ede | |||
| 0baac3dc49 | |||
| 16feb8602d | |||
| d623129d56 | |||
| 9cc4c33bb1 | |||
| c087b40d4a | |||
| a82e074bd4 | |||
| 3365c099bb | |||
| d8705de26f | |||
| 3af181e736 | |||
| ba6c01b7c6 | |||
| 7a7cdcb041 | |||
| 09e52bf199 | |||
| a8c39a1587 | |||
| 68536d6c34 | |||
| f57c27e755 | |||
| 9a97a27c8a | |||
| 07bb90dda9 | |||
| 3bb156f4da | |||
| e13e0efcc6 | |||
| 3ae0a94159 | |||
| eec67ec7dc | |||
| cf51853eec | |||
| 67838b28a4 | |||
| bf68e3b7d5 | |||
| 91ed0bb8bd | |||
| 55fe791c2a | |||
| 747efac2ec | |||
| a87df2e9f6 | |||
| 2e4a664744 | |||
| 579a22ea45 | |||
| abff850427 | |||
| f1154058ba | |||
| cf9f308b7f | |||
| 1791df236c | |||
| 7211f94f08 | |||
| 7b0343c87f | |||
| b80f0276b4 | |||
| 8f66fcbb00 | |||
| fe449ee1f3 | |||
| 34d6d95186 | |||
| 05ddc0660a | |||
| c6047a8428 | |||
| bc4838578e | |||
| 548996405a | |||
| a9a5f0bd14 | |||
| ec05ff6123 | |||
| 10c7ab421b | |||
| a8a5cc53ea | |||
| 8fe48ca438 | |||
| cbd5a515a9 | |||
| 5912adff93 | |||
| 983bc21448 | |||
| 6d08368462 | |||
| cde7bb3524 | |||
| 133dc91561 | |||
| f408f59beb | |||
| 8763bf11ab | |||
| 99592a52be | |||
| 25a8a52573 | |||
| 5901344459 | |||
| b07ae4bc42 | |||
| 0d9e61480a | |||
| baaea96b1d | |||
| e156cb5c2e | |||
| ff77b2b3dc | |||
| 4dc225184d | |||
| 49a0266224 | |||
| 7a62d96b91 | |||
| 641454fa14 | |||
| cf2f0ec936 | |||
| 76375c9471 | |||
| 0894c56c19 | |||
| 7de89ffe57 | |||
| e0c01343a8 | |||
| 48ed4f971c | |||
| 9e8c8a019e | |||
| 96f4ca2cc9 | |||
| fad59604f9 | |||
| ac0e8da0ba | |||
| fc5c3fd73e | |||
| 536fbb23a1 | |||
| ac01006398 | |||
| ade8654cc4 | |||
| 8e03f26fb5 | |||
| dab51bef7b | |||
| bea7fb5fe5 | |||
| d6b3240506 | |||
| 19e53d8bc8 | |||
| 5923d72803 | |||
| 8cde976358 | |||
| 256a5b50ac | |||
| 8b236f68f4 | |||
| c18f6d3978 | |||
| e65733754c | |||
| 4505630896 | |||
| 044804143f | |||
| 1e9d7c9b93 | |||
| 573fe3cad3 | |||
| cd3880c85c | |||
| 02cd8ed7ae | |||
| 3d69f96b96 | |||
| 963a15abe7 | |||
| 40542f0461 | |||
| bfc2af71a4 | |||
| 35142bb61d | |||
| 39626e0df9 | |||
| ca40730600 | |||
| 4158e196d6 | |||
| 1516e800dd | |||
| 0b96f69a1d | |||
| bb418e51d4 | |||
| 5fb23ab8bf | |||
| 117b01c48b | |||
| 18719815a3 | |||
| f795595107 | |||
| be3f9465eb | |||
| c598daab9b | |||
| 438b25672f | |||
| 1e12ddd8e2 | |||
| dccf55d57a | |||
| 3397845ccc | |||
| 82ae13ba3e | |||
| 8c830761f3 | |||
| ad5c134887 | |||
| 150bf124a9 | |||
| 1c96c0ccbf | |||
| cf8ad3d697 | |||
| 5b723ec954 | |||
| 633c708c33 | |||
| e4784108f7 | |||
| b4103a4edb | |||
| cfdd32708a | |||
| 8e97c1e9e8 | |||
| e009bebfaf | |||
| d3d190989d | |||
| 1b36b52019 | |||
| e526e96e2d | |||
| 1659d74938 | |||
| f7161dc39b | |||
| d8ea41e4dd | |||
| fe0a03db2f | |||
| 49afed7751 | |||
| a788b1706b | |||
| d57fd20ca2 | |||
| 5ecc1b09c8 | |||
| f1dfc9666b | |||
| b503584431 | |||
| 0dfe1c8e3a | |||
| c5630d90ec | |||
| f8973a3cf7 | |||
| 228be8376f | |||
| 3fee3b0224 | |||
| b4e705b347 | |||
| 51e59bebfc | |||
| a56ac31f4b | |||
| af05f081d2 | |||
| b97d79d261 | |||
| d227e6339f | |||
| 74c503fea0 | |||
| ed8b0c4d3c | |||
| cbb17e92f0 | |||
| f9d64cf777 | |||
| 7fdd6d19cf | |||
| 60ca252b63 | |||
| 2027837c18 | |||
| 67904568b1 | |||
| afbc559a47 | |||
| 67c312642c | |||
| 89eb007e43 | |||
| 88c3f3204b | |||
| 052ab98cd3 | |||
| cd01c0e31b | |||
| c3d3228f65 | |||
| f04647192f | |||
| c00f0c9f52 | |||
| 690e04c377 | |||
| 545880dcce | |||
| fedf5d2203 | |||
| 5242e55826 | |||
| 1bf86e73a7 | |||
| c3fe72f561 | |||
| b54555a4f7 | |||
| 4c080360a7 | |||
| fdea15eef4 | |||
| 5a4dd97abe | |||
| e47e3d936b | |||
| 0e9fd4373e | |||
| 69a10c754a | |||
| a8ba965c0d | |||
| 847aed3519 | |||
| fed9345bea | |||
| d71b6d0ab0 | |||
| 40e5521fbf | |||
| c7781d2e75 | |||
| 0ccf67000c | |||
| f4e81aca9d | |||
| 2d654cf759 | |||
| 0b728162ae | |||
| f52b426652 | |||
| ad0528d763 | |||
| 263adcf2b5 | |||
| b201e274f1 | |||
| c1a6a73b7d | |||
| ed4764cc70 | |||
| 8d84b3adbc | |||
| 35db674c88 | |||
| 3e1850a713 | |||
| 8c625c0ecd | |||
| bf1de8e7a4 | |||
| 8c2dca3770 | |||
| 23adbddfb7 | |||
| b1b6b64e68 | |||
| 0c7909df09 | |||
| 641bfd8c06 | |||
| 52140c3208 | |||
| 1baa9173cb | |||
| 26749c2116 | |||
| a1ecb9ad39 | |||
| b398fde24e | |||
| a5767b60fb | |||
| ef196c384c | |||
| 920def7446 | |||
| bec98dcaa2 | |||
| 747be07322 | |||
| c1fc6f0bce | |||
| 851c42e630 | |||
| 4e5a4a9c17 | |||
| df19d500de | |||
| 4fd4c9802e | |||
| 23d0eeab16 | |||
| b66557f3be | |||
| 580586cab3 | |||
| 9a97ef1dad | |||
| b4f7503d85 | |||
| b89406a89a | |||
| 932cdae22e | |||
| 34ef86cfff | |||
| 023bace2a9 | |||
| 467b63cffb | |||
| 49ffb0fa68 | |||
| 04984dea98 | |||
| 78e9a768b4 | |||
| 401f877388 | |||
| b3ba1aa3b9 | |||
| ae76aebe24 | |||
| b5e1bf2867 | |||
| fb3a0da54f | |||
| 7d197f405d | |||
| bc7af3a68b | |||
| 120e79229a | |||
| f0e4bbcda9 | |||
| 57e31c1dfb | |||
| 8f2f414f5e | |||
| 879f91693e | |||
| 0c504b52e4 | |||
| bbaa5f9fa8 | |||
| 2a9f7fde28 | |||
| 0ec7c87851 | |||
| cf515abc4c | |||
| 99cab08ab7 | |||
| e60d40ee9c | |||
| c90fa68f16 | |||
| 61ae86e927 | |||
| 86c995906c | |||
| a77b88aaf6 | |||
| 0d288f3206 | |||
| d9d6244931 | |||
| 4d984f0524 | |||
| b456b0143b | |||
| 3ebcfd5598 | |||
| 745ea3509e | |||
| 8f5d4e5bc2 | |||
| acd7d3bbac | |||
| f3bd6598e7 | |||
| ce48fdc445 | |||
| 5aea223c14 | |||
| 1f59d3179c | |||
| 1f6e1db695 | |||
| 5b46c372ab | |||
| f143d3e1c2 | |||
| c7639309ef | |||
| b788531e47 | |||
| 00fa2ca804 | |||
| 74cdf09ebc | |||
| 80ec12740a | |||
| a0002ff97b | |||
| 2cb0db02e3 | |||
| bed8d71f7e | |||
| 668b385a10 | |||
| d96df469a4 | |||
| 8edbe1aa2c | |||
| 99ece9a61a | |||
| 4b5253dd6b | |||
| 5e10294a16 | |||
| 226d2069d9 | |||
| 8d1365b712 | |||
| 45624d905e | |||
| a5d9c931ca | |||
| f0db2b2f6e | |||
| cde5913ace | |||
| ceea9fc501 | |||
| eb678b4533 | |||
| dcbe8bb3dc | |||
| 555310a4ca | |||
| 3a57358f58 | |||
| 9982ba2fec | |||
| 11b652f851 | |||
| 380950a615 | |||
| e8b07738a5 | |||
| 9d1163e73e | |||
| e2f0c73bab | |||
| 183873afff | |||
| b356475741 | |||
| 279bae115a | |||
| 82bb5a96ad | |||
| a4e9ffcc99 | |||
| 1c25427c5c | |||
| 2ec22eb6cd | |||
| 2ff508607d | |||
| 69a34c65f7 | |||
| f34c1520a4 | |||
| f3fa8eec50 | |||
| 9baa9b1dd6 | |||
| c1c1af1ded | |||
| 4a8c1b7de4 | |||
| 1f35165919 | |||
| 2e85508426 | |||
| 631b218057 | |||
| e8cfc88d83 | |||
| 78502a0cd0 | |||
| 2bcb6a146b | |||
| ae474b5e5b | |||
| babed18af0 | |||
| 3fa7e3e36d | |||
| 45587d5f15 | |||
| e98f6997ca | |||
| 21d1b71653 | |||
| afd7b5650f | |||
| a0687d555a | |||
| f543953389 | |||
| 2c4c7586b7 | |||
| e92921a6cc | |||
| 0322bcd047 | |||
| 26c914be96 | |||
| 75863faf58 | |||
| 99b3ec4ce3 | |||
| d97ffd863b |
@@ -8,6 +8,9 @@ dotnet_style_operator_placement_when_wrapping = beginning_of_line
|
||||
tab_width = 4
|
||||
indent_size = 4
|
||||
end_of_line = crlf
|
||||
[XamlTypeInfo.g.cs]
|
||||
dotnet_diagnostic.CS0612.severity = none
|
||||
dotnet_diagnostic.CS0618.severity = none
|
||||
dotnet_style_coalesce_expression = true:suggestion
|
||||
dotnet_style_null_propagation = true:suggestion
|
||||
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
|
||||
@@ -149,7 +152,7 @@ csharp_preferred_modifier_order = public,private,protected,internal,static,exter
|
||||
# Code-block preferences
|
||||
csharp_prefer_braces = true:silent
|
||||
csharp_prefer_simple_using_statement = true:suggestion
|
||||
csharp_style_namespace_declarations = block_scoped:silent
|
||||
csharp_style_namespace_declarations = file_scoped:error
|
||||
|
||||
# Expression-level preferences
|
||||
csharp_prefer_simple_default_expression = true:suggestion
|
||||
@@ -288,3 +291,5 @@ csharp_style_prefer_utf8_string_literals = true:suggestion
|
||||
csharp_style_prefer_readonly_struct = true:suggestion
|
||||
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
|
||||
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
|
||||
csharp_style_prefer_primary_constructors = true:silent
|
||||
csharp_prefer_system_threading_lock = true:suggestion
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,125 @@
|
||||
name: PR WinUI Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
build-winui:
|
||||
name: Build project (${{ matrix.platform }})
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: windows-latest
|
||||
continue-on-error: ${{ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: x86
|
||||
rid: win-x86
|
||||
- platform: x64
|
||||
rid: win-x64
|
||||
- platform: ARM64
|
||||
rid: win-arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET SDK
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
source-url: https://nuget.pkg.github.com/bkaankose/index.json
|
||||
env:
|
||||
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Restore WinUI project dependencies
|
||||
run: dotnet restore Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configfile nuget.config -p:Platform=${{ matrix.platform }} -p:RuntimeIdentifier=${{ matrix.rid }}
|
||||
|
||||
- name: Build WinUI project
|
||||
run: dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configuration Release --no-restore -p:Platform=${{ matrix.platform }} -p:RuntimeIdentifier=${{ matrix.rid }} -p:GenerateAppxPackageOnBuild=false -p:AppxPackageSigningEnabled=false
|
||||
|
||||
core-tests:
|
||||
name: Run Core tests
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: windows-latest
|
||||
continue-on-error: ${{ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET SDK
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
source-url: https://nuget.pkg.github.com/bkaankose/index.json
|
||||
env:
|
||||
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Restore Core test projects
|
||||
shell: pwsh
|
||||
run: |
|
||||
$coreTests = Get-ChildItem -Path . -Recurse -Filter "*Core*.Tests.csproj" | ForEach-Object { $_.FullName }
|
||||
if (-not $coreTests) {
|
||||
throw "No Core test projects were found."
|
||||
}
|
||||
|
||||
foreach ($project in $coreTests) {
|
||||
dotnet restore $project --configfile nuget.config
|
||||
}
|
||||
|
||||
- name: Run Core test projects
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path TestResults -Force | Out-Null
|
||||
$coreTests = Get-ChildItem -Path . -Recurse -Filter "*Core*.Tests.csproj"
|
||||
if (-not $coreTests) {
|
||||
throw "No Core test projects were found."
|
||||
}
|
||||
|
||||
foreach ($project in $coreTests) {
|
||||
$name = $project.BaseName
|
||||
dotnet test $project.FullName --configuration Release --no-restore --verbosity normal --logger "trx;LogFileName=$name.trx" --results-directory TestResults
|
||||
}
|
||||
|
||||
- name: Upload Core test result artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: core-test-results
|
||||
path: TestResults/*.trx
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Publish Core test report
|
||||
if: always()
|
||||
uses: EnricoMi/publish-unit-test-result-action/windows@v2
|
||||
with:
|
||||
trx_files: TestResults/*.trx
|
||||
check_name: Core test results
|
||||
|
||||
enforce-for-non-maintainers:
|
||||
name: Enforce required checks (non-maintainers)
|
||||
if: github.event.pull_request.draft == false && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-winui
|
||||
- core-tests
|
||||
|
||||
steps:
|
||||
- name: Fail when build or tests fail for non-maintainers
|
||||
if: needs.build-winui.result != 'success' || needs.core-tests.result != 'success'
|
||||
run: |
|
||||
echo "WinUI build and Core tests must pass for non-maintainer pull requests."
|
||||
exit 1
|
||||
|
||||
- name: Confirm build and test success for non-maintainers
|
||||
run: echo "WinUI build and Core tests passed."
|
||||
@@ -206,9 +206,6 @@ PublishScripts/
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Nuget personal access tokens and Credentials
|
||||
nuget.config
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
@@ -402,3 +399,4 @@ Wino/obj/x86/Debug/XamlSaveStateFile.xml
|
||||
Wino/obj/x86/Debug/XamlSaveStateFile.xml
|
||||
*.cache
|
||||
.vs/Wino/v16/.suo
|
||||
/.claude/settings.local.json
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI agent when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Wino Mail is a native Windows mail client (Windows 10 1809+ / Windows 11) replacing the deprecated Windows Mail & Calendar. It's **transitioning from UWP to WinUI 3** - always work with WinUI projects (Wino.Mail.WinUI), never edit the deprecated Wino.Mail UWP project.
|
||||
|
||||
## Build and Development Commands
|
||||
|
||||
```bash
|
||||
# Open solution
|
||||
# WinoMail.slnx is the main solution file (VS 2022+)
|
||||
|
||||
# Build WinUI project (Debug x64)
|
||||
dotnet restore Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configfile nuget.config -p:Platform=x64 -p:RuntimeIdentifier=win-x64 && dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj -c Debug --no-restore /p:Platform=x64 /p:RuntimeIdentifier=win-x64 /p:GenerateAppxPackageOnBuild=false /p:AppxPackageSigningEnabled=false
|
||||
|
||||
# Build WinUI project with diagnostic XAML/compiler logging (use when plain build only shows "XamlCompiler.exe exited with code 1")
|
||||
dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj -c Debug --no-restore /p:Platform=x64 /p:RuntimeIdentifier=win-x64 /p:GenerateAppxPackageOnBuild=false /p:AppxPackageSigningEnabled=false "/flp:logfile=winui-build.log;verbosity=diagnostic" /bl:winui-build.binlog
|
||||
|
||||
# Run tests (Debug x64)
|
||||
dotnet test Wino.Core.Tests/Wino.Core.Tests.csproj -c Debug /p:Platform=x64
|
||||
|
||||
# Copilot CLI build command (Debug x64)
|
||||
dotnet restore Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configfile nuget.config -p:Platform=x64 -p:RuntimeIdentifier=win-x64 && dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj -c Debug --no-restore /p:Platform=x64 /p:RuntimeIdentifier=win-x64 /p:GenerateAppxPackageOnBuild=false /p:AppxPackageSigningEnabled=false
|
||||
```
|
||||
|
||||
**Prerequisites:** Visual Studio 2022+ with ".NET desktop development" workload, .NET SDK 10+
|
||||
|
||||
**Startup project:** Wino.Mail.WinUI
|
||||
|
||||
**Platforms:** x86, x64, ARM64
|
||||
|
||||
## Efficient Workflow
|
||||
|
||||
- Start with targeted symbol or file search before reading full files
|
||||
- Prefer one focused task per thread; use a new thread for unrelated follow-up work
|
||||
- Keep verification narrow: build only the affected project, not the full solution, unless cross-project changes require it
|
||||
- After the first restore, prefer `--no-restore` builds unless package or project references changed
|
||||
- Summarize long build logs and inspect only the files named in diagnostics instead of loading large logs into context
|
||||
- When the prompt already names likely files, types, or symbols, start there instead of re-mapping the repository
|
||||
- If a WinUI build only reports `XamlCompiler.exe exited with code 1`, rerun with the diagnostic logging command above and inspect the terminal output plus `winui-build.log` for real `WMC`/`WMC1121`/binding diagnostics before guessing
|
||||
|
||||
## Architecture
|
||||
|
||||
### Solution Structure
|
||||
```
|
||||
Wino.Core.Domain → Entities, interfaces, translations, enums (shared contracts)
|
||||
Wino.Core → Synchronization engine, authenticators, request processing
|
||||
Wino.Services → Database, mail, folder, account services
|
||||
Wino.Authentication → OAuth2 authenticators (Outlook, Gmail)
|
||||
Wino.Mail.ViewModels → Mail-specific ViewModels
|
||||
Wino.Core.ViewModels → Shared ViewModels (settings, personalization)
|
||||
Wino.Messaging → Pub-sub message definitions
|
||||
Wino.Mail.WinUI → **Active WinUI 3 UI project** (use this)
|
||||
Wino.Mail → **Deprecated UWP project** (DO NOT EDIT)
|
||||
```
|
||||
|
||||
### Mail Synchronization Flow
|
||||
1. **WinoRequestDelegator** → Validates and delegates user actions (mark read, delete, move)
|
||||
2. **WinoRequestProcessor** → Batches requests using RequestComparer, queues to synchronizers
|
||||
3. **Synchronizers** (OutlookSynchronizer, GmailSynchronizer, ImapSynchronizer) → Execute batched operations
|
||||
4. **ChangeProcessors** → Apply changes to local SQLite database
|
||||
5. Database updates trigger **Messenger** events (MailAddedMessage, MailUpdatedMessage, etc.)
|
||||
|
||||
### Synchronizer Types
|
||||
- **OutlookSynchronizer** - Microsoft Graph SDK for Office 365
|
||||
- **GmailSynchronizer** - Gmail API
|
||||
- **ImapSynchronizer** - MimeKit/MailKit for IMAP/SMTP
|
||||
|
||||
### Queue-Based Sync Pattern
|
||||
- Initial sync queues mail IDs first (MailItemQueue table), downloads metadata only
|
||||
- MIME content downloaded on-demand when user opens mail
|
||||
- Check `MailItemFolder.IsInitialSyncCompleted` for sync state
|
||||
- See QUEUE_SYNC_IMPLEMENTATION.md for details
|
||||
|
||||
### Dependency Injection
|
||||
- `RegisterCoreServices()` in Wino.Core/CoreContainerSetup.cs
|
||||
- `RegisterSharedServices()` in Wino.Services/ServicesContainerSetup.cs
|
||||
- ViewModels registered in App.xaml.cs
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### MVVM with Source Generators
|
||||
**CORRECT - use public partial properties:**
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
public partial string SearchQuery { get; set; } = string.Empty;
|
||||
```
|
||||
|
||||
**WRONG - will not work:**
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
private string searchQuery = string.Empty;
|
||||
```
|
||||
|
||||
### Messenger Pattern
|
||||
- ViewModels inherit from CoreBaseViewModel or MailBaseViewModel
|
||||
- Register handlers in `RegisterRecipients()`, unregister in `UnregisterRecipients()`
|
||||
- Send via `WeakReferenceMessenger.Default.Send(new MessageType(...))`
|
||||
|
||||
### Data Binding - No Converters
|
||||
- **NEVER** create IValueConverter classes
|
||||
- WinUI 3 auto-converts bool to Visibility: `Visibility="{x:Bind IsVisible, Mode=OneWay}"`
|
||||
- Use XamlHelpers for complex conversions: `{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(Prop)}`
|
||||
- `x:Bind` does not implicitly convert `double` to `GridLength`; when binding `RowDefinition.Height` or `ColumnDefinition.Width`, use a `XamlHelpers` method such as `DoubleToGridLength(...)`
|
||||
- For `ComboBox` controls in XAML, never use `DisplayMemberPath` or `SelectedValuePath`; use a typed `ItemTemplate` and bind `SelectedItem` explicitly, preferably with `x:Bind`
|
||||
|
||||
## Localization
|
||||
|
||||
1. Add English strings ONLY to Wino.Core.Domain/Translations/en_US/resources.json
|
||||
2. Build project - source generators create Translator properties
|
||||
3. Use Translator.{PropertyName} in code/XAML
|
||||
4. NEVER edit any resources.json file outside Wino.Core.Domain/Translations/en_US/resources.json
|
||||
5. Treat all non-en_US translation files as managed externally and leave them untouched, even when adding new localization keys
|
||||
6. In XAML, translation bindings must use `Mode=OneTime` because `Wino.Core.Domain/Translator.cs` does not implement `INotifyPropertyChanged`
|
||||
|
||||
## Storage
|
||||
|
||||
- **SQLite database** in publisher cache folder (shared with future Wino Calendar)
|
||||
- **EML files** in app local storage, referenced by `MailCopy.FileId`
|
||||
- Paths resolved via `MimeFileService.GetMimeMessagePath()`
|
||||
|
||||
## WebView2 Mail Rendering
|
||||
|
||||
- `reader.html` for reading mails, `editor.html` for composing (Jodit editor)
|
||||
- Virtual host mapping: `https://wino.mail/reader.html`
|
||||
- JavaScript interop via `ExecuteScriptFunctionAsync()`
|
||||
- MIME content downloaded on-demand, not during sync
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Forgetting to register ViewModels in App.xaml.cs `RegisterViewModels()`
|
||||
- Not calling `RegisterRecipients()` for message handlers
|
||||
- Using private fields with `[ObservableProperty]` instead of public partial
|
||||
- Creating IValueConverter classes instead of using XamlHelpers
|
||||
- Editing UWP project files instead of WinUI equivalents
|
||||
- Hardcoding strings instead of using Translator
|
||||
- Forgetting to unregister Messenger recipients (memory leaks)
|
||||
- Putting authentication validation, token refresh, account API calls, settings serialization/deserialization, or preference-application logic into ViewModels instead of the corresponding service
|
||||
|
||||
## Code Style
|
||||
|
||||
- Avoid introducing new NuGet packages when possible
|
||||
- Use existing libraries (MimeKit, MailKit, Microsoft Graph, Gmail API)
|
||||
- Use `var` where type is obvious
|
||||
- String interpolation over string.Format
|
||||
- Wrap async operations in try-catch
|
||||
- Log errors via IWinoLogger
|
||||
- For dependency properties in WinUI code, always prefer `[GeneratedDependencyProperty]` from CommunityToolkit over manual `DependencyProperty.Register(...)` declarations.
|
||||
- When a `[RelayCommand]` needs enable/disable logic, prefer the command's `CanExecute` over binding `Button.IsEnabled` in XAML; use `[NotifyCanExecuteChangedFor]` on dependent properties and call `NotifyCanExecuteChanged()` explicitly when non-generated state affects the command.
|
||||
- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`).
|
||||
- ViewModels should only handle UI interaction/state and delegate business logic to services; account-management work belongs in `WinoAccountProfileService`, and preferences import/export/apply logic belongs in `PreferencesService`.
|
||||
- In `EventDetailsPageViewModel.LoadAttendeesAsync`, never mutate `CurrentEvent.Attendees` outside `ExecuteUIThread(...)`.
|
||||
- Never create pure C# controls or controls that heavily manipulate UI structure from `.cs` files. Define controls in XAML and keep UI composition in XAML.
|
||||
- Never add XAML-backed UI controls to `.xaml.cs`. If a view has XAML, all control declarations, flyouts, templates, and visual composition belong in the `.xaml` file; keep `.xaml.cs` limited to event handling and view glue.
|
||||
- Never subscribe to framework events like `Loaded`, `Unloaded`, or input events from constructors in `.xaml.cs` for XAML-backed controls and pages; wire them directly in XAML instead.
|
||||
- If you use `x:Load` in XAML, always give that `UIElement` an `x:Name`.
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Released on April 15. Second bugfix/improvements update for v2 beta testing.
|
||||
|
||||
- [Ability to delete custom themes](https://github.com/bkaankose/Wino-Mail/issues/844)
|
||||
- [[Proposal] Reply/Reply all sets focus to the "To" line versus Body](https://github.com/bkaankose/Wino-Mail/issues/844274)
|
||||
- Email categories. Online sync for Outlook, offline use for IMAP/Gmail.
|
||||
- Handling of read-only calendars.
|
||||
- Implemented a new Github action workflow to trigger beta releases on demand.
|
||||
@@ -15,7 +15,12 @@ Wino Mail is [Universal Windows Platform](https://learn.microsoft.com/en-us/wind
|
||||
**Min Version:** Windows 10 1809
|
||||
**Target Version:** Windows 11 22H2
|
||||
|
||||
It's pretty straightforward after cloning the repo. There are no prerequisites needed. Just open **Wino.sln** solution in your IDE and launch.
|
||||
## Prerequisites
|
||||
|
||||
* ".NET desktop development" workload in Visual Studio 2022+
|
||||
* .NET SDK 8.0+
|
||||
|
||||
With those installed, it's pretty straightforward after cloning the repo. Just open **Wino.sln** solution in your IDE and launch.
|
||||
|
||||
## Project Architecture
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<Configurations>Debug;Release</Configurations>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,80 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="ColorHashSharp" Version="1.1.0" />
|
||||
<PackageVersion Include="CommunityToolkit.Common" Version="8.4.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Diagnostics" Version="8.4.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Animations" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Segmented" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Controls.TabbedCommandBar" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Controls.TokenizingTextBox" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.250926-build.2293" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Lottie" Version="8.2.250604" />
|
||||
<PackageVersion Include="CommunityToolkit.Labs.WinUI.DependencyPropertyGenerator" Version="0.1.250926-build.2293" />
|
||||
<PackageVersion Include="EmailValidation" Version="1.3.0" />
|
||||
<PackageVersion Include="gravatar-dotnet" Version="0.1.3" />
|
||||
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageVersion Include="Ical.Net" Version="5.2.1" />
|
||||
<PackageVersion Include="IsExternalInit" Version="1.0.3" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.Graph" Version="5.103.0" />
|
||||
<PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.4.0" />
|
||||
<PackageVersion Include="Microsoft.Identity.Client" Version="4.83.3" />
|
||||
<PackageVersion Include="Microsoft.Identity.Client.Broker" Version="4.83.3" />
|
||||
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.3" />
|
||||
<PackageVersion Include="Microsoft.NETCore.UniversalWindowsPlatform" Version="6.2.14" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.260316102" />
|
||||
<PackageVersion Include="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="3.0.1" />
|
||||
<PackageVersion Include="Wino.Mail.Contracts" Version="1.0.18" />
|
||||
<PackageVersion Include="MimeKit" Version="4.15.1" />
|
||||
<PackageVersion Include="morelinq" Version="4.4.0" />
|
||||
<PackageVersion Include="Nito.AsyncEx" Version="5.1.2" />
|
||||
<PackageVersion Include="Nito.AsyncEx.Tasks" Version="5.1.2" />
|
||||
<PackageVersion Include="NodaTime" Version="3.3.1" />
|
||||
<PackageVersion Include="Sentry.Serilog" Version="6.3.1" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.1" />
|
||||
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.ApplicationInsights" Version="4.0.0" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.119.2" />
|
||||
<PackageVersion Include="SkiaSharp.Views.WinUI" Version="3.119.2" />
|
||||
<PackageVersion Include="sqlite-net-pcl" Version="1.10.196-beta" />
|
||||
<PackageVersion Include="System.Drawing.Common" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
|
||||
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.3.0" />
|
||||
<PackageVersion Include="H.NotifyIcon.WinUI" Version="2.4.1" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" />
|
||||
<PackageVersion Include="Google.Apis.Auth" Version="1.73.0" />
|
||||
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.73.0.4073" />
|
||||
<PackageVersion Include="Google.Apis.Drive.v3" Version="1.73.0.4098" />
|
||||
<PackageVersion Include="Google.Apis.Gmail.v1" Version="1.73.0.4029" />
|
||||
<PackageVersion Include="Google.Apis.PeopleService.v1" Version="1.72.0.3973" />
|
||||
<PackageVersion Include="HtmlKit" Version="1.2.0" />
|
||||
<PackageVersion Include="MailKit" Version="4.15.1" />
|
||||
<PackageVersion Include="TimePeriodLibrary.NET" Version="2.1.6" />
|
||||
<PackageVersion Include="System.Reactive" Version="6.1.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Text.Encodings.Web" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.250930001-experimental1" />
|
||||
<PackageVersion Include="WinUIEx" Version="2.9.0" />
|
||||
<!-- Testing packages -->
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageVersion Include="FluentAssertions" Version="8.9.0" />
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,64 +1,70 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true
|
||||
&mode=full">
|
||||
<img src="https://www.winomail.app/images/wino_logo.png" width=90 height=90>
|
||||
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true&mode=full">
|
||||
<img src="https://www.winomail.app/images/v2/Logo.png" width="90" height="90" alt="Wino Mail logo">
|
||||
</a>
|
||||
|
||||
<h3 align="center">Wino Mail</h3>
|
||||
|
||||
<p align="center">
|
||||
Native mail client for Windows device families.
|
||||
Native mail and calendar client for Windows.
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
|
||||
|
||||
- API integration for Outlook and Gmail
|
||||
- IMAP/SMTP support for custom mail servers
|
||||
- Send, receive, mark as (read,important,spam etc), move mails.
|
||||
- Toast notifications with background sync.
|
||||
- Instant startup performance
|
||||
- Offline use / search.
|
||||
- Modern and responsive UI
|
||||
- Lots of personalization options
|
||||
- Dark / Light mode for mail reader
|
||||
- 📨 Outlook and Gmail API integration
|
||||
- 🌐 IMAP/SMTP support for custom mail servers
|
||||
- 📅 Calendar support with event creation/compose and reminders
|
||||
- 👥 Contact management and people-centric account experience
|
||||
- ✅ Core mail actions: send, receive, read/unread, move, spam, and more
|
||||
- 🔗 Linked/Merged accounts
|
||||
- 🔔 Toast notifications with background sync
|
||||
- ⚡ Instant startup-oriented architecture
|
||||
- 🔎 Offline-capable workflows and search improvements
|
||||
- 🎛️ Modern responsive WinUI interface with personalization options
|
||||
- 🌗 Dark/Light mode for mail reader and app surfaces
|
||||
|
||||
## Download
|
||||
|
||||
Download latest version of Wino Mail from Microsoft Store for free.
|
||||
|
||||
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true
|
||||
&mode=full">
|
||||
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200"/>
|
||||
<a href="https://apps.microsoft.com/detail/Wino%20Mail/9NCRCVJC50WL?launch=true&mode=full">
|
||||
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200" alt="Get Wino Mail from Microsoft Store"/>
|
||||
</a>
|
||||
|
||||
## Beta Releases
|
||||
|
||||
Stable releases will always be distributed on Microsoft Store. However, beta releases will be distributed in [GitHub Releases](https://github.com/bkaankose/Wino-Mail/releases). Please keep in mind that beta releases might not be for daily use, only for testing purposes and recommended for experienced users or developers.
|
||||
Stable releases will always be distributed on Microsoft Store. However, beta releases will be distributed in [GitHub Releases](https://github.com/bkaankose/Wino-Mail/releases). Please keep in mind that beta releases might not be for daily use, only for testing purposes and recommended for experienced users or developers. Beta releases are also managed manually. Therefore, code in the repository might be ahead of the released Beta version at the moment. Make sure to compare versions before tryout out the Beta version.
|
||||
|
||||
These releases are distributed as side-loaded packages. To install them, download the **.msixbundle** file in GitHub releases and [follow the steps explained here.](https://learn.microsoft.com/en-us/windows/application-management/sideload-apps-in-windows)
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Check out the [contribution guidelines](/CONTRIBUTING.md) before diving into the source code or opening an issue. There are multiple ways to contribute and all of them are explained in detail there.
|
||||
|
||||
#### Attention
|
||||
|
||||
Sources here **does not belong to the Store version of Wino Mail. It belongs to beta release as of April 17 2024.** I've been working on a big patch for couple months already and the code here includes those changes, but these changes are not yet released to Microsoft Store. Therefore, if you'd like to contribute, please validate the bug before in beta version and start working on it. I will delete this text from here once this big patch goes alive in the Store, so everything will be aligned then.
|
||||
|
||||
## Donate
|
||||
|
||||
Your donations will motivate me more to work on Wino in my spare time and cover the expenses to keep [project's website](https://www.winomail.app/) alive.
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"AttributesTolerance": 2,
|
||||
"KeepFirstAttributeOnSameLine": false,
|
||||
"MaxAttributeCharactersPerLine": 0,
|
||||
"MaxAttributesPerLine": 1,
|
||||
"NewlineExemptionElements": "RadialGradientBrush, GradientStop, LinearGradientBrush, ScaleTransform, SkewTransform, RotateTransform, TranslateTransform, Trigger, Condition, Setter",
|
||||
"SeparateByGroups": false,
|
||||
"AttributeIndentation": 0,
|
||||
"AttributeIndentationStyle": 1,
|
||||
"RemoveDesignTimeReferences": false,
|
||||
"IgnoreDesignTimeReferencePrefix": false,
|
||||
"EnableAttributeReordering": true,
|
||||
"AttributeOrderingRuleGroups": [
|
||||
"x:Class",
|
||||
"xmlns, xmlns:x",
|
||||
"xmlns:*",
|
||||
"x:Key, Key, x:Name, Name, x:Uid, Uid, Title",
|
||||
"Grid.Row, Grid.RowSpan, Grid.Column, Grid.ColumnSpan, Canvas.Left, Canvas.Top, Canvas.Right, Canvas.Bottom",
|
||||
"Width, Height, MinWidth, MinHeight, MaxWidth, MaxHeight",
|
||||
"Margin, Padding, HorizontalAlignment, VerticalAlignment, HorizontalContentAlignment, VerticalContentAlignment, Panel.ZIndex",
|
||||
"*:*, *",
|
||||
"PageSource, PageIndex, Offset, Color, TargetName, Property, Value, StartPoint, EndPoint",
|
||||
"mc:Ignorable, d:IsDataSource, d:LayoutOverrides, d:IsStaticText",
|
||||
"Storyboard.*, From, To, Duration"
|
||||
],
|
||||
"FirstLineAttributes": "",
|
||||
"OrderAttributesByName": true,
|
||||
"PutEndingBracketOnNewLine": false,
|
||||
"RemoveEndingTagOfEmptyElement": true,
|
||||
"SpaceBeforeClosingSlash": true,
|
||||
"RootElementLineBreakRule": 0,
|
||||
"ReorderVSM": 2,
|
||||
"ReorderGridChildren": false,
|
||||
"ReorderCanvasChildren": false,
|
||||
"ReorderSetters": 0,
|
||||
"FormatMarkupExtension": true,
|
||||
"NoNewLineMarkupExtensions": "x:Bind, Binding",
|
||||
"ThicknessSeparator": 2,
|
||||
"ThicknessAttributes": "Margin, Padding, BorderThickness, ThumbnailClipMargin",
|
||||
"FormatOnSave": true,
|
||||
"CommentPadding": 2,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Authentication;
|
||||
|
||||
public abstract class BaseAuthenticator
|
||||
{
|
||||
public abstract MailProviderType ProviderType { get; }
|
||||
protected IAuthenticatorConfig AuthenticatorConfig { get; }
|
||||
|
||||
protected BaseAuthenticator(IAuthenticatorConfig authenticatorConfig)
|
||||
{
|
||||
|
||||
AuthenticatorConfig = authenticatorConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Apis.Auth.OAuth2;
|
||||
using Google.Apis.Util.Store;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Authentication;
|
||||
|
||||
namespace Wino.Authentication;
|
||||
|
||||
public class GmailAuthenticator : BaseAuthenticator, IGmailAuthenticator
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Identity.Client;
|
||||
using Microsoft.Identity.Client.Broker;
|
||||
using Microsoft.Identity.Client.Extensions.Msal;
|
||||
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;
|
||||
|
||||
namespace Wino.Authentication;
|
||||
|
||||
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)
|
||||
{
|
||||
_nativeAppService = nativeAppService;
|
||||
_applicationConfiguration = applicationConfiguration;
|
||||
|
||||
var authenticationRedirectUri = nativeAppService.GetWebAuthenticationBrokerUri();
|
||||
|
||||
var options = new BrokerOptions(BrokerOptions.OperatingSystems.Windows)
|
||||
{
|
||||
Title = "Wino Mail",
|
||||
ListOperatingSystemAccounts = true,
|
||||
};
|
||||
|
||||
PublicClientApplicationBuilder outlookAppBuilder = null;
|
||||
|
||||
// Being created from an app notification.
|
||||
// This is where we avoid all interactive shit for authentication.
|
||||
if (nativeAppService.GetCoreWindowHwnd == null)
|
||||
{
|
||||
outlookAppBuilder = PublicClientApplicationBuilder.Create(AuthenticatorConfig.OutlookAuthenticatorClientId)
|
||||
.WithDefaultRedirectUri()
|
||||
.WithBroker(options)
|
||||
.WithAuthority(Authority);
|
||||
}
|
||||
else
|
||||
{
|
||||
outlookAppBuilder = PublicClientApplicationBuilder.Create(AuthenticatorConfig.OutlookAuthenticatorClientId)
|
||||
.WithBroker(options)
|
||||
.WithParentActivityOrWindow(_nativeAppService.GetCoreWindowHwnd)
|
||||
.WithDefaultRedirectUri()
|
||||
.WithAuthority(Authority);
|
||||
}
|
||||
|
||||
_publicClientApplication = outlookAppBuilder.Build();
|
||||
}
|
||||
|
||||
public string[] Scope => AuthenticatorConfig.OutlookScope;
|
||||
|
||||
private async Task EnsureTokenCacheAttachedAsync()
|
||||
{
|
||||
if (!isTokenCacheAttached)
|
||||
{
|
||||
var storageProperties = new StorageCreationPropertiesBuilder(TokenCacheFileName, _applicationConfiguration.PublisherSharedFolderPath).Build();
|
||||
var msalcachehelper = await MsalCacheHelper.CreateAsync(storageProperties);
|
||||
msalcachehelper.RegisterCache(_publicClientApplication.UserTokenCache);
|
||||
|
||||
isTokenCacheAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TokenInformationEx> GetTokenInformationAsync(MailAccount account)
|
||||
{
|
||||
await EnsureTokenCacheAttachedAsync();
|
||||
|
||||
var storedAccount = (await _publicClientApplication.GetAccountsAsync()).FirstOrDefault(
|
||||
a => string.Equals(a.Username?.Trim(), account.Address?.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (storedAccount == null)
|
||||
return await GenerateTokenInformationAsync(account);
|
||||
|
||||
try
|
||||
{
|
||||
var authResult = await _publicClientApplication.AcquireTokenSilent(Scope, storedAccount).ExecuteAsync();
|
||||
|
||||
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
|
||||
}
|
||||
catch (MsalUiRequiredException)
|
||||
{
|
||||
// Somehow MSAL is not able to refresh the token silently.
|
||||
// Force interactive login which will include calendar scopes.
|
||||
// The calling code should update account.IsCalendarAccessGranted = true after successful authentication.
|
||||
|
||||
return await GenerateTokenInformationAsync(account);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureTokenCacheAttachedAsync();
|
||||
|
||||
// Interactive authentication required but window doesn't exist.
|
||||
// This can happen when being called from a notification background task and the token is expired.
|
||||
// Force account attention;
|
||||
|
||||
if (_nativeAppService.GetCoreWindowHwnd == null) throw new AuthenticationAttentionException(account);
|
||||
|
||||
AuthenticationResult authResult = await _publicClientApplication
|
||||
.AcquireTokenInteractive(Scope)
|
||||
.ExecuteAsync();
|
||||
|
||||
// If the account is null, it means it's the initial creation of it.
|
||||
// If not, make sure the authenticated user address matches the username.
|
||||
// When people refresh their token, accounts must match.
|
||||
|
||||
if (account?.Address != null && !account.Address.Equals(authResult.Account.Username, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new AuthenticationException("Authenticated address does not match with your account address. If you are signing with a Office365, it is not officially supported yet.");
|
||||
}
|
||||
|
||||
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<RootNamespace>Wino.Authentication</RootNamespace>
|
||||
<Platforms>x86;x64;arm64</Platforms>
|
||||
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
|
||||
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
|
||||
<IsTrimmable>true</IsTrimmable>
|
||||
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Diagnostics" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Google.Apis.Auth" />
|
||||
<PackageReference Include="Microsoft.Identity.Client" />
|
||||
<PackageReference Include="Microsoft.Identity.Client.Broker" />
|
||||
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" />
|
||||
<PackageReference Include="Sentry.Serilog" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
|
||||
<ProjectReference Include="..\Wino.Messages\Wino.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,34 +0,0 @@
|
||||
using Microsoft.Toolkit.Uwp.Notifications;
|
||||
using Windows.ApplicationModel;
|
||||
using Windows.ApplicationModel.Background;
|
||||
using Wino.Core.Domain;
|
||||
|
||||
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);
|
||||
|
||||
builder.AddText(Translator.Notifications_WinoUpdatedTitle);
|
||||
builder.AddText(string.Format(Translator.Notifications_WinoUpdatedMessage, 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,48 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Serilog;
|
||||
using Windows.ApplicationModel.Background;
|
||||
using Windows.Storage;
|
||||
using Wino.Core;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Services;
|
||||
using Wino.Core.UWP;
|
||||
using Wino.Services;
|
||||
|
||||
namespace Wino.BackgroundTasks
|
||||
{
|
||||
public sealed class SessionConnectedTask : IBackgroundTask
|
||||
{
|
||||
public async void Run(IBackgroundTaskInstance taskInstance)
|
||||
{
|
||||
var def = taskInstance.GetDeferral();
|
||||
|
||||
try
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.RegisterCoreServices();
|
||||
services.RegisterCoreUWPServices();
|
||||
|
||||
var providere = services.BuildServiceProvider();
|
||||
|
||||
var backgroundTaskService = providere.GetService<IBackgroundSynchronizer>();
|
||||
var dbService = providere.GetService<IDatabaseService>();
|
||||
var logInitializer = providere.GetService<ILogInitializer>();
|
||||
|
||||
logInitializer.SetupLogger(ApplicationData.Current.LocalFolder.Path);
|
||||
|
||||
await dbService.InitializeAsync();
|
||||
await backgroundTaskService.RunBackgroundSynchronizationAsync(Core.Domain.Enums.BackgroundSynchronizationReason.SessionConnected);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Background synchronization failed from background task.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
def.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,166 +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|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</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|ARM'">
|
||||
<PlatformTarget>ARM</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<OutputPath>bin\ARM\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|ARM'">
|
||||
<PlatformTarget>ARM</PlatformTarget>
|
||||
<OutputPath>bin\ARM\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" />
|
||||
<Compile Include="SessionConnectedTask.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<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>
|
||||
@@ -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,551 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Serilog;
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
using Wino.Calendar.ViewModels.Interfaces;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Collections;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.MenuItems;
|
||||
using Wino.Core.Domain.Models;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models.Navigation;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.ViewModels;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
using Wino.Messaging.Server;
|
||||
using Wino.Messaging.UI;
|
||||
|
||||
namespace Wino.Calendar.ViewModels;
|
||||
|
||||
public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
|
||||
ICalendarShellClient,
|
||||
IRecipient<CalendarDisplayTypeChangedMessage>,
|
||||
IRecipient<AccountRemovedMessage>
|
||||
{
|
||||
public IPreferencesService PreferencesService { get; }
|
||||
public IStatePersistanceService StatePersistenceService { get; }
|
||||
public IAccountCalendarStateService AccountCalendarStateService { get; }
|
||||
public INavigationService NavigationService { get; }
|
||||
public WinoApplicationMode Mode => WinoApplicationMode.Calendar;
|
||||
public bool HandlesNavigationSelection => false;
|
||||
public VisibleDateRange CurrentVisibleRange => _calendarPageViewModel.CurrentVisibleRange;
|
||||
public string VisibleDateRangeText => _calendarPageViewModel.VisibleDateRangeText;
|
||||
System.Collections.IEnumerable ICalendarShellClient.GroupedAccountCalendars => AccountCalendarStateService.GroupedAccountCalendars;
|
||||
System.Collections.IEnumerable ICalendarShellClient.DateNavigationHeaderItems => DateNavigationHeaderItems;
|
||||
object IShellClient.SelectedMenuItem
|
||||
{
|
||||
get => null;
|
||||
set { }
|
||||
}
|
||||
System.Windows.Input.ICommand ICalendarShellClient.TodayClickedCommand => TodayClickedCommand;
|
||||
System.Windows.Input.ICommand ICalendarShellClient.DateClickedCommand => DateClickedCommand;
|
||||
System.Windows.Input.ICommand ICalendarShellClient.PreviousDateRangeCommand => PreviousDateRangeCommand;
|
||||
System.Windows.Input.ICommand ICalendarShellClient.NextDateRangeCommand => NextDateRangeCommand;
|
||||
System.Windows.Input.ICommand ICalendarShellClient.SyncCommand => SyncCommand;
|
||||
|
||||
public bool CanSynchronizeCalendars => !AccountCalendarStateService.IsAnySynchronizationInProgress;
|
||||
|
||||
public MenuItemCollection MenuItems { get; private set; }
|
||||
public MenuItemCollection FooterItems { get; private set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private int _selectedMenuItemIndex = -1;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableRangeCollection<string> dateNavigationHeaderItems = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private int _selectedDateNavigationHeaderIndex;
|
||||
|
||||
public bool IsVerticalCalendar => StatePersistenceService.CalendarDisplayType == CalendarDisplayType.Month;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isStoreUpdateItemVisible;
|
||||
|
||||
private readonly SettingsItem _settingsItem = new();
|
||||
private readonly StoreUpdateMenuItem _storeUpdateMenuItem = new();
|
||||
private readonly SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
|
||||
private readonly CalendarPageViewModel _calendarPageViewModel;
|
||||
private readonly IMailDialogService _dialogService;
|
||||
private readonly IStoreUpdateService _storeUpdateService;
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly ICalendarService _calendarService;
|
||||
private readonly IDateContextProvider _dateContextProvider;
|
||||
private bool _runtimeSubscriptionsAttached;
|
||||
private bool _hasRegisteredPersistentRecipients;
|
||||
private DateTime? _navigationDate;
|
||||
|
||||
public CalendarAppShellViewModel(
|
||||
IPreferencesService preferencesService,
|
||||
IStatePersistanceService statePersistanceService,
|
||||
IAccountService accountService,
|
||||
ICalendarService calendarService,
|
||||
IAccountCalendarStateService accountCalendarStateService,
|
||||
INavigationService navigationService,
|
||||
CalendarPageViewModel calendarPageViewModel,
|
||||
IMailDialogService dialogService,
|
||||
IStoreUpdateService storeUpdateService,
|
||||
IDateContextProvider dateContextProvider)
|
||||
{
|
||||
PreferencesService = preferencesService;
|
||||
StatePersistenceService = statePersistanceService;
|
||||
AccountCalendarStateService = accountCalendarStateService;
|
||||
NavigationService = navigationService;
|
||||
_accountService = accountService;
|
||||
_calendarService = calendarService;
|
||||
_calendarPageViewModel = calendarPageViewModel;
|
||||
_dialogService = dialogService;
|
||||
_storeUpdateService = storeUpdateService;
|
||||
_dateContextProvider = dateContextProvider;
|
||||
|
||||
_calendarPageViewModel.PropertyChanged += CalendarPageViewModelPropertyChanged;
|
||||
AccountCalendarStateService.PropertyChanged += AccountCalendarStateServicePropertyChanged;
|
||||
}
|
||||
|
||||
protected override void OnDispatcherAssigned()
|
||||
{
|
||||
base.OnDispatcherAssigned();
|
||||
|
||||
AccountCalendarStateService.Dispatcher = Dispatcher;
|
||||
MenuItems = new MenuItemCollection(Dispatcher);
|
||||
FooterItems = new MenuItemCollection(Dispatcher);
|
||||
_ = RefreshFooterItemsAsync(false);
|
||||
}
|
||||
|
||||
private void CalendarPageViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(CalendarPageViewModel.CurrentVisibleRange))
|
||||
{
|
||||
OnPropertyChanged(nameof(CurrentVisibleRange));
|
||||
}
|
||||
|
||||
if (e.PropertyName == nameof(CalendarPageViewModel.CurrentVisibleRange) ||
|
||||
e.PropertyName == nameof(CalendarPageViewModel.VisibleDateRangeText))
|
||||
{
|
||||
OnPropertyChanged(nameof(VisibleDateRangeText));
|
||||
UpdateDateNavigationHeaderItems();
|
||||
}
|
||||
}
|
||||
|
||||
private void PrefefencesChanged(object sender, string e)
|
||||
{
|
||||
if (e != nameof(StatePersistenceService.CalendarDisplayType))
|
||||
return;
|
||||
|
||||
Messenger.Send(new CalendarDisplayTypeChangedMessage(StatePersistenceService.CalendarDisplayType));
|
||||
OnPropertyChanged(nameof(IsVerticalCalendar));
|
||||
UpdateDateNavigationHeaderItems();
|
||||
NavigateCalendarDate(GetDisplayTypeSwitchDate());
|
||||
}
|
||||
|
||||
private async void PreferencesServiceChanged(object sender, string e)
|
||||
{
|
||||
if (e == nameof(IPreferencesService.IsStoreUpdateNotificationsEnabled))
|
||||
{
|
||||
await RefreshFooterItemsAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||
{
|
||||
if (!_hasRegisteredPersistentRecipients)
|
||||
{
|
||||
RegisterRecipients();
|
||||
_hasRegisteredPersistentRecipients = true;
|
||||
}
|
||||
|
||||
AttachRuntimeSubscriptions();
|
||||
|
||||
var activationContext = parameters as ShellModeActivationContext;
|
||||
var shouldRunStartupFlows = activationContext?.IsInitialActivation ?? true;
|
||||
|
||||
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
|
||||
PreferencesService.PreferenceChanged += PreferencesServiceChanged;
|
||||
|
||||
await RefreshFooterItemsAsync(mode == NavigationMode.New);
|
||||
UpdateDateNavigationHeaderItems();
|
||||
await InitializeAccountCalendarsAsync();
|
||||
ValidateConfiguredNewEventCalendar();
|
||||
|
||||
TodayClicked();
|
||||
}
|
||||
|
||||
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
|
||||
{
|
||||
DetachRuntimeSubscriptions();
|
||||
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
|
||||
_ = ExecuteUIThread(() =>
|
||||
{
|
||||
DateNavigationHeaderItems.Clear();
|
||||
AccountCalendarStateService.ClearGroupedAccountCalendars();
|
||||
SelectedDateNavigationHeaderIndex = -1;
|
||||
});
|
||||
_calendarPageViewModel.CleanupForShellDeactivation();
|
||||
}
|
||||
|
||||
public void PrepareForShellShutdown()
|
||||
{
|
||||
DetachRuntimeSubscriptions();
|
||||
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
|
||||
|
||||
if (_hasRegisteredPersistentRecipients)
|
||||
{
|
||||
UnregisterRecipients();
|
||||
_hasRegisteredPersistentRecipients = false;
|
||||
}
|
||||
|
||||
DateNavigationHeaderItems.Clear();
|
||||
SelectedDateNavigationHeaderIndex = -1;
|
||||
SelectedMenuItemIndex = -1;
|
||||
MenuItems?.Clear();
|
||||
FooterItems?.Clear();
|
||||
AccountCalendarStateService.ClearGroupedAccountCalendars();
|
||||
_calendarPageViewModel.CleanupForShellDeactivation();
|
||||
}
|
||||
|
||||
private void AccountCalendarStateServicePropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != nameof(IAccountCalendarStateService.IsAnySynchronizationInProgress))
|
||||
return;
|
||||
|
||||
OnPropertyChanged(nameof(CanSynchronizeCalendars));
|
||||
SyncCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void AttachRuntimeSubscriptions()
|
||||
{
|
||||
if (_runtimeSubscriptionsAttached)
|
||||
return;
|
||||
|
||||
AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested;
|
||||
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged += AccountCalendarStateCollectivelyChanged;
|
||||
StatePersistenceService.StatePropertyChanged += PrefefencesChanged;
|
||||
_runtimeSubscriptionsAttached = true;
|
||||
}
|
||||
|
||||
private void DetachRuntimeSubscriptions()
|
||||
{
|
||||
if (!_runtimeSubscriptionsAttached)
|
||||
return;
|
||||
|
||||
AccountCalendarStateService.AccountCalendarSelectionStateChanged -= UpdateAccountCalendarRequested;
|
||||
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged -= AccountCalendarStateCollectivelyChanged;
|
||||
StatePersistenceService.StatePropertyChanged -= PrefefencesChanged;
|
||||
_runtimeSubscriptionsAttached = false;
|
||||
}
|
||||
|
||||
private async Task RefreshFooterItemsAsync(bool showNotification)
|
||||
{
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
FooterItems.Clear();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task StartStoreUpdateAsync()
|
||||
{
|
||||
await _storeUpdateService.StartUpdateAsync().ConfigureAwait(false);
|
||||
await RefreshFooterItemsAsync(false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _accountCalendarUpdateSemaphoreSlim.WaitAsync();
|
||||
|
||||
foreach (var calendar in e.AccountCalendars)
|
||||
{
|
||||
await _calendarService.UpdateAccountCalendarAsync(calendar.AccountCalendar).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error while waiting for account calendar update semaphore.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_accountCalendarUpdateSemaphoreSlim.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async void UpdateAccountCalendarRequested(object sender, AccountCalendarViewModel e)
|
||||
=> await _calendarService.UpdateAccountCalendarAsync(e.AccountCalendar).ConfigureAwait(false);
|
||||
|
||||
private async Task InitializeAccountCalendarsAsync()
|
||||
{
|
||||
await Dispatcher.ExecuteOnUIThread(() => AccountCalendarStateService.ClearGroupedAccountCalendars());
|
||||
|
||||
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
|
||||
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
var accountCalendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
|
||||
var calendarViewModels = accountCalendars.Select(calendar => new AccountCalendarViewModel(account, calendar)).ToList();
|
||||
var groupedAccountCalendarViewModel = new GroupedAccountCalendarViewModel(account, calendarViewModels);
|
||||
|
||||
await Dispatcher.ExecuteOnUIThread(() =>
|
||||
{
|
||||
AccountCalendarStateService.AddGroupedAccountCalendar(groupedAccountCalendarViewModel);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void NavigateCalendarDate(DateTime date)
|
||||
{
|
||||
_navigationDate = date.Date;
|
||||
ForceNavigateCalendarDate();
|
||||
}
|
||||
|
||||
private void ForceNavigateCalendarDate()
|
||||
{
|
||||
var args = new CalendarPageNavigationArgs
|
||||
{
|
||||
NavigationDate = _navigationDate ?? DateTime.Now.Date
|
||||
};
|
||||
|
||||
NavigationService.Navigate(WinoPage.CalendarPage, args);
|
||||
_navigationDate = null;
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSynchronizeCalendars))]
|
||||
private async Task Sync()
|
||||
{
|
||||
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions
|
||||
{
|
||||
AccountId = account.Id,
|
||||
Type = CalendarSynchronizationType.Strict
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime GetDisplayTypeSwitchDate()
|
||||
{
|
||||
var today = _dateContextProvider.GetToday();
|
||||
var settings = PreferencesService.GetCurrentCalendarSettings();
|
||||
var referenceRange = CurrentVisibleRange
|
||||
?? CalendarRangeResolver.Resolve(new CalendarDisplayRequest(StatePersistenceService.CalendarDisplayType, today), settings, today);
|
||||
var targetRange = CalendarRangeResolver.ChangeDisplayType(referenceRange, StatePersistenceService.CalendarDisplayType, settings, today);
|
||||
return targetRange.AnchorDate.ToDateTime(TimeOnly.MinValue);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void TodayClicked()
|
||||
{
|
||||
NavigateCalendarDate(_dateContextProvider.GetToday().ToDateTime(TimeOnly.MinValue));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void PreviousDateRange()
|
||||
{
|
||||
NavigateRelativePeriod(-1);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NextDateRange()
|
||||
{
|
||||
NavigateRelativePeriod(1);
|
||||
}
|
||||
|
||||
private void NavigateRelativePeriod(int direction)
|
||||
{
|
||||
var today = _dateContextProvider.GetToday();
|
||||
var settings = PreferencesService.GetCurrentCalendarSettings();
|
||||
var referenceRange = CurrentVisibleRange
|
||||
?? CalendarRangeResolver.Resolve(new CalendarDisplayRequest(StatePersistenceService.CalendarDisplayType, today), settings, today);
|
||||
var targetRange = CalendarRangeResolver.Navigate(referenceRange, direction, settings, today);
|
||||
NavigateCalendarDate(targetRange.AnchorDate.ToDateTime(TimeOnly.MinValue));
|
||||
}
|
||||
|
||||
public async Task HandleNavigationItemInvokedAsync(IMenuItem menuItem)
|
||||
{
|
||||
switch (menuItem)
|
||||
{
|
||||
case NewMailMenuItem:
|
||||
await NewEventAsync().ConfigureAwait(false);
|
||||
break;
|
||||
case SettingsItem:
|
||||
NavigationService.Navigate(WinoPage.SettingsPage);
|
||||
break;
|
||||
case StoreUpdateMenuItem:
|
||||
await StartStoreUpdateAsync().ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task NewEventAsync()
|
||||
{
|
||||
var pickedCalendar = TryResolveConfiguredNewEventCalendar();
|
||||
|
||||
if (pickedCalendar == null)
|
||||
{
|
||||
var availableGroups = AccountCalendarStateService.GroupedAccountCalendars
|
||||
.Where(group => group.AccountCalendars.Count > 0)
|
||||
.Select(group => new CalendarPickerAccountGroup
|
||||
{
|
||||
Account = group.Account,
|
||||
Calendars = group.AccountCalendars.Select(calendar => calendar.AccountCalendar).ToList()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (availableGroups.Count == 0)
|
||||
{
|
||||
_dialogService.InfoBarMessage(
|
||||
Translator.CalendarEventCompose_NoCalendarsTitle,
|
||||
Translator.CalendarEventCompose_NoCalendarsMessage,
|
||||
InfoBarMessageType.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var pickingResult = await _dialogService.ShowSingleCalendarPickerDialogAsync(availableGroups);
|
||||
if (pickingResult.ShouldNavigateToCalendarSettings)
|
||||
{
|
||||
NavigationService.Navigate(WinoPage.CalendarPreferenceSettingsPage);
|
||||
return;
|
||||
}
|
||||
|
||||
pickedCalendar = pickingResult.PickedCalendar;
|
||||
}
|
||||
|
||||
if (pickedCalendar == null)
|
||||
return;
|
||||
|
||||
var (startDate, endDate) = GetDefaultComposeDateRange();
|
||||
|
||||
NavigationService.Navigate(WinoPage.CalendarEventComposePage, new CalendarEventComposeNavigationArgs
|
||||
{
|
||||
SelectedCalendarId = pickedCalendar.Id,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate
|
||||
});
|
||||
}
|
||||
|
||||
public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args)
|
||||
{
|
||||
if (args.Handled || args.Mode != WinoApplicationMode.Calendar)
|
||||
return;
|
||||
|
||||
if (args.Action == KeyboardShortcutAction.NewEvent)
|
||||
{
|
||||
await NewEventAsync();
|
||||
args.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DateClicked(CalendarViewDayClickedEventArgs clickedDateArgs)
|
||||
=> NavigateCalendarDate(clickedDateArgs.ClickedDate);
|
||||
|
||||
protected override void RegisterRecipients()
|
||||
{
|
||||
base.RegisterRecipients();
|
||||
|
||||
UnregisterRecipients();
|
||||
|
||||
Messenger.Register<CalendarDisplayTypeChangedMessage>(this);
|
||||
Messenger.Register<AccountRemovedMessage>(this);
|
||||
}
|
||||
|
||||
protected override void UnregisterRecipients()
|
||||
{
|
||||
base.UnregisterRecipients();
|
||||
|
||||
Messenger.Unregister<CalendarDisplayTypeChangedMessage>(this);
|
||||
Messenger.Unregister<AccountRemovedMessage>(this);
|
||||
}
|
||||
|
||||
private void UpdateDateNavigationHeaderItems()
|
||||
{
|
||||
var headerText = VisibleDateRangeText;
|
||||
DateNavigationHeaderItems.ReplaceRange(string.IsNullOrWhiteSpace(headerText) ? [] : [headerText]);
|
||||
SelectedDateNavigationHeaderIndex = DateNavigationHeaderItems.Count > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
public void Receive(CalendarDisplayTypeChangedMessage message)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsVerticalCalendar));
|
||||
UpdateDateNavigationHeaderItems();
|
||||
}
|
||||
|
||||
public async void Receive(AccountRemovedMessage message)
|
||||
{
|
||||
await InitializeAccountCalendarsAsync();
|
||||
ValidateConfiguredNewEventCalendar();
|
||||
}
|
||||
|
||||
private AccountCalendar TryResolveConfiguredNewEventCalendar()
|
||||
{
|
||||
ValidateConfiguredNewEventCalendar();
|
||||
|
||||
if (PreferencesService.NewEventButtonBehavior != NewEventButtonBehavior.AlwaysUseSpecificCalendar
|
||||
|| !PreferencesService.DefaultNewEventCalendarId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return AccountCalendarStateService.AllCalendars
|
||||
.FirstOrDefault(calendar => calendar.Id == PreferencesService.DefaultNewEventCalendarId.Value)?
|
||||
.AccountCalendar;
|
||||
}
|
||||
|
||||
private void ValidateConfiguredNewEventCalendar()
|
||||
{
|
||||
if (PreferencesService.NewEventButtonBehavior != NewEventButtonBehavior.AlwaysUseSpecificCalendar
|
||||
|| !PreferencesService.DefaultNewEventCalendarId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var exists = AccountCalendarStateService.AllCalendars
|
||||
.Any(calendar => calendar.Id == PreferencesService.DefaultNewEventCalendarId.Value);
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AskEachTime;
|
||||
PreferencesService.DefaultNewEventCalendarId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static (DateTime StartDate, DateTime EndDate) GetDefaultComposeDateRange()
|
||||
{
|
||||
var localNow = DateTime.Now;
|
||||
var roundedMinutes = localNow.Minute switch
|
||||
{
|
||||
< 30 => 30,
|
||||
30 when localNow.Second == 0 && localNow.Millisecond == 0 => 30,
|
||||
_ => 60
|
||||
};
|
||||
|
||||
var startDate = new DateTime(localNow.Year, localNow.Month, localNow.Day, localNow.Hour, 0, 0);
|
||||
startDate = roundedMinutes == 60 ? startDate.AddHours(1) : startDate.AddMinutes(roundedMinutes);
|
||||
|
||||
return (startDate, startDate.AddMinutes(30));
|
||||
}
|
||||
|
||||
void IShellClient.Activate(ShellModeActivationContext activationContext)
|
||||
=> OnNavigatedTo(NavigationMode.New, activationContext);
|
||||
|
||||
void IShellClient.Deactivate()
|
||||
=> OnNavigatedFrom(NavigationMode.New, null!);
|
||||
|
||||
Task IShellClient.HandleNavigationItemInvokedAsync(IMenuItem menuItem)
|
||||
=> menuItem == null ? Task.CompletedTask : HandleNavigationItemInvokedAsync(menuItem);
|
||||
|
||||
Task IShellClient.HandleNavigationSelectionChangedAsync(IMenuItem menuItem)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using EmailValidation;
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models.Navigation;
|
||||
using Wino.Core.Domain.Validation;
|
||||
using Wino.Core.ViewModels;
|
||||
|
||||
namespace Wino.Calendar.ViewModels;
|
||||
|
||||
public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel
|
||||
{
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly ICalendarService _calendarService;
|
||||
private readonly INavigationService _navigationService;
|
||||
private readonly IMailDialogService _dialogService;
|
||||
private readonly IContactService _contactService;
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
private readonly IUnderlyingThemeService _underlyingThemeService;
|
||||
private readonly IWinoRequestDelegator _winoRequestDelegator;
|
||||
private readonly CalendarEventComposeResultValidator _composeResultValidator = new();
|
||||
|
||||
public Func<Task<string>> GetHtmlNotesAsync { get; set; }
|
||||
|
||||
public ObservableCollection<AccountCalendarViewModel> AvailableCalendars { get; } = [];
|
||||
public ObservableCollection<GroupedAccountCalendarViewModel> AvailableCalendarGroups { get; } = [];
|
||||
public ObservableCollection<CalendarComposeAttendeeViewModel> Attendees { get; } = [];
|
||||
public ObservableCollection<CalendarComposeAttachmentViewModel> Attachments { get; } = [];
|
||||
public ObservableCollection<ShowAsOption> ShowAsOptions { get; } = [];
|
||||
public ObservableCollection<ReminderOption> ReminderOptions { get; } = [];
|
||||
public ObservableCollection<int> RecurrenceIntervalOptions { get; } = [];
|
||||
public ObservableCollection<CalendarComposeFrequencyOption> RecurrenceFrequencyOptions { get; } = [];
|
||||
public ObservableCollection<CalendarComposeWeekdayOption> WeekdayOptions { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
public partial AccountCalendarViewModel SelectedCalendar { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string Title { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string Location { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsAllDay { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial DateTimeOffset StartDate { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial TimeSpan StartTime { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial TimeSpan EndTime { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial DateTimeOffset AllDayEndDate { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsRecurring { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial int SelectedRecurrenceInterval { get; set; } = 1;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial CalendarComposeFrequencyOption SelectedRecurrenceFrequencyOption { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial DateTimeOffset? RecurrenceEndDate { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string RecurrenceSummary { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ReminderOption SelectedReminderOption { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ShowAsOption SelectedShowAsOption { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsDarkWebviewRenderer { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial CalendarEventComposeResult LastCreatedResult { get; set; }
|
||||
|
||||
public CalendarSettings CurrentSettings { get; }
|
||||
public string TimePickerClockIdentifier => CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "24HourClock" : "12HourClock";
|
||||
public bool HasAttachments => Attachments.Count > 0;
|
||||
public bool IsSelectedCalendarCalDav => SelectedCalendar?.Account?.ProviderType == MailProviderType.IMAP4 &&
|
||||
SelectedCalendar.Account.ServerInformation?.CalendarSupportMode == ImapCalendarSupportMode.CalDav;
|
||||
public bool CanAddAttachments => !IsSelectedCalendarCalDav;
|
||||
public string AttachmentsDisabledTooltipText => IsSelectedCalendarCalDav
|
||||
? Translator.CalendarEventCompose_AttachmentsNotSupportedForCalDav
|
||||
: string.Empty;
|
||||
public string SelectedCalendarDisplayText => SelectedCalendar?.Name ?? Translator.CalendarEventCompose_SelectCalendar;
|
||||
public string SelectedCalendarAccountText => SelectedCalendar?.Account?.Address ?? string.Empty;
|
||||
public bool IsDailyRecurrenceSelected => SelectedRecurrenceFrequencyOption?.Frequency == CalendarItemRecurrenceFrequency.Daily;
|
||||
|
||||
public CalendarEventComposePageViewModel(IAccountService accountService,
|
||||
ICalendarService calendarService,
|
||||
INavigationService navigationService,
|
||||
IMailDialogService dialogService,
|
||||
IContactService contactService,
|
||||
IPreferencesService preferencesService,
|
||||
IUnderlyingThemeService underlyingThemeService,
|
||||
IWinoRequestDelegator winoRequestDelegator)
|
||||
{
|
||||
_accountService = accountService;
|
||||
_calendarService = calendarService;
|
||||
_navigationService = navigationService;
|
||||
_dialogService = dialogService;
|
||||
_contactService = contactService;
|
||||
_preferencesService = preferencesService;
|
||||
_underlyingThemeService = underlyingThemeService;
|
||||
_winoRequestDelegator = winoRequestDelegator;
|
||||
|
||||
CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
|
||||
IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark();
|
||||
|
||||
Attachments.CollectionChanged += AttachmentsCollectionChanged;
|
||||
|
||||
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Free));
|
||||
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Tentative));
|
||||
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Busy));
|
||||
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.OutOfOffice));
|
||||
ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.WorkingElsewhere));
|
||||
|
||||
foreach (var reminderMinutes in _calendarService.GetPredefinedReminderMinutes().OrderByDescending(x => x))
|
||||
{
|
||||
ReminderOptions.Add(new ReminderOption(reminderMinutes));
|
||||
}
|
||||
|
||||
foreach (var interval in Enumerable.Range(1, 99))
|
||||
{
|
||||
RecurrenceIntervalOptions.Add(interval);
|
||||
}
|
||||
|
||||
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Daily, Translator.CalendarEventCompose_FrequencyDay));
|
||||
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Weekly, Translator.CalendarEventCompose_FrequencyWeek));
|
||||
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Monthly, Translator.CalendarEventCompose_FrequencyMonth));
|
||||
RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Yearly, Translator.CalendarEventCompose_FrequencyYear));
|
||||
SelectedRecurrenceFrequencyOption = RecurrenceFrequencyOptions.FirstOrDefault();
|
||||
|
||||
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Monday, "MO", Translator.CalendarEventCompose_Weekday_Monday));
|
||||
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Tuesday, "TU", Translator.CalendarEventCompose_Weekday_Tuesday));
|
||||
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Wednesday, "WE", Translator.CalendarEventCompose_Weekday_Wednesday));
|
||||
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Thursday, "TH", Translator.CalendarEventCompose_Weekday_Thursday));
|
||||
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Friday, "FR", Translator.CalendarEventCompose_Weekday_Friday));
|
||||
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Saturday, "SA", Translator.CalendarEventCompose_Weekday_Saturday));
|
||||
WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Sunday, "SU", Translator.CalendarEventCompose_Weekday_Sunday));
|
||||
|
||||
SelectedReminderOption = GetDefaultReminderOption();
|
||||
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == CalendarItemShowAs.Busy);
|
||||
|
||||
var (defaultStart, defaultEnd) = GetDefaultComposeDateRange();
|
||||
ApplyDateRange(defaultStart, defaultEnd, false);
|
||||
}
|
||||
|
||||
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||
{
|
||||
base.OnNavigatedTo(mode, parameters);
|
||||
|
||||
await LoadAvailableCalendarsAsync();
|
||||
|
||||
var args = parameters as CalendarEventComposeNavigationArgs;
|
||||
ApplyNavigationArgs(args);
|
||||
UpdateRecurrenceSummary();
|
||||
}
|
||||
|
||||
partial void OnSelectedCalendarChanged(AccountCalendarViewModel value)
|
||||
{
|
||||
if (value == null)
|
||||
return;
|
||||
|
||||
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == value.DefaultShowAs)
|
||||
?? ShowAsOptions.FirstOrDefault();
|
||||
|
||||
if (IsSelectedCalendarCalDav && Attachments.Count > 0)
|
||||
{
|
||||
Attachments.Clear();
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(IsSelectedCalendarCalDav));
|
||||
OnPropertyChanged(nameof(CanAddAttachments));
|
||||
OnPropertyChanged(nameof(AttachmentsDisabledTooltipText));
|
||||
OnPropertyChanged(nameof(SelectedCalendarDisplayText));
|
||||
OnPropertyChanged(nameof(SelectedCalendarAccountText));
|
||||
}
|
||||
|
||||
partial void OnIsAllDayChanged(bool value)
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
if (AllDayEndDate.Date <= StartDate.Date)
|
||||
{
|
||||
AllDayEndDate = StartDate.AddDays(1);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateRecurrenceSummary();
|
||||
}
|
||||
|
||||
partial void OnStartDateChanged(DateTimeOffset value)
|
||||
{
|
||||
if (IsAllDay && AllDayEndDate.Date <= value.Date)
|
||||
{
|
||||
AllDayEndDate = value.AddDays(1);
|
||||
}
|
||||
|
||||
if (IsRecurring && WeekdayOptions.All(option => !option.IsSelected))
|
||||
{
|
||||
SelectSingleWeekday(value.DayOfWeek);
|
||||
}
|
||||
|
||||
UpdateRecurrenceSummary();
|
||||
}
|
||||
|
||||
partial void OnStartTimeChanged(TimeSpan value) => UpdateRecurrenceSummary();
|
||||
partial void OnEndTimeChanged(TimeSpan value) => UpdateRecurrenceSummary();
|
||||
partial void OnAllDayEndDateChanged(DateTimeOffset value) => UpdateRecurrenceSummary();
|
||||
partial void OnIsRecurringChanged(bool value)
|
||||
{
|
||||
if (value && WeekdayOptions.All(option => !option.IsSelected))
|
||||
{
|
||||
SelectSingleWeekday(StartDate.DayOfWeek);
|
||||
}
|
||||
|
||||
UpdateRecurrenceSummary();
|
||||
}
|
||||
partial void OnSelectedRecurrenceIntervalChanged(int value) => UpdateRecurrenceSummary();
|
||||
partial void OnSelectedRecurrenceFrequencyOptionChanged(CalendarComposeFrequencyOption value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsDailyRecurrenceSelected));
|
||||
UpdateRecurrenceSummary();
|
||||
}
|
||||
partial void OnRecurrenceEndDateChanged(DateTimeOffset? value) => UpdateRecurrenceSummary();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddAttachmentsAsync()
|
||||
{
|
||||
if (!CanAddAttachments)
|
||||
return;
|
||||
|
||||
var pickedFiles = await _dialogService.PickFilesMetadataAsync("*");
|
||||
if (pickedFiles.Count == 0)
|
||||
return;
|
||||
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
foreach (var file in pickedFiles)
|
||||
{
|
||||
TryAddAttachment(file.FileName, file.FullFilePath, file.FileExtension, file.Size);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public bool TryAddAttachment(string filePath, long size)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return false;
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
var fileExtension = Path.GetExtension(filePath);
|
||||
return TryAddAttachment(fileName, filePath, fileExtension, size);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveAttachment(CalendarComposeAttachmentViewModel attachment)
|
||||
{
|
||||
if (attachment == null)
|
||||
return;
|
||||
|
||||
Attachments.Remove(attachment);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ClearRecurrenceEndDate()
|
||||
{
|
||||
RecurrenceEndDate = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel()
|
||||
{
|
||||
_navigationService.GoBack();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
var uniqueAttendees = Attendees
|
||||
.GroupBy(attendee => attendee.Email, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
|
||||
var createdResult = await BuildResultAsync(uniqueAttendees);
|
||||
|
||||
try
|
||||
{
|
||||
_composeResultValidator.Validate(createdResult);
|
||||
}
|
||||
catch (CalendarEventComposeValidationException ex)
|
||||
{
|
||||
ShowValidationMessage(ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
LastCreatedResult = createdResult;
|
||||
|
||||
await _winoRequestDelegator.ExecuteAsync(new CalendarOperationPreparationRequest(
|
||||
CalendarSynchronizerOperation.CreateEvent,
|
||||
ComposeResult: createdResult));
|
||||
|
||||
NavigateBackToCalendar(createdResult.StartDate);
|
||||
}
|
||||
|
||||
private void NavigateBackToCalendar(DateTime targetDate)
|
||||
{
|
||||
_navigationService.Navigate(
|
||||
WinoPage.CalendarPage,
|
||||
new CalendarPageNavigationArgs
|
||||
{
|
||||
NavigationDate = targetDate,
|
||||
ForceReload = true
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<List<AccountContact>> SearchContactsAsync(string queryText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(queryText) || queryText.Length < 2)
|
||||
return [];
|
||||
|
||||
return await _contactService.GetAddressInformationAsync(queryText).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<CalendarComposeAttendeeViewModel> GetAttendeeAsync(string tokenText)
|
||||
{
|
||||
if (!EmailValidator.Validate(tokenText))
|
||||
return null;
|
||||
|
||||
var existing = Attendees.Any(attendee => attendee.Email.Equals(tokenText, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing)
|
||||
return null;
|
||||
|
||||
var info = await _contactService.GetAddressInformationByAddressAsync(tokenText).ConfigureAwait(false);
|
||||
if (info != null)
|
||||
{
|
||||
return CalendarComposeAttendeeViewModel.FromContact(info);
|
||||
}
|
||||
|
||||
return new CalendarComposeAttendeeViewModel(string.Empty, tokenText);
|
||||
}
|
||||
|
||||
public void AddAttendee(CalendarComposeAttendeeViewModel attendee)
|
||||
{
|
||||
if (Attendees.Any(existing => existing.Email.Equals(attendee.Email, StringComparison.OrdinalIgnoreCase)))
|
||||
return;
|
||||
|
||||
Attendees.Add(attendee);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveAttendee(CalendarComposeAttendeeViewModel attendee)
|
||||
{
|
||||
if (attendee == null)
|
||||
return;
|
||||
|
||||
Attendees.Remove(attendee);
|
||||
}
|
||||
|
||||
public void NotifyAddressExists()
|
||||
{
|
||||
_dialogService.InfoBarMessage(
|
||||
Translator.Info_ContactExistsTitle,
|
||||
Translator.Info_ContactExistsMessage,
|
||||
InfoBarMessageType.Warning);
|
||||
}
|
||||
|
||||
public void NotifyInvalidEmail(string address)
|
||||
{
|
||||
_dialogService.InfoBarMessage(
|
||||
Translator.Info_InvalidAddressTitle,
|
||||
string.Format(Translator.Info_InvalidAddressMessage, address),
|
||||
InfoBarMessageType.Warning);
|
||||
}
|
||||
|
||||
private async Task LoadAvailableCalendarsAsync()
|
||||
{
|
||||
var accountCalendars = new List<AccountCalendarViewModel>();
|
||||
var groupedCalendars = new List<GroupedAccountCalendarViewModel>();
|
||||
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
|
||||
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
var calendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
|
||||
var viewModels = calendars
|
||||
.Select(calendar => new AccountCalendarViewModel(account, calendar))
|
||||
.ToList();
|
||||
|
||||
accountCalendars.AddRange(viewModels);
|
||||
|
||||
if (viewModels.Count > 0)
|
||||
{
|
||||
groupedCalendars.Add(new GroupedAccountCalendarViewModel(account, viewModels));
|
||||
}
|
||||
}
|
||||
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
AvailableCalendars.Clear();
|
||||
AvailableCalendarGroups.Clear();
|
||||
|
||||
foreach (var calendar in accountCalendars.OrderBy(calendar => calendar.Account.Name).ThenBy(calendar => calendar.Name))
|
||||
{
|
||||
AvailableCalendars.Add(calendar);
|
||||
}
|
||||
|
||||
foreach (var group in groupedCalendars.OrderBy(group => group.Account.Name))
|
||||
{
|
||||
AvailableCalendarGroups.Add(group);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyNavigationArgs(CalendarEventComposeNavigationArgs args)
|
||||
{
|
||||
var (defaultStart, defaultEnd) = GetDefaultComposeDateRange();
|
||||
var startDate = args?.StartDate != default ? args!.StartDate : defaultStart;
|
||||
var endDate = args?.EndDate != default ? args!.EndDate : defaultEnd;
|
||||
var isAllDay = args?.IsAllDay ?? false;
|
||||
|
||||
Title = args?.Title ?? string.Empty;
|
||||
Location = args?.Location ?? string.Empty;
|
||||
|
||||
ApplyDateRange(startDate, endDate, isAllDay);
|
||||
|
||||
SelectedCalendar = ResolveSelectedCalendar(args?.SelectedCalendarId);
|
||||
if (SelectedCalendar != null)
|
||||
{
|
||||
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == SelectedCalendar.DefaultShowAs)
|
||||
?? SelectedShowAsOption
|
||||
?? ShowAsOptions.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
private AccountCalendarViewModel ResolveSelectedCalendar(Guid? selectedCalendarId)
|
||||
{
|
||||
if (selectedCalendarId.HasValue)
|
||||
{
|
||||
var selectedCalendar = AvailableCalendars.FirstOrDefault(calendar => calendar.Id == selectedCalendarId.Value);
|
||||
if (selectedCalendar != null)
|
||||
return selectedCalendar;
|
||||
}
|
||||
|
||||
return AvailableCalendars.FirstOrDefault(calendar => calendar.IsPrimary) ?? AvailableCalendars.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void ApplyDateRange(DateTime startDate, DateTime endDate, bool isAllDay)
|
||||
{
|
||||
IsAllDay = isAllDay;
|
||||
StartDate = new DateTimeOffset(startDate.Date);
|
||||
StartTime = startDate.TimeOfDay;
|
||||
EndTime = endDate.TimeOfDay;
|
||||
AllDayEndDate = new DateTimeOffset((isAllDay ? endDate.Date : startDate.Date.AddDays(1)));
|
||||
}
|
||||
|
||||
private async Task<CalendarEventComposeResult> BuildResultAsync(List<CalendarComposeAttendeeViewModel> uniqueAttendees)
|
||||
{
|
||||
if (RecurrenceEndDate.HasValue && RecurrenceEndDate.Value.Date < StartDate.Date)
|
||||
{
|
||||
throw new CalendarEventComposeValidationException(Translator.CalendarEventCompose_ValidationInvalidRecurrenceEnd);
|
||||
}
|
||||
|
||||
var htmlNotes = GetHtmlNotesAsync == null ? string.Empty : await GetHtmlNotesAsync();
|
||||
var effectiveStart = GetEffectiveStartDateTime();
|
||||
var effectiveEnd = GetEffectiveEndDateTime();
|
||||
|
||||
return new CalendarEventComposeResult
|
||||
{
|
||||
CalendarId = SelectedCalendar?.Id ?? Guid.Empty,
|
||||
AccountId = SelectedCalendar?.Account.Id ?? Guid.Empty,
|
||||
Title = Title.Trim(),
|
||||
Location = Location?.Trim() ?? string.Empty,
|
||||
HtmlNotes = htmlNotes,
|
||||
StartDate = effectiveStart,
|
||||
EndDate = effectiveEnd,
|
||||
IsAllDay = IsAllDay,
|
||||
TimeZoneId = TimeZoneInfo.Local.Id,
|
||||
ShowAs = SelectedShowAsOption?.ShowAs ?? SelectedCalendar?.DefaultShowAs ?? CalendarItemShowAs.Busy,
|
||||
SelectedReminders = BuildSelectedReminders(),
|
||||
Attendees = BuildAttendees(uniqueAttendees),
|
||||
Attachments = CanAddAttachments
|
||||
? Attachments.Select(attachment => attachment.ToDraftModel()).ToList()
|
||||
: [],
|
||||
Recurrence = BuildRecurrenceRule(),
|
||||
RecurrenceSummary = RecurrenceSummary
|
||||
};
|
||||
}
|
||||
|
||||
private List<Reminder> BuildSelectedReminders()
|
||||
{
|
||||
if (SelectedReminderOption == null)
|
||||
return [];
|
||||
|
||||
return
|
||||
[
|
||||
new Reminder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = Guid.Empty,
|
||||
DurationInSeconds = SelectedReminderOption.Minutes * 60L,
|
||||
ReminderType = CalendarItemReminderType.Popup
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private static List<CalendarEventAttendee> BuildAttendees(IEnumerable<CalendarComposeAttendeeViewModel> attendees)
|
||||
{
|
||||
return attendees
|
||||
.Select(attendee => new CalendarEventAttendee
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = Guid.Empty,
|
||||
Name = attendee.HasDistinctDisplayName ? attendee.DisplayName : string.Empty,
|
||||
Email = attendee.Email,
|
||||
AttendenceStatus = AttendeeStatus.NeedsAction,
|
||||
IsOrganizer = false,
|
||||
ResolvedContact = attendee.ResolvedContact
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private ReminderOption GetDefaultReminderOption()
|
||||
{
|
||||
var reminderMinutes = Math.Max(1, _preferencesService.DefaultReminderDurationInSeconds / 60);
|
||||
return ReminderOptions.FirstOrDefault(option => option.Minutes == reminderMinutes)
|
||||
?? ReminderOptions.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void UpdateRecurrenceSummary()
|
||||
{
|
||||
if (!HasInitializedComposeDateRange())
|
||||
{
|
||||
RecurrenceSummary = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveStart = GetEffectiveStartDateTime();
|
||||
var effectiveEnd = GetEffectiveEndDateTime();
|
||||
var selectedDays = IsDailyRecurrenceSelected
|
||||
? WeekdayOptions
|
||||
.Where(option => option.IsSelected)
|
||||
.Select(option => option.DayOfWeek)
|
||||
.ToList()
|
||||
: [];
|
||||
|
||||
RecurrenceSummary = CalendarRecurrenceSummaryFormatter.BuildSummary(
|
||||
IsRecurring,
|
||||
effectiveStart,
|
||||
effectiveEnd,
|
||||
IsAllDay,
|
||||
CurrentSettings,
|
||||
SelectedRecurrenceInterval,
|
||||
SelectedRecurrenceFrequencyOption?.Frequency ?? CalendarItemRecurrenceFrequency.Weekly,
|
||||
selectedDays,
|
||||
RecurrenceEndDate);
|
||||
}
|
||||
|
||||
private bool HasInitializedComposeDateRange()
|
||||
{
|
||||
if (StartDate == default)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !IsAllDay || AllDayEndDate != default;
|
||||
}
|
||||
|
||||
private string BuildRecurrenceRule()
|
||||
{
|
||||
if (!IsRecurring || SelectedRecurrenceFrequencyOption == null)
|
||||
return string.Empty;
|
||||
|
||||
var parts = new List<string>
|
||||
{
|
||||
$"FREQ={SelectedRecurrenceFrequencyOption.Frequency.ToString().ToUpperInvariant()}",
|
||||
$"INTERVAL={SelectedRecurrenceInterval}"
|
||||
};
|
||||
|
||||
var selectedDays = IsDailyRecurrenceSelected
|
||||
? WeekdayOptions
|
||||
.Where(option => option.IsSelected)
|
||||
.Select(option => option.RuleValue)
|
||||
.ToList()
|
||||
: [];
|
||||
|
||||
if (selectedDays.Count > 0)
|
||||
{
|
||||
parts.Add($"BYDAY={string.Join(",", selectedDays)}");
|
||||
}
|
||||
|
||||
if (RecurrenceEndDate.HasValue)
|
||||
{
|
||||
var untilValue = IsAllDay
|
||||
? RecurrenceEndDate.Value.ToString("yyyyMMdd", CultureInfo.InvariantCulture)
|
||||
: RecurrenceEndDate.Value.Date.AddDays(1).AddSeconds(-1).ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture);
|
||||
|
||||
parts.Add($"UNTIL={untilValue}");
|
||||
}
|
||||
|
||||
return $"RRULE:{string.Join(";", parts)}";
|
||||
}
|
||||
|
||||
private DateTime GetEffectiveStartDateTime()
|
||||
=> StartDate.Date.Add(IsAllDay ? TimeSpan.Zero : StartTime);
|
||||
|
||||
private DateTime GetEffectiveEndDateTime()
|
||||
=> IsAllDay
|
||||
? AllDayEndDate.Date
|
||||
: StartDate.Date.Add(EndTime);
|
||||
|
||||
private static (DateTime StartDate, DateTime EndDate) GetDefaultComposeDateRange()
|
||||
{
|
||||
var localNow = DateTime.Now;
|
||||
var roundedMinutes = localNow.Minute switch
|
||||
{
|
||||
< 30 => 30,
|
||||
30 when localNow.Second == 0 && localNow.Millisecond == 0 => 30,
|
||||
_ => 60
|
||||
};
|
||||
|
||||
var startDate = new DateTime(localNow.Year, localNow.Month, localNow.Day, localNow.Hour, 0, 0);
|
||||
startDate = roundedMinutes == 60 ? startDate.AddHours(1) : startDate.AddMinutes(roundedMinutes);
|
||||
|
||||
return (startDate, startDate.AddMinutes(30));
|
||||
}
|
||||
|
||||
private CalendarComposeWeekdayOption CreateWeekdayOption(DayOfWeek dayOfWeek, string ruleValue, string label)
|
||||
{
|
||||
var option = new CalendarComposeWeekdayOption(dayOfWeek, ruleValue, label);
|
||||
option.PropertyChanged += WeekdayOptionPropertyChanged;
|
||||
return option;
|
||||
}
|
||||
|
||||
private void WeekdayOptionPropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(CalendarComposeWeekdayOption.IsSelected))
|
||||
{
|
||||
UpdateRecurrenceSummary();
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectSingleWeekday(DayOfWeek dayOfWeek)
|
||||
{
|
||||
foreach (var option in WeekdayOptions)
|
||||
{
|
||||
option.IsSelected = option.DayOfWeek == dayOfWeek;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowValidationMessage(string message)
|
||||
{
|
||||
_dialogService.InfoBarMessage(
|
||||
Translator.CalendarEventCompose_ValidationTitle,
|
||||
message,
|
||||
InfoBarMessageType.Warning);
|
||||
}
|
||||
|
||||
private void AttachmentsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasAttachments));
|
||||
}
|
||||
|
||||
private bool TryAddAttachment(string fileName, string filePath, string fileExtension, long size)
|
||||
{
|
||||
if (!CanAddAttachments ||
|
||||
string.IsNullOrWhiteSpace(filePath) ||
|
||||
Attachments.Any(existing => existing.FilePath.Equals(filePath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Attachments.Add(new CalendarComposeAttachmentViewModel(fileName, filePath, fileExtension, size));
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public partial class CalendarComposeFrequencyOption : ObservableObject
|
||||
{
|
||||
public CalendarItemRecurrenceFrequency Frequency { get; }
|
||||
public string DisplayText { get; }
|
||||
|
||||
public CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency frequency, string displayText)
|
||||
{
|
||||
Frequency = frequency;
|
||||
DisplayText = displayText;
|
||||
}
|
||||
|
||||
public string PluralLabel(int interval)
|
||||
{
|
||||
if (interval == 1)
|
||||
return DisplayText;
|
||||
|
||||
return Frequency switch
|
||||
{
|
||||
CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDayPlural,
|
||||
CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeekPlural,
|
||||
CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonthPlural,
|
||||
CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYearPlural,
|
||||
_ => DisplayText
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public partial class CalendarComposeWeekdayOption : ObservableObject
|
||||
{
|
||||
public DayOfWeek DayOfWeek { get; }
|
||||
public string RuleValue { get; }
|
||||
public string Label { get; }
|
||||
public string FullDayName => DayOfWeek switch
|
||||
{
|
||||
DayOfWeek.Monday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[1],
|
||||
DayOfWeek.Tuesday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[2],
|
||||
DayOfWeek.Wednesday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[3],
|
||||
DayOfWeek.Thursday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[4],
|
||||
DayOfWeek.Friday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[5],
|
||||
DayOfWeek.Saturday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[6],
|
||||
DayOfWeek.Sunday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[0],
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsSelected { get; set; }
|
||||
|
||||
public CalendarComposeWeekdayOption(DayOfWeek dayOfWeek, string ruleValue, string label)
|
||||
{
|
||||
DayOfWeek = dayOfWeek;
|
||||
RuleValue = ruleValue;
|
||||
Label = label;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Calendar.ViewModels.Data;
|
||||
|
||||
public partial class AccountCalendarViewModel : ObservableObject, IAccountCalendar
|
||||
{
|
||||
public MailAccount Account { get; }
|
||||
public AccountCalendar AccountCalendar { get; }
|
||||
|
||||
public AccountCalendarViewModel(MailAccount account, AccountCalendar accountCalendar)
|
||||
{
|
||||
Account = account;
|
||||
AccountCalendar = accountCalendar;
|
||||
|
||||
IsChecked = accountCalendar.IsExtended;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
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 TextColorHex
|
||||
{
|
||||
get => AccountCalendar.TextColorHex;
|
||||
set => SetProperty(AccountCalendar.TextColorHex, value, AccountCalendar, (u, t) => u.TextColorHex = t);
|
||||
}
|
||||
|
||||
public string BackgroundColorHex
|
||||
{
|
||||
get => AccountCalendar.BackgroundColorHex;
|
||||
set => SetProperty(AccountCalendar.BackgroundColorHex, value, AccountCalendar, (u, b) => u.BackgroundColorHex = b);
|
||||
}
|
||||
|
||||
public bool IsExtended
|
||||
{
|
||||
get => AccountCalendar.IsExtended;
|
||||
set => SetProperty(AccountCalendar.IsExtended, value, AccountCalendar, (u, i) => u.IsExtended = i);
|
||||
}
|
||||
|
||||
public bool IsPrimary
|
||||
{
|
||||
get => AccountCalendar.IsPrimary;
|
||||
set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i);
|
||||
}
|
||||
|
||||
public bool IsReadOnly
|
||||
{
|
||||
get => AccountCalendar.IsReadOnly;
|
||||
set => SetProperty(AccountCalendar.IsReadOnly, value, AccountCalendar, (u, i) => u.IsReadOnly = i);
|
||||
}
|
||||
|
||||
public bool IsSynchronizationEnabled
|
||||
{
|
||||
get => AccountCalendar.IsSynchronizationEnabled;
|
||||
set => SetProperty(AccountCalendar.IsSynchronizationEnabled, value, AccountCalendar, (u, i) => u.IsSynchronizationEnabled = i);
|
||||
}
|
||||
|
||||
public Guid AccountId
|
||||
{
|
||||
get => AccountCalendar.AccountId;
|
||||
set => SetProperty(AccountCalendar.AccountId, value, AccountCalendar, (u, a) => u.AccountId = a);
|
||||
}
|
||||
|
||||
public string RemoteCalendarId
|
||||
{
|
||||
get => AccountCalendar.RemoteCalendarId;
|
||||
set => SetProperty(AccountCalendar.RemoteCalendarId, value, AccountCalendar, (u, r) => u.RemoteCalendarId = r);
|
||||
}
|
||||
|
||||
public CalendarItemShowAs DefaultShowAs
|
||||
{
|
||||
get => AccountCalendar.DefaultShowAs;
|
||||
set => SetProperty(AccountCalendar.DefaultShowAs, value, AccountCalendar, (u, s) => u.DefaultShowAs = s);
|
||||
}
|
||||
public Guid Id { get => ((IAccountCalendar)AccountCalendar).Id; set => ((IAccountCalendar)AccountCalendar).Id = value; }
|
||||
public MailAccount MailAccount
|
||||
{
|
||||
get => AccountCalendar.MailAccount ?? Account;
|
||||
set => AccountCalendar.MailAccount = value;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Itenso.TimePeriod;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Calendar.ViewModels.Data;
|
||||
|
||||
public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, ICalendarItemViewModel
|
||||
{
|
||||
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
|
||||
{
|
||||
get
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Calendar.ViewModels.Data;
|
||||
|
||||
public partial class GroupedAccountCalendarViewModel : ObservableObject
|
||||
{
|
||||
public event EventHandler CollectiveSelectionStateChanged;
|
||||
public event EventHandler<AccountCalendarViewModel> CalendarSelectionStateChanged;
|
||||
|
||||
public MailAccount Account { get; }
|
||||
public ObservableCollection<AccountCalendarViewModel> AccountCalendars { get; }
|
||||
|
||||
public GroupedAccountCalendarViewModel(MailAccount account, IEnumerable<AccountCalendarViewModel> calendarViewModels)
|
||||
{
|
||||
Account = account;
|
||||
AccountCalendars = new ObservableCollection<AccountCalendarViewModel>(calendarViewModels);
|
||||
AccountColorHex = account.AccountColorHex;
|
||||
|
||||
ManageIsCheckedState();
|
||||
|
||||
foreach (var calendarViewModel in calendarViewModels)
|
||||
{
|
||||
calendarViewModel.PropertyChanged += CalendarPropertyChanged;
|
||||
}
|
||||
|
||||
AccountCalendars.CollectionChanged += CalendarListUpdated;
|
||||
}
|
||||
|
||||
private void CalendarListUpdated(object sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action == NotifyCollectionChangedAction.Add)
|
||||
{
|
||||
foreach (AccountCalendarViewModel calendar in e.NewItems)
|
||||
{
|
||||
calendar.PropertyChanged += CalendarPropertyChanged;
|
||||
}
|
||||
}
|
||||
else if (e.Action == NotifyCollectionChangedAction.Remove)
|
||||
{
|
||||
foreach (AccountCalendarViewModel calendar in e.OldItems)
|
||||
{
|
||||
calendar.PropertyChanged -= CalendarPropertyChanged;
|
||||
}
|
||||
}
|
||||
else if (e.Action == NotifyCollectionChangedAction.Reset)
|
||||
{
|
||||
foreach (AccountCalendarViewModel calendar in e.OldItems)
|
||||
{
|
||||
calendar.PropertyChanged -= CalendarPropertyChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CalendarPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is AccountCalendarViewModel viewModel &&
|
||||
e.PropertyName == nameof(AccountCalendarViewModel.IsChecked))
|
||||
{
|
||||
ManageIsCheckedState();
|
||||
UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true);
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsExpanded { get; set; } = true;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool? IsCheckedState { get; set; } = true;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string AccountColorHex { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(CanSynchronize), nameof(IsSynchronizationProgressVisible), nameof(IsProgressIndeterminate))]
|
||||
public partial bool IsSynchronizationInProgress { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))]
|
||||
public partial int TotalItemsToSync { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))]
|
||||
public partial int RemainingItemsToSync { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string SynchronizationStatus { get; set; } = string.Empty;
|
||||
|
||||
public bool CanSynchronize => !IsSynchronizationInProgress;
|
||||
public bool IsSynchronizationProgressVisible => IsSynchronizationInProgress;
|
||||
public bool IsProgressIndeterminate => IsSynchronizationInProgress && TotalItemsToSync <= 0;
|
||||
|
||||
public double SynchronizationProgress
|
||||
{
|
||||
get
|
||||
{
|
||||
if (TotalItemsToSync <= 0)
|
||||
return 0;
|
||||
|
||||
return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
public double SynchronizationProgressValue => SynchronizationProgress;
|
||||
|
||||
private bool _isExternalPropChangeBlocked;
|
||||
|
||||
public void ApplySynchronizationProgress(AccountSynchronizationProgress progress)
|
||||
{
|
||||
if (progress == null || progress.AccountId != Account.Id)
|
||||
return;
|
||||
|
||||
IsSynchronizationInProgress = progress.IsInProgress;
|
||||
TotalItemsToSync = progress.TotalUnits;
|
||||
RemainingItemsToSync = progress.RemainingUnits;
|
||||
SynchronizationStatus = progress.Status ?? string.Empty;
|
||||
}
|
||||
|
||||
private void ManageIsCheckedState()
|
||||
{
|
||||
if (_isExternalPropChangeBlocked)
|
||||
return;
|
||||
|
||||
_isExternalPropChangeBlocked = true;
|
||||
|
||||
if (AccountCalendars.All(c => c.IsChecked))
|
||||
{
|
||||
IsCheckedState = true;
|
||||
}
|
||||
else if (AccountCalendars.All(c => !c.IsChecked))
|
||||
{
|
||||
IsCheckedState = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
IsCheckedState = null;
|
||||
}
|
||||
|
||||
_isExternalPropChangeBlocked = false;
|
||||
}
|
||||
|
||||
partial void OnIsCheckedStateChanged(bool? oldValue, bool? newValue)
|
||||
{
|
||||
if (_isExternalPropChangeBlocked)
|
||||
return;
|
||||
|
||||
_isExternalPropChangeBlocked = true;
|
||||
|
||||
if (newValue == null)
|
||||
{
|
||||
foreach (var calendar in AccountCalendars)
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Serilog;
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models;
|
||||
using Wino.Core.Domain.Models.Navigation;
|
||||
using Wino.Core.Services;
|
||||
using Wino.Core.ViewModels;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
|
||||
namespace Wino.Calendar.ViewModels;
|
||||
|
||||
public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
{
|
||||
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)
|
||||
{
|
||||
// Notify the view to re-render the description
|
||||
Messenger.Send(new CalendarDescriptionRenderingRequested());
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
public partial CalendarItemViewModel SeriesParent { get; set; }
|
||||
[ObservableProperty]
|
||||
public partial List<Reminder> Reminders { get; set; }
|
||||
|
||||
public ObservableCollection<ReminderOption> ReminderOptions { get; } = new ObservableCollection<ReminderOption>();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the event is part of a recurring series (as a child occurrence).
|
||||
/// Used to enable "View Series" functionality.
|
||||
/// </summary>
|
||||
public bool CanViewSeries => CurrentEvent?.IsRecurringChild ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the "Edit Series" button should be visible.
|
||||
/// Only visible for child occurrences of recurring events, not for master events or single events.
|
||||
/// </summary>
|
||||
public bool CanEditSeries => CurrentEvent?.IsRecurringChild ?? false;
|
||||
|
||||
/// <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
|
||||
|
||||
#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
|
||||
{
|
||||
return CurrentEvent?.CalendarItem?.Status ?? CalendarItemStatus.NotResponded;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Collections;
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Calendar.ViewModels.Interfaces;
|
||||
|
||||
public interface IAccountCalendarStateService : INotifyPropertyChanged
|
||||
{
|
||||
IDispatcher Dispatcher { get; set; }
|
||||
ReadOnlyObservableCollection<GroupedAccountCalendarViewModel> GroupedAccountCalendars { get; }
|
||||
|
||||
event EventHandler<GroupedAccountCalendarViewModel> CollectiveAccountGroupSelectionStateChanged;
|
||||
event EventHandler<AccountCalendarViewModel> AccountCalendarSelectionStateChanged;
|
||||
|
||||
public void AddGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
|
||||
public void RemoveGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
|
||||
public void ClearGroupedAccountCalendars();
|
||||
|
||||
public void AddAccountCalendar(AccountCalendarViewModel accountCalendar);
|
||||
public void RemoveAccountCalendar(AccountCalendarViewModel accountCalendar);
|
||||
|
||||
/// <summary>
|
||||
/// Enumeration of currently selected calendars.
|
||||
/// </summary>
|
||||
IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; }
|
||||
IEnumerable<AccountCalendarViewModel> AllCalendars { get; }
|
||||
bool IsAnySynchronizationInProgress { get; }
|
||||
ReadOnlyObservableGroupedCollection<MailAccount, AccountCalendarViewModel> GroupedCalendars { get; set; }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,13 @@
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
|
||||
namespace Wino.Calendar.ViewModels.Messages;
|
||||
|
||||
public class CalendarItemDoubleTappedMessage
|
||||
{
|
||||
public CalendarItemDoubleTappedMessage(CalendarItemViewModel calendarItemViewModel)
|
||||
{
|
||||
CalendarItemViewModel = calendarItemViewModel;
|
||||
}
|
||||
|
||||
public CalendarItemViewModel CalendarItemViewModel { get; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
|
||||
namespace Wino.Calendar.ViewModels.Messages;
|
||||
|
||||
public class CalendarItemRightTappedMessage
|
||||
{
|
||||
public CalendarItemRightTappedMessage(CalendarItemViewModel calendarItemViewModel)
|
||||
{
|
||||
CalendarItemViewModel = calendarItemViewModel;
|
||||
}
|
||||
|
||||
public CalendarItemViewModel CalendarItemViewModel { get; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
namespace Wino.Calendar.ViewModels.Messages;
|
||||
|
||||
public class CalendarItemTappedMessage
|
||||
{
|
||||
public CalendarItemTappedMessage(CalendarItemViewModel calendarItemViewModel)
|
||||
{
|
||||
CalendarItemViewModel = calendarItemViewModel;
|
||||
}
|
||||
|
||||
public CalendarItemViewModel CalendarItemViewModel { get; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Platforms>x86;x64;arm64</Platforms>
|
||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
|
||||
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
|
||||
<IsTrimmable>true</IsTrimmable>
|
||||
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="TimePeriodLibrary.NET" />
|
||||
<PackageReference Include="EmailValidation" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
|
||||
<ProjectReference Include="..\Wino.Core.ViewModels\Wino.Core.ViewModels.csproj" />
|
||||
<ProjectReference Include="..\Wino.Core\Wino.Core.csproj" />
|
||||
<ProjectReference Include="..\Wino.Messages\Wino.Messaging.csproj" />
|
||||
<ProjectReference Include="..\Wino.Services\Wino.Services.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,7 +0,0 @@
|
||||
<Application
|
||||
x:Class="Wino.Calendar.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Wino.Calendar">
|
||||
|
||||
</Application>
|
||||
@@ -1,100 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using Windows.ApplicationModel;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
using Windows.Foundation;
|
||||
using Windows.Foundation.Collections;
|
||||
using Windows.UI.Xaml;
|
||||
using Windows.UI.Xaml.Controls;
|
||||
using Windows.UI.Xaml.Controls.Primitives;
|
||||
using Windows.UI.Xaml.Data;
|
||||
using Windows.UI.Xaml.Input;
|
||||
using Windows.UI.Xaml.Media;
|
||||
using Windows.UI.Xaml.Navigation;
|
||||
|
||||
namespace Wino.Calendar
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
sealed partial class App : Application
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the singleton application object. This is the first line of authored code
|
||||
/// executed, and as such is the logical equivalent of main() or WinMain().
|
||||
/// </summary>
|
||||
public App()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
this.Suspending += OnSuspending;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the application is launched normally by the end user. Other entry points
|
||||
/// will be used such as when the application is launched to open a specific file.
|
||||
/// </summary>
|
||||
/// <param name="e">Details about the launch request and process.</param>
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs e)
|
||||
{
|
||||
Frame rootFrame = Window.Current.Content as Frame;
|
||||
|
||||
// Do not repeat app initialization when the Window already has content,
|
||||
// just ensure that the window is active
|
||||
if (rootFrame == null)
|
||||
{
|
||||
// Create a Frame to act as the navigation context and navigate to the first page
|
||||
rootFrame = new Frame();
|
||||
|
||||
rootFrame.NavigationFailed += OnNavigationFailed;
|
||||
|
||||
if (e.PreviousExecutionState == ApplicationExecutionState.Terminated)
|
||||
{
|
||||
//TODO: Load state from previously suspended application
|
||||
}
|
||||
|
||||
// Place the frame in the current Window
|
||||
Window.Current.Content = rootFrame;
|
||||
}
|
||||
|
||||
if (e.PrelaunchActivated == false)
|
||||
{
|
||||
if (rootFrame.Content == null)
|
||||
{
|
||||
// When the navigation stack isn't restored navigate to the first page,
|
||||
// configuring the new page by passing required information as a navigation
|
||||
// parameter
|
||||
rootFrame.Navigate(typeof(MainPage), e.Arguments);
|
||||
}
|
||||
// Ensure the current window is active
|
||||
Window.Current.Activate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when Navigation to a certain page fails
|
||||
/// </summary>
|
||||
/// <param name="sender">The Frame which failed navigation</param>
|
||||
/// <param name="e">Details about the navigation failure</param>
|
||||
void OnNavigationFailed(object sender, NavigationFailedEventArgs e)
|
||||
{
|
||||
throw new Exception("Failed to load Page " + e.SourcePageType.FullName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when application execution is being suspended. Application state is saved
|
||||
/// without knowing whether the application will be terminated or resumed with the contents
|
||||
/// of memory still intact.
|
||||
/// </summary>
|
||||
/// <param name="sender">The source of the suspend request.</param>
|
||||
/// <param name="e">Details about the suspend request.</param>
|
||||
private void OnSuspending(object sender, SuspendingEventArgs e)
|
||||
{
|
||||
var deferral = e.SuspendingOperation.GetDeferral();
|
||||
//TODO: Save application state and stop any background activity
|
||||
deferral.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
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,14 +0,0 @@
|
||||
<Page
|
||||
x:Class="Wino.Calendar.MainPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Wino.Calendar"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
|
||||
|
||||
<Grid>
|
||||
|
||||
</Grid>
|
||||
</Page>
|
||||
@@ -1,30 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using Windows.Foundation;
|
||||
using Windows.Foundation.Collections;
|
||||
using Windows.UI.Xaml;
|
||||
using Windows.UI.Xaml.Controls;
|
||||
using Windows.UI.Xaml.Controls.Primitives;
|
||||
using Windows.UI.Xaml.Data;
|
||||
using Windows.UI.Xaml.Input;
|
||||
using Windows.UI.Xaml.Media;
|
||||
using Windows.UI.Xaml.Navigation;
|
||||
|
||||
// The Blank Page item template is documented at https://go.microsoft.com/fwlink/?LinkId=402352&clcid=0x409
|
||||
|
||||
namespace Wino.Calendar
|
||||
{
|
||||
/// <summary>
|
||||
/// An empty page that can be used on its own or navigated to within a Frame.
|
||||
/// </summary>
|
||||
public sealed partial class MainPage : Page
|
||||
{
|
||||
public MainPage()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
IgnorableNamespaces="uap mp">
|
||||
|
||||
<!-- Publisher Cache Folders -->
|
||||
<Extensions>
|
||||
<Extension Category="windows.publisherCacheFolders">
|
||||
<PublisherCacheFolders>
|
||||
<Folder Name="WinoShared" />
|
||||
</PublisherCacheFolders>
|
||||
</Extension>
|
||||
</Extensions>
|
||||
|
||||
<Identity
|
||||
Name="58272BurakKSE.WinoCalendar"
|
||||
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
|
||||
Version="1.0.0.0" />
|
||||
|
||||
<mp:PhoneIdentity PhoneProductId="f047b7dd-96ec-4d54-a862-9321e271e449" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||
|
||||
<Properties>
|
||||
<DisplayName>Wino Calendar</DisplayName>
|
||||
<PublisherDisplayName>Burak KÖSE</PublisherDisplayName>
|
||||
<Logo>Assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.0.0" MaxVersionTested="10.0.0.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="x-generate"/>
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application Id="App"
|
||||
Executable="$targetnametoken$.exe"
|
||||
EntryPoint="Wino.Calendar.App">
|
||||
<uap:VisualElements
|
||||
DisplayName="Wino Calendar"
|
||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||
Square44x44Logo="Assets\Square44x44Logo.png"
|
||||
Description="Wino.Calendar"
|
||||
BackgroundColor="transparent">
|
||||
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png"/>
|
||||
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||
</uap:VisualElements>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<Capability Name="internetClient" />
|
||||
</Capabilities>
|
||||
</Package>
|
||||
@@ -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.Calendar")]
|
||||
[assembly: AssemblyDescription("")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("Wino.Calendar")]
|
||||
[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,31 +0,0 @@
|
||||
<!--
|
||||
This file contains Runtime Directives used by .NET Native. The defaults here are suitable for most
|
||||
developers. However, you can modify these parameters to modify the behavior of the .NET Native
|
||||
optimizer.
|
||||
|
||||
Runtime Directives are documented at https://go.microsoft.com/fwlink/?LinkID=391919
|
||||
|
||||
To fully enable reflection for App1.MyClass and all of its public/private members
|
||||
<Type Name="App1.MyClass" Dynamic="Required All"/>
|
||||
|
||||
To enable dynamic creation of the specific instantiation of AppClass<T> over System.Int32
|
||||
<TypeInstantiation Name="App1.AppClass" Arguments="System.Int32" Activate="Required Public" />
|
||||
|
||||
Using the Namespace directive to apply reflection policy to all the types in a particular namespace
|
||||
<Namespace Name="DataClasses.ViewModels" Serialize="All" />
|
||||
-->
|
||||
|
||||
<Directives xmlns="http://schemas.microsoft.com/netfx/2013/01/metadata">
|
||||
<Application>
|
||||
<!--
|
||||
An Assembly element with Name="*Application*" applies to all assemblies in
|
||||
the application package. The asterisks are not wildcards.
|
||||
-->
|
||||
<Assembly Name="*Application*" Dynamic="Required All" />
|
||||
|
||||
|
||||
<!-- Add your application specific runtime directives here. -->
|
||||
|
||||
|
||||
</Application>
|
||||
</Directives>
|
||||
@@ -1,172 +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)' == '' ">x86</Platform>
|
||||
<ProjectGuid>{600F4979-DB7E-409D-B7DA-B60BE4C55C35}</ProjectGuid>
|
||||
<OutputType>AppContainerExe</OutputType>
|
||||
<AppDesignerFolder>Properties</AppDesignerFolder>
|
||||
<RootNamespace>Wino.Calendar</RootNamespace>
|
||||
<AssemblyName>Wino.Calendar</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>
|
||||
<WindowsXamlEnableOverview>true</WindowsXamlEnableOverview>
|
||||
<AppxPackageSigningEnabled>True</AppxPackageSigningEnabled>
|
||||
<PackageCertificateThumbprint>125A5273FCFE8D551C3FED87F67C87A663E98F1B</PackageCertificateThumbprint>
|
||||
<PackageCertificateKeyFile />
|
||||
<GenerateTemporaryStoreCertificate>True</GenerateTemporaryStoreCertificate>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<OutputPath>bin\x86\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
||||
<NoWarn>;2008</NoWarn>
|
||||
<DebugType>full</DebugType>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'">
|
||||
<OutputPath>bin\x86\Release\</OutputPath>
|
||||
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
||||
<Optimize>true</Optimize>
|
||||
<NoWarn>;2008</NoWarn>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<UseDotNetNativeToolchain>true</UseDotNetNativeToolchain>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|ARM'">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<OutputPath>bin\ARM\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
||||
<NoWarn>;2008</NoWarn>
|
||||
<DebugType>full</DebugType>
|
||||
<PlatformTarget>ARM</PlatformTarget>
|
||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|ARM'">
|
||||
<OutputPath>bin\ARM\Release\</OutputPath>
|
||||
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
||||
<Optimize>true</Optimize>
|
||||
<NoWarn>;2008</NoWarn>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<PlatformTarget>ARM</PlatformTarget>
|
||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<UseDotNetNativeToolchain>true</UseDotNetNativeToolchain>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|ARM64'">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<OutputPath>bin\ARM64\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
||||
<NoWarn>;2008</NoWarn>
|
||||
<DebugType>full</DebugType>
|
||||
<PlatformTarget>ARM64</PlatformTarget>
|
||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<UseDotNetNativeToolchain>true</UseDotNetNativeToolchain>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|ARM64'">
|
||||
<OutputPath>bin\ARM64\Release\</OutputPath>
|
||||
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
||||
<Optimize>true</Optimize>
|
||||
<NoWarn>;2008</NoWarn>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<PlatformTarget>ARM64</PlatformTarget>
|
||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<UseDotNetNativeToolchain>true</UseDotNetNativeToolchain>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<OutputPath>bin\x64\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
||||
<NoWarn>;2008</NoWarn>
|
||||
<DebugType>full</DebugType>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'">
|
||||
<OutputPath>bin\x64\Release\</OutputPath>
|
||||
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
|
||||
<Optimize>true</Optimize>
|
||||
<NoWarn>;2008</NoWarn>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<UseVSHostingProcess>false</UseVSHostingProcess>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<UseDotNetNativeToolchain>true</UseDotNetNativeToolchain>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<RestoreProjectStyle>PackageReference</RestoreProjectStyle>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="App.xaml.cs">
|
||||
<DependentUpon>App.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="MainPage.xaml.cs">
|
||||
<DependentUpon>MainPage.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AppxManifest Include="Package.appxmanifest">
|
||||
<SubType>Designer</SubType>
|
||||
</AppxManifest>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Package.StoreAssociation.xml" />
|
||||
<Content Include="Properties\Default.rd.xml" />
|
||||
<Content Include="Assets\LockScreenLogo.scale-200.png" />
|
||||
<Content Include="Assets\SplashScreen.scale-200.png" />
|
||||
<Content Include="Assets\Square150x150Logo.scale-200.png" />
|
||||
<Content Include="Assets\Square44x44Logo.scale-200.png" />
|
||||
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
|
||||
<Content Include="Assets\StoreLogo.png" />
|
||||
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ApplicationDefinition Include="App.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<SubType>Designer</SubType>
|
||||
</ApplicationDefinition>
|
||||
<Page Include="MainPage.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<SubType>Designer</SubType>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NETCore.UniversalWindowsPlatform">
|
||||
<Version>6.2.14</Version>
|
||||
</PackageReference>
|
||||
</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>
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Wino.Core.Domain;
|
||||
|
||||
public static class AppUrls
|
||||
{
|
||||
public const string Website = "https://www.winomail.app";
|
||||
public const string Discord = "https://discord.gg/windows-apps-hub-714581497222398064";
|
||||
public const string GitHub = "https://github.com/bkaankose/Wino-Mail/";
|
||||
public const string PrivacyPolicy = "https://www.winomail.app/support/privacy";
|
||||
public const string Paypal = "https://paypal.me/bkaankose?country.x=PL&locale.x=en_US";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Wino.Core.Domain.Models.Updates;
|
||||
|
||||
namespace Wino.Core.Domain;
|
||||
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(int))]
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[JsonSerializable(typeof(bool))]
|
||||
[JsonSerializable(typeof(UpdateNotes))]
|
||||
[JsonSerializable(typeof(List<UpdateNoteSection>))]
|
||||
public partial class BasicTypesJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain;
|
||||
|
||||
public static class CalendarItemActionOptions
|
||||
{
|
||||
public static IReadOnlyList<CalendarItemShowAs> ShowAsOptions { get; } =
|
||||
[
|
||||
CalendarItemShowAs.Free,
|
||||
CalendarItemShowAs.Tentative,
|
||||
CalendarItemShowAs.Busy,
|
||||
CalendarItemShowAs.OutOfOffice,
|
||||
CalendarItemShowAs.WorkingElsewhere
|
||||
];
|
||||
|
||||
public static IReadOnlyList<CalendarItemStatus> ResponseOptions { get; } =
|
||||
[
|
||||
CalendarItemStatus.Accepted,
|
||||
CalendarItemStatus.Tentative,
|
||||
CalendarItemStatus.Cancelled
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Core.Domain;
|
||||
|
||||
public static class CalendarRecurrenceSummaryFormatter
|
||||
{
|
||||
private static readonly DayOfWeek[] OrderedDays =
|
||||
[
|
||||
DayOfWeek.Monday,
|
||||
DayOfWeek.Tuesday,
|
||||
DayOfWeek.Wednesday,
|
||||
DayOfWeek.Thursday,
|
||||
DayOfWeek.Friday,
|
||||
DayOfWeek.Saturday,
|
||||
DayOfWeek.Sunday
|
||||
];
|
||||
|
||||
public static string BuildSummary(
|
||||
bool isRecurring,
|
||||
DateTimeOffset effectiveStart,
|
||||
DateTimeOffset effectiveEnd,
|
||||
bool isAllDay,
|
||||
CalendarSettings settings,
|
||||
int interval,
|
||||
CalendarItemRecurrenceFrequency frequency,
|
||||
IReadOnlyCollection<DayOfWeek> daysOfWeek,
|
||||
DateTimeOffset? recurrenceEndDate)
|
||||
{
|
||||
var culture = settings?.CultureInfo ?? CultureInfo.CurrentCulture;
|
||||
var timeSummary = isAllDay
|
||||
? Translator.CalendarItemAllDay
|
||||
: string.Format(
|
||||
culture,
|
||||
Translator.CalendarEventCompose_TimeRangeSummary,
|
||||
effectiveStart.ToString(settings?.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", culture),
|
||||
effectiveEnd.ToString(settings?.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", culture));
|
||||
|
||||
if (!isRecurring)
|
||||
{
|
||||
return string.Format(
|
||||
culture,
|
||||
Translator.CalendarEventCompose_SingleOccurrenceSummary,
|
||||
effectiveStart.ToString("dddd yyyy-MM-dd", culture),
|
||||
timeSummary);
|
||||
}
|
||||
|
||||
var normalizedDays = NormalizeDays(daysOfWeek);
|
||||
var isEveryDay = (frequency == CalendarItemRecurrenceFrequency.Daily && interval == 1) ||
|
||||
(frequency == CalendarItemRecurrenceFrequency.Weekly && interval == 1 && normalizedDays.Count == 7);
|
||||
|
||||
var cadenceSummary = isEveryDay
|
||||
? $"{Translator.CalendarEventCompose_Every} {Translator.CalendarEventCompose_FrequencyDay}"
|
||||
: interval == 1
|
||||
? $"{Translator.CalendarEventCompose_Every} {GetSingularFrequencyLabel(frequency)}"
|
||||
: $"{Translator.CalendarEventCompose_Every} {interval.ToString(culture)} {GetPluralFrequencyLabel(frequency)}";
|
||||
|
||||
var weekdaySummary = string.Empty;
|
||||
if (frequency == CalendarItemRecurrenceFrequency.Weekly && normalizedDays.Count > 0 && normalizedDays.Count < 7)
|
||||
{
|
||||
weekdaySummary = string.Format(
|
||||
culture,
|
||||
Translator.CalendarEventCompose_WeekdaySummary,
|
||||
string.Join(", ", normalizedDays.Select(day => culture.DateTimeFormat.GetDayName(day))));
|
||||
}
|
||||
|
||||
var untilSummary = recurrenceEndDate.HasValue
|
||||
? string.Format(
|
||||
culture,
|
||||
Translator.CalendarEventCompose_UntilSummary,
|
||||
recurrenceEndDate.Value.ToString("ddd yyyy-MM-dd", culture))
|
||||
: string.Empty;
|
||||
|
||||
return string.Format(
|
||||
culture,
|
||||
Translator.CalendarEventCompose_RecurringSummarySmart,
|
||||
cadenceSummary,
|
||||
weekdaySummary,
|
||||
timeSummary,
|
||||
effectiveStart.ToString("dddd yyyy-MM-dd", culture),
|
||||
untilSummary).Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DayOfWeek> NormalizeDays(IReadOnlyCollection<DayOfWeek> daysOfWeek)
|
||||
{
|
||||
if (daysOfWeek == null || daysOfWeek.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return daysOfWeek
|
||||
.Distinct()
|
||||
.OrderBy(day => Array.IndexOf(OrderedDays, day))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string GetSingularFrequencyLabel(CalendarItemRecurrenceFrequency frequency)
|
||||
{
|
||||
return frequency switch
|
||||
{
|
||||
CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDay,
|
||||
CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeek,
|
||||
CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonth,
|
||||
CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYear,
|
||||
_ => Translator.CalendarEventCompose_FrequencyWeek
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetPluralFrequencyLabel(CalendarItemRecurrenceFrequency frequency)
|
||||
{
|
||||
return frequency switch
|
||||
{
|
||||
CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDayPlural,
|
||||
CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeekPlural,
|
||||
CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonthPlural,
|
||||
CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYearPlural,
|
||||
_ => Translator.CalendarEventCompose_FrequencyWeekPlural
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Wino.Core.Domain;
|
||||
|
||||
public static class CalendarReminderSnoozeOptions
|
||||
{
|
||||
private static readonly int[] SupportedSnoozeMinutes = [5, 10, 15, 30];
|
||||
|
||||
public static IReadOnlyList<int> GetSupportedSnoozeMinutes()
|
||||
=> SupportedSnoozeMinutes;
|
||||
|
||||
public static IReadOnlyList<int> GetAllowedSnoozeMinutes(long reminderDurationInSeconds, long defaultReminderDurationInSeconds)
|
||||
{
|
||||
var reminderMinutes = (int)Math.Max(0, reminderDurationInSeconds / 60);
|
||||
|
||||
if (reminderMinutes <= 0)
|
||||
return [];
|
||||
|
||||
var maxSnoozeMinutes = reminderMinutes;
|
||||
var defaultReminderMinutes = (int)Math.Max(0, defaultReminderDurationInSeconds / 60);
|
||||
|
||||
if (defaultReminderMinutes > 0)
|
||||
maxSnoozeMinutes = Math.Min(maxSnoozeMinutes, defaultReminderMinutes);
|
||||
|
||||
return SupportedSnoozeMinutes.Where(minutes => minutes <= maxSnoozeMinutes).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using Itenso.TimePeriod;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Core.Domain.Collections;
|
||||
|
||||
public class CalendarEventCollection
|
||||
{
|
||||
public event EventHandler<ICalendarItem> CalendarItemAdded;
|
||||
public event EventHandler<ICalendarItem> CalendarItemRemoved;
|
||||
public event EventHandler<ICalendarItem> CalendarItemUpdated;
|
||||
|
||||
public event EventHandler CalendarItemsCleared;
|
||||
|
||||
private ObservableRangeCollection<ICalendarItem> _internalRegularEvents = [];
|
||||
private ObservableRangeCollection<ICalendarItem> _internalAllDayEvents = [];
|
||||
|
||||
public ReadOnlyObservableCollection<ICalendarItem> RegularEvents { get; }
|
||||
public ReadOnlyObservableCollection<ICalendarItem> AllDayEvents { get; } // TODO: Rename this to include multi-day events.
|
||||
public ITimePeriod Period { get; }
|
||||
public CalendarSettings Settings { get; }
|
||||
|
||||
private readonly List<ICalendarItem> _allItems = new List<ICalendarItem>();
|
||||
|
||||
public CalendarEventCollection(ITimePeriod period, CalendarSettings settings)
|
||||
{
|
||||
Period = period;
|
||||
Settings = settings;
|
||||
|
||||
RegularEvents = new ReadOnlyObservableCollection<ICalendarItem>(_internalRegularEvents);
|
||||
AllDayEvents = new ReadOnlyObservableCollection<ICalendarItem>(_internalAllDayEvents);
|
||||
}
|
||||
|
||||
public bool HasCalendarEvent(AccountCalendar accountCalendar)
|
||||
=> _allItems.Any(x => x.AssignedCalendar.Id == accountCalendar.Id);
|
||||
|
||||
public ICalendarItem GetCalendarItem(Guid calendarItemId)
|
||||
{
|
||||
return _allItems.FirstOrDefault(x => x.Id == calendarItemId);
|
||||
}
|
||||
|
||||
public void ClearSelectionStates()
|
||||
{
|
||||
foreach (var item in _allItems)
|
||||
{
|
||||
if (item is ICalendarItemViewModel calendarItemViewModel)
|
||||
{
|
||||
calendarItemViewModel.IsSelected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void FilterByCalendars(IEnumerable<Guid> visibleCalendarIds)
|
||||
{
|
||||
foreach (var item in _allItems)
|
||||
{
|
||||
var collections = GetProperCollectionsForCalendarItem(item);
|
||||
|
||||
foreach (var collection in collections)
|
||||
{
|
||||
if (!visibleCalendarIds.Contains(item.AssignedCalendar.Id) && collection.Contains(item))
|
||||
{
|
||||
RemoveCalendarItemInternal(collection, item, false);
|
||||
}
|
||||
else if (visibleCalendarIds.Contains(item.AssignedCalendar.Id) && !collection.Contains(item))
|
||||
{
|
||||
AddCalendarItemInternal(collection, item, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<ObservableRangeCollection<ICalendarItem>> GetProperCollectionsForCalendarItem(ICalendarItem calendarItem)
|
||||
{
|
||||
// All-day events go to all days.
|
||||
// Multi-day events go to both.
|
||||
// Anything else goes to regular.
|
||||
|
||||
if (calendarItem.IsAllDayEvent)
|
||||
{
|
||||
return [_internalAllDayEvents];
|
||||
}
|
||||
else if (calendarItem.IsMultiDayEvent)
|
||||
{
|
||||
return [_internalRegularEvents, _internalAllDayEvents];
|
||||
}
|
||||
else
|
||||
{
|
||||
return [_internalRegularEvents];
|
||||
}
|
||||
}
|
||||
|
||||
public void AddCalendarItem(ICalendarItem calendarItem)
|
||||
{
|
||||
var collections = GetProperCollectionsForCalendarItem(calendarItem);
|
||||
|
||||
foreach (var collection in collections)
|
||||
{
|
||||
AddCalendarItemInternal(collection, calendarItem);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveCalendarItem(ICalendarItem calendarItem)
|
||||
{
|
||||
var collections = GetProperCollectionsForCalendarItem(calendarItem);
|
||||
|
||||
foreach (var collection in collections)
|
||||
{
|
||||
RemoveCalendarItemInternal(collection, calendarItem);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveCalendarItems(Func<ICalendarItem, bool> predicate)
|
||||
{
|
||||
if (predicate == null) return;
|
||||
|
||||
var itemsToRemove = _allItems.Where(predicate).ToList();
|
||||
|
||||
foreach (var item in itemsToRemove)
|
||||
{
|
||||
RemoveCalendarItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCalendarItemInternal(ObservableRangeCollection<ICalendarItem> collection, ICalendarItem calendarItem, bool create = true)
|
||||
{
|
||||
if (calendarItem is not ICalendarItemViewModel viewModel)
|
||||
throw new ArgumentException("CalendarItem must be of type ICalendarItemViewModel", nameof(calendarItem));
|
||||
|
||||
// Set the displaying context for proper title calculation
|
||||
viewModel.DisplayingPeriod = Period;
|
||||
viewModel.CalendarSettings = Settings;
|
||||
|
||||
collection.Add(calendarItem);
|
||||
|
||||
if (create)
|
||||
{
|
||||
_allItems.Add(calendarItem);
|
||||
}
|
||||
|
||||
CalendarItemAdded?.Invoke(this, calendarItem);
|
||||
}
|
||||
|
||||
private void RemoveCalendarItemInternal(ObservableRangeCollection<ICalendarItem> collection, ICalendarItem calendarItem, bool destroy = true)
|
||||
{
|
||||
if (calendarItem is not ICalendarItemViewModel)
|
||||
throw new ArgumentException("CalendarItem must be of type ICalendarItemViewModel", nameof(calendarItem));
|
||||
|
||||
collection.Remove(calendarItem);
|
||||
|
||||
if (destroy)
|
||||
{
|
||||
_allItems.Remove(calendarItem);
|
||||
}
|
||||
|
||||
CalendarItemRemoved?.Invoke(this, calendarItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing calendar item in-place. If the item's type changed (all-day vs regular),
|
||||
/// it will be moved to the appropriate collection.
|
||||
/// </summary>
|
||||
/// <param name="calendarItem">The updated calendar item data.</param>
|
||||
/// <returns>True if the item was found and updated; false otherwise.</returns>
|
||||
public bool UpdateCalendarItem(CalendarItem calendarItem)
|
||||
{
|
||||
var existingItem = _allItems.FirstOrDefault(x => x.Id == calendarItem.Id);
|
||||
if (existingItem == null)
|
||||
return false;
|
||||
|
||||
// Get the collections this item is currently in (before update)
|
||||
var oldCollections = GetProperCollectionsForCalendarItem(existingItem).ToList();
|
||||
|
||||
// Update the underlying data
|
||||
if (existingItem is ICalendarItemViewModel viewModel)
|
||||
{
|
||||
viewModel.UpdateFrom(calendarItem);
|
||||
}
|
||||
|
||||
// Get the collections this item should be in (after update)
|
||||
var newCollections = GetProperCollectionsForCalendarItem(existingItem).ToList();
|
||||
|
||||
// Check if the collections changed
|
||||
var collectionsToRemoveFrom = oldCollections.Except(newCollections).ToList();
|
||||
var collectionsToAddTo = newCollections.Except(oldCollections).ToList();
|
||||
|
||||
// Remove from old collections that are no longer applicable
|
||||
foreach (var collection in collectionsToRemoveFrom)
|
||||
{
|
||||
collection.Remove(existingItem);
|
||||
}
|
||||
|
||||
// Add to new collections that are now applicable
|
||||
foreach (var collection in collectionsToAddTo)
|
||||
{
|
||||
if (!collection.Contains(existingItem))
|
||||
{
|
||||
collection.Add(existingItem);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarItemUpdated?.Invoke(this, existingItem);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_internalAllDayEvents.Clear();
|
||||
_internalRegularEvents.Clear();
|
||||
_allItems.Clear();
|
||||
|
||||
CalendarItemsCleared?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Wino.Core.Domain.Collections;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a dynamic data collection that provides notifications when items get added, removed, or when the whole list is refreshed.
|
||||
/// </summary>
|
||||
/// <typeparam name="T"></typeparam>
|
||||
public class ObservableRangeCollection<T> : ObservableCollection<T>
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the System.Collections.ObjectModel.ObservableCollection(Of T) class.
|
||||
/// </summary>
|
||||
public ObservableRangeCollection()
|
||||
: base()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the System.Collections.ObjectModel.ObservableCollection(Of T) class that contains elements copied from the specified collection.
|
||||
/// </summary>
|
||||
/// <param name="collection">collection: The collection from which the elements are copied.</param>
|
||||
/// <exception cref="ArgumentNullException">The collection parameter cannot be null.</exception>
|
||||
public ObservableRangeCollection(IEnumerable<T> collection)
|
||||
: base(collection)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the elements of the specified collection to the end of the ObservableCollection(Of T).
|
||||
/// </summary>
|
||||
public void AddRange(IEnumerable<T> collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Add)
|
||||
{
|
||||
if (notificationMode != NotifyCollectionChangedAction.Add && notificationMode != NotifyCollectionChangedAction.Reset)
|
||||
throw new ArgumentException("Mode must be either Add or Reset for AddRange.", nameof(notificationMode));
|
||||
if (collection == null)
|
||||
throw new ArgumentNullException(nameof(collection));
|
||||
|
||||
CheckReentrancy();
|
||||
|
||||
var startIndex = Count;
|
||||
|
||||
var itemsAdded = AddArrangeCore(collection);
|
||||
|
||||
if (!itemsAdded)
|
||||
return;
|
||||
|
||||
if (notificationMode == NotifyCollectionChangedAction.Reset)
|
||||
{
|
||||
RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var changedItems = collection is List<T> ? (List<T>)collection : new List<T>(collection);
|
||||
|
||||
RaiseChangeNotificationEvents(
|
||||
action: NotifyCollectionChangedAction.Add,
|
||||
changedItems: changedItems,
|
||||
startingIndex: startIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the first occurence of each item in the specified collection from ObservableCollection(Of T). NOTE: with notificationMode = Remove, removed items starting index is not set because items are not guaranteed to be consecutive.
|
||||
/// </summary>
|
||||
public void RemoveRange(IEnumerable<T> collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Reset)
|
||||
{
|
||||
if (notificationMode != NotifyCollectionChangedAction.Remove && notificationMode != NotifyCollectionChangedAction.Reset)
|
||||
throw new ArgumentException("Mode must be either Remove or Reset for RemoveRange.", nameof(notificationMode));
|
||||
if (collection == null)
|
||||
throw new ArgumentNullException(nameof(collection));
|
||||
|
||||
CheckReentrancy();
|
||||
|
||||
if (notificationMode == NotifyCollectionChangedAction.Reset)
|
||||
{
|
||||
var raiseEvents = false;
|
||||
foreach (var item in collection)
|
||||
{
|
||||
Items.Remove(item);
|
||||
raiseEvents = true;
|
||||
}
|
||||
|
||||
if (raiseEvents)
|
||||
RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var changedItems = new List<T>(collection);
|
||||
for (var i = 0; i < changedItems.Count; i++)
|
||||
{
|
||||
if (!Items.Remove(changedItems[i]))
|
||||
{
|
||||
changedItems.RemoveAt(i); //Can't use a foreach because changedItems is intended to be (carefully) modified
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
if (changedItems.Count == 0)
|
||||
return;
|
||||
|
||||
RaiseChangeNotificationEvents(
|
||||
action: NotifyCollectionChangedAction.Remove,
|
||||
changedItems: changedItems);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the current collection and replaces it with the specified item.
|
||||
/// </summary>
|
||||
public void Replace(T item) => ReplaceRange(new T[] { item });
|
||||
|
||||
/// <summary>
|
||||
/// Clears the current collection and replaces it with the specified collection.
|
||||
/// </summary>
|
||||
public void ReplaceRange(IEnumerable<T> collection)
|
||||
{
|
||||
if (collection == null)
|
||||
throw new ArgumentNullException(nameof(collection));
|
||||
|
||||
CheckReentrancy();
|
||||
|
||||
var previouslyEmpty = Items.Count == 0;
|
||||
|
||||
Items.Clear();
|
||||
|
||||
AddArrangeCore(collection);
|
||||
|
||||
var currentlyEmpty = Items.Count == 0;
|
||||
|
||||
if (previouslyEmpty && currentlyEmpty)
|
||||
return;
|
||||
|
||||
RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset);
|
||||
}
|
||||
|
||||
public void InsertRange(IEnumerable<T> items)
|
||||
{
|
||||
CheckReentrancy();
|
||||
|
||||
foreach (var item in items)
|
||||
Items.Insert(0, item);
|
||||
|
||||
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||
}
|
||||
|
||||
private bool AddArrangeCore(IEnumerable<T> collection)
|
||||
{
|
||||
var itemAdded = false;
|
||||
foreach (var item in collection)
|
||||
{
|
||||
Items.Add(item);
|
||||
itemAdded = true;
|
||||
}
|
||||
return itemAdded;
|
||||
}
|
||||
|
||||
private void RaiseChangeNotificationEvents(NotifyCollectionChangedAction action, List<T> changedItems = null, int startingIndex = -1)
|
||||
{
|
||||
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
|
||||
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||
|
||||
if (changedItems is null)
|
||||
OnCollectionChanged(new NotifyCollectionChangedEventArgs(action));
|
||||
else
|
||||
OnCollectionChanged(new NotifyCollectionChangedEventArgs(action, changedItems: changedItems, startingIndex: startingIndex));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,36 @@
|
||||
namespace Wino.Core.Domain
|
||||
{
|
||||
public static class Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// MIME header that exists in all the drafts created from Wino.
|
||||
/// </summary>
|
||||
public const string WinoLocalDraftHeader = "X-Wino-Draft-Id";
|
||||
public const string LocalDraftStartPrefix = "localDraft_";
|
||||
namespace Wino.Core.Domain;
|
||||
|
||||
public const string ToastMailItemIdKey = nameof(ToastMailItemIdKey);
|
||||
public const string ToastActionKey = nameof(ToastActionKey);
|
||||
}
|
||||
public static class Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// MIME header that exists in all the drafts created from Wino.
|
||||
/// </summary>
|
||||
public const string WinoLocalDraftHeader = "X-Wino-Draft-Id";
|
||||
public const string DispositionNotificationToHeader = "Disposition-Notification-To";
|
||||
public const string OriginalMessageIdHeader = "Original-Message-ID";
|
||||
public const string LocalDraftStartPrefix = "localDraft_";
|
||||
|
||||
public const string CalendarEventRecurrenceRuleSeperator = "___";
|
||||
|
||||
public const string ToastMailUniqueIdKey = nameof(ToastMailUniqueIdKey);
|
||||
public const string ToastActionKey = nameof(ToastActionKey);
|
||||
public const string ToastMailAccountIdKey = nameof(ToastMailAccountIdKey);
|
||||
public const string ToastCalendarItemIdKey = nameof(ToastCalendarItemIdKey);
|
||||
public const string ToastCalendarActionKey = nameof(ToastCalendarActionKey);
|
||||
public const string ToastCalendarNavigateAction = nameof(ToastCalendarNavigateAction);
|
||||
public const string ToastCalendarJoinOnlineAction = nameof(ToastCalendarJoinOnlineAction);
|
||||
public const string ToastCalendarSnoozeAction = nameof(ToastCalendarSnoozeAction);
|
||||
public const string ToastCalendarSnoozeDurationInputId = nameof(ToastCalendarSnoozeDurationInputId);
|
||||
public const string ToastModeKey = nameof(ToastModeKey);
|
||||
public const string ToastModeMail = nameof(ToastModeMail);
|
||||
public const string ToastModeCalendar = nameof(ToastModeCalendar);
|
||||
public const string ToastStoreUpdateActionKey = nameof(ToastStoreUpdateActionKey);
|
||||
public const string ToastStoreUpdateActionInstall = nameof(ToastStoreUpdateActionInstall);
|
||||
public const string ClientLogFile = "Client_.log";
|
||||
public const string ServerLogFile = "Server_.log";
|
||||
public const string LogArchiveFileName = "WinoLogs.zip";
|
||||
|
||||
public const string WinoMailIdentiifer = nameof(WinoMailIdentiifer);
|
||||
public const string WinoCalendarIdentifier = nameof(WinoCalendarIdentifier);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities
|
||||
{
|
||||
public class AccountSignature
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string HtmlBody { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using SQLite;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Wino.Core.Domain.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// Back storage for simple name-address book.
|
||||
/// These values will be inserted during MIME fetch.
|
||||
/// </summary>
|
||||
|
||||
|
||||
// TODO: This can easily evolve to Contact store, just like People app in Windows 10/11.
|
||||
// Do it.
|
||||
public class AddressInformation : IEquatable<AddressInformation>
|
||||
{
|
||||
[PrimaryKey]
|
||||
public string Address { get; set; }
|
||||
public string Name { get; set; }
|
||||
|
||||
public string DisplayName => Address == Name ? Address : $"{Name} <{Address}>";
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
return Equals(obj as AddressInformation);
|
||||
}
|
||||
|
||||
public bool Equals(AddressInformation other)
|
||||
{
|
||||
return !(other is null) &&
|
||||
Address == other.Address &&
|
||||
Name == other.Name;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
int hashCode = -1717786383;
|
||||
hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Address);
|
||||
hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Name);
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
public static bool operator ==(AddressInformation left, AddressInformation right)
|
||||
{
|
||||
return EqualityComparer<AddressInformation>.Default.Equals(left, right);
|
||||
}
|
||||
|
||||
public static bool operator !=(AddressInformation left, AddressInformation right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Calendar;
|
||||
|
||||
[Preserve]
|
||||
public class AccountCalendar : IAccountCalendar
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
public Guid AccountId { get; set; }
|
||||
public string RemoteCalendarId { get; set; }
|
||||
public string SynchronizationDeltaToken { get; set; }
|
||||
public string Name { get; set; }
|
||||
public bool IsPrimary { get; set; }
|
||||
public bool IsReadOnly { get; set; }
|
||||
public bool IsSynchronizationEnabled { get; set; } = true;
|
||||
public bool IsExtended { get; set; } = true;
|
||||
public CalendarItemShowAs DefaultShowAs { get; set; } = CalendarItemShowAs.Busy;
|
||||
|
||||
/// <summary>
|
||||
/// Unused for now.
|
||||
/// </summary>
|
||||
public string TextColorHex { get; set; }
|
||||
public string BackgroundColorHex { get; set; }
|
||||
public bool IsBackgroundColorUserOverridden { get; set; }
|
||||
public string TimeZone { get; set; }
|
||||
|
||||
[Ignore]
|
||||
public MailAccount MailAccount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Calendar;
|
||||
|
||||
/// <summary>
|
||||
/// Represents metadata for calendar event attachments.
|
||||
/// Actual file content is downloaded on-demand.
|
||||
/// </summary>
|
||||
public class CalendarAttachment
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The calendar item this attachment belongs to.
|
||||
/// </summary>
|
||||
public Guid CalendarItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Remote identifier for the attachment from the provider (Outlook, Gmail, etc.).
|
||||
/// </summary>
|
||||
public string RemoteAttachmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// File name of the attachment.
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the attachment in bytes.
|
||||
/// </summary>
|
||||
public long Size { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME content type (e.g., "application/pdf", "image/png").
|
||||
/// </summary>
|
||||
public string ContentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the attachment has been downloaded to local storage.
|
||||
/// </summary>
|
||||
public bool IsDownloaded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Local file path where the attachment is stored (if downloaded).
|
||||
/// </summary>
|
||||
public string LocalFilePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the attachment was last modified.
|
||||
/// </summary>
|
||||
public DateTimeOffset LastModified { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Calendar;
|
||||
|
||||
public class CalendarEventAttendee
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
public Guid CalendarItemId { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Email { get; set; }
|
||||
public AttendeeStatus AttendenceStatus { get; set; }
|
||||
public bool IsOrganizer { get; set; }
|
||||
public bool IsOptionalAttendee { get; set; }
|
||||
public string Comment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolved contact from the contact store. Populated at runtime via IContactService;
|
||||
/// not persisted to the database.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public AccountContact ResolvedContact { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using Itenso.TimePeriod;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Calendar;
|
||||
|
||||
[DebuggerDisplay("{Title} ({StartDate} - {EndDate})")]
|
||||
public class CalendarItem : ICalendarItem
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
public string RemoteEventId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Location { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether this item is a local preview that hasn't been synced to the server yet.
|
||||
/// When true, the item exists only in the local database without a RemoteEventId.
|
||||
/// Used to prevent duplicates when the server returns the newly created event.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public bool IsLocalPreview => string.IsNullOrEmpty(RemoteEventId);
|
||||
|
||||
public DateTime StartDate { get; set; }
|
||||
|
||||
public DateTime EndDate
|
||||
{
|
||||
get
|
||||
{
|
||||
return StartDate.AddSeconds(DurationInSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IANA timezone identifier for the start time (e.g., "America/New_York", "Europe/London").
|
||||
/// If null or empty, UTC is assumed.
|
||||
/// </summary>
|
||||
public string StartTimeZone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IANA timezone identifier for the end time (e.g., "America/New_York", "Europe/London").
|
||||
/// If null or empty, UTC is assumed.
|
||||
/// </summary>
|
||||
public string EndTimeZone { get; set; }
|
||||
|
||||
private ITimePeriod _period;
|
||||
public ITimePeriod Period
|
||||
{
|
||||
get
|
||||
{
|
||||
_period ??= new TimeRange(StartDate, EndDate);
|
||||
|
||||
return _period;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Events that starts at midnight and ends at midnight are considered all-day events.
|
||||
/// </summary>
|
||||
public bool IsAllDayEvent
|
||||
{
|
||||
get
|
||||
{
|
||||
return
|
||||
StartDate.TimeOfDay == TimeSpan.Zero &&
|
||||
EndDate.TimeOfDay == TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Events that are child instances of a recurring event (occurrences or exceptions).
|
||||
/// </summary>
|
||||
public bool IsRecurringChild
|
||||
{
|
||||
get
|
||||
{
|
||||
return RecurringCalendarItemId != null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Events that are part of a recurring series (either as parent or child).
|
||||
/// </summary>
|
||||
public bool IsRecurringEvent => IsRecurringChild || IsRecurringParent;
|
||||
|
||||
/// <summary>
|
||||
/// Events that are the master event definition of recurrence events.
|
||||
/// </summary>
|
||||
public bool IsRecurringParent
|
||||
{
|
||||
get
|
||||
{
|
||||
return !string.IsNullOrEmpty(Recurrence) && RecurringCalendarItemId == null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Events that are not all-day events and last more than one day are considered multi-day events.
|
||||
/// </summary>
|
||||
public bool IsMultiDayEvent
|
||||
{
|
||||
get
|
||||
{
|
||||
return Period.Duration.TotalDays >= 1 && !IsAllDayEvent;
|
||||
}
|
||||
}
|
||||
|
||||
public double DurationInSeconds { get; set; }
|
||||
public string Recurrence { get; set; }
|
||||
|
||||
public string OrganizerDisplayName { get; set; }
|
||||
public string OrganizerEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The id of the parent calendar item of the recurring event.
|
||||
/// Exceptional instances are stored as a separate calendar item.
|
||||
/// This makes the calendar item a child of the recurring event.
|
||||
/// </summary>
|
||||
public Guid? RecurringCalendarItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates read-only events. Default is false.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hidden events must not be displayed to the user.
|
||||
/// This usually happens when a child instance of recurring parent is cancelled after creation.
|
||||
/// </summary>
|
||||
public bool IsHidden { get; set; }
|
||||
|
||||
// TODO
|
||||
public string CustomEventColorHex { get; set; }
|
||||
public string HtmlLink { get; set; }
|
||||
public DateTime? SnoozedUntil { get; set; }
|
||||
public CalendarItemStatus Status { get; set; }
|
||||
public CalendarItemVisibility Visibility { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates how the event should be shown in the calendar (Free, Busy, Tentative, etc.).
|
||||
/// </summary>
|
||||
public CalendarItemShowAs ShowAs { get; set; } = CalendarItemShowAs.Busy;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public Guid CalendarId { get; set; }
|
||||
|
||||
[Ignore]
|
||||
public IAccountCalendar AssignedCalendar { get; set; }
|
||||
|
||||
[Ignore]
|
||||
public bool CanChangeStartAndEndDate
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsLocked)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var accountAddress = AssignedCalendar?.MailAccount?.Address;
|
||||
|
||||
return string.IsNullOrWhiteSpace(OrganizerEmail) ||
|
||||
string.IsNullOrWhiteSpace(accountAddress) ||
|
||||
string.Equals(OrganizerEmail, accountAddress, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Id to load information related to this event (attendees, reminders, etc.).
|
||||
/// For child events, if they have their own data, use their own Id.
|
||||
/// For events that share data with their parent, return parent's Id.
|
||||
/// </summary>
|
||||
public Guid EventTrackingId => Id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the start date converted to user's local timezone for display.
|
||||
/// StartDate is stored according to StartTimeZone.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public DateTime LocalStartDate
|
||||
{
|
||||
get
|
||||
{
|
||||
return this.GetLocalStartDate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the end date converted to user's local timezone for display.
|
||||
/// EndDate is calculated from StartDate and is in StartTimeZone.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public DateTime LocalEndDate
|
||||
{
|
||||
get
|
||||
{
|
||||
return this.GetLocalEndDate();
|
||||
}
|
||||
}
|
||||
|
||||
public string GetDisplayTitle(ITimePeriod displayingPeriod, CalendarSettings calendarSettings) => Period.ToString();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Calendar;
|
||||
|
||||
public class Reminder
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
public Guid CalendarItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration in seconds before the event start time when the reminder should trigger.
|
||||
/// For example, 900 seconds = 15 minutes before event.
|
||||
/// </summary>
|
||||
public long DurationInSeconds { get; set; }
|
||||
public CalendarItemReminderType ReminderType { get; set; }
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities
|
||||
{
|
||||
public class CustomServerInformation
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
public string DisplayName { get; set; }
|
||||
public string Address { get; set; }
|
||||
public string IncomingServer { get; set; }
|
||||
public string IncomingServerUsername { get; set; }
|
||||
public string IncomingServerPassword { get; set; }
|
||||
public string IncomingServerPort { get; set; }
|
||||
|
||||
public CustomIncomingServerType IncomingServerType { get; set; }
|
||||
|
||||
public string OutgoingServer { get; set; }
|
||||
public string OutgoingServerPort { get; set; }
|
||||
public string OutgoingServerUsername { get; set; }
|
||||
public string OutgoingServerPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// useSSL True: SslOnConnect
|
||||
/// useSSL False: StartTlsWhenAvailable
|
||||
/// </summary>
|
||||
|
||||
public ImapConnectionSecurity IncomingServerSocketOption { get; set; }
|
||||
public ImapAuthenticationMethod IncomingAuthenticationMethod { get; set; }
|
||||
|
||||
|
||||
public ImapConnectionSecurity OutgoingServerSocketOption { get; set; }
|
||||
public ImapAuthenticationMethod OutgoingAuthenticationMethod { get; set; }
|
||||
|
||||
public string ProxyServer { get; set; }
|
||||
public string ProxyServerPort { get; set; }
|
||||
|
||||
[Obsolete("As 1.7.0")]
|
||||
public bool IncomingRequiresSSL { get; set; }
|
||||
|
||||
[Obsolete("As 1.7.0")]
|
||||
public bool OutgoingRequresSSL { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Mail;
|
||||
|
||||
public class AccountSignature
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
|
||||
public string HtmlBody { get; set; }
|
||||
|
||||
public Guid MailAccountId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Mail;
|
||||
|
||||
public class EmailTemplate
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public string HtmlContent { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Mail;
|
||||
|
||||
public class RemoteAccountAlias
|
||||
{
|
||||
/// <summary>
|
||||
/// Display address of the alias.
|
||||
/// </summary>
|
||||
public string AliasAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Address to be included in Reply-To header when alias is used for sending messages.
|
||||
/// </summary>
|
||||
public string ReplyToAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this alias is the primary alias for the account.
|
||||
/// </summary>
|
||||
public bool IsPrimary { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the alias is verified by the server.
|
||||
/// Only Gmail aliases are verified for now.
|
||||
/// Non-verified alias messages might be rejected by SMTP server.
|
||||
/// </summary>
|
||||
public bool IsVerified { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this alias is the root alias for the account.
|
||||
/// Root alias means the first alias that was created for the account.
|
||||
/// It can't be deleted or changed.
|
||||
/// </summary>
|
||||
public bool IsRootAlias { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional sender name for the alias.
|
||||
/// Falls back to account's sender name if not set when preparing messages.
|
||||
/// Used for Gmail only.
|
||||
/// </summary>
|
||||
public string AliasSenderName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the alias was entered by the user or discovered from the provider.
|
||||
/// </summary>
|
||||
public AliasSource Source { get; set; } = AliasSource.Manual;
|
||||
|
||||
/// <summary>
|
||||
/// Represents Wino's confidence that the alias can be used for sending.
|
||||
/// </summary>
|
||||
public AliasSendCapability SendCapability { get; set; } = AliasSendCapability.Unknown;
|
||||
}
|
||||
|
||||
public class MailAccountAlias : RemoteAccountAlias
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique Id for the alias.
|
||||
/// </summary>
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account id that this alias is attached to.
|
||||
/// </summary>
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Root aliases can't be deleted.
|
||||
/// </summary>
|
||||
public bool CanDelete => !IsRootAlias;
|
||||
|
||||
public string SelectedSigningCertificateThumbprint { get; set; }
|
||||
public bool IsSmimeEncryptionEnabled { get; set; }
|
||||
|
||||
[Ignore]
|
||||
public X509Certificate2 SelectedSigningCertificate { get; set; }
|
||||
|
||||
[Ignore]
|
||||
public ObservableCollection<X509Certificate2> Certificates { get; set; } = [];
|
||||
|
||||
[Ignore]
|
||||
public bool IsCapabilityConfirmed => SendCapability == AliasSendCapability.Confirmed;
|
||||
|
||||
[Ignore]
|
||||
public bool IsCapabilityUnknown => SendCapability == AliasSendCapability.Unknown;
|
||||
|
||||
[Ignore]
|
||||
public bool IsCapabilityDenied => SendCapability == AliasSendCapability.Denied;
|
||||
|
||||
[Ignore]
|
||||
public string CapabilityDisplayName => SendCapability switch
|
||||
{
|
||||
AliasSendCapability.Confirmed => Translator.AccountAlias_Status_Confirmed,
|
||||
AliasSendCapability.Denied => Translator.AccountAlias_Status_Denied,
|
||||
_ => Translator.AccountAlias_Status_Unknown
|
||||
};
|
||||
|
||||
[Ignore]
|
||||
public string SourceDisplayName => Source switch
|
||||
{
|
||||
AliasSource.ProviderDiscovered => Translator.AccountAlias_Source_ProviderDiscovered,
|
||||
_ => Translator.AccountAlias_Source_Manual
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Mail;
|
||||
|
||||
public class MailCategory
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid MailAccountId { get; set; }
|
||||
|
||||
public string RemoteId { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
|
||||
public bool IsFavorite { get; set; }
|
||||
|
||||
public string BackgroundColorHex { get; set; }
|
||||
|
||||
public string TextColorHex { get; set; }
|
||||
|
||||
public MailCategorySource Source { get; set; } = MailCategorySource.Local;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Mail;
|
||||
|
||||
public class MailCategoryAssignment
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid MailCategoryId { get; set; }
|
||||
|
||||
public Guid MailCopyUniqueId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Summary of the parsed MIME messages.
|
||||
/// Wino will do non-network operations on this table and others from the original MIME.
|
||||
/// </summary>
|
||||
public class MailCopy
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique Id of the mail.
|
||||
/// </summary>
|
||||
[PrimaryKey]
|
||||
public Guid UniqueId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Not unique id of the item. Some operations held on this Id, some on the UniqueId.
|
||||
/// Same message can be in different folder. In that case UniqueId is used.
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Folder that this mail belongs to.
|
||||
/// </summary>
|
||||
public Guid FolderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Conversation id for the mail.
|
||||
/// </summary>
|
||||
public string ThreadId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME MessageId if exists.
|
||||
/// </summary>
|
||||
public string MessageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// References header from MIME
|
||||
/// </summary>
|
||||
public string References { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// In-Reply-To header from MIME
|
||||
/// </summary>
|
||||
public string InReplyTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Name for the sender.
|
||||
/// </summary>
|
||||
public string FromName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Address of the sender.
|
||||
/// </summary>
|
||||
public string FromAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject of the mail.
|
||||
/// </summary>
|
||||
public string Subject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Short preview of the content.
|
||||
/// </summary>
|
||||
public string PreviewText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Date that represents this mail has been created in provider servers.
|
||||
/// Stored always in UTC.
|
||||
/// </summary>
|
||||
public DateTime CreationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Importance of the mail.
|
||||
/// </summary>
|
||||
public MailImportance Importance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Read status for the mail.
|
||||
/// </summary>
|
||||
public bool IsRead { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Flag status.
|
||||
/// Flagged for Outlook.
|
||||
/// Important for Gmail.
|
||||
/// </summary>
|
||||
public bool IsFlagged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// To support Outlook.
|
||||
/// Gmail doesn't use it.
|
||||
/// </summary>
|
||||
public bool IsFocused { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether mail has attachments included or not.
|
||||
/// </summary>
|
||||
public bool HasAttachments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of mail item (regular mail, calendar invitation, calendar response, etc.).
|
||||
/// </summary>
|
||||
public MailItemType ItemType { get; set; } = MailItemType.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Assigned draft id.
|
||||
/// </summary>
|
||||
public string DraftId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this mail is only created locally.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public bool IsLocalDraft => !string.IsNullOrEmpty(DraftId) && DraftId.StartsWith(Constants.LocalDraftStartPrefix);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this copy is draft or not.
|
||||
/// </summary>
|
||||
public bool IsDraft { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// File id that this mail is assigned to.
|
||||
/// This Id is immutable. It's used to find the file in the file system.
|
||||
/// Even after mapping local draft to remote draft, it will not change.
|
||||
/// </summary>
|
||||
public Guid FileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Folder that this mail is assigned to.
|
||||
/// Warning: This field is not populated by queries.
|
||||
/// Services or View Models are responsible for populating this field.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public MailItemFolder AssignedFolder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account that this mail is assigned to.
|
||||
/// Warning: This field is not populated by queries.
|
||||
/// Services or View Models are responsible for populating this field.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public MailAccount AssignedAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contact information of the sender if exists.
|
||||
/// Warning: This field is not populated by queries.
|
||||
/// Services or View Models are responsible for populating this field.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public AccountContact SenderContact { get; set; }
|
||||
|
||||
[Ignore]
|
||||
public bool IsReadReceiptRequested { get; set; }
|
||||
|
||||
[Ignore]
|
||||
public SentMailReceiptStatus ReadReceiptStatus { get; set; }
|
||||
|
||||
[Ignore]
|
||||
public DateTime? ReadReceiptAcknowledgedAtUtc { get; set; }
|
||||
|
||||
[Ignore]
|
||||
public Guid? ReadReceiptMessageUniqueId { get; set; }
|
||||
|
||||
public IEnumerable<Guid> GetContainingIds() => [UniqueId];
|
||||
public override string ToString() => $"{Subject} <-> {Id}";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a calendar invitation mail item to a persisted calendar event.
|
||||
/// </summary>
|
||||
public class MailInvitationCalendarMapping
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MailCopy.Id value of the invitation mail.
|
||||
/// </summary>
|
||||
public string MailCopyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// iCalendar UID extracted from invitation MIME/ICS content.
|
||||
/// </summary>
|
||||
public string InvitationUid { get; set; }
|
||||
|
||||
public Guid CalendarId { get; set; }
|
||||
public Guid CalendarItemId { get; set; }
|
||||
public string CalendarRemoteEventId { get; set; }
|
||||
|
||||
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Mail;
|
||||
|
||||
[DebuggerDisplay("{FolderName} - {SpecialFolderType}")]
|
||||
public class MailItemFolder : IMailItemFolder
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string RemoteFolderId { get; set; }
|
||||
public string ParentRemoteFolderId { get; set; }
|
||||
|
||||
public Guid MailAccountId { get; set; }
|
||||
public string FolderName { get; set; }
|
||||
public SpecialFolderType SpecialFolderType { get; set; }
|
||||
public bool IsSystemFolder { get; set; }
|
||||
public bool IsSticky { get; set; }
|
||||
public bool IsSynchronizationEnabled { get; set; }
|
||||
public bool IsHidden { get; set; }
|
||||
public bool ShowUnreadCount { get; set; }
|
||||
public DateTime? LastSynchronizedDate { get; set; }
|
||||
|
||||
// For IMAP
|
||||
public uint UidValidity { get; set; }
|
||||
public long HighestModeSeq { get; set; }
|
||||
public uint HighestKnownUid { get; set; }
|
||||
public DateTime? LastUidReconcileUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Outlook shares delta changes per-folder. Gmail is for per-account.
|
||||
/// This is only used for Outlook provider.
|
||||
/// </summary>
|
||||
public string DeltaToken { get; set; }
|
||||
|
||||
// For GMail Labels
|
||||
public string TextColorHex { get; set; }
|
||||
public string BackgroundColorHex { get; set; }
|
||||
|
||||
[Ignore]
|
||||
public List<IMailItemFolder> ChildFolders { get; set; } = [];
|
||||
|
||||
// Category and Move type folders are not valid move targets.
|
||||
// These folders are virtual. They don't exist on the server.
|
||||
public bool IsMoveTarget => !(SpecialFolderType == SpecialFolderType.More || SpecialFolderType == SpecialFolderType.Category);
|
||||
|
||||
public bool ContainsSpecialFolderType(SpecialFolderType type)
|
||||
{
|
||||
if (SpecialFolderType == type)
|
||||
return true;
|
||||
|
||||
foreach (var child in ChildFolders)
|
||||
{
|
||||
if (child.SpecialFolderType == type)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return child.ContainsSpecialFolderType(type);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static MailItemFolder CreateMoreFolder() => new MailItemFolder() { IsSticky = true, SpecialFolderType = SpecialFolderType.More, FolderName = Translator.MoreFolderNameOverride };
|
||||
public static MailItemFolder CreateCategoriesFolder() => new MailItemFolder() { IsSticky = true, SpecialFolderType = SpecialFolderType.Category, FolderName = Translator.CategoriesFolderNameOverride };
|
||||
|
||||
public override string ToString() => FolderName;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Mail;
|
||||
|
||||
public class MergedInbox
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Mail;
|
||||
|
||||
public class SentMailReceiptState
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid MailUniqueId { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
public string MessageId { get; set; }
|
||||
|
||||
public bool IsReceiptRequested { get; set; }
|
||||
|
||||
public DateTime RequestedAtUtc { get; set; }
|
||||
|
||||
public SentMailReceiptStatus Status { get; set; }
|
||||
|
||||
public DateTime? AcknowledgedAtUtc { get; set; }
|
||||
|
||||
public Guid? ReceiptMessageUniqueId { get; set; }
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities
|
||||
{
|
||||
public class MailAccount
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Given name of the account in Wino.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Display name of the authenticated user/account.
|
||||
/// API integrations will query this value from the API.
|
||||
/// IMAP is populated by user on setup dialog.
|
||||
/// </summary>
|
||||
|
||||
public string ProfileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account e-mail address.
|
||||
/// </summary>
|
||||
public string Address { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider type of the account. Outlook,Gmail etc...
|
||||
/// </summary>
|
||||
public MailProviderType ProviderType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For tracking change delta.
|
||||
/// Gmail : historyId
|
||||
/// Outlook: deltaToken
|
||||
/// </summary>
|
||||
public string SynchronizationDeltaIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the signature to be used for this account.
|
||||
/// Null if no signature should be used.
|
||||
/// </summary>
|
||||
public Guid? SignatureId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the account has any reason for an interactive user action to fix continue operating.
|
||||
/// </summary>
|
||||
public AccountAttentionReason AttentionReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the id of the merged inbox this account belongs to.
|
||||
/// </summary>
|
||||
public Guid? MergedInboxId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains the merged inbox this account belongs to.
|
||||
/// Ignored for all SQLite operations.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public MergedInbox MergedInbox { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Populated only when account has custom server information.
|
||||
/// </summary>
|
||||
|
||||
[Ignore]
|
||||
public CustomServerInformation ServerInformation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account preferences.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public MailAccountPreferences Preferences { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities
|
||||
{
|
||||
public class MailAccountPreferences
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Id of the account in MailAccount table.
|
||||
/// </summary>
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether sent draft messages should be appended to the sent folder.
|
||||
/// Some IMAP servers do this automatically, some don't.
|
||||
/// It's disabled by default.
|
||||
/// </summary>
|
||||
public bool ShouldAppendMessagesToSentFolder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the notifications are enabled for the account.
|
||||
/// </summary>
|
||||
public bool IsNotificationsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the custom account identifier color in hex.
|
||||
/// </summary>
|
||||
public string AccountColorHex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the account has Focused inbox support.
|
||||
/// Null if the account provider type doesn't support Focused inbox.
|
||||
/// </summary>
|
||||
public bool? IsFocusedInboxEnabled { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
|
||||
namespace Wino.Core.Domain.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// Summary of the parsed MIME messages.
|
||||
/// Wino will do non-network operations on this table and others from the original MIME.
|
||||
/// </summary>
|
||||
public class MailCopy : IMailItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique Id of the mail.
|
||||
/// </summary>
|
||||
[PrimaryKey]
|
||||
public Guid UniqueId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Not unique id of the item. Some operations held on this Id, some on the UniqueId.
|
||||
/// Same message can be in different folder. In that case UniqueId is used.
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Folder that this mail belongs to.
|
||||
/// </summary>
|
||||
public Guid FolderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Conversation id for the mail.
|
||||
/// </summary>
|
||||
public string ThreadId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME MessageId if exists.
|
||||
/// </summary>
|
||||
public string MessageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// References header from MIME
|
||||
/// </summary>
|
||||
public string References { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// In-Reply-To header from MIME
|
||||
/// </summary>
|
||||
public string InReplyTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Name for the sender.
|
||||
/// </summary>
|
||||
public string FromName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Address of the sender.
|
||||
/// </summary>
|
||||
public string FromAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject of the mail.
|
||||
/// </summary>
|
||||
public string Subject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Short preview of the content.
|
||||
/// </summary>
|
||||
public string PreviewText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Date that represents this mail has been created in provider servers.
|
||||
/// Stored always in UTC.
|
||||
/// </summary>
|
||||
public DateTime CreationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Importance of the mail.
|
||||
/// </summary>
|
||||
public MailImportance Importance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Read status for the mail.
|
||||
/// </summary>
|
||||
public bool IsRead { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Flag status.
|
||||
/// Flagged for Outlook.
|
||||
/// Important for Gmail.
|
||||
/// </summary>
|
||||
public bool IsFlagged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// To support Outlook.
|
||||
/// Gmail doesn't use it.
|
||||
/// </summary>
|
||||
public bool IsFocused { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether mail has attachments included or not.
|
||||
/// </summary>
|
||||
public bool HasAttachments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Assigned draft id.
|
||||
/// </summary>
|
||||
public string DraftId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this copy is draft or not.
|
||||
/// </summary>
|
||||
public bool IsDraft { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// File id that this mail is assigned to.
|
||||
/// This Id is immutable. It's used to find the file in the file system.
|
||||
/// Even after mapping local draft to remote draft, it will not change.
|
||||
/// </summary>
|
||||
public Guid FileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Folder that this mail is assigned to.
|
||||
/// Warning: This field is not populated by queries.
|
||||
/// Services or View Models are responsible for populating this field.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public MailItemFolder AssignedFolder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account that this mail is assigned to.
|
||||
/// Warning: This field is not populated by queries.
|
||||
/// Services or View Models are responsible for populating this field.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public MailAccount AssignedAccount { get; set; }
|
||||
|
||||
public override string ToString() => $"{Subject} <-> {Id}";
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
|
||||
namespace Wino.Core.Domain.Entities
|
||||
{
|
||||
[DebuggerDisplay("{FolderName} - {SpecialFolderType}")]
|
||||
public class MailItemFolder : IMailItemFolder
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string RemoteFolderId { get; set; }
|
||||
public string ParentRemoteFolderId { get; set; }
|
||||
|
||||
public Guid MailAccountId { get; set; }
|
||||
public string FolderName { get; set; }
|
||||
public SpecialFolderType SpecialFolderType { get; set; }
|
||||
public bool IsSystemFolder { get; set; }
|
||||
public bool IsSticky { get; set; }
|
||||
public bool IsSynchronizationEnabled { get; set; }
|
||||
public bool IsHidden { get; set; }
|
||||
public bool ShowUnreadCount { get; set; }
|
||||
public DateTime? LastSynchronizedDate { get; set; }
|
||||
|
||||
// For IMAP
|
||||
public uint UidValidity { get; set; }
|
||||
public long HighestModeSeq { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Outlook shares delta changes per-folder. Gmail is for per-account.
|
||||
/// This is only used for Outlook provider.
|
||||
/// </summary>
|
||||
public string DeltaToken { get; set; }
|
||||
|
||||
// For GMail Labels
|
||||
public string TextColorHex { get; set; }
|
||||
public string BackgroundColorHex { get; set; }
|
||||
|
||||
[Ignore]
|
||||
public List<IMailItemFolder> ChildFolders { get; set; } = [];
|
||||
|
||||
// Category and Move type folders are not valid move targets.
|
||||
// These folders are virtual. They don't exist on the server.
|
||||
public bool IsMoveTarget => !(SpecialFolderType == SpecialFolderType.More || SpecialFolderType == SpecialFolderType.Category);
|
||||
|
||||
public bool ContainsSpecialFolderType(SpecialFolderType type)
|
||||
{
|
||||
if (SpecialFolderType == type)
|
||||
return true;
|
||||
|
||||
foreach (var child in ChildFolders)
|
||||
{
|
||||
if (child.SpecialFolderType == type)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return child.ContainsSpecialFolderType(type);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public override string ToString() => FolderName;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities
|
||||
{
|
||||
public class MergedInbox
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Back storage for simple name-address book.
|
||||
/// These values will be inserted during MIME fetch.
|
||||
/// </summary>
|
||||
|
||||
// TODO: This can easily evolve to Contact store, just like People app in Windows 10/11.
|
||||
// Do it.
|
||||
public class AccountContact : IEquatable<AccountContact>, IContactDisplayItem
|
||||
{
|
||||
/// <summary>
|
||||
/// E-mail address of the contact.
|
||||
/// </summary>
|
||||
[PrimaryKey]
|
||||
public string Address { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name of the contact.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// File ID for the contact picture stored on disk.
|
||||
/// The actual file lives at {ApplicationDataFolderPath}/contacts/{ContactPictureFileId}.jpg.
|
||||
/// </summary>
|
||||
public Guid? ContactPictureFileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All registered accounts have their contacts registered as root.
|
||||
/// Root contacts must not be overridden by any configuration.
|
||||
/// They are created on account creation.
|
||||
/// </summary>
|
||||
public bool IsRootContact { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, indicates that the contact has been manually modified by the user.
|
||||
/// Contacts with this flag set to true should not be updated during synchronization.
|
||||
/// </summary>
|
||||
public bool IsOverridden { get; set; } = false;
|
||||
|
||||
public string DisplayName => string.IsNullOrWhiteSpace(Name) ? Address : Name;
|
||||
AccountContact IContactDisplayItem.PreviewContact => this;
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
return Equals(obj as AccountContact);
|
||||
}
|
||||
|
||||
public bool Equals(AccountContact other)
|
||||
{
|
||||
return other is not null &&
|
||||
Address == other.Address &&
|
||||
Name == other.Name;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(Address, Name);
|
||||
}
|
||||
|
||||
public static bool operator ==(AccountContact left, AccountContact right)
|
||||
{
|
||||
return EqualityComparer<AccountContact>.Default.Equals(left, right);
|
||||
}
|
||||
|
||||
public static bool operator !=(AccountContact left, AccountContact right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// A named group of contacts that can be expanded to individual addresses during mail composition.
|
||||
/// </summary>
|
||||
public class ContactGroup
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Display name of the group (e.g., "Team Alpha", "Family").</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Optional description for the group.</summary>
|
||||
public string Description { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Associates an e-mail address with a <see cref="ContactGroup"/>.
|
||||
/// </summary>
|
||||
public class ContactGroupMember
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>Group this member belongs to.</summary>
|
||||
[Indexed]
|
||||
public Guid GroupId { get; set; }
|
||||
|
||||
/// <summary>E-mail address of the member (FK to AccountContact.Address).</summary>
|
||||
[Indexed]
|
||||
public string MemberAddress { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
public class CustomServerInformation
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// This field is ignored. DisplayName is stored in MailAccount as SenderName from now.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public string DisplayName { get; set; }
|
||||
public string Address { get; set; }
|
||||
public string IncomingServer { get; set; }
|
||||
public string IncomingServerUsername { get; set; }
|
||||
public string IncomingServerPassword { get; set; }
|
||||
public string IncomingServerPort { get; set; }
|
||||
|
||||
public CustomIncomingServerType IncomingServerType { get; set; }
|
||||
|
||||
public string OutgoingServer { get; set; }
|
||||
public string OutgoingServerPort { get; set; }
|
||||
public string OutgoingServerUsername { get; set; }
|
||||
public string OutgoingServerPassword { get; set; }
|
||||
|
||||
public string CalDavServiceUrl { get; set; }
|
||||
public string CalDavUsername { get; set; }
|
||||
public string CalDavPassword { get; set; }
|
||||
public ImapCalendarSupportMode CalendarSupportMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// useSSL True: SslOnConnect
|
||||
/// useSSL False: StartTlsWhenAvailable
|
||||
/// </summary>
|
||||
|
||||
public ImapConnectionSecurity IncomingServerSocketOption { get; set; }
|
||||
public ImapAuthenticationMethod IncomingAuthenticationMethod { get; set; }
|
||||
|
||||
|
||||
public ImapConnectionSecurity OutgoingServerSocketOption { get; set; }
|
||||
public ImapAuthenticationMethod OutgoingAuthenticationMethod { get; set; }
|
||||
|
||||
public string ProxyServer { get; set; }
|
||||
public string ProxyServerPort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of concurrent clients that can connect to the server.
|
||||
/// Default is 5.
|
||||
/// </summary>
|
||||
public int MaxConcurrentClients { get; set; }
|
||||
|
||||
public Dictionary<string, string> GetConnectionProperties()
|
||||
{
|
||||
// Printout the public connection properties.
|
||||
|
||||
var connectionProperties = new Dictionary<string, string>
|
||||
{
|
||||
{ "IncomingServer", IncomingServer },
|
||||
{ "IncomingServerPort", IncomingServerPort },
|
||||
{ "IncomingServerSocketOption", IncomingServerSocketOption.ToString() },
|
||||
{ "IncomingAuthenticationMethod", IncomingAuthenticationMethod.ToString() },
|
||||
{ "OutgoingServer", OutgoingServer },
|
||||
{ "OutgoingServerPort", OutgoingServerPort },
|
||||
{ "OutgoingServerSocketOption", OutgoingServerSocketOption.ToString() },
|
||||
{ "OutgoingAuthenticationMethod", OutgoingAuthenticationMethod.ToString() },
|
||||
{ "CalendarSupportMode", CalendarSupportMode.ToString() },
|
||||
{ "CalDavServiceUrl", CalDavServiceUrl },
|
||||
{ "ProxyServer", ProxyServer },
|
||||
{ "ProxyServerPort", ProxyServerPort }
|
||||
};
|
||||
|
||||
return connectionProperties;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
public interface IContactDisplayItem
|
||||
{
|
||||
string DisplayName { get; }
|
||||
string Address { get; }
|
||||
AccountContact PreviewContact { get; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a user-defined keyboard shortcut for mail operations.
|
||||
/// </summary>
|
||||
public class KeyboardShortcut
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The application mode this shortcut applies to.
|
||||
/// </summary>
|
||||
public WinoApplicationMode Mode { get; set; } = WinoApplicationMode.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// The key combination string (e.g., "D", "Delete", "F1").
|
||||
/// </summary>
|
||||
public string Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The modifier keys for this shortcut.
|
||||
/// </summary>
|
||||
public ModifierKeys ModifierKeys { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The shortcut action this shortcut triggers.
|
||||
/// </summary>
|
||||
public KeyboardShortcutAction Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this shortcut is enabled.
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When this shortcut was created.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// User-friendly display name for the shortcut.
|
||||
/// </summary>
|
||||
public string DisplayName
|
||||
{
|
||||
get
|
||||
{
|
||||
var modifierText = string.Empty;
|
||||
if (ModifierKeys.HasFlag(ModifierKeys.Control))
|
||||
modifierText += "Ctrl+";
|
||||
if (ModifierKeys.HasFlag(ModifierKeys.Alt))
|
||||
modifierText += "Alt+";
|
||||
if (ModifierKeys.HasFlag(ModifierKeys.Shift))
|
||||
modifierText += "Shift+";
|
||||
if (ModifierKeys.HasFlag(ModifierKeys.Windows))
|
||||
modifierText += "Win+";
|
||||
|
||||
return modifierText + Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
public class MailAccount
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Given name of the account in Wino.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Display name of the authenticated user/account.
|
||||
/// API integrations will query this value from the API.
|
||||
/// IMAP is populated by user on setup dialog.
|
||||
/// </summary>
|
||||
|
||||
public string SenderName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account e-mail address.
|
||||
/// </summary>
|
||||
public string Address { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider type of the account. Outlook,Gmail etc...
|
||||
/// </summary>
|
||||
public MailProviderType ProviderType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For tracking mail change delta.
|
||||
/// Gmail : historyId
|
||||
/// Outlook: deltaToken
|
||||
/// </summary>
|
||||
public string SynchronizationDeltaIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For tracking calendar change delta.
|
||||
/// Gmail: It's per-calendar, so unused.
|
||||
/// Outlook: deltaLink
|
||||
/// </summary>
|
||||
public string CalendarSynchronizationDeltaIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Gets or sets the custom account identifier color in hex.
|
||||
/// </summary>
|
||||
public string AccountColorHex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64 encoded profile picture of the account.
|
||||
/// </summary>
|
||||
public string Base64ProfilePictureData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the listing order of the account in the accounts list.
|
||||
/// </summary>
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the account has any reason for an interactive user action to fix continue operating.
|
||||
/// </summary>
|
||||
public AccountAttentionReason AttentionReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the id of the merged inbox this account belongs to.
|
||||
/// </summary>
|
||||
public Guid? MergedInboxId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the additional IMAP provider assignment for the account.
|
||||
/// Providers that use IMAP as a synchronizer but have special requirements.
|
||||
/// </summary>
|
||||
public SpecialImapProvider SpecialImapProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether calendar access is granted for this account.
|
||||
/// When false, synchronizers will not process EventMessages or calendar invitations.
|
||||
/// Default is false for existing accounts to prevent scope issues.
|
||||
/// New accounts created after this feature will have this set to true.
|
||||
/// </summary>
|
||||
public bool IsCalendarAccessGranted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains the merged inbox this account belongs to.
|
||||
/// Ignored for all SQLite operations.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public MergedInbox MergedInbox { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Populated only when account has custom server information.
|
||||
/// </summary>
|
||||
|
||||
[Ignore]
|
||||
public CustomServerInformation ServerInformation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account preferences.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public MailAccountPreferences Preferences { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last time folder structure was synchronized.
|
||||
/// Used for optimization - skip folder sync if synced recently.
|
||||
/// </summary>
|
||||
public DateTime? LastFolderStructureSyncDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the account was created in Wino.
|
||||
/// </summary>
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timespan used for the account's initial mail synchronization.
|
||||
/// </summary>
|
||||
public InitialSynchronizationRange InitialSynchronizationRange { get; set; } = InitialSynchronizationRange.SixMonths;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the account can perform ProfileInformation sync type.
|
||||
/// </summary>
|
||||
public bool IsProfileInfoSyncSupported => ProviderType == MailProviderType.Outlook || ProviderType == MailProviderType.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the account can perform AliasInformation sync type.
|
||||
/// </summary>
|
||||
public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail || ProviderType == MailProviderType.Outlook;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the account can perform category definition sync type.
|
||||
/// </summary>
|
||||
public bool IsCategorySyncSupported => ProviderType == MailProviderType.Outlook;
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
public class MailAccountPreferences
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Id of the account in MailAccount table.
|
||||
/// </summary>
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether sent draft messages should be appended to the sent folder.
|
||||
/// Some IMAP servers do this automatically, some don't.
|
||||
/// It's disabled by default.
|
||||
/// </summary>
|
||||
public bool ShouldAppendMessagesToSentFolder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the notifications are enabled for the account.
|
||||
/// </summary>
|
||||
public bool IsNotificationsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the account has Focused inbox support.
|
||||
/// Null if the account provider type doesn't support Focused inbox.
|
||||
/// </summary>
|
||||
public bool? IsFocusedInboxEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether signature should be appended automatically.
|
||||
/// </summary>
|
||||
public bool IsSignatureEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this account's unread items should be included in taskbar badge.
|
||||
/// </summary>
|
||||
public bool IsTaskbarBadgeEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets signature for new messages. Null if signature is not needed.
|
||||
/// </summary>
|
||||
public Guid? SignatureIdForNewMessages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets signature for following messages. Null if signature is not needed.
|
||||
/// </summary>
|
||||
public Guid? SignatureIdForFollowingMessages { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
public class Thumbnail
|
||||
{
|
||||
[PrimaryKey]
|
||||
public string Domain { get; set; }
|
||||
|
||||
public string Gravatar { get; set; }
|
||||
public string Favicon { get; set; }
|
||||
public DateTime LastUpdated { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
public class WinoAccount
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
public string AccountStatus { get; set; } = string.Empty;
|
||||
|
||||
public bool HasPassword { get; set; }
|
||||
|
||||
public bool HasGoogleLogin { get; set; }
|
||||
|
||||
public bool HasFacebookLogin { get; set; }
|
||||
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
|
||||
public DateTime AccessTokenExpiresAtUtc { get; set; }
|
||||
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
|
||||
public DateTime RefreshTokenExpiresAtUtc { get; set; }
|
||||
|
||||
public DateTime LastAuthenticatedUtc { get; set; }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Wino.Core.Domain.Entities
|
||||
{
|
||||
public record SystemFolderConfiguration(MailItemFolder SentFolder,
|
||||
MailItemFolder DraftFolder,
|
||||
MailItemFolder ArchiveFolder,
|
||||
MailItemFolder TrashFolder,
|
||||
MailItemFolder JunkFolder);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Models.Authentication;
|
||||
|
||||
namespace Wino.Core.Domain.Entities
|
||||
{
|
||||
public class TokenInformation : TokenInformationBase
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
public string Address { get; set; }
|
||||
|
||||
public void RefreshTokens(TokenInformationBase tokenInformationBase)
|
||||
{
|
||||
if (tokenInformationBase == null)
|
||||
throw new ArgumentNullException(nameof(tokenInformationBase));
|
||||
|
||||
AccessToken = tokenInformationBase.AccessToken;
|
||||
RefreshToken = tokenInformationBase.RefreshToken;
|
||||
ExpiresAt = tokenInformationBase.ExpiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||