|
|
@@ -0,0 +1,444 @@
|
|
|
+using Avalonia;
|
|
|
+using Avalonia.Controls;
|
|
|
+using Avalonia.Controls.Shapes;
|
|
|
+using Avalonia.Controls.Templates;
|
|
|
+using Avalonia.Data;
|
|
|
+using Avalonia.Media;
|
|
|
+using Avalonia.Metadata;
|
|
|
+using DynamicData.Binding;
|
|
|
+using InABox.Core;
|
|
|
+using System.Collections;
|
|
|
+using System.Collections.Specialized;
|
|
|
+using System.Reactive.Linq;
|
|
|
+using System.Runtime.Serialization;
|
|
|
+
|
|
|
+namespace InABox.Avalonia.Components;
|
|
|
+
|
|
|
+public partial class CalendarView : UserControl
|
|
|
+{
|
|
|
+ public static readonly StyledProperty<double> RowHeightProperty =
|
|
|
+ AvaloniaProperty.Register<CalendarView, double>(nameof(RowHeight), defaultValue: 100);
|
|
|
+
|
|
|
+ public static readonly StyledProperty<double> MinimumColumnWidthProperty =
|
|
|
+ AvaloniaProperty.Register<CalendarView, double>(nameof(MinimumColumnWidth), defaultValue: 50);
|
|
|
+
|
|
|
+ public static readonly StyledProperty<TimeSpan> RowIntervalProperty =
|
|
|
+ AvaloniaProperty.Register<CalendarView, TimeSpan>(nameof(RowInterval), defaultValue: TimeSpan.FromHours(1));
|
|
|
+
|
|
|
+ public static readonly StyledProperty<IBinding?> ColumnMappingProperty =
|
|
|
+ AvaloniaProperty.Register<CalendarView, IBinding?>(nameof(ColumnMapping));
|
|
|
+
|
|
|
+ public static readonly StyledProperty<IBinding?> StartTimeMappingProperty =
|
|
|
+ AvaloniaProperty.Register<CalendarView, IBinding?>(nameof(StartTimeMapping));
|
|
|
+
|
|
|
+ public static readonly StyledProperty<IBinding?> EndTimeMappingProperty =
|
|
|
+ AvaloniaProperty.Register<CalendarView, IBinding?>(nameof(EndTimeMapping));
|
|
|
+
|
|
|
+ public static readonly StyledProperty<IEnumerable?> ItemsSourceProperty =
|
|
|
+ AvaloniaProperty.Register<CalendarView, IEnumerable?>(nameof(ItemsSource));
|
|
|
+
|
|
|
+ public static readonly StyledProperty<IDataTemplate?> ItemTemplateProperty =
|
|
|
+ AvaloniaProperty.Register<CalendarView, IDataTemplate?>(nameof(ItemTemplate));
|
|
|
+
|
|
|
+ public static readonly StyledProperty<IDataTemplate?> HeaderTemplateProperty =
|
|
|
+ AvaloniaProperty.Register<CalendarView, IDataTemplate?>(nameof(HeaderTemplate));
|
|
|
+
|
|
|
+ public double MinimumColumnWidth
|
|
|
+ {
|
|
|
+ get => GetValue(MinimumColumnWidthProperty);
|
|
|
+ set => SetValue(MinimumColumnWidthProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ public double RowHeight
|
|
|
+ {
|
|
|
+ get => GetValue(RowHeightProperty);
|
|
|
+ set => SetValue(RowHeightProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ public TimeSpan RowInterval
|
|
|
+ {
|
|
|
+ get => GetValue(RowIntervalProperty);
|
|
|
+ set => SetValue(RowIntervalProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ [AssignBinding]
|
|
|
+ [InheritDataTypeFromItems(nameof(ItemsSource))]
|
|
|
+ public IBinding? ColumnMapping
|
|
|
+ {
|
|
|
+ get => GetValue(ColumnMappingProperty);
|
|
|
+ set => SetValue(ColumnMappingProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ [AssignBinding]
|
|
|
+ [InheritDataTypeFromItems(nameof(ItemsSource))]
|
|
|
+ public IBinding? StartTimeMapping
|
|
|
+ {
|
|
|
+ get => GetValue(StartTimeMappingProperty);
|
|
|
+ set => SetValue(StartTimeMappingProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ [AssignBinding]
|
|
|
+ [InheritDataTypeFromItems(nameof(ItemsSource))]
|
|
|
+ public IBinding? EndTimeMapping
|
|
|
+ {
|
|
|
+ get => GetValue(EndTimeMappingProperty);
|
|
|
+ set => SetValue(EndTimeMappingProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ public IEnumerable? ItemsSource
|
|
|
+ {
|
|
|
+ get => GetValue(ItemsSourceProperty);
|
|
|
+ set => SetValue(ItemsSourceProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ [InheritDataTypeFromItems(nameof(ItemsSource))]
|
|
|
+ public IDataTemplate? ItemTemplate
|
|
|
+ {
|
|
|
+ get => GetValue(ItemTemplateProperty);
|
|
|
+ set => SetValue(ItemTemplateProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ public IDataTemplate? HeaderTemplate
|
|
|
+ {
|
|
|
+ get => GetValue(HeaderTemplateProperty);
|
|
|
+ set => SetValue(HeaderTemplateProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ static CalendarView()
|
|
|
+ {
|
|
|
+ ItemsSourceProperty.Changed.AddClassHandler<CalendarView>(ItemsSource_Changed);
|
|
|
+ RowHeightProperty.Changed.AddClassHandler<CalendarView>(Render_Changed);
|
|
|
+ MinimumColumnWidthProperty.Changed.AddClassHandler<CalendarView>(Render_Changed);
|
|
|
+ RowIntervalProperty.Changed.AddClassHandler<CalendarView>(Render_Changed);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void Render_Changed(CalendarView view, AvaloniaPropertyChangedEventArgs args)
|
|
|
+ {
|
|
|
+ view.Render();
|
|
|
+ }
|
|
|
+
|
|
|
+ public CalendarView()
|
|
|
+ {
|
|
|
+ InitializeComponent();
|
|
|
+
|
|
|
+ ScrollViewer.GetPropertyChangedObservable(ScrollViewer.OffsetProperty).Subscribe(x =>
|
|
|
+ {
|
|
|
+ LabelScroll.Offset = new(0, ScrollViewer.Offset.Y);
|
|
|
+ HeaderScroll.Offset = new(ScrollViewer.Offset.X, 0);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void ItemsSource_Changed(CalendarView view, AvaloniaPropertyChangedEventArgs args)
|
|
|
+ {
|
|
|
+ if(args.OldValue is INotifyCollectionChanged oldNotify)
|
|
|
+ {
|
|
|
+ oldNotify.CollectionChanged -= view.Collection_Changed;
|
|
|
+ }
|
|
|
+ view.Render(itemsChanged: true);
|
|
|
+ if(view.ItemsSource is INotifyCollectionChanged notify)
|
|
|
+ {
|
|
|
+ notify.CollectionChanged += view.Collection_Changed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void Collection_Changed(object? sender, NotifyCollectionChangedEventArgs e)
|
|
|
+ {
|
|
|
+ Render(itemsChanged: true);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void Scroll_SizeChanged(object? sender, SizeChangedEventArgs e)
|
|
|
+ {
|
|
|
+ Render();
|
|
|
+ }
|
|
|
+
|
|
|
+ private class Block : ContentControl
|
|
|
+ {
|
|
|
+ public static readonly StyledProperty<object?> ColumnProperty =
|
|
|
+ AvaloniaProperty.Register<Block, object?>(nameof(Column));
|
|
|
+ public static readonly StyledProperty<TimeSpan> StartTimeProperty =
|
|
|
+ AvaloniaProperty.Register<Block, TimeSpan>(nameof(StartTime));
|
|
|
+ public static readonly StyledProperty<TimeSpan> EndTimeProperty =
|
|
|
+ AvaloniaProperty.Register<Block, TimeSpan>(nameof(EndTime));
|
|
|
+
|
|
|
+ public int NColumns { get; set; } = -1;
|
|
|
+
|
|
|
+ public object? Column
|
|
|
+ {
|
|
|
+ get => GetValue(ColumnProperty);
|
|
|
+ set => SetValue(ColumnProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ public TimeSpan StartTime
|
|
|
+ {
|
|
|
+ get => GetValue(StartTimeProperty);
|
|
|
+ set => SetValue(StartTimeProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ public TimeSpan EndTime
|
|
|
+ {
|
|
|
+ get => GetValue(EndTimeProperty);
|
|
|
+ set => SetValue(EndTimeProperty, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ public override string ToString()
|
|
|
+ {
|
|
|
+ return $"Block({Column:dd/MM/yyyy}: {StartTime:hh\\:mm} - {EndTime:hh\\:mm})";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private class Column
|
|
|
+ {
|
|
|
+ public List<Block> Blocks { get; set; } = new();
|
|
|
+
|
|
|
+ public List<List<Block>>? Columns { get; set; } = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Dictionary<object, Column> _blocks = new();
|
|
|
+
|
|
|
+ private List<IDisposable> _oldSubscriptions = new();
|
|
|
+
|
|
|
+ private void RecreateBlocksList()
|
|
|
+ {
|
|
|
+ if (ItemsSource is null) return;
|
|
|
+
|
|
|
+ var dateBinding = ColumnMapping;
|
|
|
+ var startBinding = StartTimeMapping;
|
|
|
+ var endBinding = EndTimeMapping;
|
|
|
+ if(dateBinding is null || startBinding is null || endBinding is null)
|
|
|
+ {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach(var subscription in _oldSubscriptions)
|
|
|
+ {
|
|
|
+ subscription.Dispose();
|
|
|
+ }
|
|
|
+ _oldSubscriptions.Clear();
|
|
|
+ _blocks.Clear();
|
|
|
+
|
|
|
+ foreach(var item in ItemsSource)
|
|
|
+ {
|
|
|
+ var block = new Block
|
|
|
+ {
|
|
|
+ [!Block.ColumnProperty] = dateBinding,
|
|
|
+ [!Block.StartTimeProperty] = startBinding,
|
|
|
+ [!Block.EndTimeProperty] = endBinding,
|
|
|
+ [Block.DataContextProperty] = item,
|
|
|
+ [!Block.ContentTemplateProperty] = this[!ItemTemplateProperty],
|
|
|
+ Content = item
|
|
|
+ };
|
|
|
+ _oldSubscriptions.Add(block.GetObservable(Block.ColumnProperty).Skip(1).Subscribe(x => Render(itemsChanged: true)));
|
|
|
+ _oldSubscriptions.Add(block.GetObservable(Block.StartTimeProperty).Skip(1).Subscribe(x => UpdateBlock(block)));
|
|
|
+ _oldSubscriptions.Add(block.GetObservable(Block.EndTimeProperty).Skip(1).Subscribe(x => UpdateBlock(block)));
|
|
|
+
|
|
|
+ var column = block.Column;
|
|
|
+ if(column is null)
|
|
|
+ {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ _blocks.GetValueOrAdd(column).Blocks.Add(block);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void Render(bool itemsChanged = false, bool recalculatePositions = false)
|
|
|
+ {
|
|
|
+ if (itemsChanged)
|
|
|
+ {
|
|
|
+ RecreateBlocksList();
|
|
|
+ }
|
|
|
+ if (recalculatePositions)
|
|
|
+ {
|
|
|
+ foreach (var (column, columnBlocks) in _blocks)
|
|
|
+ {
|
|
|
+ columnBlocks.Columns = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var nRows = (24 / RowInterval.TotalHours);
|
|
|
+ var rowHeight = Math.Max(RowHeight, ScrollViewer.Bounds.Height / nRows);
|
|
|
+
|
|
|
+ Canvas.Children.Clear();
|
|
|
+ Canvas.Height = rowHeight * nRows;
|
|
|
+
|
|
|
+ var minColWidth = 50;
|
|
|
+ var colSpace = 1;
|
|
|
+
|
|
|
+ var nColumns = 0;
|
|
|
+ foreach (var (column, columnBlocks) in _blocks)
|
|
|
+ {
|
|
|
+ columnBlocks.Columns ??= RecalculateBlockPositionsForDay(columnBlocks.Blocks);
|
|
|
+ // columnsPerDay = Math.Max(columnsPerDay, columnBlocks.Columns.Count);
|
|
|
+ nColumns += columnBlocks.Columns.Count;
|
|
|
+ }
|
|
|
+ // nColumns = columnsPerDay * _blocks.Count
|
|
|
+
|
|
|
+ var colWidth = (Math.Max((ScrollViewer.Bounds.Width - colSpace * (_blocks.Count - 1)) / nColumns, minColWidth));
|
|
|
+
|
|
|
+ HeaderCanvas.Children.Clear();
|
|
|
+
|
|
|
+ var colX = 0.0;
|
|
|
+ var i = 0;
|
|
|
+ foreach(var (columnKey, columnBlocks) in _blocks)
|
|
|
+ {
|
|
|
+ var contentControl = new ContentControl
|
|
|
+ {
|
|
|
+ Content = columnKey,
|
|
|
+ Width = colWidth * columnBlocks.Columns!.Count,
|
|
|
+ [!Block.ContentTemplateProperty] = this[!HeaderTemplateProperty],
|
|
|
+ };
|
|
|
+ Canvas.SetLeft(contentControl, colX);
|
|
|
+ HeaderCanvas.Children.Add(contentControl);
|
|
|
+ contentControl.SizeChanged += ContentControl_SizeChanged;
|
|
|
+
|
|
|
+ foreach(var column in columnBlocks.Columns!)
|
|
|
+ {
|
|
|
+ foreach(var block in column)
|
|
|
+ {
|
|
|
+ Canvas.SetTop(block, GetRow(block.StartTime) * rowHeight);
|
|
|
+ Canvas.SetLeft(block, colX);
|
|
|
+ block.Height = Math.Max((GetRow(block.EndTime) - GetRow(block.StartTime)) * rowHeight, 5);
|
|
|
+ block.Width = colWidth * block.NColumns;
|
|
|
+ Canvas.Children.Add(block);
|
|
|
+ }
|
|
|
+
|
|
|
+ colX += colWidth;
|
|
|
+ }
|
|
|
+
|
|
|
+ if(i < _blocks.Count - 1)
|
|
|
+ {
|
|
|
+ var rectangle = new Rectangle
|
|
|
+ {
|
|
|
+ Width = 0.75,
|
|
|
+ Height = Canvas.Height,
|
|
|
+ Fill = new SolidColorBrush(Colors.LightGray)
|
|
|
+ };
|
|
|
+ Canvas.SetLeft(rectangle, colX);
|
|
|
+ Canvas.Children.Add(rectangle);
|
|
|
+
|
|
|
+ var headRectangle = new Rectangle
|
|
|
+ {
|
|
|
+ Width = 0.75,
|
|
|
+ [!Rectangle.HeightProperty] = HeaderCanvas.WhenValueChanged(x => x.Bounds)
|
|
|
+ .Select(x => x.Height)
|
|
|
+ .ToBinding(),
|
|
|
+ Fill = new SolidColorBrush(Colors.LightGray)
|
|
|
+ };
|
|
|
+ Canvas.SetLeft(headRectangle, colX);
|
|
|
+ HeaderCanvas.Children.Add(headRectangle);
|
|
|
+
|
|
|
+ colX += colSpace;
|
|
|
+ }
|
|
|
+ ++i;
|
|
|
+ }
|
|
|
+
|
|
|
+ Canvas.Width = colX;
|
|
|
+ HeaderCanvas.Width = colX;
|
|
|
+
|
|
|
+ var lines = new List<Control>();
|
|
|
+
|
|
|
+ LabelCanvas.Children.Clear();
|
|
|
+ LabelCanvas.Height = Canvas.Height;
|
|
|
+
|
|
|
+ var y = rowHeight;
|
|
|
+ for(var time = RowInterval; time < TimeSpan.FromHours(24); time += RowInterval)
|
|
|
+ {
|
|
|
+ var rectangle = new Rectangle
|
|
|
+ {
|
|
|
+ Width = Canvas.Width,
|
|
|
+ Height = 0.75,
|
|
|
+ Fill = new SolidColorBrush(Colors.LightGray)
|
|
|
+ };
|
|
|
+ Canvas.SetLeft(rectangle, 0);
|
|
|
+ Canvas.SetTop(rectangle, y);
|
|
|
+ lines.Add(rectangle);
|
|
|
+
|
|
|
+ var block = new TextBlock
|
|
|
+ {
|
|
|
+ Text = time.ToString("hh\\:mm"),
|
|
|
+ Margin = new(0, -5, 0, 0)
|
|
|
+ }.WithClass("ExtraSmall");
|
|
|
+ block.SizeChanged += Block_SizeChanged;
|
|
|
+ Canvas.SetTop(block, y);
|
|
|
+ LabelCanvas.Children.Add(block);
|
|
|
+
|
|
|
+ y += rowHeight;
|
|
|
+ }
|
|
|
+
|
|
|
+ Canvas.Children.InsertRange(0, lines);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ContentControl_SizeChanged(object? sender, SizeChangedEventArgs e)
|
|
|
+ {
|
|
|
+ HeaderCanvas.Height = HeaderCanvas.Children.Select(x => x.Bounds.Height).Max();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void Block_SizeChanged(object? sender, SizeChangedEventArgs e)
|
|
|
+ {
|
|
|
+ LabelCanvas.Width = LabelCanvas.Children.Select(x => x.Bounds.Width).Max();
|
|
|
+ }
|
|
|
+
|
|
|
+ private double GetRow(TimeSpan time)
|
|
|
+ {
|
|
|
+ return time.TotalHours / RowInterval.TotalHours;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static List<List<Block>> RecalculateBlockPositionsForDay(List<Block> dayBlocks)
|
|
|
+ {
|
|
|
+ dayBlocks.SortBy(x => x.StartTime);
|
|
|
+ var columns = new List<List<Block>>();
|
|
|
+
|
|
|
+ var remainingBlocks = dayBlocks;
|
|
|
+ while(remainingBlocks.Count > 0)
|
|
|
+ {
|
|
|
+ // At least one block will be moved, so we can use 1 less than the remaining as capacity.
|
|
|
+ var tempRemainingBlocks = new List<Block>(remainingBlocks.Count - 1);
|
|
|
+ var newBlocks = new List<Block>(remainingBlocks.Count);
|
|
|
+ var curTime = TimeSpan.MinValue;
|
|
|
+ Block? curBlock = null;
|
|
|
+ foreach(var block in remainingBlocks)
|
|
|
+ {
|
|
|
+ if(curBlock is not null && block.StartTime < curTime)
|
|
|
+ {
|
|
|
+ tempRemainingBlocks.Add(block);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ newBlocks.Add(block);
|
|
|
+ curTime = block.EndTime;
|
|
|
+ curBlock = block;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ columns.Add(newBlocks);
|
|
|
+ remainingBlocks = tempRemainingBlocks;
|
|
|
+ }
|
|
|
+ for(int i = 0; i < columns.Count; ++i)
|
|
|
+ {
|
|
|
+ foreach(var block in columns[i])
|
|
|
+ {
|
|
|
+ var nColumns = -1;
|
|
|
+ for(int j = i + 1; j < columns.Count; ++j)
|
|
|
+ {
|
|
|
+ foreach(var block2 in columns[j])
|
|
|
+ {
|
|
|
+ if(block.StartTime < block2.EndTime && block.EndTime > block2.StartTime)
|
|
|
+ {
|
|
|
+ nColumns = j - i;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if(nColumns > -1)
|
|
|
+ {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ block.NColumns = nColumns > -1 ? nColumns : columns.Count - i;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return columns;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void UpdateBlock(Block block)
|
|
|
+ {
|
|
|
+ Render(recalculatePositions: true);
|
|
|
+ }
|
|
|
+}
|