diff --git a/Wino.Core.Domain/CalendarRecurrenceSummaryFormatter.cs b/Wino.Core.Domain/CalendarRecurrenceSummaryFormatter.cs index 7cb50fcb..3092b0f8 100644 --- a/Wino.Core.Domain/CalendarRecurrenceSummaryFormatter.cs +++ b/Wino.Core.Domain/CalendarRecurrenceSummaryFormatter.cs @@ -77,7 +77,7 @@ public static class CalendarRecurrenceSummaryFormatter return string.Format( culture, - Translator.GetTranslatedString("CalendarEventCompose_RecurringSummarySmart"), + Translator.CalendarEventCompose_RecurringSummarySmart, cadenceSummary, weekdaySummary, timeSummary, diff --git a/Wino.Core.Domain/Interfaces/IStatePersistenceService.cs b/Wino.Core.Domain/Interfaces/IStatePersistenceService.cs index 55653d6e..2c544e59 100644 --- a/Wino.Core.Domain/Interfaces/IStatePersistenceService.cs +++ b/Wino.Core.Domain/Interfaces/IStatePersistenceService.cs @@ -18,6 +18,11 @@ public interface IStatePersistanceService : INotifyPropertyChanged /// string CoreWindowTitle { get; set; } + /// + /// App mode title shown in the title bar. + /// + string AppModeTitle { get; set; } + /// /// When only reader page is visible in small sized window. /// diff --git a/Wino.Core.Domain/Misc/CalendarColorPalette.cs b/Wino.Core.Domain/Misc/CalendarColorPalette.cs new file mode 100644 index 00000000..593cdbc6 --- /dev/null +++ b/Wino.Core.Domain/Misc/CalendarColorPalette.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Wino.Core.Domain.Misc; + +public static class CalendarColorPalette +{ + private static readonly string[] FlatUiColorPalette = + [ + "#E53935", "#D81B60", "#8E24AA", "#5E35B1", "#3949AB", "#1E88E5", "#039BE5", "#00ACC1", "#00897B", "#43A047", + "#7CB342", "#C0CA33", "#FDD835", "#FFB300", "#FB8C00", "#F4511E", "#6D4C41", "#757575", "#546E7A", "#C62828", + "#AD1457", "#6A1B9A", "#4527A0", "#283593", "#1565C0", "#0277BD", "#00838F", "#00695C", "#2E7D32", "#558B2F", + "#9E9D24", "#F9A825", "#FF8F00", "#EF6C00", "#D84315", "#4E342E", "#616161", "#455A64", "#EF5350", "#EC407A", + "#AB47BC", "#7E57C2", "#5C6BC0", "#42A5F5", "#29B6F6", "#26C6DA", "#26A69A", "#66BB6A", "#9CCC65", "#D4E157", + "#FFEE58", "#FFCA28", "#FFA726", "#FF7043", "#8D6E63", "#BDBDBD", "#78909C", "#F06292", "#BA68C8", "#9575CD" + ]; + + public static IReadOnlyList GetColors() => FlatUiColorPalette; + + public static string GetDistinctColor(IEnumerable usedColors) + { + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (usedColors != null) + { + foreach (var color in usedColors) + { + if (TryNormalizeHexColor(color, out var normalized)) + { + used.Add(normalized); + } + } + } + + foreach (var color in FlatUiColorPalette) + { + if (!used.Contains(color)) + { + return color; + } + } + + return FlatUiColorPalette[0]; + } + + private static bool TryNormalizeHexColor(string value, out string normalized) + { + normalized = null; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var color = value.Trim(); + if (color.StartsWith('#')) + { + color = color[1..]; + } + + if (color.Length != 6) + { + return false; + } + + if (!int.TryParse(color, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _)) + { + return false; + } + + normalized = $"#{color.ToUpperInvariant()}"; + return true; + } +} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index ea3c6d13..8d87609c 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -150,6 +150,7 @@ "CalendarEventCompose_PickCalendarTitle": "Pick a calendar", "CalendarEventCompose_Recurring": "Recurring", "CalendarEventCompose_RecurringSummary": "Occurs every {0} {1}{2} {3} effective {4}{5}", + "CalendarEventCompose_RecurringSummarySmart": "Occurs {0}{1} {2} effective {3}{4}", "CalendarEventCompose_RepeatEvery": "Repeat every", "CalendarEventCompose_SelectCalendar": "Select calendar", "CalendarEventCompose_SingleOccurrenceSummary": "Occurs on {0} {1}", diff --git a/Wino.Core.Tests/Services/AccountServiceTests.cs b/Wino.Core.Tests/Services/AccountServiceTests.cs index 28f9dcb4..69c69dfd 100644 --- a/Wino.Core.Tests/Services/AccountServiceTests.cs +++ b/Wino.Core.Tests/Services/AccountServiceTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Linq; using FluentAssertions; using Moq; using Wino.Core.Domain; @@ -5,6 +7,7 @@ using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Misc; using Wino.Core.Tests.Helpers; using Wino.Services; using Xunit; @@ -49,6 +52,7 @@ public class AccountServiceTests : IAsyncLifetime calendars.Should().HaveCount(1); calendars[0].IsPrimary.Should().BeTrue(); calendars[0].Name.Should().Be(Translator.AccountDetailsPage_TabCalendar); + ColorHelpers.GetFlatColorPalette().Should().Contain(calendars[0].BackgroundColorHex); } [Fact] @@ -72,6 +76,49 @@ public class AccountServiceTests : IAsyncLifetime calendars.Should().BeEmpty(); } + [Fact] + public async Task CreateAccountAsync_ImapLocalOnly_AssignsDistinctCalendarColorsAcrossAccounts() + { + var firstAccountId = Guid.NewGuid(); + var secondAccountId = Guid.NewGuid(); + + await _accountService.CreateAccountAsync( + CreateImapAccount(firstAccountId), + new CustomServerInformation + { + Id = Guid.NewGuid(), + AccountId = firstAccountId, + CalendarSupportMode = ImapCalendarSupportMode.LocalOnly + }); + + await _accountService.CreateAccountAsync( + CreateImapAccount(secondAccountId), + new CustomServerInformation + { + Id = Guid.NewGuid(), + AccountId = secondAccountId, + CalendarSupportMode = ImapCalendarSupportMode.LocalOnly + }); + + var calendars = await _databaseService.Connection.Table() + .OrderBy(a => a.AccountId) + .ToListAsync(); + + calendars.Should().HaveCount(2); + calendars.Select(a => a.BackgroundColorHex).Should().OnlyHaveUniqueItems(); + calendars.Should().OnlyContain(a => ColorHelpers.GetFlatColorPalette().Contains(a.BackgroundColorHex)); + } + + [Fact] + public void FlatCalendarPalette_ProvidesAtLeastFiftyDistinctColors() + { + ColorHelpers.GetFlatColorPalette() + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count() + .Should() + .BeGreaterThanOrEqualTo(50); + } + private static MailAccount CreateImapAccount(Guid accountId) { return new MailAccount diff --git a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs index 9f55c8b2..dc6ed40c 100644 --- a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs +++ b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs @@ -15,8 +15,9 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel { private readonly INativeAppService _nativeAppService; private readonly IAccountService _accountService; + private readonly IStoreRatingService _storeRatingService; - public string WebsiteUrl => AppUrls.Website; + public string GitHubUrl => AppUrls.GitHub; public string PaypalUrl => AppUrls.Paypal; [ObservableProperty] @@ -28,10 +29,13 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel [ObservableProperty] public partial int AccountCount { get; set; } - public SettingOptionsPageViewModel(INativeAppService nativeAppService, IAccountService accountService) + public SettingOptionsPageViewModel(INativeAppService nativeAppService, + IAccountService accountService, + IStoreRatingService storeRatingService) { _nativeAppService = nativeAppService; _accountService = accountService; + _storeRatingService = storeRatingService; } public override void OnNavigatedTo(NavigationMode mode, object parameters) @@ -86,4 +90,19 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel Messenger.Send(new BreadcrumbNavigationRequested(pageTitle, pageType)); } } + + [RelayCommand] + private async Task NavigateExternalAsync(object target) + { + if (target is not string stringTarget || string.IsNullOrWhiteSpace(stringTarget)) + return; + + if (stringTarget == "Store") + { + await _storeRatingService.LaunchStorePageForReviewAsync(); + return; + } + + await _nativeAppService.LaunchUriAsync(new Uri(stringTarget)); + } } diff --git a/Wino.Core/Misc/ColorHelpers.cs b/Wino.Core/Misc/ColorHelpers.cs index 752a0bd6..d917cab3 100644 --- a/Wino.Core/Misc/ColorHelpers.cs +++ b/Wino.Core/Misc/ColorHelpers.cs @@ -1,105 +1,34 @@ using System; using System.Collections.Generic; using System.Drawing; -using System.Globalization; +using System.Linq; +using Wino.Core.Domain.Misc; namespace Wino.Core.Misc; public static class ColorHelpers { - private static readonly string[] FlatUiColorPalette = - [ - "#B91C1C", "#C2410C", "#B45309", "#A16207", "#4D7C0F", "#15803D", "#047857", "#0F766E", "#0E7490", "#0369A1", - "#1D4ED8", "#4338CA", "#6D28D9", "#7E22CE", "#A21CAF", "#BE185D", "#E11D48", "#DC2626", "#EA580C", "#D97706", - "#CA8A04", "#65A30D", "#16A34A", "#059669", "#0D9488", "#0891B2", "#0284C7", "#2563EB", "#4F46E5", "#7C3AED", - "#9333EA", "#C026D3", "#DB2777", "#F43F5E", "#EF4444", "#F97316", "#F59E0B", "#EAB308", "#84CC16", "#22C55E", - "#10B981", "#14B8A6", "#06B6D4", "#0EA5E9", "#3B82F6", "#6366F1", "#8B5CF6", "#A855F7", "#D946EF", "#EC4899", - "#FB7185", "#F87171", "#FB923C", "#FBBF24", "#FACC15", "#A3E635", "#4ADE80", "#34D399", "#2DD4BF", "#22D3EE", - "#38BDF8", "#60A5FA", "#818CF8", "#A78BFA", "#C084FC", "#E879F9", "#F472B6", "#FDA4AF", "#FCA5A5", "#FDBA74", - "#FCD34D", "#FDE047", "#BEF264", "#86EFAC", "#6EE7B7", "#5EEAD4", "#67E8F9", "#7DD3FC", "#93C5FD", "#A5B4FC", - "#C4B5FD", "#D8B4FE", "#F0ABFC", "#F9A8D4", "#A16207", "#9A3412", "#7C2D12", "#6F1D1B", "#7F1D1D", "#881337", - "#831843", "#701A75", "#581C87", "#312E81", "#1E3A8A", "#1D4ED8", "#155E75", "#134E4A", "#14532D", "#3F6212", - "#365314", "#3F3F46", "#52525B", "#57534E", "#44403C", "#78716C", "#6B7280", "#4B5563", "#374151", "#1F2937", - "#A16207", "#B45309", "#C2410C", "#9F1239", "#BE123C", "#C026D3", "#7E22CE", "#6D28D9", "#4338CA", "#1D4ED8" - ]; - - public static IReadOnlyList GetFlatColorPalette() => FlatUiColorPalette; + public static IReadOnlyList GetFlatColorPalette() => CalendarColorPalette.GetColors(); public static string GenerateFlatColorHex() => GetDistinctFlatColorHex(Array.Empty()); public static string GetDistinctFlatColorHex(IEnumerable usedColors) { - var used = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (usedColors != null) + var palette = CalendarColorPalette.GetColors(); + var distinctColor = CalendarColorPalette.GetDistinctColor(usedColors); + if (palette.Contains(distinctColor)) { - foreach (var color in usedColors) - { - if (TryNormalizeHexColor(color, out var normalized)) - { - used.Add(normalized); - } - } + return distinctColor; } - foreach (var color in FlatUiColorPalette) - { - if (!used.Contains(color)) - { - return color; - } - } + var candidate = AdjustColor(palette[0], 1); - var attempt = 0; - while (attempt < 500) - { - var baseColor = FlatUiColorPalette[attempt % FlatUiColorPalette.Length]; - var cycle = (attempt / FlatUiColorPalette.Length) + 1; - var candidate = AdjustColor(baseColor, cycle); - - if (!used.Contains(candidate)) - { - return candidate; - } - - attempt++; - } - - return "#5C7A8A"; + return candidate; } public static string ToHexString(this Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}"; public static string ToRgbString(this Color c) => $"RGB({c.R}, {c.G}, {c.B})"; - - private static bool TryNormalizeHexColor(string value, out string normalized) - { - normalized = null; - if (string.IsNullOrWhiteSpace(value)) - { - return false; - } - - var color = value.Trim(); - if (color.StartsWith('#')) - { - color = color[1..]; - } - - if (color.Length != 6) - { - return false; - } - - if (!int.TryParse(color, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _)) - { - return false; - } - - normalized = $"#{color.ToUpperInvariant()}"; - return true; - } - private static string AdjustColor(string hexColor, int cycle) { var color = ColorTranslator.FromHtml(hexColor); diff --git a/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs b/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs index badbc77f..7bd51b87 100644 --- a/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs +++ b/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs @@ -331,6 +331,7 @@ public sealed partial class ImagePreviewControl : PersonPicture if (!IsActiveRefresh(refreshVersion, cancellationToken)) return; + DisplayName = string.Empty; Initials = string.Empty; ProfilePicture = bitmapImage; }).ConfigureAwait(false); diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs index 1c183c65..819976e7 100644 --- a/Wino.Mail.WinUI/Services/NavigationService.cs +++ b/Wino.Mail.WinUI/Services/NavigationService.cs @@ -188,7 +188,7 @@ public class NavigationService : NavigationServiceBase, INavigationService // Update the application mode in state persistence service _statePersistanceService.ApplicationMode = mode; - _statePersistanceService.CoreWindowTitle = mode == WinoApplicationMode.Calendar + _statePersistanceService.AppModeTitle = mode == WinoApplicationMode.Calendar ? "Wino Calendar" : "Wino Mail"; diff --git a/Wino.Mail.WinUI/Services/StatePersistenceService.cs b/Wino.Mail.WinUI/Services/StatePersistenceService.cs index de50a3ad..23ce4466 100644 --- a/Wino.Mail.WinUI/Services/StatePersistenceService.cs +++ b/Wino.Mail.WinUI/Services/StatePersistenceService.cs @@ -13,6 +13,7 @@ public class StatePersistenceService : ObservableObject, IStatePersistanceServic private const string OpenPaneLengthKey = nameof(OpenPaneLengthKey); private const string MailListPaneLengthKey = nameof(MailListPaneLengthKey); + private const string AppModeTitleKey = nameof(AppModeTitle); private readonly IConfigurationService _configurationService; @@ -22,6 +23,7 @@ public class StatePersistenceService : ObservableObject, IStatePersistanceServic _openPaneLength = _configurationService.Get(OpenPaneLengthKey, 320d); _mailListPaneLength = _configurationService.Get(MailListPaneLengthKey, 420d); + _appModeTitle = _configurationService.Get(AppModeTitleKey, "Wino Mail"); _calendarDisplayType = EnsureValidCalendarDisplayType(_configurationService.Get(nameof(CalendarDisplayType), CalendarDisplayType.Week)); _dayDisplayCount = _configurationService.Get(nameof(DayDisplayCount), 1); @@ -144,6 +146,21 @@ public class StatePersistenceService : ObservableObject, IStatePersistanceServic } } + private string _appModeTitle; + + public string AppModeTitle + { + get => _appModeTitle; + set + { + if (SetProperty(ref _appModeTitle, value)) + { + _configurationService.Set(AppModeTitleKey, value); + UpdateAppWindowTitle(); + } + } + } + private double _openPaneLength; public double OpenPaneLength { @@ -199,10 +216,15 @@ public class StatePersistenceService : ObservableObject, IStatePersistanceServic } private void UpdateAppCoreWindowTitle() + { + OnPropertyChanged(nameof(CoreWindowTitle)); + } + + private void UpdateAppWindowTitle() { if (WinoApplication.MainWindow != null) { - WinoApplication.MainWindow.Title = CoreWindowTitle; + WinoApplication.MainWindow.Title = AppModeTitle; } } diff --git a/Wino.Mail.WinUI/ShellWindow.xaml b/Wino.Mail.WinUI/ShellWindow.xaml index 3dc761f2..1410cf3f 100644 --- a/Wino.Mail.WinUI/ShellWindow.xaml +++ b/Wino.Mail.WinUI/ShellWindow.xaml @@ -26,7 +26,7 @@ + PaneToggleRequested="PaneButtonClicked" + Subtitle="{x:Bind StatePersistanceService.CoreWindowTitle, Mode=OneWay}"> diff --git a/Wino.Mail.WinUI/ShellWindow.xaml.cs b/Wino.Mail.WinUI/ShellWindow.xaml.cs index 5fa3ecab..b458ce28 100644 --- a/Wino.Mail.WinUI/ShellWindow.xaml.cs +++ b/Wino.Mail.WinUI/ShellWindow.xaml.cs @@ -68,7 +68,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, ExitWinoCommand = new RelayCommand(ForceClose); this.SetIcon("Assets/Wino_Icon.ico"); - Title = "Wino Mail"; + Title = StatePersistanceService.AppModeTitle; SystemTrayIcon.ForceCreate(); } @@ -122,6 +122,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, AppModeSegmentedControl.SelectedIndex = targetMode == WinoApplicationMode.Mail ? 0 : 1; _isApplyingActivationMode = false; + StatePersistanceService.AppModeTitle = GetAppModeTitle(targetMode); NavigationService.ChangeApplicationMode(targetMode); } @@ -317,6 +318,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, AppModeSegmentedControl.SelectedIndex = mode == WinoApplicationMode.Mail ? 0 : 1; _isApplyingActivationMode = false; + StatePersistanceService.AppModeTitle = GetAppModeTitle(mode); NavigationService.ChangeApplicationMode(mode); RestoreFromTray(); } @@ -371,4 +373,9 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, _currentMode = selectedMode; NavigationService.ChangeApplicationMode(selectedMode); } + + private static string GetAppModeTitle(WinoApplicationMode mode) + => mode == WinoApplicationMode.Calendar + ? "Wino Calendar" + : "Wino Mail"; } diff --git a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml index 3b5ecaaa..8c7ba9c8 100644 --- a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml +++ b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml @@ -2,14 +2,14 @@ x:Class="Wino.Views.AccountDetailsPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:accounts="using:Wino.Core.Domain.Models.Accounts" xmlns:abstract="using:Wino.Views.Abstract" + xmlns:accounts="using:Wino.Core.Domain.Models.Accounts" xmlns:calendar="using:Wino.Core.Domain.Entities.Calendar" xmlns:controls="using:CommunityToolkit.WinUI.Controls" xmlns:controls1="using:Wino.Controls" xmlns:coreControls="using:Wino.Mail.WinUI.Controls" - xmlns:data="using:Wino.Core.ViewModels.Data" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:data="using:Wino.Core.ViewModels.Data" xmlns:domain="using:Wino.Core.Domain" xmlns:folders="using:Wino.Core.Domain.Models.Folders" xmlns:helpers="using:Wino.Helpers" @@ -81,13 +81,9 @@ - + - + @@ -132,6 +128,7 @@ + + + + + + diff --git a/Wino.Services/AccountService.cs b/Wino.Services/AccountService.cs index 55d6f6d9..658cc41a 100644 --- a/Wino.Services/AccountService.cs +++ b/Wino.Services/AccountService.cs @@ -12,6 +12,7 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; +using Wino.Core.Domain.Misc; using Wino.Messaging.Client.Calendar; using Wino.Messaging.Client.Accounts; using Wino.Messaging.UI; @@ -20,22 +21,6 @@ namespace Wino.Services; public class AccountService : BaseDatabaseService, IAccountService { - private static readonly string[] DefaultCalendarFlatColors = - [ - "#B91C1C", - "#15803D", - "#0E7490", - "#1D4ED8", - "#7C3AED", - "#C026D3", - "#EC4899", - "#F97316", - "#EAB308", - "#22C55E", - "#06B6D4", - "#60A5FA" - ]; - public IAuthenticator ExternalAuthenticationAuthenticator { get; set; } private readonly ISignatureService _signatureService; @@ -657,20 +642,20 @@ public class AccountService : BaseDatabaseService, IAccountService IsExtended = true, RemoteCalendarId = string.Empty, TimeZone = string.Empty, - BackgroundColorHex = GetDefaultCalendarFlatColor(accountId), + BackgroundColorHex = await GetNextDistinctCalendarColorAsync().ConfigureAwait(false), TextColorHex = "#FFFFFF" }; await Connection.InsertAsync(localCalendar, typeof(AccountCalendar)).ConfigureAwait(false); } - private static string GetDefaultCalendarFlatColor(Guid accountId) + private async Task GetNextDistinctCalendarColorAsync() { - var bytes = accountId.ToByteArray(); - var hash = BitConverter.ToUInt32(bytes, 0); - var index = (int)(hash % (uint)DefaultCalendarFlatColors.Length); + var usedColors = await Connection.Table() + .ToListAsync() + .ConfigureAwait(false); - return DefaultCalendarFlatColors[index]; + return CalendarColorPalette.GetDistinctColor(usedColors.Select(a => a.BackgroundColorHex)); } public async Task UpdateAccountOrdersAsync(Dictionary accountIdOrderPair)