diff --git a/Simple QR Code Maker.Core/Simple QR Code Maker.Core.csproj b/Simple QR Code Maker.Core/Simple QR Code Maker.Core.csproj index d2f96c3..8c1cb13 100644 --- a/Simple QR Code Maker.Core/Simple QR Code Maker.Core.csproj +++ b/Simple QR Code Maker.Core/Simple QR Code Maker.Core.csproj @@ -11,6 +11,6 @@ - + diff --git a/Simple QR Code Maker/Controls/IconAndTextContent.xaml b/Simple QR Code Maker/Controls/IconAndTextContent.xaml new file mode 100644 index 0000000..4583fe8 --- /dev/null +++ b/Simple QR Code Maker/Controls/IconAndTextContent.xaml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/Simple QR Code Maker/Controls/IconAndTextContent.xaml.cs b/Simple QR Code Maker/Controls/IconAndTextContent.xaml.cs new file mode 100644 index 0000000..3f6f92e --- /dev/null +++ b/Simple QR Code Maker/Controls/IconAndTextContent.xaml.cs @@ -0,0 +1,26 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Simple_QR_Code_Maker.Controls; + +public sealed partial class IconAndTextContent : StackPanel +{ + public IconAndTextContent() + { + InitializeComponent(); + } + + public Symbol Icon + { get => (Symbol)GetValue(IconProperty); set => SetValue(IconProperty, value); + } + + public static readonly DependencyProperty IconProperty = + DependencyProperty.Register(nameof(Icon), typeof(Symbol), typeof(IconAndTextContent), new PropertyMetadata(Symbol.Placeholder)); + + public string ContentText + { get => (string)GetValue(ContentTextProperty); set => SetValue(ContentTextProperty, value); + } + + public static readonly DependencyProperty ContentTextProperty = + DependencyProperty.Register(nameof(ContentText), typeof(string), typeof(IconAndTextContent), new PropertyMetadata(string.Empty)); +} diff --git a/Simple QR Code Maker/Helpers/BarcodeHelpers.cs b/Simple QR Code Maker/Helpers/BarcodeHelpers.cs index 51ff1eb..53f4bf6 100644 --- a/Simple QR Code Maker/Helpers/BarcodeHelpers.cs +++ b/Simple QR Code Maker/Helpers/BarcodeHelpers.cs @@ -14,7 +14,28 @@ namespace Simple_QR_Code_Maker.Helpers; public static class BarcodeHelpers { - public static WriteableBitmap GetQrCodeBitmapFromText(string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background) + /// + /// Calculate the maximum safe logo size percentage based on QR code error correction level and version + /// + /// The text to encode in the QR code + /// The error correction level + /// Maximum safe logo size as a percentage (0-100) + // public static int GetMaxLogoSizePercentage(string text, ErrorCorrectionLevel correctionLevel) + public static int GetMaxLogoSizePercentage(ErrorCorrectionLevel correctionLevel) + { + // Error correction capacity by level (percentage of code that can be damaged and still readable) + // These are the theoretical maximum recovery percentages + return correctionLevel.ToString() switch + { + "L" => 20, // Low: ~7% + "M" => 23, // Medium: ~15% + "Q" => 35, // Quartile: ~25% + "H" => 40, // High: ~30% + _ => 20 + }; + } + + public static WriteableBitmap GetQrCodeBitmapFromText(string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage = null, double logoSizePercentage = 20.0, double logoPaddingPixels = 8.0) { BitmapRenderer bitmapRenderer = new() { @@ -38,6 +59,16 @@ public static WriteableBitmap GetQrCodeBitmapFromText(string text, ErrorCorrecti barcodeWriter.Options = encodingOptions; using Bitmap bitmap = barcodeWriter.Write(text); + + // If a logo is provided, overlay it on the center of the QR code + if (logoImage != null) + { + // Get the QR code details to calculate module size + QRCode qrCode = ZXing.QrCode.Internal.Encoder.encode(text, correctionLevel); + int moduleCount = qrCode.Version.DimensionForVersion; + OverlayLogoOnQrCode(bitmap, logoImage, logoSizePercentage, moduleCount, encodingOptions.Margin, logoPaddingPixels, background); + } + using MemoryStream ms = new(); bitmap.Save(ms, ImageFormat.Png); WriteableBitmap bitmapImage = new(encodingOptions.Width, encodingOptions.Height); @@ -47,6 +78,81 @@ public static WriteableBitmap GetQrCodeBitmapFromText(string text, ErrorCorrecti return bitmapImage; } + private static void OverlayLogoOnQrCode(Bitmap qrCodeBitmap, Bitmap logo, double sizePercentage, int moduleCount, int margin, double logoPaddingPixels, System.Drawing.Color backgroundColor) + { + // Calculate the pixel size of each QR code module + // The total size includes the margin on both sides + int totalModules = moduleCount + (margin * 2); + double modulePixelSize = (double)qrCodeBitmap.Width / totalModules; + + // Calculate the punchout space size based on the size percentage + // This is the area that will be covered (blocking QR code modules) + int punchoutSizePixels = (int)(Math.Min(qrCodeBitmap.Width, qrCodeBitmap.Height) * (sizePercentage / 100.0)); + + // Round the punchout size to the nearest module boundary + int punchoutSizeModules = (int)Math.Round(punchoutSizePixels / modulePixelSize); + // Ensure it's at least 1 module and odd number for better centering + if (punchoutSizeModules < 1) punchoutSizeModules = 1; + if (punchoutSizeModules % 2 == 0) punchoutSizeModules++; // Make it odd for symmetry + + // Convert back to pixels, aligned to module boundaries + int punchoutSize = (int)(punchoutSizeModules * modulePixelSize); + + // Calculate the position to center the punchout area + int punchoutX = (qrCodeBitmap.Width - punchoutSize) / 2; + int punchoutY = (qrCodeBitmap.Height - punchoutSize) / 2; + + // Convert padding to actual pixels + // Positive padding = logo smaller than punchout (adds white space) + // Negative padding = logo larger than punchout (logo extends beyond white background) + int paddingPixels = (int)Math.Round(logoPaddingPixels); + + // Calculate the actual logo display size + // Logo fills the punchout area minus the padding on all sides + int logoDisplayWidth = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); + int logoDisplayHeight = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); + + // If padding is negative, logo is larger than punchout + if (paddingPixels < 0) + { + logoDisplayWidth = punchoutSize + (Math.Abs(paddingPixels) * 2); + logoDisplayHeight = punchoutSize + (Math.Abs(paddingPixels) * 2); + } + + // Calculate logo dimensions preserving aspect ratio + float aspectRatio = (float)logo.Width / logo.Height; + int logoWidth, logoHeight; + + if (aspectRatio > 1) // Wider than tall + { + logoWidth = logoDisplayWidth; + logoHeight = (int)(logoDisplayWidth / aspectRatio); + } + else // Taller than wide or square + { + logoHeight = logoDisplayHeight; + logoWidth = (int)(logoDisplayHeight * aspectRatio); + } + + // Center the logo within the punchout area (or offset if larger) + int logoX = punchoutX + (punchoutSize - logoWidth) / 2; + int logoY = punchoutY + (punchoutSize - logoHeight) / 2; + + using Graphics g = Graphics.FromImage(qrCodeBitmap); + // Set high quality rendering + g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality; + g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; + g.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality; + + // Draw the punchout background with the same color as the QR code background + using SolidBrush backgroundBrush = new(backgroundColor); + g.FillRectangle(backgroundBrush, punchoutX, punchoutY, punchoutSize, punchoutSize); + + // Draw the logo scaled to fit within or extend beyond the punchout + g.DrawImage(logo, logoX, logoY, logoWidth, logoHeight); + } + /// /// Calculate the smallest side of a QR code based on the distance between the camera and the QR code /// @@ -106,7 +212,7 @@ public static string SmallestSideWithUnits(double distance, int numberOfBlocks, return $"{smallestSideCm:F2} x {smallestSideCm:F2} cm"; } - public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background) + public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage = null, double logoSizePercentage = 20.0, double logoPaddingPixels = 8.0) { SvgRenderer svgRenderer = new() { @@ -131,9 +237,114 @@ public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel cor SvgImage svg = barcodeWriter.Write(text); + // If a logo is provided, embed it in the SVG + if (logoImage != null) + { + // Get the QR code details to calculate module size + QRCode qrCode = ZXing.QrCode.Internal.Encoder.encode(text, correctionLevel); + int moduleCount = qrCode.Version.DimensionForVersion; + svg = EmbedLogoInSvg(svg, logoImage, logoSizePercentage, moduleCount, encodingOptions.Margin, logoPaddingPixels, background); + } + return svg; } + private static SvgImage EmbedLogoInSvg(SvgImage svg, Bitmap logo, double sizePercentage, int moduleCount, int margin, double logoPaddingPixels, System.Drawing.Color backgroundColor) + { + const int svgSize = 1024; // Should match the encoding options Width/Height + + // Calculate the pixel size of each QR code module + int totalModules = moduleCount + (margin * 2); + double modulePixelSize = (double)svgSize / totalModules; + + // Calculate the punchout space size based on the size percentage + // This is the area that will be covered (blocking QR code modules) + int punchoutSizePixels = (int)(svgSize * (sizePercentage / 100.0)); + + // Round the punchout size to the nearest module boundary + int punchoutSizeModules = (int)Math.Round(punchoutSizePixels / modulePixelSize); + if (punchoutSizeModules < 1) punchoutSizeModules = 1; + if (punchoutSizeModules % 2 == 0) punchoutSizeModules++; + + int punchoutSize = (int)(punchoutSizeModules * modulePixelSize); + + // Calculate the position to center the punchout area + int punchoutX = (svgSize - punchoutSize) / 2; + int punchoutY = (svgSize - punchoutSize) / 2; + + // Convert padding to actual pixels + // Positive padding = logo smaller than punchout (adds white space) + // Negative padding = logo larger than punchout (logo extends beyond white background) + int paddingPixels = (int)Math.Round(logoPaddingPixels); + + // Calculate the actual logo display size + // Logo fills the punchout area minus the padding on all sides + int logoDisplayWidth = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); + int logoDisplayHeight = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); + + // If padding is negative, logo is larger than punchout + if (paddingPixels < 0) + { + logoDisplayWidth = punchoutSize + (Math.Abs(paddingPixels) * 2); + logoDisplayHeight = punchoutSize + (Math.Abs(paddingPixels) * 2); + } + + // Calculate logo dimensions preserving aspect ratio + float aspectRatio = (float)logo.Width / logo.Height; + int logoWidth, logoHeight; + + if (aspectRatio > 1) // Wider than tall + { + logoWidth = logoDisplayWidth; + logoHeight = (int)(logoDisplayWidth / aspectRatio); + } + else // Taller than wide or square + { + logoHeight = logoDisplayHeight; + logoWidth = (int)(logoDisplayHeight * aspectRatio); + } + + // Center the logo within the punchout area (or offset if larger) + int logoX = punchoutX + (punchoutSize - logoWidth) / 2; + int logoY = punchoutY + (punchoutSize - logoHeight) / 2; + + // Resize the logo to the display size before encoding to reduce SVG file size + Bitmap resizedLogo = new(logoWidth, logoHeight); + using (Graphics g = Graphics.FromImage(resizedLogo)) + { + g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality; + g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; + g.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality; + g.DrawImage(logo, 0, 0, logoWidth, logoHeight); + } + + // Convert resized logo to base64 for embedding + string base64Logo; + using (MemoryStream ms = new()) + { + resizedLogo.Save(ms, ImageFormat.Png); + byte[] imageBytes = ms.ToArray(); + base64Logo = Convert.ToBase64String(imageBytes); + } + resizedLogo.Dispose(); + + // Build the SVG logo element with punchout background and logo + // Convert the background color to RGB format for SVG + string backgroundColorHex = $"rgb({backgroundColor.R},{backgroundColor.G},{backgroundColor.B})"; + + string logoSvgElement = $@" + + + + "; + + // Find the closing tag and insert the logo before it + string modifiedContent = svg.Content.Replace("", logoSvgElement + "\n"); + + return new SvgImage(modifiedContent); + } + public static IEnumerable<(string, Result)> GetStringsFromImageFile(StorageFile storageFile) { Bitmap bitmap = new(storageFile.Path); @@ -148,14 +359,14 @@ public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel cor AutoRotate = true, Options = { - TryHarder = true, - TryInverted = true, - } + TryHarder = true, + TryInverted = true, +} }; Result[] results = barcodeReader.DecodeMultiple(bitmap); - List<(string, Result)> strings = new(); + List<(string, Result)> strings = []; if (results == null || results.Length == 0) return strings; diff --git a/Simple QR Code Maker/Models/BarcodeImageItem.cs b/Simple QR Code Maker/Models/BarcodeImageItem.cs index b943b94..f0dc4c0 100644 --- a/Simple QR Code Maker/Models/BarcodeImageItem.cs +++ b/Simple QR Code Maker/Models/BarcodeImageItem.cs @@ -34,6 +34,12 @@ public partial class BarcodeImageItem : ObservableRecipient public Windows.UI.Color BackgroundColor { get; set; } + public System.Drawing.Bitmap? LogoImage { get; set; } + + public double LogoSizePercentage { get; set; } = 20.0; + + public double LogoPaddingPixels { get; set; } = 8.0; + public QRCode QRCodeDetails => Encoder.encode(CodeAsText, ErrorCorrection); public string ToolTipText => $"Smallest recommended size {SmallestSide}, {CodeAsText}"; @@ -63,7 +69,7 @@ public async Task SaveCodeAsSvgFile(StorageFile file, System.Drawing.Color { try { - SvgImage svgImage = BarcodeHelpers.GetSvgQrCodeForText(CodeAsText, correctionLevel, foreground, background); + SvgImage svgImage = BarcodeHelpers.GetSvgQrCodeForText(CodeAsText, correctionLevel, foreground, background, LogoImage, LogoSizePercentage, LogoPaddingPixels); using IRandomAccessStream randomAccessStream = await file.OpenAsync(FileAccessMode.ReadWrite); DataWriter dataWriter = new(randomAccessStream); dataWriter.WriteString(svgImage.Content); @@ -81,7 +87,7 @@ public string GetCodeAsSvgText(System.Drawing.Color foreground, System.Drawing.C { try { - SvgImage svgImage = BarcodeHelpers.GetSvgQrCodeForText(CodeAsText, correctionLevel, foreground, background); + SvgImage svgImage = BarcodeHelpers.GetSvgQrCodeForText(CodeAsText, correctionLevel, foreground, background, LogoImage, LogoSizePercentage, LogoPaddingPixels); return svgImage.Content; } catch @@ -126,6 +132,12 @@ private async Task SaveCodeSvgContext() [RelayCommand] private async Task CopyCodePngContext() { + if (CodeAsBitmap is null) + { + WeakReferenceMessenger.Default.Send(new RequestShowMessage("Failed to copy QR Code to the clipboard", "No QR Code to copy to the clipboard", InfoBarSeverity.Error)); + return; + } + StorageFolder folder = ApplicationData.Current.LocalCacheFolder; List files = []; diff --git a/Simple QR Code Maker/Models/HistoryItem.cs b/Simple QR Code Maker/Models/HistoryItem.cs index f413320..729c77f 100644 --- a/Simple QR Code Maker/Models/HistoryItem.cs +++ b/Simple QR Code Maker/Models/HistoryItem.cs @@ -39,6 +39,12 @@ public ErrorCorrectionLevel ErrorCorrection [JsonConverter(typeof(JsonStringEnumConverter))] public BarcodeFormat Format { get; set; } = BarcodeFormat.QR_CODE; + public string? LogoImagePath { get; set; } + + public double LogoSizePercentage { get; set; } = 15; + + public double LogoPaddingPixels { get; set; } = 4.0; + public HistoryItem() { diff --git a/Simple QR Code Maker/Simple QR Code Maker.csproj b/Simple QR Code Maker/Simple QR Code Maker.csproj index fa262d1..b4bec46 100644 --- a/Simple QR Code Maker/Simple QR Code Maker.csproj +++ b/Simple QR Code Maker/Simple QR Code Maker.csproj @@ -33,6 +33,7 @@ + @@ -47,15 +48,15 @@ - + - - + + - - - - + + + + @@ -65,6 +66,9 @@ Always + + MSBuild:Compile + MSBuild:Compile diff --git a/Simple QR Code Maker/ViewModels/DecodingViewModel.cs b/Simple QR Code Maker/ViewModels/DecodingViewModel.cs index 2058538..50ba22f 100644 --- a/Simple QR Code Maker/ViewModels/DecodingViewModel.cs +++ b/Simple QR Code Maker/ViewModels/DecodingViewModel.cs @@ -36,7 +36,7 @@ public partial class DecodingViewModel : ObservableRecipient, INavigationAware [ObservableProperty] private ObservableCollection decodingImageItems = []; - private string passedUrl = string.Empty; + private HistoryItem? navigationHistoryItem = null; private INavigationService NavigationService { get; } @@ -78,8 +78,16 @@ private void CheckIfCanPaste() public void OnNavigatedTo(object parameter) { - if (parameter is string url) - passedUrl = url; + // Store the HistoryItem to pass back when returning to main page + if (parameter is HistoryItem historyItem) + { + navigationHistoryItem = historyItem; + } + // For backward compatibility, also handle string parameter + else if (parameter is string url) + { + navigationHistoryItem = new HistoryItem { CodesContent = url }; + } } [RelayCommand] @@ -127,7 +135,7 @@ private async Task TryLaunchLink() [RelayCommand] private void GoBack() { - NavigationService.NavigateTo(typeof(MainViewModel).FullName!, passedUrl); + NavigationService.NavigateTo(typeof(MainViewModel).FullName!, navigationHistoryItem); } [RelayCommand] @@ -174,7 +182,21 @@ private void EditQrCode(object parameter) if (string.IsNullOrWhiteSpace(InfoBarMessage)) return; - NavigationService.NavigateTo(typeof(MainViewModel).FullName!, InfoBarMessage); + // Create a new HistoryItem with the decoded text, preserving other state if available + var editHistoryItem = navigationHistoryItem != null + ? new HistoryItem + { + CodesContent = InfoBarMessage, + Foreground = navigationHistoryItem.Foreground, + Background = navigationHistoryItem.Background, + ErrorCorrection = navigationHistoryItem.ErrorCorrection, + LogoImagePath = navigationHistoryItem.LogoImagePath, + LogoSizePercentage = navigationHistoryItem.LogoSizePercentage, + LogoPaddingPixels = navigationHistoryItem.LogoPaddingPixels, + } + : new HistoryItem { CodesContent = InfoBarMessage }; + + NavigationService.NavigateTo(typeof(MainViewModel).FullName!, editHistoryItem); } private void OpenAndDecodeBitmap(IRandomAccessStreamWithContentType streamWithContentType) diff --git a/Simple QR Code Maker/ViewModels/MainViewModel.cs b/Simple QR Code Maker/ViewModels/MainViewModel.cs index dc55f19..8f1c6bf 100644 --- a/Simple QR Code Maker/ViewModels/MainViewModel.cs +++ b/Simple QR Code Maker/ViewModels/MainViewModel.cs @@ -10,9 +10,11 @@ using Simple_QR_Code_Maker.Helpers; using Simple_QR_Code_Maker.Models; using System.Collections.ObjectModel; +using System.IO; using Windows.ApplicationModel.DataTransfer; using Windows.Storage; using Windows.Storage.Pickers; +using Windows.Storage.Streams; using WinRT.Interop; using ZXing.QrCode.Internal; @@ -86,6 +88,23 @@ public partial class MainViewModel : ObservableRecipient, INavigationAware [ObservableProperty] private bool copySharePopupOpen = false; + [ObservableProperty] + private System.Drawing.Bitmap? logoImage = null; + + [ObservableProperty] + private bool hasLogo = false; + + [ObservableProperty] + private double logoPaddingPixels = 4.0; + + [ObservableProperty] + private double logoSizePercentage = 15; + + [ObservableProperty] + private double logoSizeMaxPercentage = 20; + + private string? currentLogoPath = null; + private double MinSizeScanDistanceScaleFactor = 1; private readonly DispatcherTimer copyInfoBarTimer = new(); @@ -100,11 +119,52 @@ partial void OnSelectedHistoryItemChanged(HistoryItem? value) ForegroundColor = value.Foreground; BackgroundColor = value.Background; SelectedOption = ErrorCorrectionLevels.First(x => x.ErrorCorrectionLevel == value.ErrorCorrection); + + // Restore logo image and size if available + if (!string.IsNullOrEmpty(value.LogoImagePath)) + { + _ = LoadLogoFromHistory(value.LogoImagePath); + } + else + { + // Clear logo if history item has no logo + LogoImage?.Dispose(); + LogoImage = null; + } + + LogoSizePercentage = value.LogoSizePercentage; + LogoPaddingPixels = value.LogoPaddingPixels; SelectedHistoryItem = null; } + + private async Task LoadLogoFromHistory(string logoPath) + { + try + { + if (File.Exists(logoPath)) + { + // Dispose old logo first + LogoImage?.Dispose(); + + StorageFile file = await StorageFile.GetFileFromPathAsync(logoPath); + using var stream = await file.OpenReadAsync(); + LogoImage = new System.Drawing.Bitmap(stream.AsStreamForRead()); + currentLogoPath = logoPath; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to load logo from history: {ex.Message}"); + LogoImage?.Dispose(); + LogoImage = null; + currentLogoPath = null; + } + } - public List ErrorCorrectionLevels { get; } = + public ObservableCollection ErrorCorrectionLevels { get; set; } = new(allCorrectionLevels); + + private static readonly List allCorrectionLevels = [ new("Low 7%", ErrorCorrectionLevel.L), new("Medium 15%", ErrorCorrectionLevel.M), @@ -114,6 +174,15 @@ partial void OnSelectedHistoryItemChanged(HistoryItem? value) partial void OnSelectedOptionChanged(ErrorCorrectionOptions value) { + // Ensure logo size doesn't exceed the new error correction level's maximum + if (logoSizePercentage > logoSizeMaxPercentage) + { + logoSizePercentage = logoSizeMaxPercentage; + } + OnPropertyChanged(nameof(logoSizeMaxPercentage)); + + LogoSizeMaxPercentage = BarcodeHelpers.GetMaxLogoSizePercentage(value.ErrorCorrectionLevel); + debounceTimer.Stop(); debounceTimer.Start(); } @@ -130,10 +199,58 @@ partial void OnForegroundColorChanged(Windows.UI.Color value) debounceTimer.Start(); } - public bool CanSaveImage { get => !string.IsNullOrWhiteSpace(UrlText); } + partial void OnLogoImageChanged(System.Drawing.Bitmap? value) + { + HasLogo = value != null; + + + if (HasLogo) + { + if (SelectedOption.ErrorCorrectionLevel == ErrorCorrectionLevel.L && ErrorCorrectionLevels.Count > 1) + { + SelectedOption = ErrorCorrectionLevels[1]; + } + + if (ErrorCorrectionLevels.Count > 0 + && ErrorCorrectionLevels[0].ErrorCorrectionLevel == ErrorCorrectionLevel.L) + ErrorCorrectionLevels.RemoveAt(0); + } + else + { + if (ErrorCorrectionLevels.Count == 3) + ErrorCorrectionLevels.Insert(0,new("Low 7%", ErrorCorrectionLevel.L)); + } + + + debounceTimer.Stop(); + debounceTimer.Start(); + } + + partial void OnLogoSizePercentageChanged(double value) + { + debounceTimer.Stop(); + debounceTimer.Start(); + } + + partial void OnLogoPaddingPixelsChanged(double value) + { + debounceTimer.Stop(); + debounceTimer.Start(); + } + + public bool CanSaveImage => !string.IsNullOrWhiteSpace(UrlText); partial void OnUrlTextChanged(string value) { + // Update max logo size when text changes (affects QR version/density) + OnPropertyChanged(nameof(logoSizeMaxPercentage)); + + // Ensure current logo size doesn't exceed the new maximum + if (logoSizePercentage > logoSizeMaxPercentage) + { + logoSizePercentage = logoSizeMaxPercentage; + } + debounceTimer.Stop(); debounceTimer.Start(); } @@ -207,10 +324,7 @@ private void OnRequestPaneChange(object recipient, RequestPaneChange message) } } - private void OnSaveHistoryMessage(object recipient, SaveHistoryMessage message) - { - SaveCurrentStateToHistory(); - } + private async void OnSaveHistoryMessage(object recipient, SaveHistoryMessage message) => await SaveCurrentStateToHistory(); private void OnRequestShowMessage(object recipient, RequestShowMessage rsm) { @@ -249,6 +363,9 @@ private void CheckCanPasteText() debounceTimer.Tick -= DebounceTimer_Tick; Clipboard.ContentChanged -= Clipboard_ContentChanged; + + // Dispose of the logo image + LogoImage?.Dispose(); } private void PlaceholderTextTimer_Tick(object? sender, object e) @@ -289,17 +406,23 @@ private void GenerateQrCodeFromOneLine(string text) textToUse, SelectedOption.ErrorCorrectionLevel, ForegroundColor.ToSystemDrawingColor(), - BackgroundColor.ToSystemDrawingColor()); + BackgroundColor.ToSystemDrawingColor(), + LogoImage, + LogoSizePercentage, + LogoPaddingPixels); BarcodeImageItem barcodeImageItem = new() { CodeAsBitmap = bitmap, CodeAsText = textToUse, IsAppShowingUrlWarnings = WarnWhenNotUrl, - SizeTextVisible = HideMinimumSizeText ? Visibility.Collapsed : Visibility.Visible, + SizeTextVisible = (HideMinimumSizeText || LogoImage != null) ? Visibility.Collapsed : Visibility.Visible, ErrorCorrection = SelectedOption.ErrorCorrectionLevel, ForegroundColor = ForegroundColor, BackgroundColor = BackgroundColor, MaxSizeScaleFactor = MinSizeScanDistanceScaleFactor, + LogoImage = LogoImage, + LogoSizePercentage = LogoSizePercentage, + LogoPaddingPixels = LogoPaddingPixels, }; double ratio = barcodeImageItem.ColorContrastRatio; @@ -345,7 +468,7 @@ private async Task CopyPngToClipboard() if (QrCodeBitmaps.Count == 0) return; - SaveCurrentStateToHistory(); + await SaveCurrentStateToHistory(); StorageFolder folder = ApplicationData.Current.LocalCacheFolder; List files = []; @@ -394,7 +517,7 @@ private async Task CopySvgToClipboard() if (QrCodeBitmaps.Count == 0) return; - SaveCurrentStateToHistory(); + await SaveCurrentStateToHistory(); StorageFolder folder = ApplicationData.Current.LocalCacheFolder; List files = []; @@ -438,12 +561,12 @@ private async Task CopySvgToClipboard() } [RelayCommand] - private void CopySvgTextToClipboard() + private async Task CopySvgTextToClipboard() { if (QrCodeBitmaps.Count == 0) return; - SaveCurrentStateToHistory(); + await SaveCurrentStateToHistory(); List textStrings = []; foreach (BarcodeImageItem qrCodeItem in QrCodeBitmaps) @@ -492,35 +615,40 @@ private void CopyInfoBarTimer_Tick(object? sender, object e) } [RelayCommand] - private void ToggleFaqPaneOpen() - { - IsFaqPaneOpen = !IsFaqPaneOpen; - } + private void ToggleFaqPaneOpen() => IsFaqPaneOpen = !IsFaqPaneOpen; [RelayCommand] - private void ToggleHistoryPaneOpen() - { - IsHistoryPaneOpen = !IsHistoryPaneOpen; - } + private void ToggleHistoryPaneOpen() => IsHistoryPaneOpen = !IsHistoryPaneOpen; [RelayCommand] - private void ShareApp() - { - CopySharePopupOpen = !CopySharePopupOpen; - } + private void ShareApp() => CopySharePopupOpen = !CopySharePopupOpen; [RelayCommand] - private void OpenFile() - { - NavigationService.NavigateTo(typeof(DecodingViewModel).FullName!, UrlText); - } + private void OpenFile() => NavigationService.NavigateTo(typeof(DecodingViewModel).FullName!, CreateCurrentStateHistoryItem()); [RelayCommand] - private void GoToSettings() + private void GoToSettings() => + // pass the current state as a HistoryItem to the settings page + // so when coming back it can be fully restored + NavigationService.NavigateTo(typeof(SettingsViewModel).FullName!, CreateCurrentStateHistoryItem()); + + private HistoryItem CreateCurrentStateHistoryItem() + { + return new HistoryItem + { + CodesContent = UrlText, + Foreground = ForegroundColor, + Background = BackgroundColor, + ErrorCorrection = SelectedOption.ErrorCorrectionLevel, + LogoImagePath = LogoImage != null ? GetCurrentLogoPath() : null, + LogoSizePercentage = LogoSizePercentage, + LogoPaddingPixels = LogoPaddingPixels, + }; + } + + private string? GetCurrentLogoPath() { - // pass the contents of the UrlText to the settings page - // so when coming back it can be rehydrated - NavigationService.NavigateTo(typeof(SettingsViewModel).FullName!, UrlText); + return currentLogoPath; } [RelayCommand] @@ -529,7 +657,7 @@ private async Task SavePng() if (QrCodeBitmaps.Count == 0) return; - SaveCurrentStateToHistory(); + await SaveCurrentStateToHistory(); await SaveAllFiles(FileKind.PNG); @@ -548,7 +676,7 @@ private async Task SaveSvg() if (QrCodeBitmaps.Count == 0) return; - SaveCurrentStateToHistory(); + await SaveCurrentStateToHistory(); await SaveAllFiles(FileKind.SVG); @@ -574,6 +702,55 @@ private void AddNewLine() UrlText += $"\r{stringToAdd}"; } + [RelayCommand] + private async Task SelectLogo() + { + FileOpenPicker openPicker = new() + { + SuggestedStartLocation = PickerLocationId.PicturesLibrary, + }; + openPicker.FileTypeFilter.Add(".png"); + openPicker.FileTypeFilter.Add(".jpg"); + openPicker.FileTypeFilter.Add(".jpeg"); + openPicker.FileTypeFilter.Add(".bmp"); + openPicker.FileTypeFilter.Add(".gif"); + + Window window = new(); + IntPtr windowHandle = WindowNative.GetWindowHandle(window); + InitializeWithWindow.Initialize(openPicker, windowHandle); + + StorageFile file = await openPicker.PickSingleFileAsync(); + + if (file is null) + return; + + try + { + // Dispose of the old logo if it exists + LogoImage?.Dispose(); + + // Use stream to access file instead of direct path for better compatibility + using IRandomAccessStreamWithContentType stream = await file.OpenReadAsync(); + LogoImage = new System.Drawing.Bitmap(stream.AsStreamForRead()); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to load logo image: {ex.Message}"); + CodeInfoBarMessage = "Failed to load the selected image"; + ShowCodeInfoBar = true; + CodeInfoBarSeverity = InfoBarSeverity.Error; + CodeInfoBarTitle = "Error loading logo"; + } + } + + [RelayCommand] + private void RemoveLogo() + { + LogoImage?.Dispose(); + LogoImage = null; + currentLogoPath = null; + } + private async Task WriteImageToFile(BarcodeImageItem imageItem, StorageFile file, FileKind kindOfFile) { switch (kindOfFile) @@ -632,6 +809,7 @@ public async Task SaveAllFiles(FileKind kindOfFile) public async void OnNavigatedTo(object parameter) { await LoadHistory(); + CheckCanPasteText(); MultiLineCodeMode = await LocalSettingsService.ReadSettingAsync(nameof(MultiLineCodeMode)); BaseText = await LocalSettingsService.ReadSettingAsync(nameof(BaseText)) ?? string.Empty; UrlText = BaseText; @@ -645,47 +823,175 @@ public async void OnNavigatedTo(object parameter) await LocalSettingsService.SaveSettingAsync(nameof(MinSizeScanDistanceScaleFactor), MinSizeScanDistanceScaleFactor); } - // check on text rehydration, could be coming from Reading or Settings - if (parameter is string textParam && !string.IsNullOrWhiteSpace(textParam)) + // Check if parameter is a HistoryItem with full state restoration + if (parameter is HistoryItem historyItem) + { + RestoreFromHistoryItem(historyItem); + } + // Otherwise check for text rehydration from other pages + else if (parameter is string textParam && !string.IsNullOrWhiteSpace(textParam)) + { UrlText = textParam; + } + } + + private void RestoreFromHistoryItem(HistoryItem historyItem) + { + UrlText = historyItem.CodesContent; + ForegroundColor = historyItem.Foreground; + BackgroundColor = historyItem.Background; + SelectedOption = ErrorCorrectionLevels.First(x => x.ErrorCorrectionLevel == historyItem.ErrorCorrection); + + // Restore logo image and settings if available + if (!string.IsNullOrEmpty(historyItem.LogoImagePath)) + { + _ = LoadLogoFromHistory(historyItem.LogoImagePath); + } + else + { + // Clear logo if history item has no logo + LogoImage?.Dispose(); + LogoImage = null; + currentLogoPath = null; + } + + LogoSizePercentage = historyItem.LogoSizePercentage; + LogoPaddingPixels = historyItem.LogoPaddingPixels; } public void OnNavigatedFrom() { if (!string.IsNullOrWhiteSpace(UrlText)) - SaveCurrentStateToHistory(); + _ = SaveCurrentStateToHistory(); } - public void SaveCurrentStateToHistory() + public async Task SaveCurrentStateToHistory() { + string? logoImagePath = null; + + // Save logo image to local app storage if present + if (LogoImage != null) + { + try + { + StorageFolder logoFolder = await ApplicationData.Current.LocalFolder.CreateFolderAsync("LogoImages", CreationCollisionOption.OpenIfExists); + string fileName = $"logo_{DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}.png"; + StorageFile logoFile = await logoFolder.CreateFileAsync(fileName, CreationCollisionOption.ReplaceExisting); + + using (var stream = await logoFile.OpenAsync(FileAccessMode.ReadWrite)) + { + using (var outputStream = stream.GetOutputStreamAt(0)) + { + using (var dataWriter = new DataWriter(outputStream)) + { + using (MemoryStream ms = new()) + { + LogoImage.Save(ms, System.Drawing.Imaging.ImageFormat.Png); + byte[] bytes = ms.ToArray(); + dataWriter.WriteBytes(bytes); + await dataWriter.StoreAsync(); + } + } + } + } + + logoImagePath = logoFile.Path; + currentLogoPath = logoImagePath; // Update current logo path + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to save logo image: {ex.Message}"); + } + } + HistoryItem historyItem = new() { CodesContent = UrlText, Foreground = ForegroundColor, Background = BackgroundColor, ErrorCorrection = SelectedOption.ErrorCorrectionLevel, + LogoImagePath = logoImagePath ?? currentLogoPath, // Use saved path or current path + LogoSizePercentage = LogoSizePercentage, + LogoPaddingPixels = LogoPaddingPixels, }; HistoryItems.Remove(historyItem); HistoryItems.Insert(0, historyItem); - LocalSettingsService.SaveSettingAsync(nameof(HistoryItems), HistoryItems); + await SaveHistoryToFile(); } - private async Task LoadHistory() + private async Task SaveHistoryToFile() { - ObservableCollection? prevHistory = null; + try + { + StorageFolder localFolder = ApplicationData.Current.LocalFolder; + StorageFile historyFile = await localFolder.CreateFileAsync("History.json", CreationCollisionOption.ReplaceExisting); + + string json = System.Text.Json.JsonSerializer.Serialize(HistoryItems, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true + }); + + await FileIO.WriteTextAsync(historyFile, json); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to save history to file: {ex.Message}"); + } + } + private async Task LoadHistory() + { + ObservableCollection? historyFromFile = null; + + // First, try to load from file (new location) try { - prevHistory = await LocalSettingsService.ReadSettingAsync>(nameof(HistoryItems)); + StorageFolder localFolder = ApplicationData.Current.LocalFolder; + StorageFile historyFile = await localFolder.GetFileAsync("History.json"); + string json = await FileIO.ReadTextAsync(historyFile); + historyFromFile = System.Text.Json.JsonSerializer.Deserialize>(json); + } + catch + { + // File doesn't exist yet, that's okay } - catch { } - if (prevHistory is null || prevHistory.Count == 0) + // If we found history in the file, use it + if (historyFromFile != null && historyFromFile.Count > 0) + { + foreach (HistoryItem hisItem in historyFromFile) + HistoryItems.Add(hisItem); return; + } + + // Otherwise, try to migrate from old settings location + ObservableCollection? historyFromSettings = null; + try + { + historyFromSettings = await LocalSettingsService.ReadSettingAsync>(nameof(HistoryItems)); + } + catch { } - foreach (HistoryItem hisItem in prevHistory) - HistoryItems.Add(hisItem); + if (historyFromSettings != null && historyFromSettings.Count > 0) + { + // Migrate: add to collection and save to file + foreach (HistoryItem hisItem in historyFromSettings) + HistoryItems.Add(hisItem); + + // Save to new file location + await SaveHistoryToFile(); + + // Clear from old settings location to free up space + try + { + await LocalSettingsService.SaveSettingAsync(nameof(HistoryItems), new ObservableCollection()); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to clear old history from settings: {ex.Message}"); + } + } } } diff --git a/Simple QR Code Maker/ViewModels/SettingsViewModel.cs b/Simple QR Code Maker/ViewModels/SettingsViewModel.cs index 8f67bc4..1e6b8c3 100644 --- a/Simple QR Code Maker/ViewModels/SettingsViewModel.cs +++ b/Simple QR Code Maker/ViewModels/SettingsViewModel.cs @@ -4,6 +4,7 @@ using Simple_QR_Code_Maker.Contracts.Services; using Simple_QR_Code_Maker.Contracts.ViewModels; using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; using System.Globalization; using System.Reflection; using System.Windows.Input; @@ -41,7 +42,7 @@ public partial class SettingsViewModel : ObservableRecipient, INavigationAware private readonly DispatcherTimer settingChangedDebounceTimer = new(); - private string navigationText = string.Empty; + private HistoryItem? navigationHistoryItem = null; private INavigationService NavigationService { get; } public ILocalSettingsService LocalSettingsService { get; } @@ -142,7 +143,7 @@ partial void OnMinSizeScanDistanceScaleFactorChanged(double value) [RelayCommand] private void GoHome() { - NavigationService.NavigateTo(typeof(MainViewModel).FullName!, navigationText); + NavigationService.NavigateTo(typeof(MainViewModel).FullName!, navigationHistoryItem); } [RelayCommand] @@ -183,9 +184,15 @@ public async void OnNavigatedTo(object parameter) HideMinimumSizeText = await LocalSettingsService.ReadSettingAsync(nameof(HideMinimumSizeText)); MinSizeScanDistanceScaleFactor = await LocalSettingsService.ReadSettingAsync(nameof(MinSizeScanDistanceScaleFactor)); - if (parameter is string urlText && !string.IsNullOrWhiteSpace(urlText)) + // Store the HistoryItem to pass back when returning to main page + if (parameter is HistoryItem historyItem) { - navigationText = urlText; + navigationHistoryItem = historyItem; + } + // For backward compatibility, also handle string parameter + else if (parameter is string urlText && !string.IsNullOrWhiteSpace(urlText)) + { + navigationHistoryItem = new HistoryItem { CodesContent = urlText }; } } diff --git a/Simple QR Code Maker/Views/MainPage.xaml b/Simple QR Code Maker/Views/MainPage.xaml index d950138..2c239db 100644 --- a/Simple QR Code Maker/Views/MainPage.xaml +++ b/Simple QR Code Maker/Views/MainPage.xaml @@ -24,9 +24,7 @@ @@ -35,9 +33,7 @@ @@ -58,12 +54,9 @@ MinWidth="258" MaxWidth="800" AcceptsReturn="True" - PlaceholderText="{x:Bind ViewModel.PlaceholderText, - Mode=OneWay}" + PlaceholderText="{x:Bind ViewModel.PlaceholderText, Mode=OneWay}" ScrollViewer.HorizontalScrollBarVisibility="Auto" - Text="{x:Bind ViewModel.UrlText, - Mode=TwoWay, - UpdateSourceTrigger=PropertyChanged}" + Text="{x:Bind ViewModel.UrlText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="UrlTextBox_TextChanged" TextWrapping="NoWrap" /> @@ -75,9 +68,7 @@ VerticalAlignment="Bottom" Command="{x:Bind ViewModel.AddNewLineCommand}" ToolTipService.ToolTip="Add a return and start making another QR Code" - Visibility="{x:Bind ViewModel.CanSaveImage, - Converter={StaticResource BoolToVisibility}, - Mode=OneWay}"> + Visibility="{x:Bind ViewModel.CanSaveImage, Converter={StaticResource BoolToVisibility}, Mode=OneWay}"> @@ -89,13 +80,9 @@ Padding="0" VerticalAlignment="Bottom" Command="{x:Bind ViewModel.PasteTextIntoUrlTextCommand}" - IsEnabled="{x:Bind ViewModel.CanPasteText, - Mode=OneWay}" + IsEnabled="{x:Bind ViewModel.CanPasteText, Mode=OneWay}" ToolTipService.ToolTip="Paste text from clipboard" - Visibility="{x:Bind ViewModel.CanSaveImage, - Converter={StaticResource BoolToVisibility}, - ConverterParameter=True, - Mode=OneWay}"> + Visibility="{x:Bind ViewModel.CanSaveImage, Converter={StaticResource BoolToVisibility}, ConverterParameter=True, Mode=OneWay}"> @@ -107,11 +94,9 @@ x:Name="QrCodeOptions" HorizontalAlignment="Center" Orientation="Horizontal" - Spacing="12" - Visibility="{x:Bind ViewModel.CanSaveImage, - Mode=OneWay, - Converter={StaticResource BoolToVisibility}}"> - - @@ -164,6 +143,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -196,15 +233,16 @@ + + + + + + + IsOpen="{x:Bind ViewModel.CopySharePopupOpen, Mode=TwoWay}"> + IsOpen="{x:Bind ViewModel.ShowCodeInfoBar, Mode=TwoWay}" + Message="{x:Bind ViewModel.CodeInfoBarMessage, Mode=OneWay}" + Severity="{x:Bind ViewModel.CodeInfoBarSeverity, Mode=OneWay}" /> @@ -354,9 +388,7 @@ + SelectedItem="{x:Bind ViewModel.SelectedHistoryItem, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">