Преглед изворни кода

Merge remote-tracking branch 'origin/kenric' into frank

frankvandenbos пре 9 месеци
родитељ
комит
78fa36a0be

+ 1 - 0
PRS.Avalonia/PRS.Avalonia/App.axaml.cs

@@ -20,6 +20,7 @@ public class App : Application
     {
         CoreUtils.RegisterClasses();
         ComalUtils.RegisterClasses();
+        CoreUtils.RegisterClasses(typeof(App).Assembly);
         
         AvaloniaXamlLoader.Load(this);
     }

+ 132 - 0
PRS.Avalonia/PRS.Avalonia/Components/FormsEditor/Controls/DFHeaderControl.cs

@@ -0,0 +1,132 @@
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using CommunityToolkit.Mvvm.Input;
+using InABox.Core;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace PRS.Avalonia.DigitalForms;
+
+partial class DFHeaderControl : DigitalFormControl<DFLayoutHeader>
+{
+    private Image _image = null!;
+
+    private bool _collapsed = false;
+    public bool Collapsed
+    {
+        get => _collapsed;
+        set
+        {
+            _collapsed = value;
+            _image.Source = value ? Images.arrowWhiteRight : Images.arrowWhiteDown;
+        }
+    }
+
+    protected override Control Create()
+    {
+        var button = new Button
+        {
+            HorizontalContentAlignment = HorizontalAlignment.Stretch,
+            VerticalContentAlignment = VerticalAlignment.Stretch
+        };
+        button.Command = ClickCommand;
+        button.BorderBrush = new SolidColorBrush(Colors.Gray);
+        button.Background= new SolidColorBrush(Colors.Silver);
+
+        var panel = new DockPanel
+        {
+        };
+        _image = new Image()
+        {
+            Width = 23,
+            Height = 23,
+            Margin = new(0, 0, 5, 0)
+        };
+
+        var style = Control.Style;
+
+        var textBlock = new TextBlock
+        {
+            Text = Control.Header,
+            FontWeight = style.IsBold ? FontWeight.Bold : FontWeight.Normal,
+            FontStyle = style.IsItalic ? FontStyle.Italic : FontStyle.Normal,
+            HorizontalAlignment = HorizontalAlignment.Stretch,
+            VerticalAlignment = Control.Style.VerticalTextAlignment switch
+            {
+                DFLayoutAlignment.Start => VerticalAlignment.Top,
+                DFLayoutAlignment.End => VerticalAlignment.Bottom,
+                DFLayoutAlignment.Stretch => VerticalAlignment.Stretch,
+                _ => VerticalAlignment.Center
+            },
+            TextAlignment = Control.Style.HorizontalTextAlignment switch
+            {
+                DFLayoutAlignment.Middle => TextAlignment.Center,
+                DFLayoutAlignment.End => TextAlignment.Right,
+                DFLayoutAlignment.Stretch => TextAlignment.Justify,
+                _ => TextAlignment.Left
+            },
+        };
+        if(style.FontSize > 0)
+        {
+            textBlock.FontSize = style.FontSize;
+        }
+        if (style.BackgroundColour != System.Drawing.Color.Empty)
+        {
+            button.Background = new SolidColorBrush(ConvertColour(style.BackgroundColour));
+        }
+        if (style.ForegroundColour != System.Drawing.Color.Empty)
+        {
+            textBlock.Foreground = new SolidColorBrush(ConvertColour(style.ForegroundColour));
+        }
+
+        if (style.Underline == UnderlineType.Single)
+        {
+            textBlock.TextDecorations = TextDecorations.Underline;
+        }
+        else if (style.Underline == UnderlineType.Double) {
+            var decorations = new TextDecorationCollection();
+            var underline1 = new TextDecoration
+            {
+                Location = TextDecorationLocation.Underline
+            };
+            var underline2 = new TextDecoration
+            {
+                Location = TextDecorationLocation.Underline,
+                StrokeOffsetUnit = TextDecorationUnit.Pixel,
+                StrokeOffset = 3
+            };
+            textBlock.Padding = new(0, 0, 0, 3);
+            decorations.Add(underline1);
+            decorations.Add(underline2);
+            textBlock.TextDecorations = decorations;
+        }
+
+        DockPanel.SetDock(_image, Dock.Left);
+        DockPanel.SetDock(textBlock, Dock.Right);
+
+        panel.Children.Add(_image);
+        panel.Children.Add(textBlock);
+
+        button.Content = panel;
+
+        Collapsed = Control.Collapsed;
+
+        return button;
+    }
+
+    [RelayCommand]
+    private void Click()
+    {
+        Collapsed = !Collapsed;
+        FormViewer.CollapseRows(this, Collapsed);
+    }
+
+    private static Color ConvertColour(System.Drawing.Color colour)
+    {
+        return new Color(colour.A, colour.R, colour.G, colour.B);
+    }
+}

+ 56 - 0
PRS.Avalonia/PRS.Avalonia/Components/FormsEditor/Controls/DFImageControl.cs

@@ -0,0 +1,56 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using InABox.Clients;
+using InABox.Core;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace PRS.Avalonia.DigitalForms;
+
+class DFImageControl : DigitalFormControl<DFLayoutImage>
+{
+    private static readonly Dictionary<Guid, Bitmap?> images = new();
+
+    protected override Control Create()
+    {
+        var image = new Image();
+        if (Control.Image.IsValid())
+        {
+            if (images.TryGetValue(Control.Image.ID, out var bitmapImage))
+            {
+                image.Source = bitmapImage;
+            }
+            else
+            {
+                Client.Query(
+                    new Filter<Document>(x => x.ID).IsEqualTo(Control.Image.ID),
+                    Columns.None<Document>().Add(x => x.ID, x => x.Data),
+                    null,
+                    null,
+                    (data, error) =>
+                    {
+                        var imgData = data?.Rows.FirstOrDefault()?.Get<Document, byte[]>(x => x.Data);
+                        if (imgData is null) return;
+
+                        Bitmap img;
+                        using (var stream = new MemoryStream(imgData))
+                        {
+                            img = new Bitmap(stream);
+                        }
+
+                        images[Control.Image.ID] = img;
+                        Dispatcher.UIThread.Invoke(() => { image.Source = img; });
+                    });
+            }
+        }
+
+        image.Stretch = Stretch.Uniform;
+        return image;
+    }
+}

+ 93 - 0
PRS.Avalonia/PRS.Avalonia/Components/FormsEditor/Controls/DFLabelControl.cs

@@ -0,0 +1,93 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using InABox.Core;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace PRS.Avalonia.DigitalForms;
+
+class DFLabelControl : DigitalFormControl<DFLayoutLabel>
+{
+    protected override Control Create()
+    {
+        var border = new Border
+        {
+            HorizontalAlignment = HorizontalAlignment.Stretch,
+            VerticalAlignment = VerticalAlignment.Stretch
+        };
+
+        var style = Control.Style;
+
+        var textBlock = new TextBlock
+        {
+            Text = Control.Caption,
+            TextWrapping = TextWrapping.WrapWithOverflow,
+            FontWeight = style.IsBold ? FontWeight.Bold : FontWeight.Normal,
+            FontStyle = style.IsItalic ? FontStyle.Italic : FontStyle.Normal,
+            VerticalAlignment = Control.Style.VerticalTextAlignment switch
+            {
+                DFLayoutAlignment.Start => VerticalAlignment.Top,
+                DFLayoutAlignment.End => VerticalAlignment.Bottom,
+                DFLayoutAlignment.Stretch => VerticalAlignment.Stretch,
+                _ => VerticalAlignment.Center
+            },
+            HorizontalAlignment = HorizontalAlignment.Stretch,
+            TextAlignment = Control.Style.HorizontalTextAlignment switch
+            {
+                DFLayoutAlignment.Middle => TextAlignment.Center,
+                DFLayoutAlignment.End => TextAlignment.Right,
+                DFLayoutAlignment.Stretch => TextAlignment.Justify,
+                _ => TextAlignment.Left
+            },
+            Margin = new Thickness(5)
+        };
+
+        if(style.FontSize > 0)
+        {
+            textBlock.FontSize = style.FontSize;
+        }
+        if (style.BackgroundColour != System.Drawing.Color.Empty)
+        {
+            border.Background = new SolidColorBrush(ConvertColour(style.BackgroundColour));
+        }
+        if (style.ForegroundColour != System.Drawing.Color.Empty)
+        {
+            textBlock.Foreground = new SolidColorBrush(ConvertColour(style.ForegroundColour));
+        }
+
+        if (style.Underline == UnderlineType.Single)
+        {
+            textBlock.TextDecorations = TextDecorations.Underline;
+        }
+        else if(style.Underline == UnderlineType.Double)
+        {
+            var decorations = new TextDecorationCollection();
+            var underline1 = new TextDecoration
+            {
+                Location = TextDecorationLocation.Underline
+            };
+            var underline2 = new TextDecoration()
+            {
+                Location = TextDecorationLocation.Underline,
+                StrokeOffset = 2
+            };
+            decorations.Add(underline1);
+            decorations.Add(underline2);
+            textBlock.TextDecorations = decorations;
+        }
+
+        border.Child = textBlock;
+
+        return border;
+    }
+
+    private static Color ConvertColour(System.Drawing.Color colour)
+    {
+        return new Color(colour.A, colour.R, colour.G, colour.B);
+    }
+}

+ 40 - 0
PRS.Avalonia/PRS.Avalonia/Components/FormsEditor/Controls/IDigitalFormControl.cs

@@ -0,0 +1,40 @@
+using Avalonia.Controls;
+using InABox.Core;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace PRS.Avalonia.DigitalForms;
+
+public abstract class DigitalFormControl : ContentControl
+{
+    public DigitalFormViewer FormViewer { get; set; }
+
+    protected abstract Control Create();
+
+    public abstract void SetControl(DFLayoutControl control);
+}
+
+public abstract class DigitalFormControl<TControl> : DigitalFormControl
+    where TControl : DFLayoutControl
+{
+    private TControl control;
+    public TControl Control
+    {
+        get => control;
+        set
+        {
+            control = value;
+            Content = Create();
+            AfterSetControl(control);
+        }
+    }
+
+    protected virtual void AfterSetControl(TControl control)
+    {
+    }
+
+    public override void SetControl(DFLayoutControl control) => Control = (TControl)control;
+}

+ 1 - 0
PRS.Avalonia/PRS.Avalonia/Components/FormsEditor/DigitalFormViewer.axaml

@@ -4,4 +4,5 @@
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="PRS.Avalonia.DigitalForms.DigitalFormViewer">
+	<Grid Name="Grid"/>
 </UserControl>

+ 458 - 4
PRS.Avalonia/PRS.Avalonia/Components/FormsEditor/DigitalFormViewer.axaml.cs

@@ -1,11 +1,16 @@
 using Avalonia;
 using Avalonia.Controls;
+using Avalonia.Layout;
 using Avalonia.Markup.Xaml;
 using InABox.Core;
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
 
 namespace PRS.Avalonia.DigitalForms;
 
-public partial class DigitalFormViewer : UserControl
+public partial class DigitalFormViewer : UserControl, IDFRenderer
 {
     public static readonly StyledProperty<IDigitalFormInstance> FormProperty =
         AvaloniaProperty.Register<DigitalFormViewer, IDigitalFormInstance>(nameof(Form));
@@ -13,15 +18,464 @@ public partial class DigitalFormViewer : UserControl
         AvaloniaProperty.Register<DigitalFormViewer, Entity>(nameof(Entity));
     public static readonly StyledProperty<DFLayout> LayoutProperty =
         AvaloniaProperty.Register<DigitalFormViewer, DFLayout>(nameof(Layout));
+    public static readonly StyledProperty<bool> ReadOnlyProperty =
+        AvaloniaProperty.Register<DigitalFormViewer, bool>(nameof(ReadOnly));
 
-    public IDigitalFormInstance Form { get; set; }
+    public IDigitalFormInstance Form
+    {
+        get => GetValue(FormProperty);
+    }
+
+    public Entity Entity
+    {
+        get => GetValue(EntityProperty);
+    }
+
+    public DFLayout Layout
+    {
+        get => GetValue(LayoutProperty);
+    }
 
-    public Entity Entity { get; set; }
+    public bool ReadOnly { get; set; }
+
+    private DFLoadStorage RetainedData { get; set; }
+
+    private bool _changing;
+    private bool _isChanged;
+
+    private readonly Dictionary<DFLayoutControl, DigitalFormControl> _elementMap = new Dictionary<DFLayoutControl, DigitalFormControl>();
+
+    static DigitalFormViewer()
+    {
+        LayoutProperty.Changed.AddClassHandler<DigitalFormViewer>(Layout_Changed);
+    }
+
+    private static void Layout_Changed(DigitalFormViewer viewer, AvaloniaPropertyChangedEventArgs args)
+    {
+        if (viewer.Layout is null) return;
 
-    public DFLayout Layout { get; set; }
+        viewer.Layout.Renderer = viewer;
+        viewer.Initialise();
+    }
 
     public DigitalFormViewer()
     {
         InitializeComponent();
     }
+
+    #region Utilities
+
+    private static VerticalAlignment GetVerticalAlignment(DFLayoutAlignment alignment)
+    {
+        return alignment == DFLayoutAlignment.Start
+            ? VerticalAlignment.Top
+            : alignment == DFLayoutAlignment.End
+                ? VerticalAlignment.Bottom
+                : alignment == DFLayoutAlignment.Middle
+                    ? VerticalAlignment.Center
+                    : VerticalAlignment.Stretch;
+    }
+
+    private static HorizontalAlignment GetHorizontalAlignment(DFLayoutAlignment alignment)
+    {
+        return alignment == DFLayoutAlignment.Start
+            ? HorizontalAlignment.Left
+            : alignment == DFLayoutAlignment.End
+                ? HorizontalAlignment.Right
+                : alignment == DFLayoutAlignment.Middle
+                    ? HorizontalAlignment.Center
+                    : HorizontalAlignment.Stretch;
+    }
+
+    private static GridLength StringToGridLength(string length)
+    {
+        if (string.IsNullOrWhiteSpace(length) || string.Equals(length.ToUpper(), "AUTO"))
+            return new GridLength(1, GridUnitType.Auto);
+
+        if (!double.TryParse(length.Replace("*", ""), out var value))
+            value = 1.0F;
+        var type = length.Contains('*') ? GridUnitType.Star : GridUnitType.Pixel;
+        return new GridLength(value, type);
+    }
+
+    #endregion
+
+    #region Rows
+
+    internal void CollapseRows(DFHeaderControl header, bool collapsed)
+    {
+        var startRow = Grid.GetRow(header) + Grid.GetRowSpan(header);
+
+        var nextRow = _elementMap
+            .Where(x => x.Value is DFHeaderControl headerControl && headerControl != header)
+            .Select(x => Grid.GetRow(x.Value))
+            .Where(x => x >= startRow).DefaultIfEmpty(-1).Min();
+
+        if (nextRow == -1)
+        {
+            nextRow = Grid.RowDefinitions.Count;
+        }
+
+        for (int row = startRow; row < nextRow; ++row)
+        {
+            var rowDefinition = Grid.RowDefinitions[row];
+
+            if (collapsed)
+            {
+                rowDefinition.Height = new GridLength(0);
+                rowDefinition.MinHeight = 0;
+            }
+            else
+            {
+                rowDefinition.Height = StringToGridLength(Layout.RowHeights[row]);
+                rowDefinition.MinHeight = 50;
+            }
+        }
+        foreach(var (item, element) in _elementMap)
+        {
+            if(startRow <= item.Row - 1 && item.Row - 1 < nextRow)
+            {
+                element.IsVisible = !collapsed;
+            }
+        }
+    }
+
+    #endregion
+
+    private void DoChanged(string fieldName)
+    {
+    }
+
+    private void Initialise()
+    {
+        Render();
+        Layout.EvaluateExpressions();
+    }
+
+    #region Render
+
+    private static void SetDimensions(Control element, int row, int column, int rowspan, int colspan)
+    {
+        if (column <= 0) return;
+        if (row <= 0) return;
+
+        element.MinHeight = 50.0F;
+        element.MinWidth = 50.0F;
+        Grid.SetRow(element, row - 1);
+        Grid.SetColumn(element, column - 1);
+        Grid.SetRowSpan(element, rowspan);
+        Grid.SetColumnSpan(element, colspan);
+    }
+
+    private static Dictionary<Type, Type>? _fieldControls;
+    private DigitalFormControl? CreateFieldControl(DFLayoutField field)
+    {
+        if (_fieldControls == null)
+        {
+            _fieldControls = new();
+            foreach (var controlType in CoreUtils.Entities.Where(x => x.IsClass && !x.IsGenericType && x.HasInterface<IDigitalFormField>()))
+            {
+                var superDefinition = controlType.GetSuperclassDefinition(typeof(DigitalFormFieldControl<,,,>));
+                if (superDefinition != null)
+                {
+                    _fieldControls[superDefinition.GenericTypeArguments[0]] = controlType;
+                }
+            }
+        }
+
+        var fieldControlType = _fieldControls.GetValueOrDefault(field.GetType());
+        if (fieldControlType is not null)
+        {
+            var element = (Activator.CreateInstance(fieldControlType) as DigitalFormControl)!;
+
+            if(element is IDigitalFormField fieldControl)
+            {
+                fieldControl.FieldChangedEvent += () =>
+                {
+                    ChangeField(field.Name);
+                };
+            }
+
+            return element;
+        }
+        return null;
+    }
+
+    private DigitalFormControl? CreateControl(DFLayoutControl item)
+    {
+        if (item is DFLayoutField field)
+        {
+            return CreateFieldControl(field);
+        }
+        else if (item is DFLayoutElement)
+        {
+            return null;
+        }
+        else if (item is DFLayoutLabel)
+        {
+            return new DFLabelControl();
+        }
+        else if (item is DFLayoutHeader)
+        {
+            return new DFHeaderControl();
+        }
+        else if (item is DFLayoutImage)
+        {
+            return new DFImageControl();
+        }
+
+        return null;
+    }
+
+    private DigitalFormControl? CreateElement(DFLayoutControl item)
+    {
+        var control = CreateControl(item);
+        if(control != null)
+        {
+            control.FormViewer = this;
+            control.SetControl(item);
+        }
+        return control;
+    }
+
+    private void RenderElement(DFLayoutControl item)
+    {
+        var element = CreateElement(item);
+        if(element is null)
+        {
+            return;
+        }
+
+        SetDimensions(element, item.Row, item.Column, item.RowSpan, item.ColumnSpan);
+        Grid.Children.Add(element);
+
+        if (element != null)
+        {
+            if(item is DFLayoutField)
+            {
+                element.IsEnabled = element.IsEnabled && !ReadOnly;
+            }
+            _elementMap[item] = element;
+
+            element.HorizontalAlignment = GetHorizontalAlignment(item.HorizontalAlignment);
+            element.VerticalAlignment = GetVerticalAlignment(item.VerticalAlignment);
+        }
+    }
+
+    private void Render()
+    {
+        _elementMap.Clear();
+
+        Grid.Children.Clear();
+        Grid.RowDefinitions.Clear();
+        Grid.ColumnDefinitions.Clear();
+
+        foreach (var column in Layout.ColumnWidths)
+            Grid.ColumnDefinitions.Add(new ColumnDefinition { Width = StringToGridLength(column) });
+
+        foreach (var row in Layout.RowHeights)
+            Grid.RowDefinitions.Add(new RowDefinition { Height = StringToGridLength(row), MinHeight = 50 });
+
+        foreach (var item in Layout.GetElements(false))
+        {
+            try
+            {
+                if(item.Row > 0 && item.Column > 0)
+                {
+                    RenderElement(item);
+                }
+            }
+            catch (Exception e)
+            {
+                Logger.Send(LogType.Error, "", string.Format("*** Unknown Error: {0}\n{1}", e.Message, e.StackTrace));
+            }
+        }
+
+        AfterRender();
+    }
+
+    private void AfterRender()
+    {
+        foreach (var header in _elementMap.Values.OfType<DFHeaderControl>())
+        {
+            if (header.Collapsed)
+            {
+                CollapseRows(header, true);
+            }
+        }
+    }
+
+    #endregion
+
+    #region Fields
+
+    public void ChangeField(string fieldName)
+    {
+        if (!_changing)
+        {
+            Layout.ChangeField(fieldName);
+            _isChanged = true;
+            DoChanged(fieldName);
+        }
+    }
+
+    private IDigitalFormField? GetFieldControl(DFLayoutField field)
+    {
+        return _elementMap.GetValueOrDefault(field) as IDigitalFormField;
+    }
+
+    private DFLayoutField? GetField(string fieldName)
+    {
+        return Layout.Elements.OfType<DFLayoutField>().FirstOrDefault(x => fieldName == x.Name);
+    }
+
+    public void SetFieldValue(string fieldName, object? value)
+    {
+        var field = GetField(fieldName);
+        if (field is null) return;
+
+        var fieldControl = GetFieldControl(field);
+        if(fieldControl is null) return;
+
+        var property = field.GetPropertyValue<string>("Property");
+        if (Entity != null && !property.IsNullOrWhiteSpace())
+            fieldControl.SetValue(CoreUtils.GetPropertyValue(Entity, property));
+        else
+            fieldControl.SetValue(value);
+    }
+
+    private void GetFieldValue(DFLayoutField field, DFSaveStorageEntry storage, DFSaveStorageEntry? retained, out object? entityValue)
+    {
+        var fieldControl = GetFieldControl(field);
+        if(fieldControl != null)
+        {
+            entityValue = fieldControl.GetEntityValue();
+            fieldControl.Serialize(storage);
+
+            if(retained is not null)
+            {
+                fieldControl.Serialize(retained);
+            }
+        }
+        else
+        {
+            entityValue = null;
+        }
+    }
+
+    public object? GetFieldValue(string fieldName)
+    {
+        var field = GetField(fieldName);
+        if (field is null)
+        {
+            return null;
+        }
+        var fieldControl = GetFieldControl(field);
+        return fieldControl?.GetValue();
+    }
+
+    public object? GetFieldData(string fieldName, string dataField)
+    {
+        var field = GetField(fieldName);
+        if (field is null)
+        {
+            return null;
+        }
+        var fieldControl = GetFieldControl(field);
+        return fieldControl?.GetData(dataField);
+    }
+
+    public void SetFieldColour(string fieldName, Color? colour)
+    {
+        var field = GetField(fieldName);
+        if (field is null) return;
+
+        var fieldControl = GetFieldControl(field);
+        if (fieldControl is null) return;
+
+        fieldControl.SetColour(colour);
+    }
+
+    public void LoadValues(DFLoadStorage data)
+    {
+        _changing = true;
+
+        foreach(var field in Layout.Elements.OfType<DFLayoutField>())
+        {
+            var fieldControl = GetFieldControl(field);
+            if(fieldControl is null)
+            {
+                continue;
+            }
+
+            var property = field.GetPropertyValue<string>("Property");
+            if (Entity != null && !property.IsNullOrWhiteSpace())
+            {
+                fieldControl.SetValue(CoreUtils.GetPropertyValue(Entity, property));
+            }
+            else if (RetainedData.HasValue(field.Name))
+            {
+                fieldControl.Deserialize(RetainedData.GetEntry(field.Name));
+            }
+            else if (data.HasValue(field.Name))
+            {
+                fieldControl.Deserialize(data.GetEntry(field.Name));
+            }
+            else
+            {
+                fieldControl.SetValue(field.GetProperties().GetDefaultValue());
+            }
+        }
+
+        Layout.EvaluateExpressions();
+        _changing = false;
+        _isChanged = false;
+    }
+
+    /// <summary>
+    /// Takes values from editors and saves them to a dictionary; must be called after <see cref="Initialize"/>.
+    /// </summary>
+    /// <returns>A dictionary of <see cref="DigitalFormVariable.Code"/> -> value.</returns>
+    public DFSaveStorage SaveValues()
+    {
+        var result = new DFSaveStorage();
+        var retained = new DFSaveStorage();
+        foreach (var formElement in Layout.Elements)
+            if (formElement is DFLayoutField field)
+            {
+                GetFieldValue(field, result.GetEntry(field.Name), field.GetProperties().Retain ? retained.GetEntry(field.Name) : null, out var entityValue);
+
+                if(Entity != null)
+                {
+                    var property = field.GetPropertyValue<string>("Property");
+                    if (!string.IsNullOrWhiteSpace(property))
+                        CoreUtils.SetPropertyValue(Entity, property, entityValue);
+                }
+            }
+
+        RetainedData = retained.ToLoadStorage();
+
+        return result;
+    }
+
+    public bool Validate(out List<string> messages)
+    {
+        messages = new List<string>();
+        var valid = true;
+        foreach(var formElement in Layout.Elements)
+        {
+            if(formElement is DFLayoutField field)
+            {
+                var fieldControl = GetFieldControl(field);
+                if(fieldControl != null && !fieldControl.Validate(out var message))
+                {
+                    messages.Add(message);
+                    valid = false;
+                }
+            }
+        }
+        return valid;
+    }
+
+    #endregion
 }

+ 5 - 2
PRS.Avalonia/PRS.Avalonia/Components/FormsEditor/DigitalFormsHostView.axaml

@@ -4,11 +4,14 @@
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
 			 xmlns:forms="using:PRS.Avalonia.DigitalForms"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
-             x:Class="PRS.Avalonia.DigitalForms.DigitalFormsHostView">
+             x:Class="PRS.Avalonia.DigitalForms.DigitalFormsHostView"
+			 x:DataType="forms:IDigitalFormsHostViewModel">
 	<TabControl TabStripPlacement="Bottom">
 		<TabItem Header="Form">
 			<ScrollViewer>
-				<forms:DigitalFormViewer/>
+				<forms:DigitalFormViewer Layout="{Binding Layout}"
+										 Entity="{Binding Parent}"
+										 Form="{Binding Form}"/>
 			</ScrollViewer>
 		</TabItem>
 		<TabItem Header="Documents"/>

+ 28 - 11
PRS.Avalonia/PRS.Avalonia/Components/FormsEditor/DigitalFormsHostViewModel.cs

@@ -34,14 +34,23 @@ public class DigitalFormCacheModel<TParent, TParentLink, TForm> : ISerializeBina
     }
 }
 
-public partial class DigitalFormsHostViewModel<TModel, TShell, TParent, TParentLink, TForm> : ModuleViewModel
+public interface IDigitalFormsHostViewModel
+{
+    Entity Parent { get; }
+
+    IDigitalFormInstance Form { get; }
+
+    DFLayout Layout { get; }
+}
+
+public partial class DigitalFormsHostViewModel<TModel, TShell, TParent, TParentLink, TForm> : ModuleViewModel, IDigitalFormsHostViewModel
     where TModel : DigitalFormInstanceModel<TModel, TShell, TForm>
     where TShell : DigitalFormInstanceShell<TModel, TParent, TParentLink, TForm>, new()
     where TParent : Entity, IRemotable, IPersistent, new()
     where TParentLink : EntityLink<TParent>, new()
     where TForm : EntityForm<TParent, TParentLink, TForm>, IRemotable, IPersistent, IDigitalFormInstance<TParentLink>, new()
 {
-    public override string Title => "WIP";
+    public override string Title => Form?.Description ?? "View Form";
 
     [ObservableProperty]
     private Guid _formID;
@@ -62,24 +71,30 @@ public partial class DigitalFormsHostViewModel<TModel, TShell, TParent, TParentL
     private DigitalFormDocumentModel _documents;
 
     [ObservableProperty]
-    private TParent _parent;
+    private bool _newForm;
 
     [ObservableProperty]
-    private TForm _form;
+    private bool _readOnly;
 
     [ObservableProperty]
-    private DFLayout _layout = new();
+    private TParent _parent;
 
     [ObservableProperty]
-    private bool _newForm;
+    private TForm _form;
 
     [ObservableProperty]
-    private bool _readOnly;
+    private DFLayout _layout;
 
     public event Action? OnSaved;
 
     private string CacheFileName => $"{typeof(TForm)}.{InstanceID}";
 
+    Entity IDigitalFormsHostViewModel.Parent => Parent;
+
+    IDigitalFormInstance IDigitalFormsHostViewModel.Form => Form;
+
+    DFLayout IDigitalFormsHostViewModel.Layout => Layout;
+
     public DigitalFormsHostViewModel()
     {
         PrimaryMenu.Add(new(Images.save, SaveForm));
@@ -105,10 +120,10 @@ public partial class DigitalFormsHostViewModel<TModel, TShell, TParent, TParentL
 
     protected override async Task<TimeSpan> OnRefresh()
     {
-        await Model.RefreshAsync(false);
+        await Model.RefreshAsync(DataAccess.Status == ConnectionStatus.Connected);
 
         Variables = Model.Variables.Where(x => x.FormID == FormID).Select(x => x.Entity).ToArray();
-        DigitalFormLayout = Model.Layouts.First().Entity;
+        DigitalFormLayout = Model.Layouts.Where(x => x.FormID == FormID).First().Entity;
 
         if(DataAccess.Status == ConnectionStatus.Connected)
         {
@@ -149,8 +164,10 @@ public partial class DigitalFormsHostViewModel<TModel, TShell, TParent, TParentL
             }
         }
 
-        Layout.LoadLayout(DigitalFormLayout.Layout);
-        Layout.LoadVariables(Variables);
+        var layout = new DFLayout();
+        layout.LoadLayout(DigitalFormLayout.Layout);
+        layout.LoadVariables(Variables);
+        Layout = layout;
 
         NewForm = Form.FormData.IsNullOrWhiteSpace();
         ReadOnly = Form.FormCompleted != DateTime.MinValue;

+ 111 - 0
PRS.Avalonia/PRS.Avalonia/Components/FormsEditor/Fields/IDigitalFormField.cs

@@ -0,0 +1,111 @@
+using InABox.Core;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Drawing;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace PRS.Avalonia.DigitalForms;
+
+internal delegate void FieldChangedEvent();
+
+internal interface IDigitalFormField
+{
+    public event FieldChangedEvent? FieldChangedEvent;
+    
+    void Deserialize(DFLoadStorageEntry storage);
+    void Serialize(DFSaveStorageEntry storage);
+
+    public object? GetValue();
+    /// <summary>
+    /// Sets the value in this control.
+    /// </summary>
+    /// <param name="value">The value to set. This will be the return value from a call to <see cref="DFLayoutFieldProperties.ParseValue(object)"/>.</param>
+    public void SetValue(object? value);
+
+    public object? GetEntityValue();
+
+    /// <summary>
+    /// Gets additional data for this field by the specific field name of <paramref name="field"/>.
+    /// </summary>
+    /// <param name="field">A name which specifies what data is requested.</param>
+    /// <returns>The additional data.</returns>
+    public object? GetData(string field);
+
+    /// <summary>
+    /// Check that the data is valid - if it is not, output a message for the user.
+    /// This function gets called when the user completes a form, or edits an already completed form.
+    /// </summary>
+    /// <param name="message">The message to the user.</param>
+    /// <returns><see langword="true"/> if the data is valid.</returns>
+    public bool Validate([NotNullWhen(false)] out string? message);
+
+    public void SetColour(Color? colour);
+}
+
+internal abstract class DigitalFormFieldControl<TField, TProperties, TValue, TSerialized> : DigitalFormControl<TField>, IDigitalFormField
+    where TField : DFLayoutField<TProperties>
+    where TProperties : DFLayoutFieldProperties<TValue, TSerialized>, new()
+{
+    public event FieldChangedEvent? FieldChangedEvent;
+
+    public TField Field { get => Control; set => Control = value; }
+
+    public void Serialize(DFSaveStorageEntry storage)
+    {
+        Field.Properties.SerializeValue(storage, GetSerializedValue());
+    }
+    public void Deserialize(DFLoadStorageEntry storage)
+    {
+        SetSerializedValue(Field.Properties.DeserializeValue(storage));
+    }
+
+    protected void ChangeField() => FieldChangedEvent?.Invoke();
+
+    /// <summary>
+    /// Checks whether the user has supplied a field - for use with <see cref="Validate(out string?)"/>.
+    /// </summary>
+    /// <returns><see langword="true"/> if the user has not supplied a value.</returns>
+    protected abstract bool IsEmpty();
+
+    public virtual bool Validate([NotNullWhen(false)] out string? message)
+    {
+        if(Field.Properties.Required && IsEmpty())
+        {
+            message = $"Field [{Field.Name}] is required!";
+            return false;
+        }
+        message = null;
+        return true;
+    }
+
+    protected override void AfterSetControl(TField control)
+    {
+        base.AfterSetControl(control);
+        if (!string.IsNullOrWhiteSpace(control.Properties.Expression)
+            || (!control.Properties.Property.IsNullOrWhiteSpace() && control.Properties.ReadOnlyProperty))
+        {
+            IsEnabled = false;
+        }
+    }
+
+    public abstract TSerialized GetSerializedValue();
+    public abstract void SetSerializedValue(TSerialized value);
+
+    public abstract TValue GetValue();
+    public abstract void SetValue(TValue? value);
+
+    public virtual object? GetEntityValue() => GetValue();
+
+    public virtual object? GetData(string dataField)
+    {
+        return null;
+    }
+
+    object? IDigitalFormField.GetValue() => GetValue();
+    void IDigitalFormField.SetValue(object? value) => SetValue(value != null ? (TValue)value : default);
+
+    public abstract void SetColour(Color? colour);
+}

+ 1 - 1
PRS.Avalonia/PRS.Avalonia/Components/FormsList/FormsList.axaml

@@ -56,7 +56,7 @@
 									  Command="{Binding $parent[local:FormsList].FormCheckedCommand}"
 									  CommandParameter="{Binding .}"/>
 							<Label Grid.Row="0" Grid.Column="1" Grid.ColumnSpan="3"
-								   Content="{Binding FormDescription}"
+								   Content="{Binding Description}"
 								   FontSize="{StaticResource PrsFontSizeSmall}"
 								   FontWeight="{StaticResource PrsFontWeightBold}"
 								   VerticalAlignment="Stretch"

+ 4 - 0
PRS.Avalonia/PRS.Avalonia/Images/Images.cs

@@ -26,6 +26,10 @@ public static class Images
 
     // Note: this list is alphabeticised.
     
+    public static SvgImage? arrowWhiteDown => LoadSVG("/Images/arrow_white_down.svg");
+    public static SvgImage? arrowWhiteLeft => LoadSVG("/Images/arrow_white_left.svg");
+    public static SvgImage? arrowWhiteRight => LoadSVG("/Images/arrow_white_right.svg");
+    public static SvgImage? arrowWhiteUp => LoadSVG("/Images/arrow_white_up.svg");
     public static SvgImage? badge => LoadSVG("/Images/badge.svg");
     public static SvgImage? barcode => LoadSVG("/Images/barcode.svg");
     public static SvgImage? books => LoadSVG("/Images/books.svg");

+ 1 - 0
PRS.Avalonia/PRS.Avalonia/Repositories/DigitalFormInstance/IDigitalFormInstanceShell.cs

@@ -11,6 +11,7 @@ public interface IDigitalFormInstanceShell : IShell
     Guid FormID { get; set; }
     string FormCode { get; set; }
     string FormDescription { get; set; }
+    string Description { get; set; }
     string Data { get; set; }
     DateTime Created { get; }
     DateTime Started { get; set; }