From dbc1c61218695d3618413fd27acad68e960d2acd Mon Sep 17 00:00:00 2001 From: Jeff H Date: Wed, 17 Dec 2025 00:41:12 -0500 Subject: [PATCH 01/15] init refactor for underlying infrastructure of carousel from SBC to Maui. Views WIP. no build --- App.xaml | 2 + Components/Carousel/GlobalBroadcaster.cs | 88 ++++ Components/Carousel/IDateNow.cs | 11 + Components/Carousel/ISortable.cs | 6 + Components/Carousel/LibraryExtensions.cs | 40 ++ Components/Carousel/NotifyPropertyChanged.cs | 45 ++ Components/Carousel/RenderCanvas.xaml | 11 + Components/Carousel/RenderCanvas.xaml.cs | 314 ++++++++++++++ Components/Carousel/RenderListView.xaml | 275 +++++++++++++ Components/Carousel/RenderListView.xaml.cs | 226 ++++++++++ Components/Carousel/RenderListViewAdapter.cs | 387 ++++++++++++++++++ Components/Carousel/RenderListViewModel.cs | 232 +++++++++++ Components/Carousel/SkiaSharpExtensions.cs | 118 ++++++ Components/Carousel/SortOrder.cs | 8 + Components/Carousel/SortProperty.cs | 8 + Components/GalleryPopup.xaml | 58 +++ Components/GalleryPopup.xaml.cs | 63 +++ Components/ToolbarView.xaml | 11 - Converters/BooleanConverters.cs | 35 ++ Documentation/ViewCell_Rendering_Technique.md | 174 ++++++++ Logic/Constants/AppConstants.cs | 70 ++++ Logic/Extensions/SkiaSharpExtensions.cs | 38 ++ Logic/Models/DrawableEllipse.cs | 2 +- Logic/Models/DrawableGroup.cs | 2 +- Logic/Models/DrawableImage.cs | 2 +- Logic/Models/DrawableLine.cs | 2 +- Logic/Models/DrawablePath.cs | 2 +- Logic/Models/DrawableRectangle.cs | 2 +- Logic/Models/DrawableStamps.cs | 2 +- Logic/Models/ExternalModels.cs | 75 ++++ Logic/Models/IDrawableElement.cs | 2 +- Logic/Models/Layer.cs | 2 +- Logic/Models/NavigationModel.cs | 52 +-- Logic/Utils/DrawingStorageMomento.cs | 344 ++++++++++++++++ Logic/ViewModels/GalleryViewModel.cs | 112 +++++ Logic/ViewModels/MainViewModel.cs | 147 ++++++- Logic/ViewModels/ToolbarViewModel.cs | 85 ---- LunaDraw.csproj | 8 + MauiProgram.cs | 2 + Pages/MainPage.xaml | 5 + Pages/MainPage.xaml.cs | 69 +++- README.md | 2 +- Resources/Images/icon_more_vert.svg | 1 + .../DrawingStorageMomentoTests.cs | 140 +++++++ 44 files changed, 3127 insertions(+), 153 deletions(-) create mode 100644 Components/Carousel/GlobalBroadcaster.cs create mode 100644 Components/Carousel/IDateNow.cs create mode 100644 Components/Carousel/ISortable.cs create mode 100644 Components/Carousel/LibraryExtensions.cs create mode 100644 Components/Carousel/NotifyPropertyChanged.cs create mode 100644 Components/Carousel/RenderCanvas.xaml create mode 100644 Components/Carousel/RenderCanvas.xaml.cs create mode 100644 Components/Carousel/RenderListView.xaml create mode 100644 Components/Carousel/RenderListView.xaml.cs create mode 100644 Components/Carousel/RenderListViewAdapter.cs create mode 100644 Components/Carousel/RenderListViewModel.cs create mode 100644 Components/Carousel/SkiaSharpExtensions.cs create mode 100644 Components/Carousel/SortOrder.cs create mode 100644 Components/Carousel/SortProperty.cs create mode 100644 Components/GalleryPopup.xaml create mode 100644 Components/GalleryPopup.xaml.cs create mode 100644 Converters/BooleanConverters.cs create mode 100644 Documentation/ViewCell_Rendering_Technique.md create mode 100644 Logic/Constants/AppConstants.cs create mode 100644 Logic/Models/ExternalModels.cs create mode 100644 Logic/Utils/DrawingStorageMomento.cs create mode 100644 Logic/ViewModels/GalleryViewModel.cs create mode 100644 Resources/Images/icon_more_vert.svg create mode 100644 tests/LunaDraw.Tests/DrawingStorageMomentoTests.cs diff --git a/App.xaml b/App.xaml index fc81ac9..3931f50 100644 --- a/App.xaml +++ b/App.xaml @@ -12,6 +12,8 @@ + + diff --git a/Components/Carousel/GlobalBroadcaster.cs b/Components/Carousel/GlobalBroadcaster.cs new file mode 100644 index 0000000..fa2d7a6 --- /dev/null +++ b/Components/Carousel/GlobalBroadcaster.cs @@ -0,0 +1,88 @@ +namespace LunaDraw.Components.Carousel; + +using System; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using CommunityToolkit.Mvvm.Messaging; + +public enum AppMessageStateType +{ + ThreadMonitorState = 1 << 0, + ImageLoadingState = 1 << 1, + ParallaxScrollState = 1 << 2 +} + +public class GlobalBroadcaster +{ + public static IDisposable Broadcast( + TSender sender, + AppMessageStateType messageCenterType = AppMessageStateType.ThreadMonitorState, + TimeSpan? delay = null) where TSender : class + { + return Observable.Create(x => + { + x.OnNext(Unit.Default); + return Disposable.Create(() => { }); + }) + .Throttle(delay ?? TimeSpan.FromMilliseconds(189)) + .Subscribe(x => + { + MainThread.BeginInvokeOnMainThread(() => + { + try + { + WeakReferenceMessenger.Default.Send(sender, messageCenterType.ToString()); + } + catch (Exception ex) + { + // Crashes.TrackError(ex); + } + }); + + // TODO: Change this into a before and after middleware factory based of sender type + //if (!(sender is SetMoreMenuVisibility) || + // sender is SetMoreMenuVisibility setMoreMenuVisibility && + // !setMoreMenuVisibility.IsVisible) + // MessagingCenter.Send(new SetMoreMenuVisibility(), messageCenterType.ToString()); + }); + } + + public static IDisposable Subscribe( + object subscriber, + AppMessageStateType messageCenterType = AppMessageStateType.ThreadMonitorState, + MessageHandler? callBackAction = null) + where T : class + { + WeakReferenceMessenger.Default.Register(subscriber, + messageCenterType.ToString(), + callBackAction!); + + return Disposable.Create(() => WeakReferenceMessenger.Default.Unregister(subscriber, messageCenterType.ToString())); + } +} + +public class GlobalMessage +{ + public required dynamic Arguments { get; set; } + public AppMessageStateType MessageCenterType { get; set; } + public required dynamic Subscriber { get; set; } +} + +public enum ImageLoadingType +{ + NotLoading = 0, + IsLoading = 1, + ForceRedraw = 2 +} + +public class ImageLoadingState +{ + public ImageLoadingState(ImageLoadingType state) + { + LoadingState = state; + } + + public ImageLoadingType LoadingState { get; set; } + public AppMessageStateType Type { get; set; } +} \ No newline at end of file diff --git a/Components/Carousel/IDateNow.cs b/Components/Carousel/IDateNow.cs new file mode 100644 index 0000000..08d395a --- /dev/null +++ b/Components/Carousel/IDateNow.cs @@ -0,0 +1,11 @@ + +namespace LunaDraw.Components.Carousel; + +using System; + +internal interface IDateNow +{ + DateTimeOffset DateCreated { get; } + + DateTimeOffset DateUpdated { get; } +} diff --git a/Components/Carousel/ISortable.cs b/Components/Carousel/ISortable.cs new file mode 100644 index 0000000..d09d90d --- /dev/null +++ b/Components/Carousel/ISortable.cs @@ -0,0 +1,6 @@ + +namespace LunaDraw.Components.Carousel; + +internal interface ISortable : IDateNow +{ +} diff --git a/Components/Carousel/LibraryExtensions.cs b/Components/Carousel/LibraryExtensions.cs new file mode 100644 index 0000000..f272461 --- /dev/null +++ b/Components/Carousel/LibraryExtensions.cs @@ -0,0 +1,40 @@ +using SkiaSharp; + +namespace LunaDraw.Components.Carousel; + +public static class LibraryExtensions +{ + + public static SKPaint BasePaint => new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = SKColors.Black, + StrokeWidth = 3, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round, + IsAntialias = true + }; + + public static SKMatrix MaxScaleCentered(this SKCanvas canvas, + int width, + int height, + SKRect bounds, + float imageX = 0, + float imageY = 0, + float imageScale = 1) + { + canvas.Translate(width / 2f, height / 2f); + + var ratio = bounds.Width < bounds.Height + ? height / bounds.Height + : width / bounds.Width; + + canvas.Scale(ratio); + canvas.Translate(-bounds.MidX + imageX, -bounds.MidY + imageY); + + if (imageScale != 1) + canvas.Scale(imageScale); + + return canvas.TotalMatrix; + } +} \ No newline at end of file diff --git a/Components/Carousel/NotifyPropertyChanged.cs b/Components/Carousel/NotifyPropertyChanged.cs new file mode 100644 index 0000000..aaa7acd --- /dev/null +++ b/Components/Carousel/NotifyPropertyChanged.cs @@ -0,0 +1,45 @@ +namespace LunaDraw.Components.Carousel; + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; + +public abstract class NotifyPropertyChanged : INotifyPropertyChanged +{ + event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged + { + add => PropertyChanged += value; + remove => PropertyChanged -= value; + } + + private event PropertyChangedEventHandler? PropertyChanged; + + protected bool SetProperty(ref T backingStore, T value, + [CallerMemberName] string propertyName = "", + Action? onChanged = null) + { + if (EqualityComparer.Default.Equals(backingStore, value)) + return false; + + backingStore = value; + + onChanged?.Invoke(); + OnPropertyChanged(propertyName); + + return true; + } + + public IObservable WhenPropertyChanged + { + get => Observable + .FromEventPattern( + h => PropertyChanged += h, + h => PropertyChanged -= h) + .Select(x => x.EventArgs.PropertyName ?? ""); + } + + protected void OnPropertyChanged(string propertyName) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} \ No newline at end of file diff --git a/Components/Carousel/RenderCanvas.xaml b/Components/Carousel/RenderCanvas.xaml new file mode 100644 index 0000000..fdfbe69 --- /dev/null +++ b/Components/Carousel/RenderCanvas.xaml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/Components/Carousel/RenderCanvas.xaml.cs b/Components/Carousel/RenderCanvas.xaml.cs new file mode 100644 index 0000000..64e6d04 --- /dev/null +++ b/Components/Carousel/RenderCanvas.xaml.cs @@ -0,0 +1,314 @@ +namespace LunaDraw.Components.Carousel +{ + using LunaDraw.Logic.Models; + using CodeSoupCafe.Xamarin.Extensions; + using SkiaSharp; + using SkiaSharp.Views.Maui; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reactive.Linq; + using System.Reactive.Subjects; + + public abstract class ItemState : IEquatable, ISortable + { + public abstract DateTimeOffset DateCreated { get; } + public abstract DateTimeOffset DateUpdated { get; } + + public Dictionary Properties = []; + + public object? this[string key] + { + get + { + return default; + } + } + + /// + /// TODO: Use reflection to implement the abstract Equals and GetHashCode in the inherited class + /// + /// + /// + public override abstract bool Equals(object? other); + public override abstract int GetHashCode(); + } + + public class RenderViewModel : NotifyPropertyChanged + { + internal static TimeSpan RenderCanvasTriggerChartUpdateDelay = TimeSpan.FromMilliseconds(187); + private string? id; + private ItemState? state; + private bool isLoading; + + public string? Id + { + get => id; + set => SetProperty(ref id, value); + } + + public bool IsLoading + { + get => isLoading; + set => SetProperty(ref isLoading, value); + } + public ItemState? ChartState + { + get => state; + set => SetProperty(ref state, value); + } + } + + public partial class RenderCanvas : ContentView, IDisposable + { + public static readonly BindableProperty ViewModelProperty = + BindableProperty.Create(nameof(ViewModel), typeof(RenderViewModel), typeof(RenderCanvas), propertyChanged: OnViewModelChanged); + + private readonly Subject resetTriggered = new Subject(); + private readonly List subscriptions = new List(); + public SKImage? Image; + private List? drawing; + private int? establishedWidth, establishedHeight = 0; + + public RenderCanvas() + { + InitializeComponent(); + + try + { + CanvasViewRef?.PaintSurface += OnCanvasViewPaintSurface; + + CreateSubscriptions(); + } + catch // (Exception ex) + { + // Crashes.TrackError(ex); + } + } + + public IObservable ResetTriggered => resetTriggered.AsObservable(); + + public RenderViewModel ViewModel + { + get => (RenderViewModel)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + + public void OnCanvasViewPaintSurface(object? sender, SKPaintSurfaceEventArgs args) + { + try + { + //if (Image != null) + //{ + // var screenBounds = new SKRect(0, 0, args.Info.Width, args.Info.Height); + // var imageBounds = SKMatrix.CreateIdentity().MapRect(screenBounds); + + // imageBounds = imageBounds.AspectFitFill(Image.Width, Image.Height); + // args.Surface?.Canvas.DrawImage(Image, imageBounds); + + // return; + //} + + if (isLoaded || + args?.Surface?.Canvas == null || + drawing == null || + ViewModel?.Id == null) + return; + + SKSurface? surface = args.Surface; + surface?.Canvas?.Clear(BackgroundColor.ToSKColor()); + + ViewModel.IsLoading = true; + + var pathBounds = new SKPath(); + + var bounds = pathBounds?.Bounds.AspectFill(new SKSize(args.Info.Width * 0.9f, args.Info.Height * 0.9f)); + + if (bounds == null) + { + var opacityPaint = LibraryExtensions.BasePaint.AsOpacity(10); + opacityPaint.Style = SKPaintStyle.Fill; + + surface?.Canvas?.DrawRect(0, 0, args.Info.Width, args.Info.Height, opacityPaint); + + return; + } + + surface?.Canvas?.MaxScaleCentered( + Convert.ToInt32(args.Info.Width * 0.95), + Convert.ToInt32(args.Info.Height * 0.95), + bounds.Value, + Convert.ToInt32(args.Info.Width * 0.05), + Convert.ToInt32(args.Info.Height * 0.05), + 1); + + if (surface?.Canvas == null) + return; + + // surface?.RenderChart(new RenderChartModel(ViewModel.ChartId, ViewModel.CollectionType, drawing)); + surface?.Canvas?.Flush(); + + isLoaded = true; + + if (Image != null) + Image.Dispose(); + + Image = surface?.Snapshot(); + } + catch //(Exception ex) + { + // Crashes.TrackError(ex); + } + finally + { + ViewModel.IsLoading = false; + } + } + + private static void OnGenericPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + if (oldValue == newValue || + newValue == null) return; + + var self = (RenderCanvas)bindable; + self.isLoaded = false; + + self.resetTriggered.OnNext(false); + } + + private static void OnViewModelChanged(BindableObject bindable, object oldValue, object newValue) + { + if (newValue is RenderViewModel _) + { + var self = (RenderCanvas)bindable; + + self.isLoaded = false; + self.CreateViewModelSubs(self); + self.resetTriggered.OnNext(false); + } + + return; + } + + private void CreateSubscriptions() + { + subscriptions.Add(ResetTriggered + .Subscribe(x => LoadAndReset())); + } + + private bool isViewModelSubsSet; + + private void CreateViewModelSubs(RenderCanvas self) + { + if (isViewModelSubsSet) + return; + + isViewModelSubsSet = true; + + self.subscriptions.Add(self.ViewModel.WhenPropertyChanged + .Where(x => x == nameof(ItemState)) + .Throttle(RenderViewModel.RenderCanvasTriggerChartUpdateDelay) + .Subscribe(propertyName => + { + self.isLoaded = false; + self.resetTriggered.OnNext(false); + })); + + self.subscriptions.Add(GlobalBroadcaster.Subscribe(self, + AppMessageStateType.ImageLoadingState, + (_, imageLoadingState) => + { + switch (imageLoadingState.LoadingState) + { + case ImageLoadingType.IsLoading: + self.resetTriggered.OnNext(false); + break; + case ImageLoadingType.ForceRedraw: + self.isLoaded = false; + self.Image?.Dispose(); + self.Image = null; + + self.resetTriggered.OnNext(false); + break; + } + })); + } + + private void LoadAndReset() + { + if (isDisposed + || ViewModel?.ChartState?.Properties == null) + return; + + MainThread.BeginInvokeOnMainThread(() => + { + if (isDisposed) + return; + + if (CanvasViewRef != null) + try + { + CanvasViewRef?.InvalidateSurface(); + } + catch// (Exception ex) + { + // Crashes.TrackError(ex); + } + }); + } + + #region IDisposable Support + + private bool disposedValue = false; // To detect redundant calls + private bool isDisposed; + private bool isLoaded; + + // This code added to correctly implement the disposable pattern. + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + // TODO: uncomment the following line if the finalizer is overridden above. + // GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + try + { + // TODO: dispose managed state (managed objects). + subscriptions?.ForEach(x => x.Dispose()); + subscriptions?.Clear(); + + CanvasViewRef.PaintSurface -= OnCanvasViewPaintSurface; + + isDisposed = true; + } + catch //(Exception ex) + { + // Crashes.TrackError(ex); + } + } + + // TODO: free unmanaged resources (unmanaged objects) and override a finalizer below. + // TODO: set large fields to null. + + disposedValue = true; + } + } + + // TODO: override a finalizer only if Dispose(bool disposing) above has code to free unmanaged resources. + ~RenderCanvas() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + //Dispose(false); + } + + #endregion IDisposable Support + } +} \ No newline at end of file diff --git a/Components/Carousel/RenderListView.xaml b/Components/Carousel/RenderListView.xaml new file mode 100644 index 0000000..236cca3 --- /dev/null +++ b/Components/Carousel/RenderListView.xaml @@ -0,0 +1,275 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +