Browse Source

avalonia: datagrid

Kenric Nugteren 2 tháng trước cách đây
mục cha
commit
48ff825539

+ 85 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/AvaloniaDataGrid.axaml

@@ -0,0 +1,85 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="InABox.Avalonia.Components.AvaloniaDataGrid"
+			 xmlns:components="using:InABox.Avalonia.Components"
+			 xmlns:converters="using:InABox.Avalonia.Converters"
+			 x:DataType="components:AvaloniaDataGrid">
+    <UserControl.Resources>
+        <converters:DateTimeToAgeConverter x:Key="DateTimeToAgeConverter" EmptyValue="Never Updated" Prefix="Updated" />
+    </UserControl.Resources>
+	<Grid>
+		<Grid.RowDefinitions>
+			<RowDefinition Height="Auto"/>
+			<RowDefinition Height="*"/>
+			<RowDefinition Height="Auto"/>
+		</Grid.RowDefinitions>
+		<Grid.ColumnDefinitions>
+			<ColumnDefinition Width="*"/>
+			<ColumnDefinition Width="Auto"/>
+		</Grid.ColumnDefinitions>
+		<components:SearchBar Name="SearchBar" Grid.Row="0" Grid.ColumnSpan="2"
+							  PlaceholderText="Search"
+							  Command="{Binding $parent[components:AvaloniaDataGrid].SearchCommand}"
+							  Text="{Binding $parent[components:AvaloniaDataGrid].SearchText}"
+							  Background="Transparent"
+							  Margin="0,0,0,5"/>
+		<DataGrid Name="Grid" Grid.Row="1" Grid.ColumnSpan="2"
+				  ItemsSource="{Binding $parent[components:AvaloniaDataGrid].ItemsSource}"
+				  RowBackground="White"
+				  Foreground="Black"
+				  AutoGenerateColumns="False"
+				  SelectionMode="{Binding $parent[components:AvaloniaDataGrid].SelectionMode}"
+				  Tapped="DataGrid_Tapped"
+				  RowHeight="{Binding $parent[components:AvaloniaDataGrid].RowHeight}"
+				  GridLinesVisibility="Vertical"
+				  FontSize="{StaticResource PrsFontSizeSmall}"
+				  CornerRadius="{StaticResource PrsCornerRadius}"
+				  BorderThickness="{StaticResource PrsBorderThickness}"
+				  BorderBrush="{StaticResource PrsTileBorder}">
+			<DataGrid.Styles>
+				<Style Selector="DataGridRow:nth-child(even)">
+					<Setter Property="Background" Value="WhiteSmoke"/>
+				</Style>
+				<Style Selector="DataGridColumnHeader:nth-child(1)">
+					<Setter Property="CornerRadius" Value="4,0,0,0"/>
+				</Style>
+				<Style Selector="DataGridColumnHeader:nth-last-child(1)">
+					<Setter Property="CornerRadius" Value="0,4,0,0"/>
+				</Style>
+				<Style Selector="DataGridColumnHeader">
+					<Setter Property="FontSize" Value="{StaticResource PrsFontSizeExtraSmall}"/>
+				</Style>
+				<Style Selector="DataGridCell">
+					<Setter Property="FontSize" Value="{StaticResource PrsFontSizeExtraSmall}"/>
+				</Style>
+			</DataGrid.Styles>
+		</DataGrid>
+		
+        <Border Name="_recordCountBox" Classes="Standard"
+				Grid.Row="2"
+				Grid.Column="0"
+                Margin="0,2,0,0">
+            <DockPanel>
+                <Label Name="_recordCount" DockPanel.Dock="Right"/>
+                <Label Name="_lastUpdated" Content="{Binding $parent[components:AvaloniaDataGrid].LastUpdated, FallbackValue={x:Null}, Converter={StaticResource DateTimeToAgeConverter}}" DockPanel.Dock="Right"/>
+            </DockPanel>
+        </Border>
+        <Button Classes="Standard"
+            Grid.Row="2"
+            Grid.Column="1"
+            Padding="4"
+            Command="{Binding $parent[components:AvaloniaDataGrid].RefreshCommand}"
+            IsVisible="{Binding $parent[components:AvaloniaDataGrid].RefreshVisible}"
+            Margin="2,2,0,0">
+            <Image
+                Classes="Small">
+                <Image.Source>
+                    <SvgImage Source="/Images/refresh.svg"/>
+                </Image.Source>
+            </Image>
+        </Button>
+	</Grid>
+</UserControl>

+ 238 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/AvaloniaDataGrid.axaml.cs

@@ -0,0 +1,238 @@
+using Avalonia;
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+using Avalonia.VisualTree;
+using CommunityToolkit.Mvvm.Input;
+using InABox.Core;
+using System.Collections;
+using System.ComponentModel;
+
+namespace InABox.Avalonia.Components;
+
+public class AvaloniaDataGridSelectionChangedEventArgs(object?[] selection)
+{
+    public object?[] Selection { get; set; } = selection;
+}
+public class AvaloniaDataGridRefreshRequestedEventArgs()
+{
+}
+
+public partial class AvaloniaDataGrid : UserControl, INotifyPropertyChanged
+{
+    public static StyledProperty<bool> CanSearchProperty =
+        AvaloniaProperty.Register<AvaloniaDataGrid, bool>(nameof(CanSearch), true);
+    public static StyledProperty<IEnumerable> ItemsSourceProperty =
+        AvaloniaProperty.Register<AvaloniaDataGrid, IEnumerable>(nameof(ItemsSource));
+    public static StyledProperty<DateTime> LastUpdatedProperty =
+        AvaloniaProperty.Register<AvaloniaDataGrid, DateTime>(nameof(LastUpdated));
+    public static StyledProperty<bool> ShowRecordCountProperty =
+        AvaloniaProperty.Register<AvaloniaDataGrid, bool>(nameof(ShowRecordCount));
+    public static StyledProperty<bool> RefreshVisibleProperty =
+        AvaloniaProperty.Register<AvaloniaDataGrid, bool>(nameof(RefreshVisible));
+    public static StyledProperty<SelectionMode> SelectionModeProperty =
+        AvaloniaProperty.Register<AvaloniaDataGrid, SelectionMode>(nameof(SelectionMode), SelectionMode.Single);
+    public static StyledProperty<double> RowHeightProperty =
+        AvaloniaProperty.Register<AvaloniaDataGrid, double>(nameof(RowHeight), 30);
+
+    public string SearchText { get; set; } = "";
+
+    public bool CanSearch
+    {
+        get => GetValue(CanSearchProperty);
+        set => SetValue(CanSearchProperty, value);
+    }
+    public IEnumerable ItemsSource
+    {
+        get => GetValue(ItemsSourceProperty);
+        set => SetValue(ItemsSourceProperty, value);
+    }
+    public bool ShowRecordCount
+    {
+        get => GetValue(ShowRecordCountProperty);
+        set => SetValue(ShowRecordCountProperty, value);
+    }
+    public bool RefreshVisible
+    {
+        get => GetValue(RefreshVisibleProperty);
+        set => SetValue(RefreshVisibleProperty, value);
+    }
+    public SelectionMode SelectionMode
+    {
+        get => GetValue(SelectionModeProperty);
+        set => SetValue(SelectionModeProperty, value);
+    }
+    public double RowHeight
+    {
+        get => GetValue(RowHeightProperty);
+        set => SetValue(RowHeightProperty, value);
+    }
+
+    public int ItemCount { get; set; }
+
+    public DateTime LastUpdated
+    {
+        get => GetValue(LastUpdatedProperty);
+        set => SetValue(LastUpdatedProperty, value);
+    }
+
+    public AvaloniaDataGridColumns Columns { get; private set; }
+
+    public IEnumerable<object?> SelectedItems => Grid.SelectedItems.Cast<object?>();
+
+    public event EventHandler<AvaloniaDataGridSelectionChangedEventArgs>? SelectionChanged;
+    public event EventHandler<AvaloniaDataGridRefreshRequestedEventArgs>? RefreshRequested;
+
+    #region Static Constructor and Property Changed Handlers
+
+    static AvaloniaDataGrid()
+    {
+        ItemsSourceProperty.Changed.AddClassHandler<AvaloniaDataGrid>(ItemsSource_Changed);
+        LastUpdatedProperty.Changed.AddClassHandler<AvaloniaDataGrid>(LastUpdated_Changed);
+        ShowRecordCountProperty.Changed.AddClassHandler<AvaloniaDataGrid>(ShowRecordCount_Changed);
+    }
+
+    private static void ShowRecordCount_Changed(AvaloniaDataGrid grid, AvaloniaPropertyChangedEventArgs args)
+    {
+        grid.UpdateSummaryRow();
+    }
+
+    private static void LastUpdated_Changed(AvaloniaDataGrid grid, AvaloniaPropertyChangedEventArgs args)
+    {
+        grid.UpdateSummaryRow();
+    }
+
+    private static void ItemsSource_Changed(AvaloniaDataGrid grid, AvaloniaPropertyChangedEventArgs args)
+    {
+        grid.Grid.ItemsSource = grid.ItemsSource;
+        grid.Grid.CollectionView.CollectionChanged -= grid.CollectionView_CollectionChanged;
+        grid.Grid.CollectionView.CollectionChanged += grid.CollectionView_CollectionChanged;
+        grid.UpdateSummaryRow();
+    }
+
+    #endregion
+
+    public AvaloniaDataGrid()
+    {
+        InitializeComponent();
+
+        Columns = new AvaloniaDataGridColumns();
+        Columns.Changed += Columns_Changed;
+    }
+
+    private void CollectionView_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
+    {
+        ItemCount = (Grid.CollectionView as DataGridCollectionView)?.ItemCount ?? 0;
+    }
+
+    private void Columns_Changed(AvaloniaDataGridColumns columns)
+    {
+        Grid.Columns.Clear();
+        foreach(var column in columns)
+        {
+            Grid.Columns.Add(column.CreateColumn());
+        }
+        // Summaries
+
+        var searchableColumns = Columns.Any(x => x.Searchable);
+        SearchBar.IsVisible = searchableColumns && CanSearch;
+    }
+
+    private void UpdateSummaryRow()
+    {
+        _lastUpdated.IsVisible = LastUpdated != DateTime.MinValue;
+        _recordCount.Content = $" {ItemCount} records";
+        _recordCount.IsVisible = ShowRecordCount && ItemsSource is IEnumerable;
+        _recordCountBox.IsVisible = _recordCount.IsVisible || _lastUpdated.IsVisible;
+    }
+
+    public void ClearSelection()
+    {
+        Grid.SelectedItem = null;
+        Grid.SelectedItems.Clear();
+    }
+
+    private bool DoSearch(object? item)
+    {
+        if (SearchText.IsNullOrWhiteSpace()) return true;
+        if (item is null) return false;
+
+        foreach(var column in Columns)
+        {
+            if(column.Filter(item, SearchText)) return true;
+        }
+        return false;
+    }
+
+    [RelayCommand]
+    private void Search()
+    {
+        Grid.CollectionView.Filter = DoSearch;
+        Grid.CollectionView.Refresh();
+        UpdateSummaryRow();
+    }
+
+    [RelayCommand]
+    private void Refresh()
+    {
+        RefreshRequested?.Invoke(this, new());
+        Grid.CollectionView.Refresh();
+    }
+
+    private void DataGrid_Tapped(object sender, TappedEventArgs e)
+    {
+        if (SelectionMode == SelectionMode.Multiple) return;
+
+        var position = e.GetPosition(Grid);
+
+        var parent = (e.Source as Visual)?.GetVisualAncestors().Where(x => x is DataGridCell || x is DataGridColumnHeader).FirstOrDefault();
+        if (parent is null) return;
+
+        if(parent is DataGridCell cell)
+        {
+            var cellCollection = cell.GetVisualParent<DataGridCellsPresenter>();
+            if (cellCollection is null) return;
+
+            var colIdx = cellCollection.Children.IndexOf(cell);
+
+            var row = cellCollection.GetVisualAncestors().OfType<DataGridRow>().FirstOrDefault();
+            if (row is null) return;
+
+            var rowCollection = row.GetVisualParent<DataGridRowsPresenter>();
+            if (rowCollection is null) return;
+
+            var rowIdx = rowCollection.Children.IndexOf(row);
+
+            var item = (Grid.CollectionView as DataGridCollectionView)?[rowIdx];
+
+            var column = Columns[colIdx];
+            if(column.Tapped is not null)
+            {
+                column.Tapped?.Invoke(column, item);
+            }
+            else
+            {
+                SelectionChanged?.Invoke(this, new AvaloniaDataGridSelectionChangedEventArgs(new object?[] { item }));
+            }
+        }
+        else if(parent is DataGridColumnHeader header)
+        {
+            var headerCollection = header.GetVisualParent<DataGridColumnHeadersPresenter>();
+            if (headerCollection is null) return;
+
+            var colIdx = headerCollection.Children.IndexOf(header);
+            var column = Columns[colIdx];
+            if(column.Tapped is not null)
+            {
+                column.Tapped?.Invoke(column, null);
+            }
+            else
+            {
+                SelectionChanged?.Invoke(this, new AvaloniaDataGridSelectionChangedEventArgs(Array.Empty<object?>()));
+            }
+        }
+    }
+}

+ 115 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/AvaloniaDataGridColumns.cs

@@ -0,0 +1,115 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components;
+
+public class AvaloniaDataGridColumns : IList<IAvaloniaDataGridColumn>
+{
+    private readonly List<IAvaloniaDataGridColumn> _columns = new();
+
+    public IAvaloniaDataGridColumn this[int index]
+    {
+        get => _columns[index];
+        set
+        {
+            _columns[index] = value;
+            DoChanged();
+        }
+    }
+
+    public int Count => _columns.Count;
+
+    public bool IsReadOnly => false;
+
+    private bool _observing = true;
+    private bool _changed = false;
+
+    public delegate void ChangedEventHandler(AvaloniaDataGridColumns columns);
+
+    public event ChangedEventHandler? Changed;
+
+    public AvaloniaDataGridColumns BeginUpdate()
+    {
+        _observing = false;
+        return this;
+    }
+    public AvaloniaDataGridColumns EndUpdate()
+    {
+        _observing = true;
+        if (_changed)
+        {
+            DoChanged();
+        }
+        return this;
+    }
+
+    private void DoChanged()
+    {
+        if (!_observing)
+        {
+            _changed = true;
+            return;
+        }
+        Changed?.Invoke(this);
+        _changed = false;
+    }
+
+    public void Add(IAvaloniaDataGridColumn item)
+    {
+        _columns.Add(item);
+        DoChanged();
+    }
+
+    public void Clear()
+    {
+        _columns.Clear();
+        DoChanged();
+    }
+
+    public bool Contains(IAvaloniaDataGridColumn item) => _columns.Contains(item);
+
+    public void CopyTo(IAvaloniaDataGridColumn[] array, int arrayIndex)
+    {
+        _columns.CopyTo(array, arrayIndex);
+    }
+
+    public IEnumerator<IAvaloniaDataGridColumn> GetEnumerator()
+    {
+        return _columns.GetEnumerator();
+    }
+
+    public int IndexOf(IAvaloniaDataGridColumn item) => _columns.IndexOf(item);
+
+    public void Insert(int index, IAvaloniaDataGridColumn item)
+    {
+        _columns.Insert(index, item);
+        DoChanged();
+    }
+
+    public bool Remove(IAvaloniaDataGridColumn item)
+    {
+        if (_columns.Remove(item))
+        {
+            DoChanged();
+            return true;
+        }
+        else
+        {
+            return false;
+        }
+    }
+
+    public void RemoveAt(int index)
+    {
+        _columns.RemoveAt(index);
+        DoChanged();
+    }
+
+    IEnumerator IEnumerable.GetEnumerator() => _columns.GetEnumerator();
+}

+ 72 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/Columns/AvaloniaDataGridColumn.cs

@@ -0,0 +1,72 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Styling;
+using InABox.Core;
+using Microsoft.Maui.Devices;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components;
+
+public abstract class AvaloniaDataGridColumn : IAvaloniaDataGridColumn
+{
+    public string ColumnName { get; set; }
+    public string Caption { get; set; }
+    public GridLength Width { get; set; }
+    public TextAlignment Alignment { get; set; }
+    public virtual bool Searchable => true;
+
+    public Action<IAvaloniaDataGridColumn, object?>? Tapped { get; set; }
+
+    public abstract DataGridColumn CreateColumn();
+
+    public abstract bool Filter(object? item, string filter);
+}
+public abstract class AvaloniaDataGridColumn<TEntity, TType> : AvaloniaDataGridColumn
+{
+    public Expression<Func<TEntity, TType>> Column
+    {
+        set
+        {
+            ColumnName = CoreUtils.ExpressionToString(value).Replace("x => x.", "");
+        }
+    }
+
+    public TColumn CreateColumn<TColumn>() where TColumn : DataGridColumn, new()
+    {
+        var result = new TColumn();
+        result.Header = Caption.NotWhiteSpaceOr(ColumnName);
+        if(result is DataGridBoundColumn bound)
+        {
+            bound.Binding = new Binding(ColumnName, BindingMode.OneWay);
+        }
+        result.Width = new DataGridLength(Width.Value, Width.GridUnitType switch
+        {
+            GridUnitType.Auto => DataGridLengthUnitType.Auto,
+            GridUnitType.Pixel => DataGridLengthUnitType.Pixel,
+            GridUnitType.Star or _ => DataGridLengthUnitType.Star
+        });
+        return result;
+    }
+
+    public sealed override bool Filter(object? item, string filter)
+    {
+        return Filter((TEntity)item, filter);
+    }
+    public virtual bool Filter(TEntity item, string filter)
+    {
+        if (item is null) return false;
+        var property = item.GetType().GetProperty(ColumnName);
+        if(property is null) return false;
+        var value = property.GetValue(item);
+        var sValue = value?.ToString() ?? "";
+        return sValue.Contains(filter, StringComparison.CurrentCultureIgnoreCase);
+    }
+}

+ 46 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/Columns/AvaloniaDataGridDateColumn.cs

@@ -0,0 +1,46 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Avalonia.Layout;
+using Avalonia.Media;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components;
+
+public class AvaloniaDataGridDateColumn<TEntity> : AvaloniaDataGridColumn<TEntity, DateTime>
+{
+    public string Format { get; set; } = "dd MMM yy";
+
+    public bool BlankIfZero { get; set; } = true;
+
+    public AvaloniaDataGridDateColumn()
+    {
+        Width = GridLength.Auto;
+        Alignment = TextAlignment.Center;
+    }
+
+    public override DataGridColumn CreateColumn()
+    {
+        var column = CreateColumn<DataGridTemplateColumn>();
+        column.CellTemplate = new FuncDataTemplate<object?>((value, scope) =>
+        {
+            var label = new TextBlock
+            {
+                Margin = new(2, 0, 0, 0),
+                TextAlignment = Alignment,
+                VerticalAlignment = VerticalAlignment.Center
+            };
+            label.Bind(TextBlock.TextProperty, new Binding(ColumnName)
+            {
+                Converter = new FuncValueConverter<DateTime, string>(x => x == DateTime.MinValue && BlankIfZero ? "" : x.ToString(Format))
+            });
+            return label;
+        });
+        return column;
+    }
+}

+ 21 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/Columns/AvaloniaDataGridDoubleColumn.cs

@@ -0,0 +1,21 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Avalonia.Layout;
+using Avalonia.Media;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components;
+
+public class AvaloniaDataGridDoubleColumn<TEntity> : AvaloniaDataGridNumericColumn<TEntity, double>
+{
+    public AvaloniaDataGridDoubleColumn()
+    {
+        Format = "F2";
+    }
+}

+ 34 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/Columns/AvaloniaDataGridImageColumn.cs

@@ -0,0 +1,34 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Markup.Xaml.Templates;
+using Avalonia.Media;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components;
+
+public class AvaloniaDataGridImageColumn<TEntity> : AvaloniaDataGridColumn<TEntity, IImage>
+{
+    public override bool Searchable => false;
+
+    public override DataGridColumn CreateColumn()
+    {
+        var column = CreateColumn<DataGridTemplateColumn>();
+        column.CellTemplate = new FuncDataTemplate<object?>((value, scope) =>
+        {
+            var image = new Image();
+            image.Bind(Image.SourceProperty, new Binding(ColumnName));
+            return image;
+        });
+        return column;
+    }
+
+    public override bool Filter(TEntity item, string filter)
+    {
+        return false;
+    }
+}

+ 21 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/Columns/AvaloniaDataGridIntegerColumn.cs

@@ -0,0 +1,21 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Avalonia.Layout;
+using Avalonia.Media;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components;
+
+public class AvaloniaDataGridIntegerColumn<TEntity> : AvaloniaDataGridNumericColumn<TEntity, int>
+{
+    public AvaloniaDataGridIntegerColumn()
+    {
+        Format = "N";
+    }
+}

+ 48 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/Columns/AvaloniaDataGridNumericColumn.cs

@@ -0,0 +1,48 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Avalonia.Layout;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components;
+
+public class AvaloniaDataGridNumericColumn<TEntity, TType> : AvaloniaDataGridColumn<TEntity, TType>
+    where TType : struct, IComparable, IComparable<TType>, IConvertible, IEquatable<TType>, IFormattable
+{
+    public bool BlankIfZero { get; set; }
+
+    public string Format { get; set; }
+
+    public AvaloniaDataGridNumericColumn()
+    {
+        Width = GridLength.Auto;
+        BlankIfZero = true;
+    }
+
+    // Summaries
+
+    public override DataGridColumn CreateColumn()
+    {
+        var column = CreateColumn<DataGridTemplateColumn>();
+        column.CellTemplate = new FuncDataTemplate<object?>((value, scope) =>
+        {
+            var label = new TextBlock
+            {
+                Margin = new(2, 0, 0, 0),
+                TextAlignment = Alignment,
+                VerticalAlignment = VerticalAlignment.Center
+            };
+            label.Bind(TextBlock.TextProperty, new Binding(ColumnName)
+            {
+                Converter = new FuncValueConverter<TType, string>(x => BlankIfZero && Equals(x, default(TType)) ? "" : string.Format($"{{0:{Format}}}", x))
+            });
+            return label;
+        });
+        return column;
+    }
+}

+ 31 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/Columns/AvaloniaDataGridTextColumn.cs

@@ -0,0 +1,31 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Layout;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components;
+
+public class AvaloniaDataGridTextColumn<TEntity> : AvaloniaDataGridColumn<TEntity, string>
+{
+    public override DataGridColumn CreateColumn()
+    {
+        var column = CreateColumn<DataGridTemplateColumn>();
+        column.CellTemplate = new FuncDataTemplate<object?>((value, scope) =>
+        {
+            var label = new TextBlock
+            {
+                Margin = new(2, 0, 0, 0),
+                TextAlignment = Alignment,
+                VerticalAlignment = VerticalAlignment.Center
+            };
+            label.Bind(TextBlock.TextProperty, new Binding(ColumnName));
+            return label;
+        });
+        return column;
+    }
+}

+ 46 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/Columns/AvaloniaDataGridTimeColumn.cs

@@ -0,0 +1,46 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Avalonia.Layout;
+using Avalonia.Media;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components;
+
+public class AvaloniaDataGridTimeColumn<TEntity> : AvaloniaDataGridColumn<TEntity, TimeSpan>
+{
+    public string Format { get; set; } = @"h\:mm";
+
+    public bool BlankIfZero { get; set; } = true;
+
+    public AvaloniaDataGridTimeColumn()
+    {
+        Width = GridLength.Auto;
+        Alignment = TextAlignment.Center;
+    }
+
+    public override DataGridColumn CreateColumn()
+    {
+        var column = CreateColumn<DataGridTemplateColumn>();
+        column.CellTemplate = new FuncDataTemplate<object?>((value, scope) =>
+        {
+            var label = new TextBlock
+            {
+                Margin = new(2, 0, 0, 0),
+                TextAlignment = Alignment,
+                VerticalAlignment = VerticalAlignment.Center
+            };
+            label.Bind(TextBlock.TextProperty, new Binding(ColumnName)
+            {
+                Converter = new FuncValueConverter<TimeSpan, string>(x => x == TimeSpan.Zero && BlankIfZero ? "" : x.ToString(Format))
+            });
+            return label;
+        });
+        return column;
+    }
+}

+ 24 - 0
InABox.Avalonia/Components/AvaloniaDataGrid/Columns/IAvaloniaDataGridColumn.cs

@@ -0,0 +1,24 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components;
+
+public interface IAvaloniaDataGridColumn
+{
+    string ColumnName { get; set; }
+    string Caption { get; set; }
+    GridLength Width { get; set; }
+    TextAlignment Alignment { get; set; }
+    bool Searchable { get; }
+
+    Action<IAvaloniaDataGridColumn, object?>? Tapped { get; set; }
+
+    DataGridColumn CreateColumn();
+
+    bool Filter(object? item, string filter);
+}

+ 45 - 0
InABox.Avalonia/Images/refresh.svg

@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="-60 -60 612 612"
+     style="enable-background:new 0 0 492 492;" xml:space="preserve" fill="#FFFFFF">
+<g>
+	<g>
+		<path d="M484.08,296.216c-5.1-5.128-11.848-7.936-19.032-7.936H330.516c-14.828,0-26.86,12.036-26.86,26.868v22.796    c0,7.168,2.784,14.064,7.884,19.16c5.092,5.088,11.82,8.052,18.976,8.052H366.1c-31.544,30.752-74.928,50.08-120.388,50.08    c-71.832,0-136.028-45.596-159.744-113.344c-5.392-15.404-19.972-25.784-36.28-25.784c-4.316,0-8.592,0.708-12.7,2.144    c-9.692,3.396-17.48,10.352-21.932,19.596c-4.456,9.248-5.04,19.684-1.648,29.368c34.496,98.54,127.692,164.74,232.144,164.74    c64.132,0,123.448-23.948,169.572-67.656v25.22c0,14.836,12.384,27.108,27.224,27.108h22.792c14.84,0,26.86-12.272,26.86-27.108    V315.24C492,308.056,489.2,301.304,484.08,296.216z"/>
+	</g>
+</g>
+    <g>
+	<g>
+		<path d="M478.628,164.78C444.132,66.244,350.916,0.044,246.464,0.044c-64.136,0-123.464,23.952-169.588,67.66v-25.22    c0-14.832-12.344-27.112-27.184-27.112H26.896C12.06,15.372,0,27.652,0,42.484V176.76c0,7.18,2.824,13.868,7.944,18.964    c5.096,5.128,11.86,7.932,19.044,7.932l-0.08,0.06h134.604c14.84,0,26.832-12.028,26.832-26.86v-22.8    c0-14.836-11.992-27.216-26.832-27.216h-35.576c31.544-30.752,74.932-50.076,120.392-50.076    c71.832,0,136.024,45.596,159.74,113.348c5.392,15.404,19.968,25.78,36.28,25.78c4.32,0,8.588-0.704,12.7-2.144    c9.696-3.396,17.48-10.348,21.932-19.596C481.432,184.9,482.02,174.472,478.628,164.78z"/>
+	</g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+    <g>
+</g>
+</svg>

+ 6 - 0
InABox.Avalonia/InABox.Avalonia.csproj

@@ -12,6 +12,7 @@
 
     <ItemGroup>
       <None Remove="Images\cross.svg" />
+      <None Remove="Images\refresh.svg" />
       <None Remove="Images\search.svg" />
       <None Remove="Images\tick.svg" />
     </ItemGroup>
@@ -24,6 +25,7 @@
     <ItemGroup>
       <PackageReference Include="Autofac" Version="8.2.0" />
       <PackageReference Include="Avalonia" Version="11.2.3" />
+      <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
       <PackageReference Include="Avalonia.Native" Version="11.2.3" />
       <PackageReference Include="Avalonia.Svg.Skia" Version="11.2.0.2" />
       <PackageReference Include="AvaloniaDialogs" Version="3.6.1" />
@@ -79,4 +81,8 @@
       <UpToDateCheckInput Remove="Dialogs\TextBoxDialog\TextBoxDialogView.axaml" />
     </ItemGroup>
 
+    <ItemGroup>
+      <AvaloniaResource Include="Images\refresh.svg" />
+    </ItemGroup>
+
 </Project>