diff --git a/.gitignore b/.gitignore index 8a30d25..8a2aa37 100644 --- a/.gitignore +++ b/.gitignore @@ -396,3 +396,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +samples/Maui.ServerDrivenUI.ApiSample/Properties/ServiceDependencies/** diff --git a/Maui.ServerDrivenUI/Abstractions/IServerDrivenUISettings.cs b/Maui.ServerDrivenUI/Abstractions/IServerDrivenUISettings.cs index 9855829..e60f079 100644 --- a/Maui.ServerDrivenUI/Abstractions/IServerDrivenUISettings.cs +++ b/Maui.ServerDrivenUI/Abstractions/IServerDrivenUISettings.cs @@ -10,7 +10,7 @@ public interface IServerDrivenUISettings TimeSpan UIElementCacheExpiration { get; set; } - void RegisterElementGetter(Func> UiElementGetter); + void RegisterElementGetter(Func> UiElementGetter); void AddServerElement(string key); } diff --git a/Maui.ServerDrivenUI/AppBuilderExtensions.cs b/Maui.ServerDrivenUI/AppBuilderExtensions.cs index 6cd45dc..e9fb340 100644 --- a/Maui.ServerDrivenUI/AppBuilderExtensions.cs +++ b/Maui.ServerDrivenUI/AppBuilderExtensions.cs @@ -49,7 +49,7 @@ private static void ConfigureDb(LiteDBOptions config, ServerDrivenUISettings set private static bool InitServerDrivenUIService() { - var service = Application.Current!.Handler.MauiContext!.Services.GetService(); + var service = ServiceProviderHelper.ServiceProvider!.GetService(); _ = Task.Run(service!.FetchAsync); return false; diff --git a/Maui.ServerDrivenUI/Models/CustomNamespace.cs b/Maui.ServerDrivenUI/Models/CustomNamespace.cs index ba726b1..94817aa 100644 --- a/Maui.ServerDrivenUI/Models/CustomNamespace.cs +++ b/Maui.ServerDrivenUI/Models/CustomNamespace.cs @@ -1,4 +1,6 @@ -namespace Maui.ServerDrivenUI; +using System.Text.Json.Serialization; + +namespace Maui.ServerDrivenUI; /// /// Represents a xaml namespace eg.: xmlns:alias="clr-namespace:namespace;assembly=assembly" @@ -6,9 +8,56 @@ /// Define the alias from custom namespaces /// clr-namespace /// Assembly where the custom control is located -public class CustomNamespace(string alias, string @namespace, string? assembly = null) +public class CustomNamespace : IEquatable { - public string Alias { get; private set; } = alias; - public string Namespace { get; private set; } = @namespace; - public string? Assembly { get; private set; } = assembly; + public string Alias + { + get; set; + } + + public string Namespace + { + get; set; + } + + public string? Assembly + { + get; set; + } + + [JsonConstructor] + [Obsolete("This constructor should only be used from serializer")] + public CustomNamespace() + { + Alias = string.Empty; + Namespace = string.Empty; + } + + public CustomNamespace(string alias, string @namespace, string? assembly = null) + { + Alias = alias; + Namespace = @namespace; + Assembly = assembly; + } + + public bool Equals(CustomNamespace? other) + { + if (other is null) + return false; + + return Namespace == other?.Namespace + && Alias == other?.Alias + && Assembly == other?.Assembly; + } + + public override bool Equals(object? obj) + { + if(obj is CustomNamespace cn) + return Equals(cn); + + return false; + } + + public override int GetHashCode() => + (Namespace, Alias, Assembly).GetHashCode(); } diff --git a/Maui.ServerDrivenUI/Models/ServerDrivenUISettings.cs b/Maui.ServerDrivenUI/Models/ServerDrivenUISettings.cs index 01ae1a4..dbc735d 100644 --- a/Maui.ServerDrivenUI/Models/ServerDrivenUISettings.cs +++ b/Maui.ServerDrivenUI/Models/ServerDrivenUISettings.cs @@ -1,4 +1,6 @@  +using Maui.ServerDrivenUI.Services; + namespace Maui.ServerDrivenUI.Models; internal sealed class ServerDrivenUISettings : IServerDrivenUISettings @@ -17,6 +19,6 @@ public void AddServerElement(string key) throw new DependencyRegistrationException($"The key: '{key}' already has been registered"); } - public void RegisterElementGetter(Func> uiElementGetter) => + public void RegisterElementGetter(Func> uiElementGetter) => ElementResolver = new UIElementResolver(uiElementGetter); } diff --git a/Maui.ServerDrivenUI/Models/ServerUIElement.cs b/Maui.ServerDrivenUI/Models/ServerUIElement.cs index a89e817..4fa2bb1 100644 --- a/Maui.ServerDrivenUI/Models/ServerUIElement.cs +++ b/Maui.ServerDrivenUI/Models/ServerUIElement.cs @@ -1,4 +1,5 @@ using Maui.ServerDrivenUI.Services; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace Maui.ServerDrivenUI; @@ -13,33 +14,51 @@ public class ServerUIElement /// /// Unique key to map your Maui Visual Element to the server json /// - public string Key { get; set; } + public string Key + { + get; set; + } /// /// Maui element type /// - public string Type { get; set; } + public string Type + { + get; set; + } /// /// Class with namespace eg.: "MyNamespace.Folder.ClassName" /// - public string Class { get; set; } + public string Class + { + get; set; + } /// /// Visual element properties /// - public Dictionary Properties { get; set; } + public Dictionary Properties + { + get; set; + } = []; /// /// Custom namespaces to be used as root element /// - public IList CustomNamespaces { get; set; } + public IList CustomNamespaces + { + get; set; + } /// /// Inner visual element content /// - public IList Content { get; set; } + public IList Content + { + get; set; + } #endregion @@ -82,8 +101,19 @@ public ServerUIElement() #region Public Methods - public string ToXaml() => - XamlConverterService.ConvertToXml(this); + public string ToXaml(IList? customNamespaces = null) + { + if (customNamespaces != null) + { + foreach (var customNamespace in customNamespaces) + { + if (!CustomNamespaces.Contains(customNamespace)) + CustomNamespaces.Add(customNamespace); + } + } + + return XamlConverterService.ConvertToXml(this); + } #endregion } diff --git a/Maui.ServerDrivenUI/Models/UIElementResolver.cs b/Maui.ServerDrivenUI/Models/UIElementResolver.cs index 88884ea..b208a85 100644 --- a/Maui.ServerDrivenUI/Models/UIElementResolver.cs +++ b/Maui.ServerDrivenUI/Models/UIElementResolver.cs @@ -1,9 +1,11 @@ -namespace Maui.ServerDrivenUI.Models; +using Maui.ServerDrivenUI.Services; -internal sealed class UIElementResolver(Func> uiElementGetter) : IUIElementResolver +namespace Maui.ServerDrivenUI.Models; + +internal sealed class UIElementResolver(Func> uiElementGetter) : IUIElementResolver { - private readonly Func> _uiElementGetter = uiElementGetter; + private readonly Func> _uiElementGetter = uiElementGetter; public Task GetElementAsync(string elementKey) => - _uiElementGetter(elementKey); + _uiElementGetter(elementKey, ServiceProviderHelper.ServiceProvider!); } diff --git a/Maui.ServerDrivenUI/Services/ServerDrivenUIService.cs b/Maui.ServerDrivenUI/Services/ServerDrivenUIService.cs index 56f300a..f313c29 100644 --- a/Maui.ServerDrivenUI/Services/ServerDrivenUIService.cs +++ b/Maui.ServerDrivenUI/Services/ServerDrivenUIService.cs @@ -12,6 +12,8 @@ public class ServerDrivenUIService private readonly TaskCompletionSource _fetchFinished = new(); + private ServerUIElement[] _memoryCache = []; + #endregion #region IServerDrivenUIService @@ -21,11 +23,11 @@ public Task ClearCacheAsync() => public async Task FetchAsync() { - var elements = await GetElementsAsync().ConfigureAwait(false); + _memoryCache = await GetElementsAsync().ConfigureAwait(false); try { - await SaveIntoCacheAsync(elements).ConfigureAwait(false); + await SaveIntoCacheAsync(_memoryCache).ConfigureAwait(false); } catch (Exception ex) { @@ -39,18 +41,21 @@ public async Task FetchAsync() public async Task GetXamlAsync(string elementKey) { // Try to get the value from cache - var element = await _cacheProvider.GetAsync(elementKey) + var xaml = await _cacheProvider.GetAsync(elementKey) .ConfigureAwait(false); // If the value is not in cache wait fetch finish, then try to get it from the cache again - if (!(element?.HasValue ?? true) && await _fetchFinished.Task.ConfigureAwait(false)) + if (!(xaml?.HasValue ?? false) && await _fetchFinished.Task.ConfigureAwait(false)) { - element = await _cacheProvider.GetAsync(elementKey).ConfigureAwait(false) + xaml = await _cacheProvider.GetAsync(elementKey).ConfigureAwait(false) ?? throw new KeyNotFoundException($"Visual element not found for specified key: '{elementKey}'"); - } - return element?.Value?.ToXaml() + var xamlValue = xaml?.Value; + if (!(xaml?.HasValue ?? false)) + xamlValue = _memoryCache.FirstOrDefault(e => e.Key == elementKey)?.ToXaml(); + + return xamlValue ?? string.Empty; } @@ -59,14 +64,18 @@ public async Task GetXamlAsync(string elementKey) #region Private Methods private Task SaveIntoCacheAsync(ServerUIElement[] elements) => - Task.WhenAll(elements.Select(e => _cacheProvider.SetAsync(e.Key, e, _settings.UIElementCacheExpiration))); + Task.WhenAll(elements.Select(e => _cacheProvider.SetAsync(e.Key, e.ToXaml(), _settings.UIElementCacheExpiration))); private Task DownloadServerElementsAsync() { if (_settings.ElementResolver is null) throw new DependencyRegistrationException("You need to set 'ElementGetter' in 'ConfigureServerDrivenUI(s=> s.RegisterElementGetter((k)=> yourApiCall(k)))' registration."); - return Task.WhenAll(_settings.CacheEntryKeys.Select(_settings.ElementResolver.GetElementAsync)); + return Task.WhenAll(_settings.CacheEntryKeys.Select(async k => { + var element = await _settings.ElementResolver.GetElementAsync(k).ConfigureAwait(false); + element.Key = k; + return element; + })); } private async Task GetElementsAsync() diff --git a/Maui.ServerDrivenUI/Services/XamlConverterService.cs b/Maui.ServerDrivenUI/Services/XamlConverterService.cs index 1943339..ffed00b 100644 --- a/Maui.ServerDrivenUI/Services/XamlConverterService.cs +++ b/Maui.ServerDrivenUI/Services/XamlConverterService.cs @@ -1,9 +1,15 @@ -using System.Text; +using Newtonsoft.Json.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; namespace Maui.ServerDrivenUI.Services; internal static class XamlConverterService { + internal static Dictionary LabelsSpans = new Dictionary(); + public static string ConvertToXml(ServerUIElement element) { var strBuilder = new StringBuilder(); @@ -19,19 +25,89 @@ public static string ConvertToXml(ServerUIElement element) foreach (var cn in element.CustomNamespaces) strBuilder.AppendLine($"xmlns:{cn.Alias}=\"clr-namespace:{cn.Namespace}{ParseAssembly(cn)}\""); - foreach (var prop in element.Properties) - strBuilder.AppendLine($"{prop.Key}=\"{prop.Value}\""); + var contentPropertiesBuilder = ParseProperties(element, strBuilder); strBuilder.Append('>'); strBuilder.AppendLine(); - strBuilder.AppendJoin('\n', element.Content.Select(c => c.ToXaml())); + strBuilder.AppendJoin('\n', element.Content.Select(c => c.ToXaml(element.CustomNamespaces))); + strBuilder.AppendLine(); + strBuilder.Append(contentPropertiesBuilder); strBuilder.AppendLine(); strBuilder.Append($""); return strBuilder.ToString(); } + private static StringBuilder ParseProperties(ServerUIElement element, StringBuilder strBuilder) + { + var contentPropertiesBuilder = new StringBuilder(); + foreach (var prop in element.Properties) + { + if (prop.Value is JsonValue jv) + { + var valueStr = jv.GetValue(); + var teste2 = jv.ToString(); + strBuilder.AppendLine($"{prop.Key}=\"{valueStr}\""); + } + else if (prop.Value is JsonArray array) + { + contentPropertiesBuilder.AppendLine($"<{element.Type}.{prop.Key}>"); + + foreach (var innerJNode in array.Where(n => n != null)) + { + var innerElementJson = innerJNode!.ToString(); + var innerXaml = GetInnerXaml(innerElementJson, element.CustomNamespaces); + + contentPropertiesBuilder.AppendLine(innerXaml); + } + + contentPropertiesBuilder.AppendLine($""); + } + else + { + if (prop.Key.Equals("FormattedText", StringComparison.OrdinalIgnoreCase)) + { + FormattedStringWorkArround(prop.Value, element, strBuilder); + } + else + { + var innerElementJson = prop.Value.ToString(); + var innerXaml = GetInnerXaml(innerElementJson, element.CustomNamespaces); + + contentPropertiesBuilder.AppendLine($"<{element.Type}.{prop.Key}>"); + contentPropertiesBuilder.AppendLine(innerXaml); + contentPropertiesBuilder.AppendLine($""); + } + } + } + + return contentPropertiesBuilder; + } + + private static string GetInnerXaml(string innerElementJson, IList extraCustomNamespaces) + { + var innerElement = ParseUIElement(innerElementJson, extraCustomNamespaces); + var innerXaml = ConvertToXml(innerElement!); + return innerXaml; + } + + private static ServerUIElement? ParseUIElement(string innerElementJson, IList extraCustomNamespaces) + { + var innerElement = JsonSerializer.Deserialize(innerElementJson); + + if (extraCustomNamespaces?.Count > 0 && innerElement != null) + { + foreach (var customNamespace in extraCustomNamespaces) + { + if (!innerElement.CustomNamespaces.Contains(customNamespace)) + innerElement.CustomNamespaces.Add(customNamespace); + } + } + + return innerElement; + } + private static string ParseAssembly(CustomNamespace custom) { if (string.IsNullOrWhiteSpace(custom.Assembly)) @@ -39,4 +115,98 @@ private static string ParseAssembly(CustomNamespace custom) return $";assembly={custom.Assembly}"; } + + //HACK: This is a workaround to handle FormattedString, because it is not supported by XAML loader. + private static void FormattedStringWorkArround(JsonNode objectNode, ServerUIElement element, StringBuilder strBuilder) + { + var formattedString = new FormattedString(); + var innerElementJson = objectNode.ToString(); + var innerElement = ParseUIElement(innerElementJson, element.CustomNamespaces); + + if ((innerElement?.Properties.TryGetValue("Spans", out var spans) ?? false) && spans is JsonArray array) + { + foreach (var innerJNode in array.Where(n => n != null)) + { + var innerNodeJson = innerJNode!.ToString(); + + var span = new Span(); + var spanType = typeof(Span); + var uIElement = ParseUIElement(innerNodeJson, element.CustomNamespaces); + foreach (var propName in uIElement?.Properties ?? []) + { + var property = spanType.GetProperties() + .FirstOrDefault(p => p.Name.Equals(propName.Key, StringComparison.OrdinalIgnoreCase)); + + SetPropertyValue(property, propName.Value.ToString(), span, element.CustomNamespaces); + } + + formattedString.Spans.Add(span); + } + } + + if (formattedString.Spans.Count > 0) + { + var elementName = element.Properties.FirstOrDefault(p => p.Key.Equals("x:Name", StringComparison.OrdinalIgnoreCase)).Value?.ToString(); + if (string.IsNullOrWhiteSpace(elementName)) + { + elementName = Guid.NewGuid().ToString().Replace("-", string.Empty); + strBuilder.AppendLine($"x:Name=\"{elementName}\""); + } + + _ = LabelsSpans.TryAdd(elementName!, formattedString); + } + } + + private static void SetPropertyValue(PropertyInfo? property, string value, object instance, IList customNamespaces) + { + if (property == null) + return; + + if (value.StartsWith("{")) + { + value = value.TrimStart('{').TrimEnd('}'); + if (value.StartsWith("Binding")) + { + value = value.Replace("Binding ", string.Empty); + + var separator = value.IndexOf(","); + if (separator >= -1) + { + value = value[..separator]; + } + } + else if (value.StartsWith("x:Static")) + { + value = value.Replace("x:Static ", string.Empty); + var keys = value.Split(':'); + var alias = keys[0]; + var memberClass = keys[1].Split('.'); + var className = memberClass[0]; + var member = memberClass[1]; + + var customNamespace = customNamespaces.FirstOrDefault(c => c.Alias.Equals(alias, StringComparison.OrdinalIgnoreCase)); + var assembly = customNamespace?.Assembly != null + ? AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(assembly => assembly.GetName().Name == customNamespace.Assembly) + : Assembly.GetAssembly(Application.Current!.GetType()); + + var type = assembly?.GetType($"{customNamespace?.Namespace}.{className}"); + if (type != null) + { + var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static | + BindingFlags.FlattenHierarchy) + .Where(fi => fi.IsLiteral && !fi.IsInitOnly) + .ToList(); + + var field = fields.FirstOrDefault(f => f.Name.Equals(member, StringComparison.OrdinalIgnoreCase)); + var constantValue = field?.GetValue(null); + property.SetValue(instance, constantValue); + } + } + } + else if (Enum.TryParse(property.PropertyType.Name, true, out TypeCode enumValue)) + { + var convertedValue = Convert.ChangeType(value, enumValue); + property.SetValue(instance, convertedValue); + } + } } diff --git a/Maui.ServerDrivenUI/Views/ServerDrivenVisualElement.cs b/Maui.ServerDrivenUI/Views/ServerDrivenVisualElement.cs index bfc68d0..e038e81 100644 --- a/Maui.ServerDrivenUI/Views/ServerDrivenVisualElement.cs +++ b/Maui.ServerDrivenUI/Views/ServerDrivenVisualElement.cs @@ -5,8 +5,9 @@ namespace Maui.ServerDrivenUI.Views; internal class ServerDrivenVisualElement { private const string SERVICE_NOT_FOUND = "IServerDrivenUIService not found, make sure you are calling 'ConfigureServerDrivenUI(s=> s.RegisterElementGetter((k)=> yourApiCall(k)))'"; + private const int MAX_RETRIES = 3; - internal static async Task InitializeComponentAsync(IServerDrivenVisualElement element) + internal static async Task InitializeComponentAsync(IServerDrivenVisualElement element, int attempt = 0) { try { @@ -22,18 +23,35 @@ internal static async Task InitializeComponentAsync(IServerDrivenVisualElement e .GetXamlAsync(element.ServerKey ?? element.GetType().Name) .ConfigureAwait(false); - MainThread.BeginInvokeOnMainThread(() => - { + MainThread.BeginInvokeOnMainThread(() => { var onLoaded = element.OnLoaded; var visualElement = (element as VisualElement); var currentBindingContext = visualElement?.BindingContext; - visualElement?.LoadFromXaml(xaml); + try + { + visualElement?.LoadFromXaml(xaml); + + if (XamlConverterService.LabelsSpans.Any()) + { + foreach (var labelSpan in XamlConverterService.LabelsSpans) + { + if (visualElement?.FindByName