Range thing.

This commit is contained in:
Burak Kaan Köse
2026-03-21 00:58:01 +01:00
parent 01f7a09cb7
commit 51fef043ee
45 changed files with 1327 additions and 3753 deletions
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Threading;
@@ -9,16 +10,15 @@ using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Calendar.ViewModels.Interfaces;
using Wino.Core.Domain;
using Wino.Core.Domain.Collections;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.MenuItems;
using Wino.Core.Domain.Models.Calendar;
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;
@@ -30,8 +30,6 @@ namespace Wino.Calendar.ViewModels;
public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
ICalendarShellClient,
IRecipient<VisibleDateRangeChangedMessage>,
IRecipient<CalendarEnableStatusChangedMessage>,
IRecipient<CalendarDisplayTypeChangedMessage>,
IRecipient<AccountRemovedMessage>
{
@@ -41,6 +39,8 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
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
@@ -50,6 +50,8 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
}
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;
public MenuItemCollection MenuItems { get; private set; }
public MenuItemCollection FooterItems { get; private set; }
@@ -57,23 +59,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
[ObservableProperty]
private int _selectedMenuItemIndex = -1;
[ObservableProperty]
private bool isCalendarEnabled;
/// <summary>
/// Gets or sets the display date of the calendar.
/// </summary>
[ObservableProperty]
private DateTimeOffset _displayDate;
/// <summary>
/// Gets or sets the highlighted range in the CalendarView and displayed date range in FlipView.
/// </summary>
[ObservableProperty]
private DateRange highlightedDateRange;
[ObservableProperty]
private ObservableRangeCollection<string> dateNavigationHeaderItems = [];
@@ -87,36 +72,44 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
private readonly SettingsItem _settingsItem = new();
private readonly StoreUpdateMenuItem _storeUpdateMenuItem = new();
// For updating account calendars asynchronously.
private SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
private readonly SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
private readonly CalendarPageViewModel _calendarPageViewModel;
private readonly IMailDialogService _dialogService;
private readonly IUpdateManager _updateManager;
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,
IUpdateManager updateManager,
IStoreUpdateService storeUpdateService)
public CalendarAppShellViewModel(
IPreferencesService preferencesService,
IStatePersistanceService statePersistanceService,
IAccountService accountService,
ICalendarService calendarService,
IAccountCalendarStateService accountCalendarStateService,
INavigationService navigationService,
CalendarPageViewModel calendarPageViewModel,
IMailDialogService dialogService,
IUpdateManager updateManager,
IStoreUpdateService storeUpdateService,
IDateContextProvider dateContextProvider)
{
PreferencesService = preferencesService;
StatePersistenceService = statePersistanceService;
AccountCalendarStateService = accountCalendarStateService;
NavigationService = navigationService;
_accountService = accountService;
_calendarService = calendarService;
_calendarPageViewModel = calendarPageViewModel;
_dialogService = dialogService;
_updateManager = updateManager;
_storeUpdateService = storeUpdateService;
_dateContextProvider = dateContextProvider;
AccountCalendarStateService = accountCalendarStateService;
NavigationService = navigationService;
PreferencesService = preferencesService;
StatePersistenceService = statePersistanceService;
_calendarPageViewModel.PropertyChanged += CalendarPageViewModelPropertyChanged;
}
protected override void OnDispatcherAssigned()
@@ -129,17 +122,30 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
_ = 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))
{
Messenger.Send(new CalendarDisplayTypeChangedMessage(StatePersistenceService.CalendarDisplayType));
if (e != nameof(StatePersistenceService.CalendarDisplayType))
return;
UpdateDateNavigationHeaderItems();
// Change the calendar.
DateClicked(new CalendarViewDayClickedEventArgs(GetDisplayTypeSwitchDate()));
}
Messenger.Send(new CalendarDisplayTypeChangedMessage(StatePersistenceService.CalendarDisplayType));
OnPropertyChanged(nameof(IsVerticalCalendar));
UpdateDateNavigationHeaderItems();
NavigateCalendarDate(GetDisplayTypeSwitchDate());
}
private async void PreferencesServiceChanged(object sender, string e)
@@ -167,9 +173,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
PreferencesService.PreferenceChanged += PreferencesServiceChanged;
await RefreshFooterItemsAsync(mode == NavigationMode.New);
UpdateDateNavigationHeaderItems();
await InitializeAccountCalendarsAsync();
ValidateConfiguredNewEventCalendar();
@@ -189,7 +193,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
{
DateNavigationHeaderItems.Clear();
AccountCalendarStateService.ClearGroupedAccountCalendars();
HighlightedDateRange = null;
SelectedDateNavigationHeaderIndex = -1;
});
_calendarPageViewModel.CleanupForShellDeactivation();
@@ -223,7 +226,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
return;
var notes = await _updateManager.GetLatestUpdateNotesAsync();
if (notes.Sections.Count == 0)
return;
@@ -246,10 +248,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
private async void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e)
{
// When using three-state checkbox, multiple accounts will be selected/unselected at the same time.
// Reporting all these changes one by one to the UI is not efficient and may cause problems in the future.
// Update all calendar states at once.
try
{
await _accountCalendarUpdateSemaphoreSlim.WaitAsync();
@@ -281,15 +279,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
foreach (var account in accounts)
{
var accountCalendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
var calendarViewModels = new List<AccountCalendarViewModel>();
foreach (var calendar in accountCalendars)
{
var calendarViewModel = new AccountCalendarViewModel(account, calendar);
calendarViewModels.Add(calendarViewModel);
}
var calendarViewModels = accountCalendars.Select(calendar => new AccountCalendarViewModel(account, calendar)).ToList();
var groupedAccountCalendarViewModel = new GroupedAccountCalendarViewModel(account, calendarViewModels);
await Dispatcher.ExecuteOnUIThread(() =>
@@ -299,9 +289,15 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
}
}
private void NavigateCalendarDate(DateTime date)
{
_navigationDate = date.Date;
ForceNavigateCalendarDate();
}
private void ForceNavigateCalendarDate()
{
var args = new CalendarPageNavigationArgs()
var args = new CalendarPageNavigationArgs
{
NavigationDate = _navigationDate ?? DateTime.Now.Date
};
@@ -310,73 +306,56 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
_navigationDate = null;
}
partial void OnSelectedMenuItemIndexChanged(int oldValue, int newValue) { }
[RelayCommand]
private async Task Sync()
{
// Sync all calendars.
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
foreach (var account in accounts)
{
var t = new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions()
Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions
{
AccountId = account.Id,
Type = CalendarSynchronizationType.CalendarEvents
});
Messenger.Send(t);
}));
}
}
/// <summary>
/// When calendar type switches, we need to navigate to the most ideal date.
/// This method returns that date.
/// </summary>
private DateTime GetDisplayTypeSwitchDate()
{
var today = _dateContextProvider.GetToday();
var settings = PreferencesService.GetCurrentCalendarSettings();
switch (StatePersistenceService.CalendarDisplayType)
{
case CalendarDisplayType.Day:
if (HighlightedDateRange.IsInRange(DateTime.Now)) return DateTime.Now.Date;
return HighlightedDateRange.StartDate;
case CalendarDisplayType.Week:
if (HighlightedDateRange == null || HighlightedDateRange.IsInRange(DateTime.Now))
{
return DateTime.Now.Date.GetWeekStartDateForDate(settings.FirstDayOfWeek);
}
return HighlightedDateRange.StartDate.GetWeekStartDateForDate(settings.FirstDayOfWeek);
case CalendarDisplayType.WorkWeek:
break;
case CalendarDisplayType.Month:
break;
default:
break;
}
return DateTime.Today.Date;
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);
}
private DateTime? _navigationDate;
private readonly IAccountService _accountService;
private readonly ICalendarService _calendarService;
private readonly CalendarPageViewModel _calendarPageViewModel;
private readonly IMailDialogService _dialogService;
private readonly IUpdateManager _updateManager;
private readonly IStoreUpdateService _storeUpdateService;
#region Commands
[RelayCommand]
private void TodayClicked()
{
_navigationDate = DateTime.Now.Date;
NavigateCalendarDate(_dateContextProvider.GetToday().ToDateTime(TimeOnly.MinValue));
}
ForceNavigateCalendarDate();
[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)
@@ -421,7 +400,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
}
var pickingResult = await _dialogService.ShowSingleCalendarPickerDialogAsync(availableGroups);
if (pickingResult.ShouldNavigateToCalendarSettings)
{
NavigationService.Navigate(WinoPage.CalendarSettingsPage);
@@ -456,17 +434,9 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
}
}
[RelayCommand]
private void DateClicked(CalendarViewDayClickedEventArgs clickedDateArgs)
{
_navigationDate = clickedDateArgs.ClickedDate;
ForceNavigateCalendarDate();
}
#endregion
=> NavigateCalendarDate(clickedDateArgs.ClickedDate);
protected override void RegisterRecipients()
{
@@ -474,8 +444,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
UnregisterRecipients();
Messenger.Register<VisibleDateRangeChangedMessage>(this);
Messenger.Register<CalendarEnableStatusChangedMessage>(this);
Messenger.Register<CalendarDisplayTypeChangedMessage>(this);
Messenger.Register<AccountRemovedMessage>(this);
}
@@ -484,99 +452,17 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
{
base.UnregisterRecipients();
Messenger.Unregister<VisibleDateRangeChangedMessage>(this);
Messenger.Unregister<CalendarEnableStatusChangedMessage>(this);
Messenger.Unregister<CalendarDisplayTypeChangedMessage>(this);
Messenger.Unregister<AccountRemovedMessage>(this);
}
public void Receive(VisibleDateRangeChangedMessage message) => HighlightedDateRange = message.DateRange;
/// <summary>
/// Sets the header navigation items based on visible date range and calendar type.
/// </summary>
private void UpdateDateNavigationHeaderItems()
{
var settings = PreferencesService.GetCurrentCalendarSettings();
var cultureInfo = settings.CultureInfo ?? CultureInfo.CurrentUICulture;
var visibleRange = HighlightedDateRange ?? new DateRange(DateTime.Today, DateTime.Today.AddDays(1));
var headerText = GetHeaderText(visibleRange, cultureInfo);
DateNavigationHeaderItems.ReplaceRange([headerText]);
var headerText = VisibleDateRangeText;
DateNavigationHeaderItems.ReplaceRange(string.IsNullOrWhiteSpace(headerText) ? [] : [headerText]);
SelectedDateNavigationHeaderIndex = DateNavigationHeaderItems.Count > 0 ? 0 : -1;
}
private string GetHeaderText(DateRange visibleRange, CultureInfo cultureInfo)
{
var startDate = visibleRange.StartDate.Date;
var endDate = visibleRange.EndDate.Date > startDate ? visibleRange.EndDate.Date.AddDays(-1) : startDate;
switch (StatePersistenceService.CalendarDisplayType)
{
case CalendarDisplayType.Day:
return startDate.ToString("MMMM d, dddd", cultureInfo);
case CalendarDisplayType.Week:
case CalendarDisplayType.WorkWeek:
if (startDate.Month == endDate.Month && startDate.Year == endDate.Year)
{
return $"{startDate.ToString("MMMM d", cultureInfo)} - {endDate.ToString("%d", cultureInfo)}";
}
return $"{startDate.ToString("MMMM d", cultureInfo)} - {endDate.ToString("MMMM d", cultureInfo)}";
case CalendarDisplayType.Month:
return GetDominantMonthHeaderText(startDate, endDate, cultureInfo);
default:
return startDate.ToString("d", cultureInfo);
}
}
private static string GetDominantMonthHeaderText(DateTime startDate, DateTime endDate, CultureInfo cultureInfo)
{
if (endDate < startDate)
{
endDate = startDate;
}
var monthDayCounts = new Dictionary<(int Year, int Month), int>();
for (var day = startDate; day <= endDate; day = day.AddDays(1))
{
var key = (day.Year, day.Month);
if (monthDayCounts.TryGetValue(key, out var count))
{
monthDayCounts[key] = count + 1;
}
else
{
monthDayCounts[key] = 1;
}
}
var dominantKey = (Year: startDate.Year, Month: startDate.Month);
var dominantCount = -1;
foreach (var pair in monthDayCounts)
{
if (pair.Value > dominantCount)
{
dominantCount = pair.Value;
dominantKey = pair.Key;
}
}
return new DateTime(dominantKey.Year, dominantKey.Month, 1).ToString("Y", cultureInfo);
}
partial void OnHighlightedDateRangeChanged(DateRange value)
{
UpdateDateNavigationHeaderItems();
}
public async void Receive(CalendarEnableStatusChangedMessage message)
=> await ExecuteUIThread(() => IsCalendarEnabled = message.IsEnabled);
public void Receive(CalendarDisplayTypeChangedMessage message)
{
OnPropertyChanged(nameof(IsVerticalCalendar));
@@ -615,11 +501,11 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
var exists = AccountCalendarStateService.AllCalendars
.Any(calendar => calendar.Id == PreferencesService.DefaultNewEventCalendarId.Value);
if (exists)
return;
PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AskEachTime;
PreferencesService.DefaultNewEventCalendarId = null;
if (!exists)
{
PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AskEachTime;
PreferencesService.DefaultNewEventCalendarId = null;
}
}
private static (DateTime StartDate, DateTime EndDate) GetDefaultComposeDateRange()
@@ -650,12 +536,3 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
Task IShellClient.HandleNavigationSelectionChangedAsync(IMenuItem menuItem)
=> Task.CompletedTask;
}
File diff suppressed because it is too large Load Diff
@@ -1,10 +0,0 @@
namespace Wino.Core.Domain.Enums;
/// <summary>
/// Trigger to load more data.
/// </summary>
public enum CalendarInitInitiative
{
User,
App
}
+4 -1
View File
@@ -49,9 +49,12 @@ public interface ICalendarShellClient : IShellClient
IStatePersistanceService StatePersistenceService { get; }
IEnumerable DateNavigationHeaderItems { get; }
int SelectedDateNavigationHeaderIndex { get; }
DateRange? HighlightedDateRange { get; }
VisibleDateRange? CurrentVisibleRange { get; }
string VisibleDateRangeText { get; }
ICommand TodayClickedCommand { get; }
ICommand DateClickedCommand { get; }
ICommand PreviousDateRangeCommand { get; }
ICommand NextDateRangeCommand { get; }
IEnumerable GroupedAccountCalendars { get; }
}
@@ -0,0 +1,6 @@
using System;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Calendar;
public readonly record struct CalendarDisplayRequest(CalendarDisplayType DisplayType, DateOnly AnchorDate);
@@ -0,0 +1,103 @@
using System;
using System.Linq;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Calendar;
public static class CalendarRangeResolver
{
public static VisibleDateRange Resolve(CalendarDisplayRequest request, CalendarSettings settings, DateOnly today)
{
var startDate = GetStartDate(request.DisplayType, request.AnchorDate, settings);
var endDate = GetEndDate(request.DisplayType, request.AnchorDate, startDate, settings);
var dayCount = endDate.DayNumber - startDate.DayNumber + 1;
var dates = Enumerable.Range(0, dayCount)
.Select(offset => startDate.AddDays(offset))
.ToArray();
return new VisibleDateRange(
request.DisplayType,
request.AnchorDate,
startDate,
endDate,
request.AnchorDate,
dayCount,
today >= startDate && today <= endDate,
startDate.Year == endDate.Year && startDate.Month == endDate.Month,
dates);
}
public static VisibleDateRange ChangeDisplayType(VisibleDateRange currentRange, CalendarDisplayType targetDisplayType, CalendarSettings settings, DateOnly today)
{
if (currentRange.DisplayType == targetDisplayType)
{
return currentRange;
}
var anchorDate = currentRange.AnchorDate;
if (currentRange.DisplayType == CalendarDisplayType.Month)
{
anchorDate = currentRange.Contains(today) ? today : currentRange.StartDate;
}
return Resolve(new CalendarDisplayRequest(targetDisplayType, anchorDate), settings, today);
}
public static VisibleDateRange Navigate(VisibleDateRange currentRange, int direction, CalendarSettings settings, DateOnly today)
{
if (direction == 0)
{
return currentRange;
}
var normalizedDirection = Math.Sign(direction);
var anchorDate = currentRange.DisplayType switch
{
CalendarDisplayType.Day => currentRange.AnchorDate.AddDays(normalizedDirection),
CalendarDisplayType.Week => currentRange.AnchorDate.AddDays(7 * normalizedDirection),
CalendarDisplayType.WorkWeek => currentRange.AnchorDate.AddDays(7 * normalizedDirection),
CalendarDisplayType.Month => currentRange.AnchorDate.AddMonths(normalizedDirection),
_ => currentRange.AnchorDate
};
return Resolve(new CalendarDisplayRequest(currentRange.DisplayType, anchorDate), settings, today);
}
private static DateOnly GetStartDate(CalendarDisplayType displayType, DateOnly anchorDate, CalendarSettings settings)
{
return displayType switch
{
CalendarDisplayType.Day => anchorDate,
CalendarDisplayType.Week => GetStartOfWeek(anchorDate, settings.FirstDayOfWeek),
CalendarDisplayType.WorkWeek => GetStartOfWorkWeek(anchorDate, settings),
CalendarDisplayType.Month => new DateOnly(anchorDate.Year, anchorDate.Month, 1),
_ => anchorDate
};
}
private static DateOnly GetEndDate(CalendarDisplayType displayType, DateOnly anchorDate, DateOnly startDate, CalendarSettings settings)
{
return displayType switch
{
CalendarDisplayType.Day => anchorDate,
CalendarDisplayType.Week => startDate.AddDays(6),
CalendarDisplayType.WorkWeek => startDate.AddDays(settings.WorkWeekDayCount - 1),
CalendarDisplayType.Month => new DateOnly(anchorDate.Year, anchorDate.Month, DateTime.DaysInMonth(anchorDate.Year, anchorDate.Month)),
_ => anchorDate
};
}
private static DateOnly GetStartOfWeek(DateOnly date, DayOfWeek firstDayOfWeek)
{
var offset = ((int)date.DayOfWeek - (int)firstDayOfWeek + 7) % 7;
return date.AddDays(-offset);
}
private static DateOnly GetStartOfWorkWeek(DateOnly anchorDate, CalendarSettings settings)
{
var startOfWeek = GetStartOfWeek(anchorDate, settings.FirstDayOfWeek);
var offsetToWorkWeekStart = settings.GetWeekOffset(settings.WorkWeekStart);
return startOfWeek.AddDays(offsetToWorkWeekStart);
}
}
@@ -0,0 +1,24 @@
using System;
using System.Globalization;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Calendar;
public sealed class CalendarRangeTextFormatter : ICalendarRangeTextFormatter
{
public string Format(VisibleDateRange range, IDateContextProvider dateContextProvider)
{
var culture = dateContextProvider.Culture;
var startText = FormatDate(range.StartDate, culture);
if (range.DisplayType == CalendarDisplayType.Day)
{
return startText;
}
return $"{startText} - {FormatDate(range.EndDate, culture)}";
}
private static string FormatDate(DateOnly date, CultureInfo culture)
=> date.ToString(culture.DateTimeFormat.ShortDatePattern, culture);
}
@@ -7,12 +7,33 @@ namespace Wino.Core.Domain.Models.Calendar;
public record CalendarSettings(DayOfWeek FirstDayOfWeek,
List<DayOfWeek> WorkingDays,
DayOfWeek WorkWeekStart,
DayOfWeek WorkWeekEnd,
TimeSpan WorkingHourStart,
TimeSpan WorkingHourEnd,
double HourHeight,
DayHeaderDisplayType DayHeaderDisplayType,
CultureInfo CultureInfo)
{
public int WorkWeekDayCount
{
get
{
var startOffset = GetWeekOffset(WorkWeekStart);
var endOffset = GetWeekOffset(WorkWeekEnd);
if (endOffset < startOffset)
{
endOffset += 7;
}
return (endOffset - startOffset) + 1;
}
}
public int GetWeekOffset(DayOfWeek dayOfWeek)
=> ((int)dayOfWeek - (int)FirstDayOfWeek + 7) % 7;
public TimeSpan? GetTimeSpan(string selectedTime)
{
// Regardless of the format, we need to parse the time to a TimeSpan.
@@ -0,0 +1,6 @@
namespace Wino.Core.Domain.Models.Calendar;
public interface ICalendarRangeTextFormatter
{
string Format(VisibleDateRange range, IDateContextProvider dateContextProvider);
}
@@ -0,0 +1,11 @@
using System;
using System.Globalization;
namespace Wino.Core.Domain.Models.Calendar;
public interface IDateContextProvider
{
CultureInfo Culture { get; }
TimeZoneInfo TimeZone { get; }
DateOnly GetToday();
}
@@ -0,0 +1,17 @@
using System;
using System.Globalization;
namespace Wino.Core.Domain.Models.Calendar;
public sealed class SystemDateContextProvider : IDateContextProvider
{
public CultureInfo Culture => CultureInfo.CurrentCulture;
public TimeZoneInfo TimeZone => TimeZoneInfo.Local;
public DateOnly GetToday()
{
var localNow = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, TimeZone);
return DateOnly.FromDateTime(localNow.DateTime);
}
}
@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Itenso.TimePeriod;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Calendar;
public sealed record VisibleDateRange(
CalendarDisplayType DisplayType,
DateOnly AnchorDate,
DateOnly StartDate,
DateOnly EndDate,
DateOnly PrimaryDate,
int DayCount,
bool ContainsToday,
bool SpansSingleMonth,
IReadOnlyList<DateOnly> Dates)
{
public DateRange ToDateRangeExclusive()
=> new(StartDate.ToDateTime(TimeOnly.MinValue), EndDate.AddDays(1).ToDateTime(TimeOnly.MinValue));
public ITimePeriod ToTimePeriod()
=> new TimeRange(StartDate.ToDateTime(TimeOnly.MinValue), EndDate.AddDays(1).ToDateTime(TimeOnly.MinValue));
public bool Contains(DateOnly date)
=> date >= StartDate && date <= EndDate;
public bool Contains(DateTime date)
=> Contains(DateOnly.FromDateTime(date));
public static VisibleDateRange FromDateRange(CalendarDisplayType displayType, DateRange dateRange, DateOnly anchorDate, DateOnly today)
{
var startDate = DateOnly.FromDateTime(dateRange.StartDate);
var endDate = DateOnly.FromDateTime(dateRange.EndDate.AddDays(-1));
var dayCount = endDate.DayNumber - startDate.DayNumber + 1;
var dates = Enumerable.Range(0, dayCount)
.Select(offset => startDate.AddDays(offset))
.ToArray();
return new VisibleDateRange(
displayType,
anchorDate,
startDate,
endDate,
anchorDate,
dayCount,
today >= startDate && today <= endDate,
startDate.Year == endDate.Year && startDate.Month == endDate.Month,
dates);
}
}
@@ -40,7 +40,7 @@ public static class SettingsNavigationInfoProvider
Translator.WinoAccount_SettingsSection_Title,
Translator.WinoAccount_SettingsSection_Description,
"\uE77B",
searchKeywords: Translator.SettingsSearch_WinoAccount_Keywords),
searchKeywords: string.Empty),
new(null, Translator.SettingsOptions_GeneralSection, string.Empty, "\uE713", isSeparator: true),
new(WinoPage.AppPreferencesPage,
Translator.SettingsAppPreferences_Title,
@@ -18,6 +18,7 @@
"AccountCreationDialog_Initializing": "initializing",
"AccountCreationDialog_PreparingFolders": "We are getting folder information at the moment.",
"AccountCreationDialog_SigninIn": "Account information is being saved.",
"Purchased": "Purchased",
"AccountEditDialog_Message": "Account Name",
"AccountEditDialog_Title": "Edit Account",
"AccountPickerDialog_Title": "Pick an account",
@@ -1146,6 +1147,56 @@
"WelcomeWindow_SkipForNow": "Skip for now — I'll set it up later",
"WelcomeWindow_AppDescription": "A fast, focused inbox — redesigned for Windows 11",
"WelcomeWizard_Step1Title": "Welcome",
"SystemTrayMenu_Open": "Open",
"WinoAccount_Titlebar_SyncBenefitTitle": "Sync settings",
"WinoAccount_Titlebar_SyncBenefitDescription": "Keep your Wino preferences in sync across devices.",
"WinoAccount_Titlebar_AddonsBenefitTitle": "Unlock add-ons",
"WinoAccount_Titlebar_AddonsBenefitDescription": "Access premium features like Wino AI Pack.",
"WinoAccount_Management_Description": "Manage your Wino Account, AI Pack access, and synchronized settings.",
"WinoAccount_Management_SignedOutTitle": "Sign in to Wino Mail",
"WinoAccount_Management_SignedOutDescription": "Sign in or create an account to sync your email, access AI features, and manage your settings across devices.",
"WinoAccount_Management_ProfileSectionHeader": "Profile",
"WinoAccount_Management_AddOnsSectionHeader": "Wino Add-Ons",
"WinoAccount_Management_DataSectionHeader": "Data",
"WinoAccount_Management_AccountActionsSectionHeader": "Account actions",
"WinoAccount_Management_AccountCardTitle": "Account",
"WinoAccount_Management_AccountCardDescription": "Your Wino Account email address and current account state.",
"WinoAccount_Management_AiPackCardTitle": "AI Pack",
"WinoAccount_Management_AiPackCardDescription": "See whether Wino AI Pack is active and how much usage is left.",
"WinoAccount_Management_AiPackActive": "AI Pack is active",
"WinoAccount_Management_AiPackInactive": "AI Pack is not active",
"WinoAccount_Management_AiPackUsage": "{0} of {1} uses consumed. {2} remaining.",
"WinoAccount_Management_AiPackBillingPeriod": "Billing period: {0:d} - {1:d}",
"WinoAccount_Management_AiPackUnknownUsage": "Usage details are not available yet.",
"WinoAccount_Management_AiPackBuyDescription": "Buy Wino AI Pack to translate, rewrite or summarize emails with AI.",
"WinoAccount_Management_AiPackPromoTitle": "Unlock AI Pack",
"WinoAccount_Management_AiPackPromoDescription": "Supercharge your email workflow with AI-powered tools. Translate messages into 50+ languages, rewrite for clarity and tone, and get instant summaries of long threads.",
"WinoAccount_Management_AiPackPromoPrice": "$4.99 / mo",
"WinoAccount_Management_AiPackPromoRequests": "1,000 requests",
"WinoAccount_Management_AiPackGetButton": "Get AI Pack",
"WinoAccount_Management_PurchaseRequiresSignIn": "Sign in with your Wino Account to complete this purchase.",
"WinoAccount_Management_PurchaseStartFailed": "Wino could not start the checkout session for this add-on.",
"WinoAccount_Management_AiPackSubscriptionActive": "Your subscription is active",
"WinoAccount_Management_AiPackRenews": "Renews {0:d}",
"WinoAccount_Management_AiPackRequestsUsed": "Requests used this month",
"WinoAccount_Management_AiPackResets": "Resets {0:d}",
"WinoAccount_Management_AiPackFeatureTranslate": "Translate",
"WinoAccount_Management_AiPackFeatureRewrite": "Rewrite",
"WinoAccount_Management_AiPackFeatureSummarize": "Summarize",
"WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences",
"WinoAccount_Management_SyncPreferencesDescription": "Import or export your preferences to cloud. Import them across devices.",
"WinoAccount_Management_SignOutTitle": "Sign out",
"WinoAccount_Management_SignOutDescription": "Sign out of your account on this device",
"WinoAccount_Management_StatusLabel": "Status: {0}",
"WinoAccount_Management_NoRemoteSettings": "There are no synchronized settings stored for this account yet.",
"WinoAccount_Management_ExportSucceeded": "Your settings were exported to your Wino Account.",
"WinoAccount_Management_ImportSucceeded": "Imported {0} settings from your Wino Account.",
"WinoAccount_Management_ImportPartial": "Imported {0} settings. {1} settings could not be restored.",
"WinoAccount_Management_SerializeFailed": "Wino could not serialize your current preferences.",
"WinoAccount_Management_EmptyExport": "There are no preference values to export.",
"WinoAccount_Management_ImportEmpty": "The synchronized settings payload does not contain any values to restore.",
"WinoAccount_Management_LoadFailed": "Wino could not load the latest Wino Account information.",
"WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.",
"WinoAccount_SettingsSection_Title": "Wino Account",
"WinoAccount_SettingsSection_Description": "Create or sign in to a Wino Account using your localhost auth service.",
"WinoAccount_RegisterButton_Title": "Register account",
@@ -1168,6 +1219,10 @@
"WinoAccount_RegisterDialog_DifferenceTitle": "Wino Account is separate from your mail accounts",
"WinoAccount_RegisterDialog_DifferenceDescription": "Your Outlook, Gmail, IMAP, or other email accounts stay exactly as they are. A Wino Account only manages Wino-specific features and account-based add-ons.",
"WinoAccount_RegisterDialog_PrimaryButton": "Register",
"WinoAccount_RegisterDialog_PrivacyTitle": "Privacy and API processing",
"WinoAccount_RegisterDialog_PrivacyDescription": "Optional add-ons such as Wino AI Pack may send selected email HTML content to the Wino API service only when you use those features.",
"WinoAccount_RegisterDialog_PrivacyLinkText": "Read the privacy policy",
"WinoAccount_RegisterDialog_PrivacyCheckbox": "I agree to the privacy policy.",
"WinoAccount_LoginDialog_Title": "Sign In to Wino Account",
"WinoAccount_LoginDialog_Description": "Sign in to your Wino Account to sync your Wino setup and access account-based features.",
"WinoAccount_LoginDialog_HeroTitle": "Welcome back",
@@ -1175,17 +1230,28 @@
"WinoAccount_LoginDialog_BenefitsDescription": "Use your Wino Account to continue syncing settings across devices and to access paid add-ons such as Wino AI Pack.",
"WinoAccount_LoginDialog_DifferenceTitle": "This is not your email mailbox sign-in",
"WinoAccount_LoginDialog_DifferenceDescription": "Signing in here does not add or replace your Outlook, Gmail, or IMAP accounts in Wino. It only signs you in to Wino-specific services.",
"WinoAccount_LoginDialog_ForgotPasswordLink": "Forgot password?",
"WinoAccount_EmailLabel": "Email",
"WinoAccount_EmailPlaceholder": "name@example.com",
"WinoAccount_PasswordLabel": "Password",
"WinoAccount_ConfirmPasswordLabel": "Confirm password",
"WinoAccount_ForgotPasswordDialog_Title": "Reset your password",
"WinoAccount_ForgotPasswordDialog_PrimaryButton": "Send reset email",
"WinoAccount_ForgotPasswordDialog_BackToSignIn": "Back to sign in",
"WinoAccount_ForgotPasswordDialog_Description": "Enter your Wino Account email address and we will send you a password reset link if the address is registered.",
"WinoAccount_Validation_EmailRequired": "Email is required.",
"WinoAccount_Validation_PasswordRequired": "Password is required.",
"WinoAccount_Validation_PasswordMismatch": "Passwords do not match.",
"WinoAccount_Validation_PrivacyConsentRequired": "You must accept the privacy policy before creating a Wino Account.",
"WinoAccount_Error_InvalidCredentials": "The email address or password is incorrect.",
"WinoAccount_Error_AccountLocked": "This account is temporarily locked.",
"WinoAccount_Error_AccountBanned": "This account has been banned.",
"WinoAccount_Error_AccountSuspended": "This account has been suspended.",
"WinoAccount_Error_EmailNotConfirmed": "Please confirm your email address before signing in.",
"WinoAccount_Error_EmailConfirmationRequired": "Please confirm your email address before signing in.",
"WinoAccount_Error_EmailConfirmationResendNotAvailable": "A new confirmation email is not available yet.",
"WinoAccount_Error_EmailConfirmationResendInvalid": "This confirmation request is no longer valid. Please try signing in again.",
"WinoAccount_Error_EmailNotRegistered": "This email address is not registered.",
"WinoAccount_Error_RefreshTokenInvalid": "Your session is no longer valid. Please sign in again.",
"WinoAccount_Error_EmailAlreadyRegistered": "This email address is already registered.",
"WinoAccount_Error_ExternalLoginEmailRequired": "An email address is required to complete external sign-in.",
@@ -1196,14 +1262,25 @@
"WinoAccount_Error_ValidationFailed": "The request is invalid. Please review the entered values.",
"WinoAccount_RegisterSuccessMessage": "Wino Account registration completed for {0}.",
"WinoAccount_LoginSuccessMessage": "Signed in to Wino Account as {0}.",
"WinoAccount_EmailConfirmationSentDialog_Title": "Confirm your email address",
"WinoAccount_EmailConfirmationSentDialog_Message": "We sent an email confirmation to {0}. Please confirm it and try signing in again.",
"WinoAccount_EmailConfirmationPendingDialog_Title": "Email confirmation required",
"WinoAccount_EmailConfirmationPendingDialog_Message": "We are still waiting for you to confirm {0}.",
"WinoAccount_EmailConfirmationPendingDialog_ResendButton": "Resend confirmation email",
"WinoAccount_EmailConfirmationPendingDialog_Countdown": "You can resend the confirmation email in {0}.",
"WinoAccount_EmailConfirmationPendingDialog_ReadyToResend": "You can resend the confirmation email now.",
"WinoAccount_EmailConfirmationResentDialog_Title": "Confirmation email resent",
"WinoAccount_EmailConfirmationResentDialog_Message": "We sent another confirmation email to {0}. Please confirm it and try signing in again.",
"WinoAccount_ForgotPasswordDialog_SuccessTitle": "Password reset email sent",
"WinoAccount_ForgotPasswordDialog_SuccessMessage": "We sent a password reset email to {0}. Open that message to choose a new password.",
"WinoAccount_ChangePassword_Title": "Change password",
"WinoAccount_ChangePassword_Description": "Send a password reset email to this Wino Account.",
"WinoAccount_ChangePassword_Action": "Send reset email",
"WinoAccount_ChangePassword_ConfirmationMessage": "Do you want Wino to send a password reset email to {0}?",
"WinoAccount_SignOut_SuccessMessage": "Signed out from Wino Account {0}.",
"WinoAccount_SignOut_NoAccountMessage": "There is no active Wino Account to sign out.",
"WinoAccount_Titlebar_SignedOutTitle": "Wino Account",
"WinoAccount_Titlebar_SignedOutDescription": "Sign in or create a Wino Account to manage your Wino session.",
"WinoAccount_Titlebar_SyncBenefitTitle": "Sync settings",
"WinoAccount_Titlebar_SyncBenefitDescription": "Keep your Wino preferences in sync across devices.",
"WinoAccount_Titlebar_AddonsBenefitTitle": "Unlock add-ons",
"WinoAccount_Titlebar_AddonsBenefitDescription": "Access premium features like Wino AI Pack.",
"WinoAccount_Titlebar_SignedInStatus": "Status: {0}",
"WelcomeWizard_Step2Title": "Add Account",
"WelcomeWizard_Step3Title": "Finish Setup",
@@ -0,0 +1,225 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using CommunityToolkit.Mvvm.Collections;
using FluentAssertions;
using Itenso.TimePeriod;
using Moq;
using Wino.Calendar.ViewModels;
using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Interfaces;
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.Calendar;
using Xunit;
namespace Wino.Core.Tests;
public class CalendarPageViewModelTests
{
[Fact]
public async Task ApplyDisplayRequestAsync_UpdatesVisibleRangeAndThreePeriodLoadWindow()
{
var settings = CreateSettings(firstDayOfWeek: DayOfWeek.Monday);
var today = new DateOnly(2026, 3, 20);
var preferencesService = CreatePreferencesService(settings);
var calendarService = new Mock<ICalendarService>();
ITimePeriod? requestedPeriod = null;
calendarService
.Setup(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()))
.Callback<IAccountCalendar, ITimePeriod>((_, period) => requestedPeriod = period)
.ReturnsAsync([]);
var viewModel = CreateViewModel(calendarService.Object, preferencesService.Object, today);
var request = new CalendarDisplayRequest(CalendarDisplayType.Week, new DateOnly(2026, 3, 18));
await viewModel.ApplyDisplayRequestAsync(request);
viewModel.CurrentVisibleRange.StartDate.Should().Be(new DateOnly(2026, 3, 16));
viewModel.CurrentVisibleRange.EndDate.Should().Be(new DateOnly(2026, 3, 22));
viewModel.LoadedDateWindow.StartDate.Should().Be(new DateTime(2026, 3, 9));
viewModel.LoadedDateWindow.EndDate.Should().Be(new DateTime(2026, 3, 30));
viewModel.VisibleDateRangeText.Should().Be("3/16/2026 - 3/22/2026");
requestedPeriod.Should().NotBeNull();
requestedPeriod!.Start.Should().Be(new DateTime(2026, 3, 9));
requestedPeriod.End.Should().Be(new DateTime(2026, 3, 30));
calendarService.Verify(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()), Times.Once);
}
[Fact]
public async Task ApplyDisplayRequestAsync_DoesNotReloadWhenResolvedRangeIsUnchanged()
{
var settings = CreateSettings();
var preferencesService = CreatePreferencesService(settings);
var calendarService = new Mock<ICalendarService>();
calendarService
.Setup(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()))
.ReturnsAsync([]);
var viewModel = CreateViewModel(calendarService.Object, preferencesService.Object, new DateOnly(2026, 3, 20));
var request = new CalendarDisplayRequest(CalendarDisplayType.Day, new DateOnly(2026, 3, 20));
await viewModel.ApplyDisplayRequestAsync(request);
await viewModel.ApplyDisplayRequestAsync(request);
calendarService.Verify(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()), Times.Once);
}
[Fact]
public async Task ReloadCurrentVisibleRangeAsync_RecomputesWhenCalendarSettingsChange()
{
var currentSettings = CreateSettings(firstDayOfWeek: DayOfWeek.Monday);
var preferencesService = CreatePreferencesService(() => currentSettings);
var calendarService = new Mock<ICalendarService>();
calendarService
.Setup(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()))
.ReturnsAsync([]);
var viewModel = CreateViewModel(calendarService.Object, preferencesService.Object, new DateOnly(2026, 3, 20));
var request = new CalendarDisplayRequest(CalendarDisplayType.Week, new DateOnly(2026, 3, 18));
await viewModel.ApplyDisplayRequestAsync(request);
viewModel.CurrentVisibleRange.StartDate.Should().Be(new DateOnly(2026, 3, 16));
currentSettings = CreateSettings(firstDayOfWeek: DayOfWeek.Sunday);
await viewModel.ReloadCurrentVisibleRangeAsync();
viewModel.CurrentVisibleRange.StartDate.Should().Be(new DateOnly(2026, 3, 15));
calendarService.Verify(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()), Times.Exactly(2));
}
private static CalendarPageViewModel CreateViewModel(
ICalendarService calendarService,
IPreferencesService preferencesService,
DateOnly today)
{
var account = new MailAccount
{
Id = Guid.NewGuid(),
Name = "Primary",
SenderName = "Primary",
Address = "primary@example.com",
ProviderType = MailProviderType.Outlook
};
var calendar = new AccountCalendar
{
Id = Guid.NewGuid(),
AccountId = account.Id,
Name = "Calendar",
RemoteCalendarId = "calendar",
SynchronizationDeltaToken = string.Empty,
TextColorHex = "#000000",
BackgroundColorHex = "#ffffff",
TimeZone = TimeZoneInfo.Utc.Id,
IsExtended = true,
IsPrimary = true,
IsSynchronizationEnabled = true
};
var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar);
var accountCalendarStateService = new FakeAccountCalendarStateService([accountCalendarViewModel]);
var statePersistenceService = new Mock<IStatePersistanceService>();
statePersistenceService.SetupAllProperties();
statePersistenceService.Object.ApplicationMode = WinoApplicationMode.Calendar;
statePersistenceService.Object.CalendarDisplayType = CalendarDisplayType.Week;
return new CalendarPageViewModel(
statePersistenceService.Object,
calendarService,
Mock.Of<INavigationService>(),
Mock.Of<IKeyPressService>(),
Mock.Of<INativeAppService>(),
accountCalendarStateService,
preferencesService,
Mock.Of<IWinoRequestDelegator>(),
Mock.Of<IMailDialogService>(),
new TestDateContextProvider("en-US", today),
new CalendarRangeTextFormatter());
}
private static Mock<IPreferencesService> CreatePreferencesService(CalendarSettings settings)
=> CreatePreferencesService(() => settings);
private static Mock<IPreferencesService> CreatePreferencesService(Func<CalendarSettings> settingsFactory)
{
var preferencesService = new Mock<IPreferencesService>();
preferencesService.Setup(service => service.GetCurrentCalendarSettings()).Returns(settingsFactory);
return preferencesService;
}
private static CalendarSettings CreateSettings(
DayOfWeek firstDayOfWeek = DayOfWeek.Monday,
DayOfWeek workWeekStart = DayOfWeek.Monday,
DayOfWeek workWeekEnd = DayOfWeek.Friday,
string cultureName = "en-US")
{
return new CalendarSettings(
firstDayOfWeek,
[DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday],
workWeekStart,
workWeekEnd,
TimeSpan.FromHours(8),
TimeSpan.FromHours(17),
64,
DayHeaderDisplayType.TwentyFourHour,
CultureInfo.GetCultureInfo(cultureName));
}
private sealed class FakeAccountCalendarStateService : IAccountCalendarStateService
{
private readonly List<AccountCalendarViewModel> _calendars;
private readonly ObservableCollection<GroupedAccountCalendarViewModel> _groupedCalendars = [];
public FakeAccountCalendarStateService(IEnumerable<AccountCalendarViewModel> calendars)
{
_calendars = calendars.ToList();
GroupedAccountCalendars = new ReadOnlyObservableCollection<GroupedAccountCalendarViewModel>(_groupedCalendars);
}
public IDispatcher Dispatcher { get; set; } = null!;
public ReadOnlyObservableCollection<GroupedAccountCalendarViewModel> GroupedAccountCalendars { get; }
public event EventHandler<GroupedAccountCalendarViewModel>? CollectiveAccountGroupSelectionStateChanged
{
add { }
remove { }
}
public event EventHandler<AccountCalendarViewModel>? AccountCalendarSelectionStateChanged
{
add { }
remove { }
}
public event PropertyChangedEventHandler? PropertyChanged
{
add { }
remove { }
}
public IEnumerable<AccountCalendarViewModel> ActiveCalendars => _calendars;
public IEnumerable<AccountCalendarViewModel> AllCalendars => _calendars;
public ReadOnlyObservableGroupedCollection<MailAccount, AccountCalendarViewModel> GroupedCalendars { get; set; } = null!;
public void AddGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar) => _groupedCalendars.Add(groupedAccountCalendar);
public void RemoveGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar) => _groupedCalendars.Remove(groupedAccountCalendar);
public void ClearGroupedAccountCalendars() => _groupedCalendars.Clear();
public void AddAccountCalendar(AccountCalendarViewModel accountCalendar) => _calendars.Add(accountCalendar);
public void RemoveAccountCalendar(AccountCalendarViewModel accountCalendar) => _calendars.Remove(accountCalendar);
}
private sealed class TestDateContextProvider(string cultureName, DateOnly today) : IDateContextProvider
{
public CultureInfo Culture => CultureInfo.GetCultureInfo(cultureName);
public TimeZoneInfo TimeZone => TimeZoneInfo.Utc;
public DateOnly GetToday() => today;
}
}
@@ -0,0 +1,189 @@
using System.Globalization;
using FluentAssertions;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
using Xunit;
namespace Wino.Core.Tests;
public class CalendarRangeResolverTests
{
[Fact]
public void Resolve_Day_ReturnsAnchorDateOnly()
{
var settings = CreateSettings();
var today = new DateOnly(2026, 3, 20);
var range = CalendarRangeResolver.Resolve(new CalendarDisplayRequest(CalendarDisplayType.Day, today), settings, today);
range.StartDate.Should().Be(today);
range.EndDate.Should().Be(today);
range.DayCount.Should().Be(1);
range.ContainsToday.Should().BeTrue();
range.Dates.Should().ContainSingle().Which.Should().Be(today);
}
[Fact]
public void Resolve_Week_HonorsConfiguredFirstDayOfWeek()
{
var settings = CreateSettings(firstDayOfWeek: DayOfWeek.Sunday);
var anchor = new DateOnly(2026, 3, 18);
var range = CalendarRangeResolver.Resolve(new CalendarDisplayRequest(CalendarDisplayType.Week, anchor), settings, today: anchor);
range.StartDate.Should().Be(new DateOnly(2026, 3, 15));
range.EndDate.Should().Be(new DateOnly(2026, 3, 21));
range.DayCount.Should().Be(7);
}
[Fact]
public void Resolve_WorkWeek_UsesConfiguredBounds()
{
var settings = CreateSettings(
firstDayOfWeek: DayOfWeek.Sunday,
workWeekStart: DayOfWeek.Monday,
workWeekEnd: DayOfWeek.Thursday);
var anchor = new DateOnly(2026, 3, 18);
var range = CalendarRangeResolver.Resolve(new CalendarDisplayRequest(CalendarDisplayType.WorkWeek, anchor), settings, today: anchor);
range.StartDate.Should().Be(new DateOnly(2026, 3, 16));
range.EndDate.Should().Be(new DateOnly(2026, 3, 19));
range.DayCount.Should().Be(4);
}
[Fact]
public void Resolve_Month_CoversEntireAnchorMonth()
{
var settings = CreateSettings();
var anchor = new DateOnly(2026, 2, 14);
var range = CalendarRangeResolver.Resolve(new CalendarDisplayRequest(CalendarDisplayType.Month, anchor), settings, today: anchor);
range.StartDate.Should().Be(new DateOnly(2026, 2, 1));
range.EndDate.Should().Be(new DateOnly(2026, 2, 28));
range.DayCount.Should().Be(28);
range.SpansSingleMonth.Should().BeTrue();
}
[Theory]
[InlineData(CalendarDisplayType.Day, 2026, 3, 18, 2026, 3, 19, 2026, 3, 17)]
[InlineData(CalendarDisplayType.Week, 2026, 3, 18, 2026, 3, 25, 2026, 3, 11)]
[InlineData(CalendarDisplayType.WorkWeek, 2026, 3, 18, 2026, 3, 25, 2026, 3, 11)]
[InlineData(CalendarDisplayType.Month, 2026, 3, 18, 2026, 4, 18, 2026, 2, 18)]
public void Navigate_MovesExactlyOnePeriod(
CalendarDisplayType displayType,
int year,
int month,
int day,
int nextYear,
int nextMonth,
int nextDay,
int previousYear,
int previousMonth,
int previousDay)
{
var settings = CreateSettings();
var today = new DateOnly(2026, 3, 20);
var current = CalendarRangeResolver.Resolve(
new CalendarDisplayRequest(displayType, new DateOnly(year, month, day)),
settings,
today);
var next = CalendarRangeResolver.Navigate(current, 1, settings, today);
var previous = CalendarRangeResolver.Navigate(current, -1, settings, today);
next.AnchorDate.Should().Be(new DateOnly(nextYear, nextMonth, nextDay));
previous.AnchorDate.Should().Be(new DateOnly(previousYear, previousMonth, previousDay));
}
[Fact]
public void ChangeDisplayType_FromMonth_UsesTodayWhenTodayIsInsideCurrentMonth()
{
var settings = CreateSettings();
var today = new DateOnly(2026, 3, 20);
var monthRange = CalendarRangeResolver.Resolve(
new CalendarDisplayRequest(CalendarDisplayType.Month, new DateOnly(2026, 3, 5)),
settings,
today);
var dayRange = CalendarRangeResolver.ChangeDisplayType(monthRange, CalendarDisplayType.Day, settings, today);
dayRange.AnchorDate.Should().Be(today);
dayRange.StartDate.Should().Be(today);
dayRange.EndDate.Should().Be(today);
}
[Fact]
public void Formatter_Day_UsesSingleDate()
{
var formatter = new CalendarRangeTextFormatter();
var range = new VisibleDateRange(
CalendarDisplayType.Day,
new DateOnly(2026, 3, 20),
new DateOnly(2026, 3, 20),
new DateOnly(2026, 3, 20),
new DateOnly(2026, 3, 20),
1,
true,
true,
[new DateOnly(2026, 3, 20)]);
var text = formatter.Format(range, new TestDateContextProvider("en-US", today: new DateOnly(2026, 3, 20)));
text.Should().Be("3/20/2026");
}
[Fact]
public void Formatter_Range_UsesCultureShortDatePattern()
{
var formatter = new CalendarRangeTextFormatter();
var range = new VisibleDateRange(
CalendarDisplayType.Week,
new DateOnly(2026, 3, 20),
new DateOnly(2026, 3, 16),
new DateOnly(2026, 3, 22),
new DateOnly(2026, 3, 20),
7,
true,
true,
[
new DateOnly(2026, 3, 16),
new DateOnly(2026, 3, 17),
new DateOnly(2026, 3, 18),
new DateOnly(2026, 3, 19),
new DateOnly(2026, 3, 20),
new DateOnly(2026, 3, 21),
new DateOnly(2026, 3, 22)
]);
var text = formatter.Format(range, new TestDateContextProvider("de-DE", today: new DateOnly(2026, 3, 20)));
text.Should().Be("16.03.2026 - 22.03.2026");
}
private static CalendarSettings CreateSettings(
DayOfWeek firstDayOfWeek = DayOfWeek.Monday,
DayOfWeek workWeekStart = DayOfWeek.Monday,
DayOfWeek workWeekEnd = DayOfWeek.Friday,
string cultureName = "en-US")
{
return new CalendarSettings(
firstDayOfWeek,
[DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday],
workWeekStart,
workWeekEnd,
TimeSpan.FromHours(8),
TimeSpan.FromHours(17),
64,
DayHeaderDisplayType.TwentyFourHour,
CultureInfo.GetCultureInfo(cultureName));
}
private sealed class TestDateContextProvider(string cultureName, DateOnly today) : IDateContextProvider
{
public CultureInfo Culture => CultureInfo.GetCultureInfo(cultureName);
public TimeZoneInfo TimeZone => TimeZoneInfo.Utc;
public DateOnly GetToday() => today;
}
}
+1
View File
@@ -21,6 +21,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Wino.Calendar.ViewModels\Wino.Calendar.ViewModels.csproj" />
<ProjectReference Include="..\Wino.Core\Wino.Core.csproj" />
<ProjectReference Include="..\Wino.Services\Wino.Services.csproj" />
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
-3
View File
@@ -116,12 +116,9 @@
<ResourceDictionary Source="Styles/CalendarThemeResources.xaml" />
<ResourceDictionary Source="Styles/WinoDayTimelineCanvas.xaml" />
<ResourceDictionary Source="Styles/WinoCalendarView.xaml" />
<ResourceDictionary Source="Styles/WinoCalendarTypeSelectorControl.xaml" />
<styles:WinoExpanderStyle />
<styles:WinoCalendarResources />
<ResourceDictionary Source="Styles/CalendarShellNavigationViewStyle.xaml" />
<ResourceDictionary Source="AppThemes/Default.xaml" />
</ResourceDictionary.MergedDictionaries>
+2
View File
@@ -283,6 +283,8 @@ public partial class App : WinoApplication,
services.AddTransient<IProviderService, ProviderService>();
services.AddSingleton<IAuthenticatorConfig, MailAuthenticatorConfiguration>();
services.AddSingleton<IAccountCalendarStateService, AccountCalendarStateService>();
services.AddSingleton<IDateContextProvider, SystemDateContextProvider>();
services.AddSingleton<ICalendarRangeTextFormatter, CalendarRangeTextFormatter>();
}
private void RegisterViewModels(IServiceCollection services)
@@ -1,381 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.WinUI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Calendar.Args;
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
using Wino.Helpers;
namespace Wino.Calendar.Controls;
public partial class WinoCalendarControl : Control, IDisposable
{
private const string PART_WinoFlipView = nameof(PART_WinoFlipView);
private const string PART_IdleGrid = nameof(PART_IdleGrid);
public event EventHandler<TimelineCellSelectedArgs>? TimelineCellSelected;
public event EventHandler<TimelineCellUnselectedArgs>? TimelineCellUnselected;
public event EventHandler? ScrollPositionChanging;
#region Dependency Properties
/// <summary>
/// Gets or sets the collection of day ranges to render.
/// Each day range usually represents a week, but it may support other ranges.
/// </summary>
[GeneratedDependencyProperty]
public partial ObservableCollection<DayRangeRenderModel>? DayRanges { get; set; }
[GeneratedDependencyProperty(DefaultValue = -1)]
public partial int SelectedFlipViewIndex { get; set; }
[GeneratedDependencyProperty]
public partial DayRangeRenderModel? SelectedFlipViewDayRange { get; set; }
[GeneratedDependencyProperty]
public partial WinoDayTimelineCanvas? ActiveCanvas { get; set; }
[GeneratedDependencyProperty(DefaultValue = true)]
public partial bool IsFlipIdle { get; set; }
[GeneratedDependencyProperty]
public partial ScrollViewer? ActiveScrollViewer { get; set; }
[GeneratedDependencyProperty]
public partial ItemsPanelTemplate? VerticalItemsPanelTemplate { get; set; }
[GeneratedDependencyProperty]
public partial ItemsPanelTemplate? HorizontalItemsPanelTemplate { get; set; }
[GeneratedDependencyProperty(DefaultValue = CalendarOrientation.Horizontal)]
public partial CalendarOrientation Orientation { get; set; }
/// <summary>
/// Gets or sets the day-week-month-year display type.
/// Orientation is not determined by this property, but Orientation property.
/// This property is used to determine the template to use for the calendar.
/// </summary>
[GeneratedDependencyProperty(DefaultValue = CalendarDisplayType.Day)]
public partial CalendarDisplayType DisplayType { get; set; }
#endregion
private WinoCalendarFlipView? InternalFlipView;
private Grid? IdleGrid;
private ScrollViewer? _previousScrollViewer;
private WinoDayTimelineCanvas? _previousCanvas;
public WinoCalendarControl()
{
DefaultStyleKey = typeof(WinoCalendarControl);
SizeChanged += CalendarSizeChanged;
}
partial void OnVerticalItemsPanelTemplateChanged(ItemsPanelTemplate? newValue)
=> ManageCalendarOrientation();
partial void OnHorizontalItemsPanelTemplateChanged(ItemsPanelTemplate? newValue)
=> ManageCalendarOrientation();
partial void OnOrientationChanged(CalendarOrientation newValue)
=> ManageCalendarOrientation();
partial void OnDisplayTypeChanged(CalendarDisplayType newValue)
=> ManageDisplayType();
partial void OnIsFlipIdleChanged(bool newValue)
=> UpdateIdleState();
partial void OnDayRangesChanged(ObservableCollection<DayRangeRenderModel>? newValue)
=> EnsureStableSelection();
partial void OnSelectedFlipViewDayRangeChanged(DayRangeRenderModel? newValue)
=> EnsureStableSelection();
partial void OnActiveScrollViewerPropertyChanged(DependencyPropertyChangedEventArgs e)
{
var newValue = e.NewValue as ScrollViewer;
if (_previousScrollViewer != null)
{
DeregisterScrollChanges(_previousScrollViewer);
}
if (newValue != null)
{
RegisterScrollChanges(newValue);
}
_previousScrollViewer = newValue;
ManageHighlightedDateRange();
}
partial void OnActiveCanvasPropertyChanged(DependencyPropertyChangedEventArgs e)
{
var newValue = e.NewValue as WinoDayTimelineCanvas;
if (_previousCanvas != null)
{
DeregisterCanvas(_previousCanvas);
}
if (newValue != null)
{
RegisterCanvas(newValue);
}
_previousCanvas = newValue;
ManageHighlightedDateRange();
}
private void ManageCalendarOrientation()
{
if (InternalFlipView == null || HorizontalItemsPanelTemplate == null || VerticalItemsPanelTemplate == null) return;
InternalFlipView.ItemsPanel = Orientation == CalendarOrientation.Horizontal ? HorizontalItemsPanelTemplate : VerticalItemsPanelTemplate;
}
private void ManageDisplayType()
{
if (InternalFlipView == null) return;
InternalFlipView.DisplayType = DisplayType;
}
private void ManageHighlightedDateRange()
{
if (InternalFlipView?.IsProgrammaticNavigationInProgress == true)
{
return;
}
SelectedFlipViewDayRange = InternalFlipView?.SelectedItem as DayRangeRenderModel;
}
private void DeregisterCanvas(WinoDayTimelineCanvas canvas)
{
if (canvas == null) return;
canvas.SelectedDateTime = null;
canvas.TimelineCellSelected -= ActiveTimelineCellSelected;
canvas.TimelineCellUnselected -= ActiveTimelineCellUnselected;
}
private void RegisterCanvas(WinoDayTimelineCanvas canvas)
{
if (canvas == null) return;
canvas.SelectedDateTime = null;
canvas.TimelineCellSelected += ActiveTimelineCellSelected;
canvas.TimelineCellUnselected += ActiveTimelineCellUnselected;
}
private void RegisterScrollChanges(ScrollViewer scrollViewer)
{
if (scrollViewer == null) return;
scrollViewer.ViewChanging += ScrollViewChanging;
}
private void DeregisterScrollChanges(ScrollViewer scrollViewer)
{
if (scrollViewer == null) return;
scrollViewer.ViewChanging -= ScrollViewChanging;
}
private void ScrollViewChanging(object? sender, ScrollViewerViewChangingEventArgs e)
=> ScrollPositionChanging?.Invoke(this, EventArgs.Empty);
private void CalendarSizeChanged(object sender, SizeChangedEventArgs e)
{
if (ActiveCanvas == null) return;
ActiveCanvas.SelectedDateTime = null;
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
if (InternalFlipView != null)
{
InternalFlipView.ProgrammaticNavigationCompleted -= InternalFlipViewProgrammaticNavigationCompleted;
if (InternalFlipView is IDisposable disposableFlipView)
{
disposableFlipView.Dispose();
}
}
if (_previousScrollViewer != null)
{
DeregisterScrollChanges(_previousScrollViewer);
_previousScrollViewer = null;
}
if (_previousCanvas != null)
{
DeregisterCanvas(_previousCanvas);
_previousCanvas = null;
}
InternalFlipView = GetTemplateChild(PART_WinoFlipView) as WinoCalendarFlipView;
IdleGrid = GetTemplateChild(PART_IdleGrid) as Grid;
if (InternalFlipView != null)
{
InternalFlipView.ProgrammaticNavigationCompleted += InternalFlipViewProgrammaticNavigationCompleted;
}
UpdateIdleState();
ManageCalendarOrientation();
ManageDisplayType();
EnsureStableSelection();
}
private void InternalFlipViewProgrammaticNavigationCompleted(object? sender, ProgrammaticNavigationCompletedEventArgs e)
{
SelectedFlipViewDayRange = e.DayRange;
}
private void UpdateIdleState()
{
if (InternalFlipView != null)
{
InternalFlipView.Opacity = IsFlipIdle ? 0 : 1;
}
if (IdleGrid != null)
{
IdleGrid.Visibility = IsFlipIdle ? Visibility.Visible : Visibility.Collapsed;
}
}
private void EnsureStableSelection()
{
if (InternalFlipView == null || DayRanges == null || DayRanges.Count == 0)
return;
var targetIndex = SelectedFlipViewIndex;
if (SelectedFlipViewDayRange != null)
{
var selectedRangeIndex = DayRanges.IndexOf(SelectedFlipViewDayRange);
if (selectedRangeIndex >= 0)
{
targetIndex = selectedRangeIndex;
}
}
if (targetIndex < 0 || targetIndex >= DayRanges.Count)
{
targetIndex = 0;
}
if (InternalFlipView.SelectedIndex != targetIndex)
{
InternalFlipView.SelectedIndex = targetIndex;
}
}
private void ActiveTimelineCellUnselected(object? sender, TimelineCellUnselectedArgs e)
=> TimelineCellUnselected?.Invoke(this, e);
private void ActiveTimelineCellSelected(object? sender, TimelineCellSelectedArgs e)
=> TimelineCellSelected?.Invoke(this, e);
public void NavigateToDay(DateTime dateTime) => InternalFlipView?.NavigateToDay(dateTime);
public async void NavigateToHour(TimeSpan timeSpan)
{
if (ActiveScrollViewer == null) return;
// Total height of the FlipViewItem is the same as vertical ScrollViewer to position day headers.
await Task.Yield();
await DispatcherQueue.EnqueueAsync(() =>
{
if (ActiveScrollViewer == null) return;
double hourHeght = 60;
double totalHeight = ActiveScrollViewer.ScrollableHeight;
double scrollPosition = timeSpan.TotalHours * hourHeght;
ActiveScrollViewer.ChangeView(null, scrollPosition, null, disableAnimation: false);
});
}
public void ResetTimelineSelection()
{
if (ActiveCanvas == null) return;
ActiveCanvas.SelectedDateTime = null;
}
public void GoNextRange()
{
if (InternalFlipView == null) return;
InternalFlipView.GoNextFlip();
}
public void GoPreviousRange()
{
if (InternalFlipView == null) return;
InternalFlipView.GoPreviousFlip();
}
public void UnselectActiveTimelineCell()
{
if (ActiveCanvas == null) return;
ActiveCanvas.SelectedDateTime = null;
}
public CalendarItemControl GetCalendarItemControl(CalendarItemViewModel calendarItemViewModel)
{
return this.FindDescendants<CalendarItemControl>().FirstOrDefault(a => a.CalendarItem == calendarItemViewModel)!;
}
public void Dispose()
{
SizeChanged -= CalendarSizeChanged;
if (_previousScrollViewer != null)
{
DeregisterScrollChanges(_previousScrollViewer);
_previousScrollViewer = null;
}
if (_previousCanvas != null)
{
DeregisterCanvas(_previousCanvas);
_previousCanvas = null;
}
if (InternalFlipView != null)
{
InternalFlipView.ProgrammaticNavigationCompleted -= InternalFlipViewProgrammaticNavigationCompleted;
if (InternalFlipView is IDisposable disposableFlipView)
{
disposableFlipView.Dispose();
}
InternalFlipView = null;
}
IdleGrid = null;
ActiveCanvas = null;
ActiveScrollViewer = null;
TimelineCellSelected = null;
TimelineCellUnselected = null;
ScrollPositionChanging = null;
}
}
@@ -1,321 +0,0 @@
using System;
using System.Collections.Specialized;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.WinUI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain.Collections;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Calendar.Controls;
public partial class WinoCalendarFlipView : CustomCalendarFlipView, IDisposable
{
public static readonly DependencyProperty IsIdleProperty = DependencyProperty.Register(nameof(IsIdle), typeof(bool), typeof(WinoCalendarFlipView), new PropertyMetadata(true));
public static readonly DependencyProperty ActiveCanvasProperty = DependencyProperty.Register(nameof(ActiveCanvas), typeof(WinoDayTimelineCanvas), typeof(WinoCalendarFlipView), new PropertyMetadata(null));
public static readonly DependencyProperty ActiveVerticalScrollViewerProperty = DependencyProperty.Register(nameof(ActiveVerticalScrollViewer), typeof(ScrollViewer), typeof(WinoCalendarFlipView), new PropertyMetadata(null));
/// <summary>
/// Gets or sets the active canvas that is currently displayed in the flip view.
/// Each day-range of flip view item has a canvas that displays the day timeline.
/// </summary>
public WinoDayTimelineCanvas? ActiveCanvas
{
get { return (WinoDayTimelineCanvas?)GetValue(ActiveCanvasProperty); }
set { SetValue(ActiveCanvasProperty, value); }
}
/// <summary>
/// Gets or sets the scroll viewer that is currently active in the flip view.
/// It's the vertical scroll that scrolls the timeline only, not the header part that belongs
/// to parent FlipView control.
/// </summary>
public ScrollViewer? ActiveVerticalScrollViewer
{
get { return (ScrollViewer?)GetValue(ActiveVerticalScrollViewerProperty); }
set { SetValue(ActiveVerticalScrollViewerProperty, value); }
}
public bool IsIdle
{
get { return (bool)GetValue(IsIdleProperty); }
set { SetValue(IsIdleProperty, value); }
}
internal bool IsProgrammaticNavigationInProgress { get; private set; }
internal int? PendingTargetIndex { get; private set; }
internal event EventHandler<ProgrammaticNavigationCompletedEventArgs>? ProgrammaticNavigationCompleted;
private INotifyCollectionChanged? _trackedItemsSource;
private readonly long _itemsSourceCallbackToken;
private FrameworkElement? _pendingActiveElementContainer;
private bool _isActiveElementResolutionPending;
public WinoCalendarFlipView()
{
_itemsSourceCallbackToken = RegisterPropertyChangedCallback(ItemsSourceProperty, new DependencyPropertyChangedCallback(OnItemsSourceChanged));
}
private static void OnItemsSourceChanged(DependencyObject d, DependencyProperty e)
{
if (d is WinoCalendarFlipView flipView)
{
flipView.RegisterItemsSourceChange();
}
}
private void RegisterItemsSourceChange()
{
if (_trackedItemsSource != null)
{
_trackedItemsSource.CollectionChanged -= ItemsSourceUpdated;
}
_trackedItemsSource = GetItemsSource();
if (_trackedItemsSource != null)
{
_trackedItemsSource.CollectionChanged += ItemsSourceUpdated;
}
UpdateIdleState();
}
protected override void OnSelectedItemChanged(object? oldValue, object? newValue)
{
base.OnSelectedItemChanged(oldValue, newValue);
UpdateActiveElements();
}
protected override void OnContainerPrepared(DependencyObject element, object item)
{
base.OnContainerPrepared(element, item);
// Check if this is the currently selected item's container
var index = IndexFromContainer(element);
if (index >= 0 && index == SelectedIndex)
{
// Container for selected item is now ready, update active elements
UpdateActiveElements();
}
}
private void ItemsSourceUpdated(object? sender, NotifyCollectionChangedEventArgs e)
{
UpdateIdleState();
}
private void UpdateIdleState()
{
var itemsSource = GetItemsSource();
IsIdle = itemsSource == null || itemsSource.Count == 0;
}
private void UpdateActiveElements()
{
var itemsSource = GetItemsSource();
if (SelectedIndex < 0)
{
CancelPendingActiveElementResolution();
if (itemsSource == null || itemsSource.Count == 0)
{
ActiveCanvas = null;
ActiveVerticalScrollViewer = null;
}
return;
}
if (TryResolveActiveElements())
{
CancelPendingActiveElementResolution();
}
else
{
ScheduleActiveElementResolution();
}
}
private bool TryResolveActiveElements()
{
if (SelectedIndex < 0)
{
return false;
}
if (ContainerFromIndex(SelectedIndex) is not FlipViewItem container)
{
return false;
}
var activeCanvas = container.FindDescendant<WinoDayTimelineCanvas>();
var activeVerticalScrollViewer = container.FindDescendant<ScrollViewer>();
if (activeCanvas == null && activeVerticalScrollViewer == null)
{
return false;
}
ActiveCanvas = activeCanvas;
ActiveVerticalScrollViewer = activeVerticalScrollViewer;
return true;
}
private void ScheduleActiveElementResolution()
{
if (ContainerFromIndex(SelectedIndex) is FrameworkElement container &&
!ReferenceEquals(_pendingActiveElementContainer, container))
{
if (_pendingActiveElementContainer != null)
{
_pendingActiveElementContainer.Loaded -= PendingActiveElementContainerLoaded;
}
_pendingActiveElementContainer = container;
_pendingActiveElementContainer.Loaded += PendingActiveElementContainerLoaded;
}
if (_isActiveElementResolutionPending)
{
return;
}
_isActiveElementResolutionPending = true;
LayoutUpdated += FlipViewLayoutUpdated;
DispatcherQueue.TryEnqueue(RetryActiveElementResolution);
}
private void RetryActiveElementResolution()
{
if (TryResolveActiveElements())
{
CancelPendingActiveElementResolution();
}
}
private void PendingActiveElementContainerLoaded(object sender, RoutedEventArgs e)
=> RetryActiveElementResolution();
private void FlipViewLayoutUpdated(object? sender, object e)
=> RetryActiveElementResolution();
private void CancelPendingActiveElementResolution()
{
if (_pendingActiveElementContainer != null)
{
_pendingActiveElementContainer.Loaded -= PendingActiveElementContainerLoaded;
_pendingActiveElementContainer = null;
}
if (_isActiveElementResolutionPending)
{
LayoutUpdated -= FlipViewLayoutUpdated;
_isActiveElementResolutionPending = false;
}
}
/// <summary>
/// Navigates to the specified date in the calendar.
/// </summary>
/// <param name="dateTime">Date to navigate.</param>
public async void NavigateToDay(DateTime dateTime)
{
await Task.Yield();
await DispatcherQueue.EnqueueAsync(() =>
{
// Find the day range that contains the date.
var dayRanges = GetItemsSource();
var dayRange = dayRanges?.FirstOrDefault(a => a.CalendarDays.Any(b => b.RepresentingDate.Date == dateTime.Date));
if (dayRange != null && dayRanges != null)
{
var navigationItemIndex = dayRanges.IndexOf(dayRange);
var hasNavigationWork = navigationItemIndex != SelectedIndex;
IsProgrammaticNavigationInProgress = hasNavigationWork;
PendingTargetIndex = navigationItemIndex;
if (!hasNavigationWork)
{
PendingTargetIndex = null;
return;
}
try
{
if (Math.Abs(navigationItemIndex - SelectedIndex) > 4)
{
// Difference between dates are high.
// No need to animate this much, just go without animating.
SelectedIndex = navigationItemIndex;
}
else
{
// Until we reach the day in the flip, simulate next-prev button clicks.
// This will make sure the FlipView animations are triggered.
// Setting SelectedIndex directly doesn't trigger the animations.
while (SelectedIndex != navigationItemIndex)
{
if (SelectedIndex > navigationItemIndex)
{
GoPreviousFlip();
}
else
{
GoNextFlip();
}
}
}
}
finally
{
if (SelectedIndex == PendingTargetIndex)
{
ProgrammaticNavigationCompleted?.Invoke(this, new ProgrammaticNavigationCompletedEventArgs(SelectedItem as DayRangeRenderModel ?? dayRange));
}
IsProgrammaticNavigationInProgress = false;
PendingTargetIndex = null;
}
}
});
}
private ObservableRangeCollection<DayRangeRenderModel>? GetItemsSource()
=> ItemsSource as ObservableRangeCollection<DayRangeRenderModel>;
public void Dispose()
{
CancelPendingActiveElementResolution();
if (_trackedItemsSource != null)
{
_trackedItemsSource.CollectionChanged -= ItemsSourceUpdated;
_trackedItemsSource = null;
}
UnregisterPropertyChangedCallback(ItemsSourceProperty, _itemsSourceCallbackToken);
ProgrammaticNavigationCompleted = null;
}
}
internal sealed class ProgrammaticNavigationCompletedEventArgs : EventArgs
{
public ProgrammaticNavigationCompletedEventArgs(DayRangeRenderModel dayRange)
{
DayRange = dayRange;
}
public DayRangeRenderModel DayRange { get; }
}
@@ -1,178 +0,0 @@
using System;
using System.Windows.Input;
using CommunityToolkit.Diagnostics;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
using Wino.Core.Domain.Models.Calendar;
using Wino.Helpers;
namespace Wino.Calendar.Controls;
public partial class WinoCalendarView : Control, IDisposable
{
private const string PART_DayViewItemBorder = nameof(PART_DayViewItemBorder);
private const string PART_CalendarView = nameof(PART_CalendarView);
public static readonly DependencyProperty HighlightedDateRangeProperty = DependencyProperty.Register(nameof(HighlightedDateRange), typeof(DateRange), typeof(WinoCalendarView), new PropertyMetadata(null, new PropertyChangedCallback(OnHighlightedDateRangeChanged)));
public static readonly DependencyProperty VisibleDateBackgroundProperty = DependencyProperty.Register(nameof(VisibleDateBackground), typeof(Brush), typeof(WinoCalendarView), new PropertyMetadata(null, new PropertyChangedCallback(OnPropertiesChanged)));
public static readonly DependencyProperty DateClickedCommandProperty = DependencyProperty.Register(nameof(DateClickedCommand), typeof(ICommand), typeof(WinoCalendarView), new PropertyMetadata(null));
public static readonly DependencyProperty TodayBackgroundColorProperty = DependencyProperty.Register(nameof(TodayBackgroundColor), typeof(Color), typeof(WinoCalendarView), new PropertyMetadata(new Color()));
public Color TodayBackgroundColor
{
get { return (Color)GetValue(TodayBackgroundColorProperty); }
set { SetValue(TodayBackgroundColorProperty, value); }
}
/// <summary>
/// Gets or sets the command to execute when a date is picked.
/// Unused.
/// </summary>
public ICommand? DateClickedCommand
{
get { return (ICommand?)GetValue(DateClickedCommandProperty); }
set { SetValue(DateClickedCommandProperty, value); }
}
/// <summary>
/// Gets or sets the highlighted range of dates.
/// </summary>
public DateRange? HighlightedDateRange
{
get { return (DateRange?)GetValue(HighlightedDateRangeProperty); }
set { SetValue(HighlightedDateRangeProperty, value); }
}
public Brush? VisibleDateBackground
{
get { return (Brush?)GetValue(VisibleDateBackgroundProperty); }
set { SetValue(VisibleDateBackgroundProperty, value); }
}
private CalendarView? CalendarView;
private long _displayModeCallbackToken = -1;
public WinoCalendarView()
{
DefaultStyleKey = typeof(WinoCalendarView);
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
if (CalendarView != null)
{
CalendarView.SelectedDatesChanged -= InternalCalendarViewSelectionChanged;
if (_displayModeCallbackToken != -1)
{
CalendarView.UnregisterPropertyChangedCallback(CalendarView.DisplayModeProperty, _displayModeCallbackToken);
_displayModeCallbackToken = -1;
}
}
CalendarView = GetTemplateChild(PART_CalendarView) as CalendarView;
Guard.IsNotNull(CalendarView, nameof(CalendarView));
if (CalendarView == null) return;
CalendarView.SelectedDatesChanged -= InternalCalendarViewSelectionChanged;
CalendarView.SelectedDatesChanged += InternalCalendarViewSelectionChanged;
// TODO: Should come from settings.
CalendarView.FirstDayOfWeek = Windows.Globalization.DayOfWeek.Monday;
// Everytime display mode changes, update the visible date range backgrounds.
// If users go back from year -> month -> day, we need to update the visible date range backgrounds.
_displayModeCallbackToken = CalendarView.RegisterPropertyChangedCallback(CalendarView.DisplayModeProperty, (s, e) => UpdateVisibleDateRangeBackgrounds());
}
private void InternalCalendarViewSelectionChanged(CalendarView sender, CalendarViewSelectedDatesChangedEventArgs args)
{
if (args.AddedDates?.Count > 0)
{
var clickedDate = args.AddedDates[0].Date;
SetInnerDisplayDate(clickedDate);
var clickArgs = new CalendarViewDayClickedEventArgs(clickedDate);
DateClickedCommand?.Execute(clickArgs);
}
// Reset selection, we don't show selected dates but react to them.
CalendarView?.SelectedDates.Clear();
}
private static void OnPropertiesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is WinoCalendarView control)
{
control.UpdateVisibleDateRangeBackgrounds();
}
}
private void SetInnerDisplayDate(DateTime dateTime) => CalendarView?.SetDisplayDate(dateTime);
// Changing selected dates will trigger the selection changed event.
// It will behave like user clicked the date.
public void GoToDay(DateTime dateTime) => CalendarView?.SelectedDates.Add(dateTime);
private static void OnHighlightedDateRangeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is WinoCalendarView control)
{
if (control.HighlightedDateRange == null) return;
control.SetInnerDisplayDate(control.HighlightedDateRange.StartDate);
control.UpdateVisibleDateRangeBackgrounds();
}
}
public void UpdateVisibleDateRangeBackgrounds()
{
if (HighlightedDateRange == null || VisibleDateBackground == null || CalendarView == null) return;
var markDateCalendarDayItems = WinoVisualTreeHelper.FindDescendants<CalendarViewDayItem>(CalendarView);
foreach (var calendarDayItem in markDateCalendarDayItems)
{
var border = WinoVisualTreeHelper.GetChildObject<Border>(calendarDayItem, PART_DayViewItemBorder);
if (border == null) return;
if (calendarDayItem.Date.Date == DateTime.Today.Date)
{
border.Background = new SolidColorBrush(TodayBackgroundColor);
}
else if (calendarDayItem.Date.Date >= HighlightedDateRange.StartDate.Date && calendarDayItem.Date.Date < HighlightedDateRange.EndDate.Date)
{
border.Background = VisibleDateBackground;
}
else
{
border.Background = null;
}
}
}
public void Dispose()
{
if (CalendarView == null)
return;
CalendarView.SelectedDatesChanged -= InternalCalendarViewSelectionChanged;
if (_displayModeCallbackToken != -1)
{
CalendarView.UnregisterPropertyChangedCallback(CalendarView.DisplayModeProperty, _displayModeCallbackToken);
_displayModeCallbackToken = -1;
}
CalendarView = null;
}
}
@@ -1,332 +0,0 @@
using System;
using System.Diagnostics;
using System.Linq;
using CommunityToolkit.WinUI;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using SkiaSharp;
using SkiaSharp.Views.Windows;
using Windows.Foundation;
using Wino.Calendar.Args;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Calendar.Controls;
public partial class WinoDayTimelineCanvas : Control, IDisposable
{
public event EventHandler<TimelineCellSelectedArgs>? TimelineCellSelected;
public event EventHandler<TimelineCellUnselectedArgs>? TimelineCellUnselected;
private const string PART_InternalCanvas = nameof(PART_InternalCanvas);
private SKXamlCanvas? Canvas;
public static readonly DependencyProperty RenderOptionsProperty = DependencyProperty.Register(nameof(RenderOptions), typeof(CalendarRenderOptions), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged)));
public static readonly DependencyProperty SeperatorColorProperty = DependencyProperty.Register(nameof(SeperatorColor), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged)));
public static readonly DependencyProperty HalfHourSeperatorColorProperty = DependencyProperty.Register(nameof(HalfHourSeperatorColor), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged)));
public static readonly DependencyProperty SelectedCellBackgroundBrushProperty = DependencyProperty.Register(nameof(SelectedCellBackgroundBrush), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged)));
public static readonly DependencyProperty WorkingHourCellBackgroundColorProperty = DependencyProperty.Register(nameof(WorkingHourCellBackgroundColor), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged)));
public static readonly DependencyProperty SelectedDateTimeProperty = DependencyProperty.Register(nameof(SelectedDateTime), typeof(DateTime?), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnSelectedDateTimeChanged)));
public static readonly DependencyProperty PositionerUIElementProperty = DependencyProperty.Register(nameof(PositionerUIElement), typeof(UIElement), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null));
public UIElement? PositionerUIElement
{
get { return (UIElement?)GetValue(PositionerUIElementProperty); }
set { SetValue(PositionerUIElementProperty, value); }
}
public CalendarRenderOptions? RenderOptions
{
get { return (CalendarRenderOptions?)GetValue(RenderOptionsProperty); }
set { SetValue(RenderOptionsProperty, value); }
}
public SolidColorBrush? HalfHourSeperatorColor
{
get { return (SolidColorBrush?)GetValue(HalfHourSeperatorColorProperty); }
set { SetValue(HalfHourSeperatorColorProperty, value); }
}
public SolidColorBrush? SeperatorColor
{
get { return (SolidColorBrush?)GetValue(SeperatorColorProperty); }
set { SetValue(SeperatorColorProperty, value); }
}
public SolidColorBrush? WorkingHourCellBackgroundColor
{
get { return (SolidColorBrush?)GetValue(WorkingHourCellBackgroundColorProperty); }
set { SetValue(WorkingHourCellBackgroundColorProperty, value); }
}
public SolidColorBrush? SelectedCellBackgroundBrush
{
get { return (SolidColorBrush?)GetValue(SelectedCellBackgroundBrushProperty); }
set { SetValue(SelectedCellBackgroundBrushProperty, value); }
}
public DateTime? SelectedDateTime
{
get { return (DateTime?)GetValue(SelectedDateTimeProperty); }
set { SetValue(SelectedDateTimeProperty, value); }
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
Canvas = GetTemplateChild(PART_InternalCanvas) as SKXamlCanvas;
if (Canvas != null)
{
Canvas.PaintSurface += OnCanvasPaintSurface;
Canvas.PointerPressed += OnCanvasPointerPressed;
}
ForceDraw();
}
private static void OnSelectedDateTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is WinoDayTimelineCanvas control)
{
if (e.OldValue != null && e.NewValue == null)
{
control.RaiseCellUnselected();
}
control.ForceDraw();
}
}
private void RaiseCellUnselected()
{
TimelineCellUnselected?.Invoke(this, new TimelineCellUnselectedArgs());
}
private void OnCanvasPointerPressed(object? sender, PointerRoutedEventArgs e)
{
if (RenderOptions == null) return;
var canvas = Canvas;
if (canvas == null) return;
var hourHeight = RenderOptions.CalendarSettings.HourHeight;
// When users click to cell we need to find the day, hour and minutes (first 30 minutes or second 30 minutes) that it represents on the timeline.
if (PositionerUIElement == null)
{
PositionerUIElement = this.FindParents().LastOrDefault(a => a is Grid);
}
if (PositionerUIElement == null)
return;
PointerPoint positionerRootPoint = e.GetCurrentPoint(PositionerUIElement);
PointerPoint canvasPointerPoint = e.GetCurrentPoint(canvas);
Point touchPoint = canvasPointerPoint.Position;
var singleDayWidth = (canvas.ActualWidth / RenderOptions.TotalDayCount);
int day = (int)(touchPoint.X / singleDayWidth);
int hour = (int)(touchPoint.Y / hourHeight);
bool isSecondHalf = touchPoint.Y % hourHeight > (hourHeight / 2);
var diffX = positionerRootPoint.Position.X - touchPoint.X;
var diffY = positionerRootPoint.Position.Y - touchPoint.Y;
var cellStartRelativePositionX = diffX + (day * singleDayWidth);
var cellEndRelativePositionX = cellStartRelativePositionX + singleDayWidth;
var cellStartRelativePositionY = diffY + (hour * hourHeight) + (isSecondHalf ? hourHeight / 2 : 0);
var cellEndRelativePositionY = cellStartRelativePositionY + (isSecondHalf ? (hourHeight / 2) : hourHeight);
var cellSize = new Size(cellEndRelativePositionX - cellStartRelativePositionX, hourHeight / 2);
var positionerPoint = new Point(cellStartRelativePositionX, cellStartRelativePositionY);
var clickedDateTime = RenderOptions.DateRange.StartDate.AddDays(day).AddHours(hour).AddMinutes(isSecondHalf ? 30 : 0);
// If there is already a selected date, in order to mimic the popup behavior, we need to dismiss the previous selection first.
// Next click will be a new selection.
// Raise the events directly here instead of DP to not lose pointer position.
if (clickedDateTime == SelectedDateTime || SelectedDateTime != null)
{
SelectedDateTime = null;
}
else
{
SelectedDateTime = clickedDateTime;
TimelineCellSelected?.Invoke(this, new TimelineCellSelectedArgs(clickedDateTime, touchPoint, positionerPoint, cellSize));
}
Debug.WriteLine($"Clicked: {clickedDateTime}");
}
public WinoDayTimelineCanvas()
{
DefaultStyleKey = typeof(WinoDayTimelineCanvas);
}
private static void OnRenderingPropertiesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is WinoDayTimelineCanvas control)
{
control.ForceDraw();
}
}
private void ForceDraw() => Canvas?.Invalidate();
private bool CanDrawTimeline()
{
return RenderOptions != null
&& Canvas != null
&& WorkingHourCellBackgroundColor != null
&& SeperatorColor != null
&& HalfHourSeperatorColor != null
&& SelectedCellBackgroundBrush != null;
}
private void OnCanvasPaintSurface(object? sender, SKPaintSurfaceEventArgs e)
{
if (!CanDrawTimeline()) return;
var renderOptions = RenderOptions!;
var workingHourCellBackgroundColor = WorkingHourCellBackgroundColor!;
var seperatorColor = SeperatorColor!;
var halfHourSeperatorColor = HalfHourSeperatorColor!;
var selectedCellBackgroundBrush = SelectedCellBackgroundBrush!;
var canvas = e.Surface.Canvas;
canvas.Clear(SKColors.Transparent);
int hours = 24;
double canvasWidth = e.Info.Width;
double canvasHeight = e.Info.Height;
if (canvasWidth == 0 || canvasHeight == 0) return;
// Calculate the width of each rectangle (1 day column)
// Equal distribution of the whole width.
double rectWidth = canvasWidth / renderOptions.TotalDayCount;
// Calculate the height of each rectangle (1 hour row)
double rectHeight = renderOptions.CalendarSettings.HourHeight;
// Define stroke and fill colors
var strokeColor = ToSKColor(seperatorColor.Color);
float strokeThickness = 0.5f;
// Create paints for drawing
using var strokePaint = new SKPaint
{
Color = strokeColor,
StrokeWidth = strokeThickness,
Style = SKPaintStyle.Stroke,
IsAntialias = true
};
using var fillPaint = new SKPaint
{
Style = SKPaintStyle.Fill,
IsAntialias = true
};
using var dashedPaint = new SKPaint
{
Color = ToSKColor(halfHourSeperatorColor.Color),
StrokeWidth = strokeThickness,
Style = SKPaintStyle.Stroke,
PathEffect = SKPathEffect.CreateDash([2f, 2f], 0),
IsAntialias = true
};
for (int day = 0; day < renderOptions.TotalDayCount; day++)
{
var currentDay = renderOptions.DateRange.StartDate.AddDays(day);
bool isWorkingDay = renderOptions.CalendarSettings.WorkingDays.Contains(currentDay.DayOfWeek);
// Loop through each hour (rows)
for (int hour = 0; hour < hours; hour++)
{
var renderTime = TimeSpan.FromHours(hour);
var representingDateTime = currentDay.AddHours(hour);
// Calculate the position and size of the rectangle
float x = (float)(day * rectWidth);
float y = (float)(hour * rectHeight);
float width = (float)rectWidth;
float height = (float)rectHeight;
var rectangle = new SKRect(x, y, x + width, y + height);
// Draw the rectangle border.
// This is the main rectangle.
canvas.DrawRect(rectangle, strokePaint);
// Fill another rectangle with the working hour background color
// This rectangle must be placed with -1 margin to prevent invisible borders of the main rectangle.
if (isWorkingDay && renderTime >= renderOptions.CalendarSettings.WorkingHourStart && renderTime <= renderOptions.CalendarSettings.WorkingHourEnd)
{
var backgroundRectangle = new SKRect(x + 1, y + 1, x + width - 1, y + height - 1);
canvas.DrawRect(backgroundRectangle, strokePaint);
fillPaint.Color = ToSKColor(workingHourCellBackgroundColor.Color);
canvas.DrawRect(backgroundRectangle, fillPaint);
}
// Draw a line in the center of the rectangle for representing half hours.
float lineY = y + height / 2;
canvas.DrawLine(x, lineY, x + width, lineY, dashedPaint);
}
// Draw selected item background color for the date if possible.
if (SelectedDateTime != null)
{
var selectedDateTime = SelectedDateTime.Value;
if (selectedDateTime.Date == currentDay.Date)
{
var selectionRectHeight = rectHeight / 2;
var selectedY = selectedDateTime.Hour * rectHeight + (selectedDateTime.Minute / 60) * rectHeight;
// Second half of the hour is selected.
if (selectedDateTime.TimeOfDay.Minutes == 30)
{
selectedY += rectHeight / 2;
}
var selectedRectangle = new SKRect(
(float)(day * rectWidth),
(float)selectedY,
(float)(day * rectWidth + rectWidth),
(float)(selectedY + selectionRectHeight));
fillPaint.Color = ToSKColor(selectedCellBackgroundBrush.Color);
canvas.DrawRect(selectedRectangle, fillPaint);
}
}
}
}
private static SKColor ToSKColor(Windows.UI.Color color)
{
return new SKColor(color.R, color.G, color.B, color.A);
}
public void Dispose()
{
if (Canvas == null) return;
Canvas.PaintSurface -= OnCanvasPaintSurface;
Canvas.PointerPressed -= OnCanvasPointerPressed;
Canvas = null;
}
}
@@ -2,7 +2,6 @@
x:Class="Wino.Mail.WinUI.Controls.UpdateNotesFlipViewControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:Wino.Core.Domain.Models.Updates"
@@ -22,14 +21,21 @@
<DataTemplate x:DataType="models:UpdateNoteSection">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Padding="8" Spacing="12">
<controls:MarkdownTextBlock HorizontalAlignment="Center" Text="{x:Bind Title, Mode=OneTime}" />
<TextBlock
HorizontalAlignment="Center"
Text="{x:Bind Title, Mode=OneTime}"
TextAlignment="Center"
TextWrapping="WrapWholeWords" />
<Image
Width="{x:Bind ActualImageWidth, Mode=OneTime}"
Height="{x:Bind ActualImageHeight, Mode=OneTime}"
HorizontalAlignment="Center"
Source="{x:Bind ImageUrl, Mode=OneTime}"
Stretch="Uniform" />
<controls:MarkdownTextBlock HorizontalAlignment="Stretch" Text="{x:Bind Description, Mode=OneTime}" />
<TextBlock
HorizontalAlignment="Stretch"
Text="{x:Bind Description, Mode=OneTime}"
TextWrapping="WrapWholeWords" />
</StackPanel>
</ScrollViewer>
</DataTemplate>
@@ -474,17 +474,14 @@ public class NavigationService : NavigationServiceBase, INavigationService
return true;
}
private static LoadCalendarMessage CreateLoadCalendarMessage(CalendarPageNavigationArgs args)
private LoadCalendarMessage CreateLoadCalendarMessage(CalendarPageNavigationArgs args)
{
var targetDate = args.RequestDefaultNavigation
? DateTime.Now.Date
: args.NavigationDate;
? DateOnly.FromDateTime(DateTime.Now.Date)
: DateOnly.FromDateTime(args.NavigationDate.Date);
var initiative = args.RequestDefaultNavigation
? CalendarInitInitiative.App
: CalendarInitInitiative.User;
return new LoadCalendarMessage(targetDate, initiative);
var displayRequest = new CalendarDisplayRequest(_statePersistanceService.CalendarDisplayType, targetDate);
return new LoadCalendarMessage(displayRequest);
}
private bool NavigateInnerShellFrame(Frame frame, Type pageType, object? parameter, NavigationTransitionType transition)
@@ -396,6 +396,8 @@ public class PreferencesService(IConfigurationService configurationService) : Ob
return new CalendarSettings(FirstDayOfWeek,
workingDays,
WorkingDayStart,
WorkingDayEnd,
WorkingHourStart,
WorkingHourEnd,
HourHeight,
@@ -1,447 +0,0 @@
<ResourceDictionary
x:Class="Wino.Styles.WinoCalendarResources"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:calendar="using:Wino.Mail.WinUI.Controls.Calendar"
xmlns:controls="using:Wino.Calendar.Controls"
xmlns:controls2="using:Wino.Mail.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:data="using:Wino.Calendar.ViewModels.Data"
xmlns:helpers="using:Wino.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:Wino.Core.Domain.Models.Calendar"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
xmlns:selectors="using:Wino.Calendar.Selectors"
xmlns:selectors1="using:Wino.Selectors"
xmlns:toolkitControls="using:CommunityToolkit.WinUI.Controls">
<!-- 08:00 or 8 AM/PM on the left etc. -->
<DataTemplate x:Key="DayCalendarHourHeaderTemplate" x:DataType="models:DayHeaderRenderModel">
<Grid Height="{x:Bind HourHeight}">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Top"
Text="{x:Bind DayHeader}" />
</Grid>
</DataTemplate>
<!-- ShowAs Status Stripe Templates -->
<DataTemplate x:Key="FreeStripeTemplate" x:DataType="data:CalendarItemViewModel">
<Border Background="#4CAF50" />
</DataTemplate>
<DataTemplate x:Key="TentativeStripeTemplate" x:DataType="data:CalendarItemViewModel">
<Border Background="#FFC107" />
</DataTemplate>
<DataTemplate x:Key="BusyStripeTemplate" x:DataType="data:CalendarItemViewModel">
<Border Background="#ff7675" />
</DataTemplate>
<DataTemplate x:Key="OutOfOfficeStripeTemplate" x:DataType="data:CalendarItemViewModel">
<Border Background="#9C27B0" />
</DataTemplate>
<DataTemplate x:Key="WorkingElsewhereStripeTemplate" x:DataType="data:CalendarItemViewModel">
<Border Background="#2196F3" />
</DataTemplate>
<!-- ShowAs Status Stripe Selector -->
<selectors1:CalendarItemShowAsStripeTemplateSelector
x:Key="ShowAsStripeSelector"
BusyTemplate="{StaticResource BusyStripeTemplate}"
FreeTemplate="{StaticResource FreeStripeTemplate}"
OutOfOfficeTemplate="{StaticResource OutOfOfficeStripeTemplate}"
TentativeTemplate="{StaticResource TentativeStripeTemplate}"
WorkingElsewhereTemplate="{StaticResource WorkingElsewhereStripeTemplate}" />
<!-- Vertical panel that renders items on canvas. -->
<DataTemplate x:Key="DayCalendarItemVerticalRenderTemplate" x:DataType="models:CalendarDayModel">
<ItemsControl
x:Name="RegularEventItemsControl"
ItemsSource="{x:Bind EventsCollection.RegularEvents}"
Loaded="OnRegularEventItemsControlLoaded">
<ItemsControl.ItemTemplate>
<!-- Default Calendar Item View Model Template -->
<DataTemplate x:DataType="data:CalendarItemViewModel">
<controls:CalendarItemControl CalendarItem="{x:Bind}" IsCustomEventArea="False" />
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemContainerTransitions>
<TransitionCollection>
<PaneThemeTransition Edge="Left" />
</TransitionCollection>
</ItemsControl.ItemContainerTransitions>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<controls:WinoCalendarPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</DataTemplate>
<!-- Equally distributed days of week representation in FlipView. -->
<!-- Used for day-week-work week templates. -->
<!-- Horizontal template -->
<DataTemplate x:Key="FlipTemplate" x:DataType="models:DayRangeRenderModel">
<Grid
Background="Transparent"
ColumnSpacing="0"
RowSpacing="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" MinHeight="100" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ItemsControl
x:Name="DayColumnsItemsControl"
Margin="50,0,16,0"
ItemsSource="{x:Bind CalendarDays}"
Loaded="OnDayColumnsItemsControlLoaded">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:CalendarDayModel">
<controls:DayColumnControl DayModel="{x:Bind}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<toolkitControls:UniformGrid Orientation="Horizontal" Rows="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<ScrollViewer
Grid.Row="1"
Grid.ColumnSpan="2"
Margin="0"
Padding="0,0,16,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="50" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Rendering left hour headers. -->
<ItemsControl ItemTemplate="{StaticResource DayCalendarHourHeaderTemplate}" ItemsSource="{x:Bind DayHeaders}" />
<!-- Drawing canvas for timeline. -->
<controls:WinoDayTimelineCanvas
Grid.Column="1"
HalfHourSeperatorColor="{ThemeResource CalendarSeperatorBrush}"
RenderOptions="{x:Bind CalendarRenderOptions}"
SelectedCellBackgroundBrush="{ThemeResource CalendarFieldSelectedBackgroundBrush}"
SeperatorColor="{ThemeResource CalendarSeperatorBrush}"
WorkingHourCellBackgroundColor="{ThemeResource CalendarFieldWorkingHoursBackgroundBrush}" />
<!-- Each vertical day grids that renders events. -->
<ItemsControl
x:Name="EventGridsItemsControl"
Grid.Column="1"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
ItemTemplate="{StaticResource DayCalendarItemVerticalRenderTemplate}"
ItemsSource="{x:Bind CalendarDays}"
Loaded="OnEventGridsItemsControlLoaded">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<toolkitControls:UniformGrid Orientation="Horizontal" Rows="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid>
</ScrollViewer>
</Grid>
</DataTemplate>
<!-- Template that displays 35 days in total. -->
<!-- Used for monthly view -->
<!-- Vertical template -->
<DataTemplate x:Key="MonthlyFlipTemplate" x:DataType="models:DayRangeRenderModel">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Height="20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
</Grid>
<ItemsControl Grid.Row="1" ItemsSource="{x:Bind CalendarDays}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:CalendarDayModel">
<controls:DayColumnControl DayModel="{x:Bind Mode=OneWay}" Template="{StaticResource MonthlyColumnControlTemplate}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<controls2:EqualGridPanel Columns="7" Rows="5" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid>
</DataTemplate>
<ItemsPanelTemplate x:Key="VerticalFlipViewItemsPanel">
<VirtualizingStackPanel Orientation="Vertical" />
</ItemsPanelTemplate>
<ItemsPanelTemplate x:Key="HorizontalFlipViewItemsPanel">
<VirtualizingStackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
<!-- Default style for WinoCalendarControl -->
<Style TargetType="controls:WinoCalendarControl">
<Style.Setters>
<Setter Property="HorizontalItemsPanelTemplate" Value="{StaticResource HorizontalFlipViewItemsPanel}" />
<Setter Property="VerticalItemsPanelTemplate" Value="{StaticResource VerticalFlipViewItemsPanel}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="controls:WinoCalendarControl">
<Grid>
<controls:WinoCalendarFlipView
x:Name="PART_WinoFlipView"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
ActiveCanvas="{x:Bind ActiveCanvas, Mode=TwoWay}"
ActiveVerticalScrollViewer="{x:Bind ActiveScrollViewer, Mode=TwoWay}"
Background="Transparent"
IsIdle="{x:Bind IsFlipIdle, Mode=TwoWay}"
IsTabStop="False"
ItemsSource="{TemplateBinding DayRanges}"
SelectedIndex="{Binding SelectedFlipViewIndex, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay}">
<controls:WinoCalendarFlipView.ItemTemplateSelector>
<selectors1:WinoCalendarItemTemplateSelector
DayWeekWorkWeekTemplate="{StaticResource FlipTemplate}"
DisplayType="{x:Bind DisplayType, Mode=OneWay}"
MonthlyTemplate="{StaticResource MonthlyFlipTemplate}" />
</controls:WinoCalendarFlipView.ItemTemplateSelector>
</controls:WinoCalendarFlipView>
<Grid x:Name="PART_IdleGrid" Visibility="Collapsed">
<muxc:ProgressRing
Width="50"
Height="50"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsActive="True" />
</Grid>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style.Setters>
</Style>
<!-- Top header control for days. -->
<ControlTemplate x:Key="DailyColumnControlTemplate" TargetType="controls:DayColumnControl">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="25" />
<RowDefinition Height="7" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Name of the day. Monday, Tuesday etc. at the top. -->
<TextBlock
x:Name="PART_ColumnHeaderText"
Margin="8,0,0,0"
FontSize="16"
TextTrimming="CharacterEllipsis" />
<Grid
Grid.Row="2"
Grid.RowSpan="2"
BorderBrush="{ThemeResource CalendarSeperatorBrush}"
BorderThickness="1,1,0,1" />
<!-- Border for today indication. -->
<Border
x:Name="PART_IsTodayBorder"
Grid.Row="1"
Height="5"
Margin="2,0,2,0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
Background="{ThemeResource SystemAccentColor}"
CornerRadius="2"
Visibility="Collapsed" />
<!-- Place where full day events go. -->
<Grid
x:Name="PART_DayDataAreaGrid"
Grid.Row="2"
Padding="6"
BorderBrush="{ThemeResource CalendarSeperatorBrush}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="35" />
</Grid.RowDefinitions>
<!-- Day number -->
<TextBlock x:Name="PART_HeaderDateDayText" FontSize="17" />
<!-- Extras -->
<StackPanel Grid.Column="1" HorizontalAlignment="Right" />
<!-- All-Multi Day Events -->
<ItemsControl
x:Name="PART_AllDayItemsControl"
Grid.Row="1"
Grid.ColumnSpan="2"
Margin="0,6">
<ItemsControl.ItemTemplateSelector>
<selectors:CustomAreaCalendarItemSelector>
<selectors:CustomAreaCalendarItemSelector.AllDayTemplate>
<DataTemplate x:DataType="data:CalendarItemViewModel">
<controls:CalendarItemControl CalendarItem="{x:Bind}" IsCustomEventArea="True" />
</DataTemplate>
</selectors:CustomAreaCalendarItemSelector.AllDayTemplate>
<selectors:CustomAreaCalendarItemSelector.MultiDayTemplate>
<DataTemplate x:DataType="data:CalendarItemViewModel">
<controls:CalendarItemControl CalendarItem="{x:Bind}" IsCustomEventArea="True" />
</DataTemplate>
</selectors:CustomAreaCalendarItemSelector.MultiDayTemplate>
</selectors:CustomAreaCalendarItemSelector>
</ItemsControl.ItemTemplateSelector>
<ItemsControl.ItemContainerTransitions>
<TransitionCollection>
<AddDeleteThemeTransition />
</TransitionCollection>
</ItemsControl.ItemContainerTransitions>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" Spacing="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="TodayOrNotStates">
<VisualState x:Name="NotTodayState" />
<VisualState x:Name="TodayState">
<VisualState.Setters>
<Setter Target="PART_IsTodayBorder.Visibility" Value="Visible" />
<Setter Target="PART_HeaderDateDayText.Foreground" Value="{ThemeResource SystemAccentColor}" />
<Setter Target="PART_HeaderDateDayText.FontWeight" Value="Semibold" />
<Setter Target="PART_ColumnHeaderText.FontWeight" Value="Semibold" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
<!-- Monthly data control for months -->
<ControlTemplate x:Key="MonthlyColumnControlTemplate" TargetType="controls:DayColumnControl">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="5" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Border for today indication. -->
<Border
x:Name="PART_IsTodayBorder"
Grid.Row="0"
Height="5"
Margin="2,0,2,0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
Background="{ThemeResource SystemAccentColor}"
CornerRadius="2"
Visibility="Collapsed" />
<!-- Border -->
<Grid
Grid.Row="0"
Grid.RowSpan="2"
BorderBrush="{ThemeResource CalendarSeperatorBrush}"
BorderThickness="1,1,0,1" />
<!-- Place where full day events go. -->
<Grid
x:Name="PART_DayDataAreaGrid"
Grid.Row="1"
Padding="6"
BorderBrush="{ThemeResource CalendarSeperatorBrush}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="35" />
</Grid.RowDefinitions>
<!-- Day number -->
<TextBlock x:Name="PART_HeaderDateDayText" FontSize="17" />
<!-- Extras -->
<StackPanel Grid.Column="1" HorizontalAlignment="Right" />
<!-- All events summary. -->
<ScrollViewer
Grid.Row="1"
Grid.ColumnSpan="2"
Margin="0,6"
Padding="0,0,16,0">
<ItemsControl x:Name="PART_AllDayItemsControl">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="data:CalendarItemViewModel">
<controls:CalendarItemControl CalendarItem="{x:Bind}" IsCustomEventArea="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemContainerTransitions>
<TransitionCollection>
<AddDeleteThemeTransition />
</TransitionCollection>
</ItemsControl.ItemContainerTransitions>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" Spacing="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ScrollViewer>
</Grid>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="TodayOrNotStates">
<VisualState x:Name="NotTodayState" />
<VisualState x:Name="TodayState">
<VisualState.Setters>
<Setter Target="PART_IsTodayBorder.Visibility" Value="Visible" />
<Setter Target="PART_HeaderDateDayText.Foreground" Value="{ThemeResource SystemAccentColor}" />
<Setter Target="PART_HeaderDateDayText.FontWeight" Value="Semibold" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
<!-- Default style for DayColumnControl -->
<Style TargetType="controls:DayColumnControl">
<Setter Property="Template" Value="{StaticResource DailyColumnControlTemplate}" />
</Style>
</ResourceDictionary>
@@ -1,49 +0,0 @@
using CommunityToolkit.WinUI.Controls;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Calendar.Controls;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Styles;
public sealed partial class WinoCalendarResources : ResourceDictionary
{
public WinoCalendarResources()
{
this.InitializeComponent();
}
private void OnRegularEventItemsControlLoaded(object sender, RoutedEventArgs e)
{
if (sender is ItemsControl itemsControl && itemsControl.DataContext is CalendarDayModel dayModel)
{
if (itemsControl.ItemsPanelRoot is WinoCalendarPanel panel)
{
panel.HourHeight = dayModel.CalendarRenderOptions.CalendarSettings.HourHeight;
panel.Period = dayModel.Period;
}
}
}
private void OnDayColumnsItemsControlLoaded(object sender, RoutedEventArgs e)
{
if (sender is ItemsControl itemsControl && itemsControl.DataContext is DayRangeRenderModel rangeModel)
{
if (itemsControl.ItemsPanelRoot is UniformGrid uniformGrid)
{
uniformGrid.Columns = rangeModel.CalendarRenderOptions.TotalDayCount;
}
}
}
private void OnEventGridsItemsControlLoaded(object sender, RoutedEventArgs e)
{
if (sender is ItemsControl itemsControl && itemsControl.DataContext is DayRangeRenderModel rangeModel)
{
if (itemsControl.ItemsPanelRoot is UniformGrid uniformGrid)
{
uniformGrid.Columns = rangeModel.CalendarRenderOptions.TotalDayCount;
}
}
}
}
@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Wino.Calendar.Controls">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="CalendarThemeResources.xaml" />
</ResourceDictionary.MergedDictionaries>
<Style TargetType="controls:WinoCalendarView">
<Setter Property="VisibleDateBackground" Value="{ThemeResource WinoCalendarViewVisibleDayBackgroundBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="controls:WinoCalendarView">
<CalendarView
x:Name="PART_CalendarView"
BorderBrush="{ThemeResource WinoCalendarViewBorderBrush}"
CalendarItemCornerRadius="5"
CornerRadius="4"
DayItemMargin="0"
IsTodayHighlighted="False"
SelectionMode="Single">
<CalendarView.CalendarViewDayItemStyle>
<Style TargetType="CalendarViewDayItem">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CalendarViewDayItem">
<Grid>
<Border
x:Name="PART_DayViewItemBorder"
Margin="0,-1,0,-3"
CornerRadius="5">
<ContentPresenter />
</Border>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</CalendarView.CalendarViewDayItemStyle>
</CalendarView>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
@@ -1,19 +0,0 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Wino.Calendar.Controls"
xmlns:skia="using:SkiaSharp.Views.Windows">
<!-- Background Timeline Canvas -->
<Style TargetType="controls:WinoDayTimelineCanvas">
<Style.Setters>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="controls:WinoDayTimelineCanvas">
<skia:SKXamlCanvas x:Name="PART_InternalCanvas" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style.Setters>
</Style>
</ResourceDictionary>
@@ -131,13 +131,26 @@
Text="{x:Bind domain:Translator.CalendarEventCompose_NewEventButton, Mode=OneTime}" />
</coreControls:WinoNavigationViewItem>
<calendarControls:WinoCalendarView
x:Name="CalendarView"
<Border
x:Name="RangeSummaryCard"
Grid.Row="1"
HorizontalAlignment="Center"
DateClickedCommand="{x:Bind ViewModel.DateClickedCommand}"
HighlightedDateRange="{x:Bind ViewModel.HighlightedDateRange, Mode=OneWay}"
TodayBackgroundColor="{ThemeResource SystemAccentColor}" />
Margin="16,0,16,12"
Padding="12"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8">
<StackPanel Spacing="12">
<TextBlock
FontWeight="SemiBold"
Text="{x:Bind ViewModel.VisibleDateRangeText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<muxc:CalendarView
x:Name="VisibleDateRangeCalendarView"
HorizontalAlignment="Stretch"
SelectionMode="Multiple"
SelectedDatesChanged="VisibleDateRangeCalendarViewSelectedDatesChanged" />
</StackPanel>
</Border>
<!-- Account Calendars Host -->
<ListView
@@ -261,8 +274,6 @@
</VisualState>
<VisualState x:Name="SmallScreen">
<VisualState.Setters>
<Setter Target="NavigationTitleStack.Visibility" Value="Collapsed" />
<Setter Target="SearchBox.(Grid.ColumnSpan)" Value="2" />
<Setter Target="navigationView.IsPaneOpen" Value="False" />
</VisualState.Setters>
<VisualState.StateTriggers>
@@ -273,28 +284,14 @@
<VisualStateGroup x:Name="CalendarOrientationStates">
<VisualState x:Name="HorizontalCalendar" />
<VisualState x:Name="VerticalCalendar">
<VisualState.Setters>
<Setter Target="DayHeaderNavigationItemsFlipView.ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Vertical" />
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Target="PreviousDateButtonPathIcon.Data" Value="F1 M 3.2 10.52 C 2.986666 10.733333 2.92 10.98 3 11.259999 C 3.08 11.54 3.266666 11.713333 3.56 11.78 C 3.853333 11.846666 4.093333 11.773333 4.28 11.559999 L 9.24 6.32 L 9.24 19.039999 C 9.24 19.253332 9.313333 19.433332 9.46 19.58 C 9.606666 19.726665 9.786666 19.799999 10 19.799999 C 10.213333 19.799999 10.393332 19.726665 10.54 19.58 C 10.686666 19.433332 10.76 19.253332 10.76 19.039999 L 10.76 6.32 L 15.719999 11.559999 C 15.906666 11.773333 16.139999 11.846666 16.42 11.78 C 16.700001 11.713333 16.886665 11.54 16.98 11.259999 C 17.073332 10.98 17.013332 10.733333 16.799999 10.52 L 10.719999 4.119999 C 10.559999 3.959999 10.373333 3.853333 10.16 3.799999 C 10.053333 3.799999 9.946667 3.799999 9.84 3.799999 C 9.626666 3.853333 9.439999 3.959999 9.28 4.119999 Z " />
<Setter Target="NextDateButtonPathIcon.Data" Value="F1 M 16.799999 13.079999 C 16.933332 12.92 16.993332 12.74 16.98 12.539999 C 16.966665 12.34 16.886665 12.166667 16.74 12.02 C 16.593334 11.873333 16.42 11.799999 16.219999 11.799999 C 16.02 11.799999 15.853333 11.879999 15.719999 12.039999 L 10.76 17.279999 L 10.76 4.559999 C 10.76 4.346666 10.686666 4.166668 10.54 4.02 C 10.393332 3.873333 10.213333 3.799999 10 3.799999 C 9.786666 3.799999 9.606666 3.873333 9.46 4.02 C 9.313333 4.166668 9.24 4.346666 9.24 4.559999 L 9.24 17.279999 L 4.28 12.039999 C 4.146667 11.879999 3.98 11.799999 3.78 11.799999 C 3.58 11.799999 3.4 11.873333 3.24 12.02 C 3.08 12.166667 3 12.34 3 12.539999 C 3 12.74 3.066667 12.92 3.2 13.079999 L 9.28 19.48 C 9.439999 19.639999 9.626666 19.746666 9.84 19.799999 C 9.946667 19.799999 10.053333 19.799999 10.16 19.799999 C 10.373333 19.746666 10.559999 19.639999 10.719999 19.48 Z " />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="VerticalCalendar" />
</VisualStateGroup>
<VisualStateGroup x:Name="ShellStateContentGroup">
<VisualState x:Name="DefaultShellContentState" />
<VisualState x:Name="EventDetailsContentState">
<VisualState.Setters>
<Setter Target="ShellContentArea.Visibility" Value="Collapsed" />
<Setter Target="CalendarTypeSelector.Visibility" Value="Collapsed" />
<Setter Target="CalendarView.IsEnabled" Value="False" />
<Setter Target="RangeSummaryCard.IsEnabled" Value="False" />
<Setter Target="CalendarHostListView.IsEnabled" Value="False" />
</VisualState.Setters>
<VisualState.StateTriggers>
@@ -1,5 +1,6 @@
using System;
using System.Diagnostics;
using System.ComponentModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
@@ -11,6 +12,7 @@ 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.Mail.Views.Abstract;
using Wino.Messaging.Client.Calendar;
using Windows.System;
@@ -22,6 +24,7 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract,
{
private const string STATE_HorizontalCalendar = "HorizontalCalendar";
private const string STATE_VerticalCalendar = "VerticalCalendar";
private bool _isSynchronizingVisibleDateRangeCalendar;
public Frame GetShellFrame() => InnerShellFrame;
@@ -29,12 +32,14 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract,
{
InitializeComponent();
ViewModel.PropertyChanged += ViewModelPropertyChanged;
ManageCalendarDisplayType(ViewModel.StatePersistenceService.CalendarDisplayType);
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
UpdateNavigationPaneLayout(navigationView.DisplayMode);
SynchronizeVisibleDateRangeCalendar();
}
private void ManageCalendarDisplayType(Core.Domain.Enums.CalendarDisplayType displayType)
@@ -49,9 +54,11 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract,
}
}
private void PreviousDateClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new GoPreviousDateRequestedMessage());
private void PreviousDateClicked(object sender, RoutedEventArgs e)
=> ViewModel.PreviousDateRangeCommand.Execute(null);
private void NextDateClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new GoNextDateRequestedMessage());
private void NextDateClicked(object sender, RoutedEventArgs e)
=> ViewModel.NextDateRangeCommand.Execute(null);
private async void NewCalendarEventNavigationItemTapped(object sender, TappedRoutedEventArgs e)
{
@@ -59,6 +66,33 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract,
await InvokeNewCalendarEventAsync();
}
private void VisibleDateRangeCalendarViewSelectedDatesChanged(CalendarView sender, CalendarViewSelectedDatesChangedEventArgs args)
{
if (_isSynchronizingVisibleDateRangeCalendar)
return;
DateTimeOffset? interactedDate = null;
if (args.AddedDates.Count > 0)
{
interactedDate = args.AddedDates[0];
}
else if (args.RemovedDates.Count > 0)
{
interactedDate = args.RemovedDates[0];
}
if (interactedDate is null)
return;
var clickedArgs = new CalendarViewDayClickedEventArgs(interactedDate.Value.DateTime);
if (ViewModel.DateClickedCommand.CanExecute(clickedArgs))
{
ViewModel.DateClickedCommand.Execute(clickedArgs);
}
}
private async void NewCalendarEventNavigationItemKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key is not (VirtualKey.Enter or VirtualKey.Space))
@@ -102,6 +136,15 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract,
public void Receive(CalendarDisplayTypeChangedMessage message)
{
ManageCalendarDisplayType(message.NewDisplayType);
SynchronizeVisibleDateRangeCalendar();
}
private void ViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ViewModel.CurrentVisibleRange) or nameof(ViewModel.VisibleDateRangeText))
{
SynchronizeVisibleDateRangeCalendar();
}
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
@@ -226,4 +269,39 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract,
_ => key.ToString()
};
}
private void SynchronizeVisibleDateRangeCalendar()
{
if (!DispatcherQueue.HasThreadAccess)
{
var enqueued = DispatcherQueue.TryEnqueue(SynchronizeVisibleDateRangeCalendar);
if (!enqueued)
throw new InvalidOperationException("Could not marshal visible date range calendar synchronization onto the UI thread.");
return;
}
_isSynchronizingVisibleDateRangeCalendar = true;
try
{
VisibleDateRangeCalendarView.SelectedDates.Clear();
var currentRange = ViewModel.CurrentVisibleRange;
if (currentRange == null)
return;
foreach (var date in currentRange.Dates)
{
VisibleDateRangeCalendarView.SelectedDates.Add(new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue)));
}
VisibleDateRangeCalendarView.SetDisplayDate(new DateTimeOffset(currentRange.AnchorDate.ToDateTime(TimeOnly.MinValue)));
}
finally
{
_isSynchronizingVisibleDateRangeCalendar = false;
}
}
}
File diff suppressed because one or more lines are too long
@@ -1,210 +1,42 @@
using System;
using System;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Navigation;
using Wino.Calendar.Args;
using Wino.Calendar.Views.Abstract;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
using Wino.Messaging.Client.Calendar;
namespace Wino.Calendar.Views;
public sealed partial class CalendarPage : CalendarPageAbstract,
IRecipient<ScrollToDateMessage>,
IRecipient<ScrollToHourMessage>,
IRecipient<GoNextDateRequestedMessage>,
IRecipient<GoPreviousDateRequestedMessage>
public sealed partial class CalendarPage : CalendarPageAbstract
{
private const int PopupDialogOffset = 12;
public CalendarPage()
{
InitializeComponent();
ViewModel.DetailsShowCalendarItemChanged += CalendarItemDetailContextChanged;
}
protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
{
base.OnNavigatingFrom(e);
ViewModel.DetailsShowCalendarItemChanged -= CalendarItemDetailContextChanged;
QuickEventPopupDialog.IsOpen = false;
EventDetailsPopup.IsOpen = false;
EventDetailsPopup.PlacementTarget = null;
CalendarControl.ResetTimelineSelection();
if (CalendarControl is IDisposable disposableCalendarControl)
{
disposableCalendarControl.Dispose();
}
Bindings.StopTracking();
}
private void CalendarItemDetailContextChanged(object? sender, EventArgs e)
{
if (ViewModel.DisplayDetailsCalendarItemViewModel != null)
{
var control = CalendarControl.GetCalendarItemControl(ViewModel.DisplayDetailsCalendarItemViewModel);
if (control != null)
{
EventDetailsPopup.PlacementTarget = control;
}
}
}
protected override void RegisterRecipients()
{
base.RegisterRecipients();
WeakReferenceMessenger.Default.Register<ScrollToDateMessage>(this);
WeakReferenceMessenger.Default.Register<ScrollToHourMessage>(this);
WeakReferenceMessenger.Default.Register<GoNextDateRequestedMessage>(this);
WeakReferenceMessenger.Default.Register<GoPreviousDateRequestedMessage>(this);
}
protected override void UnregisterRecipients()
{
base.UnregisterRecipients();
WeakReferenceMessenger.Default.Unregister<ScrollToDateMessage>(this);
WeakReferenceMessenger.Default.Unregister<ScrollToHourMessage>(this);
WeakReferenceMessenger.Default.Unregister<GoNextDateRequestedMessage>(this);
WeakReferenceMessenger.Default.Unregister<GoPreviousDateRequestedMessage>(this);
}
public void Receive(ScrollToHourMessage message) => DispatcherQueue.TryEnqueue(() => CalendarControl.NavigateToHour(message.TimeSpan));
public void Receive(ScrollToDateMessage message) => DispatcherQueue.TryEnqueue(() => CalendarControl.NavigateToDay(message.Date));
public void Receive(GoNextDateRequestedMessage message) => DispatcherQueue.TryEnqueue(() => CalendarControl.GoNextRange());
public void Receive(GoPreviousDateRequestedMessage message) => DispatcherQueue.TryEnqueue(() => CalendarControl.GoPreviousRange());
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
if (e.NavigationMode == NavigationMode.Back)
if (e.NavigationMode == NavigationMode.Back && ViewModel.RestoreVisibleState())
{
if (ViewModel.RestoreVisibleState())
{
var restoreDate = ViewModel.GetRestoreDate();
DispatcherQueue.TryEnqueue(() => CalendarControl.NavigateToDay(restoreDate));
}
else
{
WeakReferenceMessenger.Default.Send(new LoadCalendarMessage(DateTime.Now.Date, CalendarInitInitiative.App));
}
return;
}
if (e.Parameter is CalendarPageNavigationArgs args)
var anchorDate = DateOnly.FromDateTime(DateTime.Now.Date);
if (e.Parameter is CalendarPageNavigationArgs args && !args.RequestDefaultNavigation)
{
if (args.RequestDefaultNavigation)
{
// Go today.
WeakReferenceMessenger.Default.Send(new LoadCalendarMessage(DateTime.Now.Date, CalendarInitInitiative.App));
}
else
{
// Go specified date.
WeakReferenceMessenger.Default.Send(new LoadCalendarMessage(args.NavigationDate, CalendarInitInitiative.User));
}
}
}
private void CellSelected(object sender, TimelineCellSelectedArgs e)
{
// Dismiss event details if exists and cancel the selection.
// This is to prevent the event details from being displayed when the user clicks somewhere else.
if (EventDetailsPopup.IsOpen)
{
CalendarControl.UnselectActiveTimelineCell();
ViewModel.DisplayDetailsCalendarItemViewModel = null;
return;
anchorDate = DateOnly.FromDateTime(args.NavigationDate.Date);
}
ViewModel.SelectedQuickEventDate = e.ClickedDate;
TeachingTipPositionerGrid.Width = e.CellSize.Width;
TeachingTipPositionerGrid.Height = e.CellSize.Height;
Canvas.SetLeft(TeachingTipPositionerGrid, e.PositionerPoint.X);
Canvas.SetTop(TeachingTipPositionerGrid, e.PositionerPoint.Y);
// Adjust the start and end time in the flyout.
var startTime = ViewModel.SelectedQuickEventDate.Value.TimeOfDay;
var endTime = startTime.Add(TimeSpan.FromMinutes(30));
ViewModel.SelectQuickEventTimeRange(startTime, endTime);
QuickEventPopupDialog.IsOpen = true;
}
private void CellUnselected(object sender, TimelineCellUnselectedArgs e)
{
QuickEventPopupDialog.IsOpen = false;
}
private void QuickEventAccountSelectorSelectionChanged(object sender, SelectionChangedEventArgs e)
{
QuickEventAccountSelectorFlyout.Hide();
}
private void QuickEventPopupClosed(object sender, object e)
{
// Reset the timeline selection when the tip is closed.
CalendarControl.ResetTimelineSelection();
}
private void PopupPlacementChanged(object sender, object e)
{
if (sender is Popup senderPopup)
{
// When the quick event Popup is positioned for different calendar types,
// we must adjust the offset to make sure the tip is not hidden and has nice
// spacing from the cell.
switch (senderPopup.ActualPlacement)
{
case PopupPlacementMode.Top:
senderPopup.VerticalOffset = PopupDialogOffset * -1;
break;
case PopupPlacementMode.Bottom:
senderPopup.VerticalOffset = PopupDialogOffset;
break;
case PopupPlacementMode.Left:
senderPopup.HorizontalOffset = PopupDialogOffset * -1;
break;
case PopupPlacementMode.Right:
senderPopup.HorizontalOffset = PopupDialogOffset;
break;
default:
break;
}
}
}
private void StartTimeDurationSubmitted(ComboBox sender, ComboBoxTextSubmittedEventArgs args)
=> ViewModel.SelectedStartTimeString = args.Text;
private void EndTimeDurationSubmitted(ComboBox sender, ComboBoxTextSubmittedEventArgs args)
=> ViewModel.SelectedEndTimeString = args.Text;
private void EventDetailsPopupClosed(object sender, object e)
{
ViewModel.DisplayDetailsCalendarItemViewModel = null;
}
private void CalendarScrolling(object sender, EventArgs e)
{
// In case of scrolling, we must dismiss the event details dialog.
ViewModel.DisplayDetailsCalendarItemViewModel = null;
var request = new CalendarDisplayRequest(ViewModel.StatePersistanceService.CalendarDisplayType, anchorDate);
WeakReferenceMessenger.Default.Send(new LoadCalendarMessage(request));
}
}
@@ -1,6 +1,6 @@
using CommunityToolkit.WinUI.Controls;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using CommunityToolkit.WinUI.Controls;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Settings;
using Wino.Core.ViewModels.Data;
@@ -51,11 +51,6 @@ public sealed partial class SettingOptionsPage : SettingOptionsPageAbstract
ViewModel.NavigateToAddAccount();
}
private void WinoAccountManagementClicked(object sender, RoutedEventArgs e)
{
ViewModel.NavigateToWinoAccountManagementCommand.Execute(null);
}
private void SettingsSearchTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput || string.IsNullOrWhiteSpace(sender.Text))
+7 -3
View File
@@ -3,7 +3,6 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Views.Abstract"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -93,8 +92,13 @@
Grid.Column="1"
VerticalAlignment="Center"
Spacing="8">
<controls:MarkdownTextBlock Text="{x:Bind Title, Mode=OneTime}" />
<controls:MarkdownTextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind Description, Mode=OneTime}" />
<TextBlock
Text="{x:Bind Title, Mode=OneTime}"
TextWrapping="WrapWholeWords" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind Description, Mode=OneTime}"
TextWrapping="WrapWholeWords" />
</StackPanel>
</Grid>
</DataTemplate>
+20 -5
View File
@@ -568,11 +568,26 @@
Text="{x:Bind domain:Translator.CalendarEventCompose_NewEventButton, Mode=OneTime}" />
</coreControls:WinoNavigationViewItem>
<calendarControls:WinoCalendarView
x:Name="CalendarView"
<Border
x:Name="RangeSummaryCard"
Grid.Row="1"
HorizontalAlignment="Center"
TodayBackgroundColor="{ThemeResource SystemAccentColor}" />
Margin="16,0,16,12"
Padding="12"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8">
<StackPanel Spacing="12">
<TextBlock
FontWeight="SemiBold"
Text="{x:Bind ViewModel.CalendarClient.VisibleDateRangeText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<muxc:CalendarView
x:Name="VisibleDateRangeCalendarView"
HorizontalAlignment="Stretch"
SelectionMode="Multiple"
SelectedDatesChanged="VisibleDateRangeCalendarViewSelectedDatesChanged" />
</StackPanel>
</Border>
<ListView
x:Name="CalendarHostListView"
@@ -764,7 +779,7 @@
<VisualState.Setters>
<Setter Target="ShellContentArea.Visibility" Value="Collapsed" />
<Setter Target="CalendarTypeSelector.Visibility" Value="Collapsed" />
<Setter Target="CalendarView.IsEnabled" Value="False" />
<Setter Target="RangeSummaryCard.IsEnabled" Value="False" />
<Setter Target="CalendarHostListView.IsEnabled" Value="False" />
</VisualState.Setters>
</VisualState>
+74 -13
View File
@@ -12,19 +12,18 @@ using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Navigation;
using Windows.Foundation;
using Windows.System;
using Wino.Calendar.Controls;
using Wino.Calendar.Views;
using Wino.Calendar.ViewModels;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
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.Folders;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Navigation;
using Wino.Mail.ViewModels;
using Wino.Mail.ViewModels.Data;
using Wino.Mail.WinUI.Controls;
using Wino.MenuFlyouts;
@@ -34,10 +33,7 @@ using Wino.Messaging.Client.Calendar;
using Wino.Messaging.Client.Mails;
using Wino.Messaging.Client.Shell;
using Wino.Messaging.UI;
using Wino.Views.Mail;
using Wino.Views;
using Wino.Views.Settings;
using Windows.System;
namespace Wino.Mail.WinUI.Views;
@@ -55,6 +51,7 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
private const string StateEventDetailsContent = "EventDetailsContentState";
private WinoApplicationMode? _activeMode;
private bool _isSyncingNavigationViewSelection;
private bool _isSynchronizingVisibleDateRangeCalendar;
public WinoAppShell()
{
@@ -203,7 +200,7 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
if (ViewModel.IsCalendarMode)
{
ViewModel.StatePersistenceService.CoreWindowTitle = string.Empty;
ViewModel.StatePersistenceService.CoreWindowTitle = ViewModel.CalendarClient.VisibleDateRangeText ?? string.Empty;
return;
}
@@ -227,7 +224,6 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
private void InitializeCalendarControls()
{
CalendarTypeSelector.TodayClickedCommand = ViewModel.CalendarClient.TodayClickedCommand;
CalendarView.DateClickedCommand = ViewModel.CalendarClient.DateClickedCommand;
DayHeaderNavigationItemsFlipView.ItemsSource = ViewModel.CalendarClient.DateNavigationHeaderItems;
CalendarHostListView.ItemsSource = ViewModel.CalendarClient.GroupedAccountCalendars;
@@ -239,8 +235,8 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
DayHeaderNavigationItemsFlipView.ItemsSource = ViewModel.CalendarClient.DateNavigationHeaderItems;
DayHeaderNavigationItemsFlipView.SelectedIndex = ViewModel.CalendarClient.SelectedDateNavigationHeaderIndex;
CalendarTypeSelector.DisplayDayCount = ViewModel.CalendarClient.StatePersistenceService.DayDisplayCount;
CalendarView.HighlightedDateRange = ViewModel.CalendarClient.HighlightedDateRange;
CalendarHostListView.ItemsSource = ViewModel.CalendarClient.GroupedAccountCalendars;
SynchronizeVisibleDateRangeCalendar();
}
private void RefreshNavigationViewBindings(bool syncMailSelection = true)
@@ -283,9 +279,11 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
await InvokeNewCalendarEventAsync();
}
private void PreviousDateClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new GoPreviousDateRequestedMessage());
private void PreviousDateClicked(object sender, RoutedEventArgs e)
=> ViewModel.CalendarClient.PreviousDateRangeCommand.Execute(null);
private void NextDateClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new GoNextDateRequestedMessage());
private void NextDateClicked(object sender, RoutedEventArgs e)
=> ViewModel.CalendarClient.NextDateRangeCommand.Execute(null);
private Task InvokeNewCalendarEventAsync()
=> ViewModel.CalendarClient.HandleNavigationItemInvokedAsync(new NewCalendarEventMenuItem());
@@ -512,13 +510,76 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
return;
}
if (e.PropertyName == nameof(ICalendarShellClient.HighlightedDateRange))
if (e.PropertyName == nameof(ICalendarShellClient.CurrentVisibleRange) ||
e.PropertyName == nameof(ICalendarShellClient.VisibleDateRangeText))
{
CalendarView.HighlightedDateRange = ViewModel.CalendarClient.HighlightedDateRange;
SynchronizeVisibleDateRangeCalendar();
UpdateTitleBarSubtitle();
}
}
private void VisibleDateRangeCalendarViewSelectedDatesChanged(CalendarView sender, CalendarViewSelectedDatesChangedEventArgs args)
{
if (_isSynchronizingVisibleDateRangeCalendar)
return;
DateTimeOffset? interactedDate = null;
if (args.AddedDates.Count > 0)
{
interactedDate = args.AddedDates[0];
}
else if (args.RemovedDates.Count > 0)
{
interactedDate = args.RemovedDates[0];
}
if (interactedDate is null)
return;
var clickedArgs = new CalendarViewDayClickedEventArgs(interactedDate.Value.DateTime);
if (ViewModel.CalendarClient.DateClickedCommand.CanExecute(clickedArgs))
{
ViewModel.CalendarClient.DateClickedCommand.Execute(clickedArgs);
}
}
private void SynchronizeVisibleDateRangeCalendar()
{
if (!DispatcherQueue.HasThreadAccess)
{
var enqueued = DispatcherQueue.TryEnqueue(SynchronizeVisibleDateRangeCalendar);
if (!enqueued)
throw new InvalidOperationException("Could not marshal visible date range calendar synchronization onto the UI thread.");
return;
}
_isSynchronizingVisibleDateRangeCalendar = true;
try
{
VisibleDateRangeCalendarView.SelectedDates.Clear();
var currentRange = ViewModel.CalendarClient.CurrentVisibleRange;
if (currentRange == null)
return;
foreach (var date in currentRange.Dates)
{
VisibleDateRangeCalendarView.SelectedDates.Add(new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue)));
}
VisibleDateRangeCalendarView.SetDisplayDate(new DateTimeOffset(currentRange.AnchorDate.ToDateTime(TimeOnly.MinValue)));
}
finally
{
_isSynchronizingVisibleDateRangeCalendar = false;
}
}
private void ViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(ViewModel.SelectedMenuItem) || !ViewModel.CurrentClient.HandlesNavigationSelection)
-14
View File
@@ -119,10 +119,7 @@
<None Remove="Styles\CalendarShellNavigationViewStyle.xaml" />
<None Remove="Styles\CalendarThemeResources.xaml" />
<None Remove="Styles\DayHeaderControl.xaml" />
<None Remove="Styles\WinoCalendarResources.xaml" />
<None Remove="Styles\WinoCalendarTypeSelectorControl.xaml" />
<None Remove="Styles\WinoCalendarView.xaml" />
<None Remove="Styles\WinoDayTimelineCanvas.xaml" />
<None Remove="Views\Calendar\CalendarAppShell.xaml" />
<None Remove="Views\Calendar\CalendarPage.xaml" />
<None Remove="Views\Calendar\CalendarSettingsPage.xaml" />
@@ -205,10 +202,8 @@
<PackageReference Include="CommunityToolkit.WinUI.Controls.TabbedCommandBar" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.TokenizingTextBox" />
<PackageReference Include="CommunityToolkit.WinUI.Extensions" />
<PackageReference Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" />
<PackageReference Include="Microsoft.Graphics.Win2D" />
<PackageReference Include="SkiaSharp.Views.WinUI" />
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="Microsoft.WindowsAppSDK" />
@@ -362,18 +357,9 @@
<Page Update="Styles\DayHeaderControl.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="Styles\WinoCalendarResources.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="Styles\WinoCalendarTypeSelectorControl.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="Styles\WinoCalendarView.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="Styles\WinoDayTimelineCanvas.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="Views\Calendar\CalendarAppShell.xaml">
@@ -1,6 +0,0 @@
namespace Wino.Messaging.Client.Calendar;
/// <summary>
/// Raised when user clicks '>' button from the shell.
/// </summary>
public record GoNextDateRequestedMessage;
@@ -1,6 +0,0 @@
namespace Wino.Messaging.Client.Calendar;
/// <summary>
/// Raised when user clicks '<' button from the shell.
/// </summary>
public record GoPreviousDateRequestedMessage;
@@ -1,13 +1,10 @@
using System;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Messaging.Client.Calendar;
/// <summary>
/// Raised when a new calendar range is requested for drawing.
/// Raised when a new calendar display range is requested.
/// </summary>
/// <param name="DisplayType">Type of the calendar.</param>
/// <param name="DisplayDate">Exact date to highlight.</param>
/// <param name="DayDisplayCount">How many days to load with Day calendar display type.</param>
/// <param name="ForceRedraw">Remove all days and force re-render of everything. Used when settings are updated.</param>
public record LoadCalendarMessage(DateTime DisplayDate, CalendarInitInitiative CalendarInitInitiative, bool ForceRedraw = false);
/// <param name="DisplayRequest">Display type and anchor date to resolve.</param>
/// <param name="ForceReload">Force a reload even if the resolved range did not change.</param>
public record LoadCalendarMessage(CalendarDisplayRequest DisplayRequest, bool ForceReload = false);
@@ -1,9 +0,0 @@
using System;
namespace Wino.Messaging.Client.Calendar;
/// <summary>
/// Raised when requested date is already loaded into calendar flip view to scroll to it.
/// </summary>
/// <param name="Date">Date to scroll.</param>
public record ScrollToDateMessage(DateTime Date);
@@ -1,9 +0,0 @@
using System;
namespace Wino.Messaging.Client.Calendar;
/// <summary>
/// Emitted when vertical scroll position is requested to be changed.
/// </summary>
/// <param name="TimeSpan">Hour to scroll vertically on flip view item.</param>
public record ScrollToHourMessage(TimeSpan TimeSpan);
@@ -1,10 +0,0 @@
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Messaging.Client.Calendar;
/// <summary>
/// Raised when calendar's visible date range is updated.
/// Used to update the background of the visible date range in CalendarView.
/// </summary>
/// <param name="DateRange">New visible date range.</param>
public record VisibleDateRangeChangedMessage(DateRange DateRange);