commit 0e9fb324481f5a614f8b02985ffe1910882d9082 Author: logikonline Date: Tue Mar 3 23:19:45 2026 -0500 feat(scenarios): add buy me a coffee maui library Create a .NET MAUI library for Buy Me a Coffee integration with branded button, QR code, and widget controls. Includes 8 theme presets (yellow, black, white, blue, violet, orange, red, green), customizable styling, and SkiaSharp-based rendering. Supports opening BMC pages in browser and generating QR codes for donations. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9c0a56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +## Build output +bin/ +obj/ + +## Visual Studio +.vs/ +*.user +*.suo + +## NuGet +*.nupkg +packages/ + +## OS files +Thumbs.db +.DS_Store + +## Rider +.idea/ diff --git a/BuyMeCofee.Maui/BuyMeACoffeeExtensions.cs b/BuyMeCofee.Maui/BuyMeACoffeeExtensions.cs new file mode 100644 index 0000000..5803941 --- /dev/null +++ b/BuyMeCofee.Maui/BuyMeACoffeeExtensions.cs @@ -0,0 +1,16 @@ +using SkiaSharp.Views.Maui.Controls.Hosting; + +namespace BuyMeCofee.Maui; + +public static class BuyMeACoffeeExtensions +{ + /// + /// Registers Buy Me a Coffee controls and their dependencies (SkiaSharp). + /// Call this in your MauiProgram.cs CreateMauiApp() builder. + /// + public static MauiAppBuilder UseBuyMeACoffee(this MauiAppBuilder builder) + { + builder.UseSkiaSharp(); + return builder; + } +} diff --git a/BuyMeCofee.Maui/BuyMeCofee.Maui.csproj b/BuyMeCofee.Maui/BuyMeCofee.Maui.csproj new file mode 100644 index 0000000..e9e8ae6 --- /dev/null +++ b/BuyMeCofee.Maui/BuyMeCofee.Maui.csproj @@ -0,0 +1,39 @@ + + + + net10.0-android + $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + + true + true + enable + enable + + + SourceGen + + 15.0 + 15.0 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + + + + + + + + + + + + + diff --git a/BuyMeCofee.Maui/Constants/BmcColors.cs b/BuyMeCofee.Maui/Constants/BmcColors.cs new file mode 100644 index 0000000..be77fac --- /dev/null +++ b/BuyMeCofee.Maui/Constants/BmcColors.cs @@ -0,0 +1,21 @@ +namespace BuyMeCofee.Maui.Constants; + +public static class BmcColors +{ + public const string CupYellow = "#FFDD00"; + public const string BrandDark = "#0D0C22"; + + // Button theme backgrounds + public const string YellowBg = "#FFDD00"; + public const string BlackBg = "#0D0C22"; + public const string WhiteBg = "#FFFFFF"; + public const string BlueBg = "#5F7FFF"; + public const string VioletBg = "#BD5CFF"; + public const string OrangeBg = "#FF813F"; + public const string RedBg = "#FF6073"; + public const string GreenBg = "#78DEC7"; + + // Widget + public const string WidgetPurple = "#6C5CE7"; + public const string WhiteStroke = "#E0E0E0"; +} diff --git a/BuyMeCofee.Maui/Constants/BmcConstants.cs b/BuyMeCofee.Maui/Constants/BmcConstants.cs new file mode 100644 index 0000000..0d72511 --- /dev/null +++ b/BuyMeCofee.Maui/Constants/BmcConstants.cs @@ -0,0 +1,11 @@ +namespace BuyMeCofee.Maui.Constants; + +public static class BmcConstants +{ + public const string BaseUrl = "https://buymeacoffee.com/"; + public const string DefaultButtonText = "Buy me a coffee"; + public const string DefaultSupportButtonText = "Support"; + public const double ButtonCornerRadius = 8.0; + public const double WidgetCornerRadius = 16.0; + public static readonly int[] DefaultSuggestedAmounts = [25, 50, 100]; +} diff --git a/BuyMeCofee.Maui/Controls/BuyMeACoffeeButton.cs b/BuyMeCofee.Maui/Controls/BuyMeACoffeeButton.cs new file mode 100644 index 0000000..2b37c70 --- /dev/null +++ b/BuyMeCofee.Maui/Controls/BuyMeACoffeeButton.cs @@ -0,0 +1,233 @@ +using BuyMeCofee.Maui.Constants; +using BuyMeCofee.Maui.Enums; +using BuyMeCofee.Maui.Helpers; +using Microsoft.Maui.Controls.Shapes; + +namespace BuyMeCofee.Maui.Controls; + +/// +/// A branded Buy Me a Coffee button with the official cup logo and 8 color theme presets. +/// Opens the user's BMC page in the default browser when tapped. +/// +public class BuyMeACoffeeButton : ContentView +{ + #region Bindable Properties + + public static readonly BindableProperty UsernameProperty = + BindableProperty.Create(nameof(Username), typeof(string), typeof(BuyMeACoffeeButton), + string.Empty); + + public static readonly BindableProperty ButtonTextProperty = + BindableProperty.Create(nameof(ButtonText), typeof(string), typeof(BuyMeACoffeeButton), + BmcConstants.DefaultButtonText, propertyChanged: OnVisualPropertyChanged); + + public static readonly BindableProperty ThemeProperty = + BindableProperty.Create(nameof(Theme), typeof(BmcButtonTheme), typeof(BuyMeACoffeeButton), + BmcButtonTheme.Yellow, propertyChanged: OnThemePropertyChanged); + + public static readonly BindableProperty CustomBackgroundColorProperty = + BindableProperty.Create(nameof(CustomBackgroundColor), typeof(Color), typeof(BuyMeACoffeeButton), + null, propertyChanged: OnVisualPropertyChanged); + + public static readonly BindableProperty CustomTextColorProperty = + BindableProperty.Create(nameof(CustomTextColor), typeof(Color), typeof(BuyMeACoffeeButton), + null, propertyChanged: OnVisualPropertyChanged); + + public static readonly BindableProperty CornerRadiusProperty = + BindableProperty.Create(nameof(CornerRadius), typeof(double), typeof(BuyMeACoffeeButton), + BmcConstants.ButtonCornerRadius, propertyChanged: OnVisualPropertyChanged); + + public static readonly BindableProperty FontSizeProperty = + BindableProperty.Create(nameof(FontSize), typeof(double), typeof(BuyMeACoffeeButton), + 16.0, propertyChanged: OnVisualPropertyChanged); + + public static readonly BindableProperty CupSizeProperty = + BindableProperty.Create(nameof(CupSize), typeof(double), typeof(BuyMeACoffeeButton), + 28.0, propertyChanged: OnVisualPropertyChanged); + + #endregion + + #region Properties + + /// Your Buy Me a Coffee username/slug. + public string Username + { + get => (string)GetValue(UsernameProperty); + set => SetValue(UsernameProperty, value); + } + + /// Button label text. Default: "Buy me a coffee" + public string ButtonText + { + get => (string)GetValue(ButtonTextProperty); + set => SetValue(ButtonTextProperty, value); + } + + /// Color theme preset. Default: Yellow (official BMC brand color). + public BmcButtonTheme Theme + { + get => (BmcButtonTheme)GetValue(ThemeProperty); + set => SetValue(ThemeProperty, value); + } + + /// Custom background color. Only used when Theme is set to Custom. + public Color? CustomBackgroundColor + { + get => (Color?)GetValue(CustomBackgroundColorProperty); + set => SetValue(CustomBackgroundColorProperty, value); + } + + /// Custom text color. Only used when Theme is set to Custom. + public Color? CustomTextColor + { + get => (Color?)GetValue(CustomTextColorProperty); + set => SetValue(CustomTextColorProperty, value); + } + + /// Border corner radius. Default: 8 + public double CornerRadius + { + get => (double)GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + /// Font size for the button text. Default: 16 + public double FontSize + { + get => (double)GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + /// Cup logo image height. Default: 28 + public double CupSize + { + get => (double)GetValue(CupSizeProperty); + set => SetValue(CupSizeProperty, value); + } + + #endregion + + private Border _border = null!; + private Image _cupImage = null!; + private Label _textLabel = null!; + + public BuyMeACoffeeButton() + { + BuildLayout(); + ApplyTheme(); + } + + private void BuildLayout() + { + _cupImage = new Image + { + Source = BmcBrandAssets.GetCupLogo(), + HeightRequest = CupSize, + Aspect = Aspect.AspectFit, + VerticalOptions = LayoutOptions.Center, + }; + + _textLabel = new Label + { + Text = ButtonText, + FontSize = FontSize, + FontAttributes = FontAttributes.Bold, + VerticalOptions = LayoutOptions.Center, + }; + + var stack = new HorizontalStackLayout + { + Spacing = 8, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + Children = { _cupImage, _textLabel } + }; + + _border = new Border + { + StrokeShape = new RoundRectangle { CornerRadius = new Microsoft.Maui.CornerRadius(CornerRadius) }, + Stroke = Colors.Transparent, + Padding = new Thickness(16, 10), + Content = stack, + Shadow = new Shadow + { + Brush = new SolidColorBrush(Colors.Black), + Offset = new Point(0, 2), + Radius = 4, + Opacity = 0.25f, + } + }; + + var tap = new TapGestureRecognizer(); + tap.Tapped += OnTapped; + _border.GestureRecognizers.Add(tap); + + var hover = new PointerGestureRecognizer(); + hover.PointerEntered += (_, _) => _border.Opacity = 0.85; + hover.PointerExited += (_, _) => _border.Opacity = 1.0; + _border.GestureRecognizers.Add(hover); + + Content = _border; + } + + private void ApplyTheme() + { + if (_border is null) return; + + Color bg, text; + Color stroke = Colors.Transparent; + + if (Theme == BmcButtonTheme.Custom) + { + bg = CustomBackgroundColor ?? Color.FromArgb(BmcColors.YellowBg); + text = CustomTextColor ?? Color.FromArgb(BmcColors.BrandDark); + } + else + { + var info = BmcThemeResolver.Resolve(Theme); + bg = info.Background; + text = info.TextColor; + stroke = info.StrokeColor; + } + + _border.BackgroundColor = bg; + _border.Stroke = new SolidColorBrush(stroke); + _textLabel.TextColor = text; + } + + private void UpdateVisuals() + { + if (_border is null) return; + + _textLabel.Text = ButtonText; + _textLabel.FontSize = FontSize; + _cupImage.HeightRequest = CupSize; + + if (_border.StrokeShape is RoundRectangle rr) + rr.CornerRadius = new Microsoft.Maui.CornerRadius(CornerRadius); + + ApplyTheme(); + } + + private async void OnTapped(object? sender, TappedEventArgs e) + { + if (string.IsNullOrWhiteSpace(Username)) return; + + await _border.ScaleToAsync(0.95, 80, Easing.CubicIn); + await _border.ScaleToAsync(1.0, 80, Easing.CubicOut); + + await Launcher.Default.OpenAsync(new Uri($"{BmcConstants.BaseUrl}{Username}")); + } + + private static void OnVisualPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is BuyMeACoffeeButton button) + button.UpdateVisuals(); + } + + private static void OnThemePropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is BuyMeACoffeeButton button) + button.ApplyTheme(); + } +} diff --git a/BuyMeCofee.Maui/Controls/BuyMeACoffeeQrCode.cs b/BuyMeCofee.Maui/Controls/BuyMeACoffeeQrCode.cs new file mode 100644 index 0000000..e9a6141 --- /dev/null +++ b/BuyMeCofee.Maui/Controls/BuyMeACoffeeQrCode.cs @@ -0,0 +1,219 @@ +using BuyMeCofee.Maui.Constants; +using BuyMeCofee.Maui.Helpers; +using QRCoder; +using SkiaSharp; +using SkiaSharp.Views.Maui; +using SkiaSharp.Views.Maui.Controls; + +namespace BuyMeCofee.Maui.Controls; + +/// +/// Generates and displays a QR code linking to a Buy Me a Coffee profile, +/// with the BMC coffee cup logo overlaid in the center. +/// +public class BuyMeACoffeeQrCode : ContentView +{ + #region Bindable Properties + + public static readonly BindableProperty UsernameProperty = + BindableProperty.Create(nameof(Username), typeof(string), typeof(BuyMeACoffeeQrCode), + string.Empty, propertyChanged: OnQrPropertyChanged); + + public static readonly BindableProperty SizeProperty = + BindableProperty.Create(nameof(Size), typeof(double), typeof(BuyMeACoffeeQrCode), + 200.0, propertyChanged: OnSizeChanged); + + public static readonly BindableProperty ForegroundColorProperty = + BindableProperty.Create(nameof(ForegroundColor), typeof(Color), typeof(BuyMeACoffeeQrCode), + Colors.Black, propertyChanged: OnQrPropertyChanged); + + public static new readonly BindableProperty BackgroundColorProperty = + BindableProperty.Create(nameof(BackgroundColor), typeof(Color), typeof(BuyMeACoffeeQrCode), + Colors.White, propertyChanged: OnQrPropertyChanged); + + public static readonly BindableProperty LogoSizeFractionProperty = + BindableProperty.Create(nameof(LogoSizeFraction), typeof(double), typeof(BuyMeACoffeeQrCode), + 0.25, propertyChanged: OnVisualPropertyChanged); + + #endregion + + #region Properties + + /// Your Buy Me a Coffee username/slug. + public string Username + { + get => (string)GetValue(UsernameProperty); + set => SetValue(UsernameProperty, value); + } + + /// Width and height of the QR code control. Default: 200 + public double Size + { + get => (double)GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + /// QR code foreground (module) color. Default: Black + public Color ForegroundColor + { + get => (Color)GetValue(ForegroundColorProperty); + set => SetValue(ForegroundColorProperty, value); + } + + /// QR code background color. Default: White + public new Color BackgroundColor + { + get => (Color)GetValue(BackgroundColorProperty); + set => SetValue(BackgroundColorProperty, value); + } + + /// Logo size as fraction of QR code size (0.0 - 0.35). Default: 0.25 + public double LogoSizeFraction + { + get => (double)GetValue(LogoSizeFractionProperty); + set => SetValue(LogoSizeFractionProperty, value); + } + + #endregion + + private readonly SKCanvasView _canvas; + private SKBitmap? _qrBitmap; + private SKBitmap? _logoBitmap; + + public BuyMeACoffeeQrCode() + { + _canvas = new SKCanvasView + { + WidthRequest = Size, + HeightRequest = Size, + }; + _canvas.PaintSurface += OnPaintSurface; + + Content = _canvas; + LoadLogo(); + RegenerateQr(); + } + + private void LoadLogo() + { + try + { + using var stream = BmcBrandAssets.GetCupLogoStream(); + _logoBitmap = SKBitmap.Decode(stream); + } + catch + { + _logoBitmap = null; + } + } + + private void RegenerateQr() + { + _qrBitmap?.Dispose(); + _qrBitmap = null; + + if (string.IsNullOrWhiteSpace(Username)) + { + _canvas.InvalidateSurface(); + return; + } + + var url = $"{BmcConstants.BaseUrl}{Username}"; + + using var generator = new QRCodeGenerator(); + var data = generator.CreateQrCode(url, QRCodeGenerator.ECCLevel.H); + var renderer = new PngByteQRCode(data); + + var darkRgba = ColorToRgba(ForegroundColor); + var lightRgba = ColorToRgba(BackgroundColor); + var pngBytes = renderer.GetGraphic(20, darkRgba, lightRgba); + + using var stream = new MemoryStream(pngBytes); + _qrBitmap = SKBitmap.Decode(stream); + + _canvas.InvalidateSurface(); + } + + private void OnPaintSurface(object? sender, SKPaintSurfaceEventArgs e) + { + var canvas = e.Surface.Canvas; + canvas.Clear(SKColors.Transparent); + + if (_qrBitmap is null) return; + + var info = e.Info; + var size = Math.Min(info.Width, info.Height); + + // Draw QR code scaled to fill + var qrRect = new SKRect(0, 0, size, size); + using var qrPaint = new SKPaint { IsAntialias = false }; + canvas.DrawBitmap(_qrBitmap, qrRect, qrPaint); + + if (_logoBitmap is null) return; + + // Draw white quiet zone behind logo + var logoSize = (float)(size * Math.Clamp(LogoSizeFraction, 0.0, 0.35)); + var logoPadding = logoSize * 0.15f; + var cx = size / 2f; + var cy = size / 2f; + + var quietRect = new SKRect( + cx - logoSize / 2 - logoPadding, + cy - logoSize / 2 - logoPadding, + cx + logoSize / 2 + logoPadding, + cy + logoSize / 2 + logoPadding); + + using var quietPaint = new SKPaint + { + Color = SKColors.White, + IsAntialias = true, + Style = SKPaintStyle.Fill, + }; + canvas.DrawRoundRect(quietRect, 8, 8, quietPaint); + + // Draw logo centered + var logoRect = new SKRect( + cx - logoSize / 2, cy - logoSize / 2, + cx + logoSize / 2, cy + logoSize / 2); + using var logoPaint = new SKPaint { IsAntialias = true }; + canvas.DrawBitmap(_logoBitmap, logoRect, logoPaint); + } + + private static byte[] ColorToRgba(Color color) + { + return + [ + (byte)(color.Red * 255), + (byte)(color.Green * 255), + (byte)(color.Blue * 255), + (byte)(color.Alpha * 255), + ]; + } + + #region Property Changed Handlers + + private static void OnQrPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is BuyMeACoffeeQrCode qr) + qr.RegenerateQr(); + } + + private static void OnVisualPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is BuyMeACoffeeQrCode qr) + qr._canvas.InvalidateSurface(); + } + + private static void OnSizeChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is BuyMeACoffeeQrCode qr) + { + var size = (double)newValue; + qr._canvas.WidthRequest = size; + qr._canvas.HeightRequest = size; + qr._canvas.InvalidateSurface(); + } + } + + #endregion +} diff --git a/BuyMeCofee.Maui/Controls/BuyMeACoffeeWidget.cs b/BuyMeCofee.Maui/Controls/BuyMeACoffeeWidget.cs new file mode 100644 index 0000000..d9560bb --- /dev/null +++ b/BuyMeCofee.Maui/Controls/BuyMeACoffeeWidget.cs @@ -0,0 +1,442 @@ +using BuyMeCofee.Maui.Constants; +using BuyMeCofee.Maui.Helpers; +using BuyMeCofee.Maui.Models; +using Microsoft.Maui.Controls.Shapes; + +namespace BuyMeCofee.Maui.Controls; + +/// +/// A branded Buy Me a Coffee support widget with amount entry, preset chips, +/// name/message fields, and a monthly toggle. Opens the BMC page on support tap. +/// +public class BuyMeACoffeeWidget : ContentView +{ + #region Bindable Properties + + public static readonly BindableProperty UsernameProperty = + BindableProperty.Create(nameof(Username), typeof(string), typeof(BuyMeACoffeeWidget), + string.Empty, propertyChanged: OnUsernameChanged); + + public static readonly BindableProperty DisplayNameProperty = + BindableProperty.Create(nameof(DisplayName), typeof(string), typeof(BuyMeACoffeeWidget), + string.Empty, propertyChanged: OnDisplayNameChanged); + + public static readonly BindableProperty SuggestedAmountsProperty = + BindableProperty.Create(nameof(SuggestedAmounts), typeof(int[]), typeof(BuyMeACoffeeWidget), + BmcConstants.DefaultSuggestedAmounts, propertyChanged: OnChipsChanged); + + public static readonly BindableProperty DefaultAmountProperty = + BindableProperty.Create(nameof(DefaultAmount), typeof(int), typeof(BuyMeACoffeeWidget), + 5, propertyChanged: OnDefaultAmountChanged); + + public static readonly BindableProperty AccentColorProperty = + BindableProperty.Create(nameof(AccentColor), typeof(Color), typeof(BuyMeACoffeeWidget), + Color.FromArgb(BmcColors.WidgetPurple), propertyChanged: OnAccentChanged); + + public static readonly BindableProperty ShowMonthlyOptionProperty = + BindableProperty.Create(nameof(ShowMonthlyOption), typeof(bool), typeof(BuyMeACoffeeWidget), + true, propertyChanged: OnShowMonthlyChanged); + + public static readonly BindableProperty SupportButtonTextProperty = + BindableProperty.Create(nameof(SupportButtonText), typeof(string), typeof(BuyMeACoffeeWidget), + BmcConstants.DefaultSupportButtonText); + + #endregion + + #region Properties + + /// Your Buy Me a Coffee username/slug. + public string Username + { + get => (string)GetValue(UsernameProperty); + set => SetValue(UsernameProperty, value); + } + + /// Name displayed in "Support {DisplayName}" header. + public string DisplayName + { + get => (string)GetValue(DisplayNameProperty); + set => SetValue(DisplayNameProperty, value); + } + + /// Preset amount chips. Default: [25, 50, 100] + public int[] SuggestedAmounts + { + get => (int[])GetValue(SuggestedAmountsProperty); + set => SetValue(SuggestedAmountsProperty, value); + } + + /// Initial amount in the entry field. Default: 5 + public int DefaultAmount + { + get => (int)GetValue(DefaultAmountProperty); + set => SetValue(DefaultAmountProperty, value); + } + + /// Accent color for the Support button. Default: BMC purple (#6C5CE7) + public Color AccentColor + { + get => (Color)GetValue(AccentColorProperty); + set => SetValue(AccentColorProperty, value); + } + + /// Whether to show the "Make this monthly" checkbox. Default: true + public bool ShowMonthlyOption + { + get => (bool)GetValue(ShowMonthlyOptionProperty); + set => SetValue(ShowMonthlyOptionProperty, value); + } + + /// Text on the support button. Default: "Support" + public string SupportButtonText + { + get => (string)GetValue(SupportButtonTextProperty); + set => SetValue(SupportButtonTextProperty, value); + } + + #endregion + + /// Raised when the user taps the Support button, before opening the browser. + public event EventHandler? SupportRequested; + + // Internal UI elements + private Label _headerLabel = null!; + private Entry _amountEntry = null!; + private HorizontalStackLayout _chipsRow = null!; + private Entry _nameEntry = null!; + private Editor _messageEditor = null!; + private CheckBox _monthlyCheckBox = null!; + private HorizontalStackLayout _monthlyRow = null!; + private Border _supportButton = null!; + private Label _supportButtonLabel = null!; + private Label _footerLabel = null!; + + private int _currentAmount; + + public BuyMeACoffeeWidget() + { + _currentAmount = DefaultAmount; + BuildLayout(); + } + + private void BuildLayout() + { + // Header + _headerLabel = new Label + { + Text = string.IsNullOrWhiteSpace(DisplayName) ? "Support" : $"Support {DisplayName}", + FontSize = 20, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(0, 0, 0, 8), + }; + + // Amount row: $ + Entry + chips + var dollarLabel = new Label + { + Text = "$", + FontSize = 18, + VerticalOptions = LayoutOptions.Center, + TextColor = Colors.Gray, + }; + + _amountEntry = new Entry + { + Text = _currentAmount.ToString(), + Keyboard = Keyboard.Numeric, + FontSize = 16, + Placeholder = "Enter amount", + HorizontalOptions = LayoutOptions.Fill, + VerticalOptions = LayoutOptions.Center, + }; + _amountEntry.TextChanged += OnAmountTextChanged; + + _chipsRow = new HorizontalStackLayout { Spacing = 6 }; + BuildChips(); + + var amountRow = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition(GridLength.Auto), + new ColumnDefinition(GridLength.Star), + new ColumnDefinition(GridLength.Auto), + }, + ColumnSpacing = 8, + VerticalOptions = LayoutOptions.Center, + }; + amountRow.Add(dollarLabel, 0); + amountRow.Add(_amountEntry, 1); + amountRow.Add(_chipsRow, 2); + + var amountBorder = CreateFieldBorder(amountRow); + + // Name field + _nameEntry = new Entry + { + Placeholder = "Name or @yoursocial", + FontSize = 14, + }; + var nameBorder = CreateFieldBorder(_nameEntry); + + // Message field + _messageEditor = new Editor + { + Placeholder = "Say something nice...", + FontSize = 14, + HeightRequest = 80, + AutoSize = EditorAutoSizeOption.TextChanges, + }; + var messageBorder = CreateFieldBorder(_messageEditor); + + // Monthly toggle + _monthlyCheckBox = new CheckBox + { + VerticalOptions = LayoutOptions.Center, + }; + var monthlyLabel = new Label + { + Text = "Make this monthly", + FontSize = 14, + VerticalOptions = LayoutOptions.Center, + }; + _monthlyRow = new HorizontalStackLayout + { + Spacing = 4, + IsVisible = ShowMonthlyOption, + Children = { _monthlyCheckBox, monthlyLabel } + }; + + // Support button + _supportButtonLabel = new Label + { + Text = SupportButtonText, + FontSize = 16, + FontAttributes = FontAttributes.Bold, + TextColor = Colors.White, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + }; + + _supportButton = new Border + { + BackgroundColor = AccentColor, + StrokeShape = new RoundRectangle { CornerRadius = new Microsoft.Maui.CornerRadius(24) }, + Stroke = Colors.Transparent, + Padding = new Thickness(0, 14), + HorizontalOptions = LayoutOptions.Fill, + Content = _supportButtonLabel, + }; + + var supportTap = new TapGestureRecognizer(); + supportTap.Tapped += OnSupportTapped; + _supportButton.GestureRecognizers.Add(supportTap); + + var supportHover = new PointerGestureRecognizer(); + supportHover.PointerEntered += (_, _) => _supportButton.Opacity = 0.85; + supportHover.PointerExited += (_, _) => _supportButton.Opacity = 1.0; + _supportButton.GestureRecognizers.Add(supportHover); + + // Redirect note + var redirectNote = new Label + { + Text = "You'll complete your support on buymeacoffee.com", + FontSize = 11, + TextColor = Colors.Gray, + HorizontalOptions = LayoutOptions.Center, + Margin = new Thickness(0, -4, 0, 0), + }; + + // Footer + var footerCup = new Image + { + Source = BmcBrandAssets.GetCupLogo(), + HeightRequest = 16, + Aspect = Aspect.AspectFit, + VerticalOptions = LayoutOptions.Center, + }; + _footerLabel = new Label + { + Text = $"buymeacoffee.com/{Username}", + FontSize = 12, + TextColor = Colors.Gray, + VerticalOptions = LayoutOptions.Center, + TextDecorations = TextDecorations.Underline, + }; + var footerStack = new HorizontalStackLayout + { + Spacing = 4, + HorizontalOptions = LayoutOptions.Center, + Children = { footerCup, _footerLabel } + }; + var footerTap = new TapGestureRecognizer(); + footerTap.Tapped += OnFooterTapped; + footerStack.GestureRecognizers.Add(footerTap); + + // Card layout + var cardContent = new VerticalStackLayout + { + Spacing = 12, + Padding = new Thickness(20), + Children = + { + _headerLabel, + amountBorder, + nameBorder, + messageBorder, + _monthlyRow, + _supportButton, + redirectNote, + footerStack, + } + }; + + var card = new Border + { + StrokeShape = new RoundRectangle { CornerRadius = new Microsoft.Maui.CornerRadius(BmcConstants.WidgetCornerRadius) }, + Stroke = new SolidColorBrush(Color.FromArgb("#E0E0E0")), + StrokeThickness = 1, + BackgroundColor = Colors.White, + Shadow = new Shadow + { + Brush = new SolidColorBrush(Colors.Black), + Offset = new Point(0, 4), + Radius = 12, + Opacity = 0.10f, + }, + Content = cardContent, + MaximumWidthRequest = 380, + }; + + Content = card; + } + + private Border CreateFieldBorder(View content) + { + return new Border + { + StrokeShape = new RoundRectangle { CornerRadius = new Microsoft.Maui.CornerRadius(10) }, + Stroke = new SolidColorBrush(Color.FromArgb("#E0E0E0")), + StrokeThickness = 1, + Padding = new Thickness(12, 6), + Content = content, + }; + } + + private void BuildChips() + { + _chipsRow.Children.Clear(); + foreach (var amount in SuggestedAmounts) + { + var chipLabel = new Label + { + Text = $"+{amount}", + FontSize = 13, + FontAttributes = FontAttributes.Bold, + TextColor = Color.FromArgb(BmcColors.BrandDark), + VerticalOptions = LayoutOptions.Center, + Padding = new Thickness(10, 4), + }; + + var chip = new Border + { + StrokeShape = new RoundRectangle { CornerRadius = new Microsoft.Maui.CornerRadius(6) }, + Stroke = new SolidColorBrush(Color.FromArgb("#E0E0E0")), + StrokeThickness = 1, + BackgroundColor = Color.FromArgb("#F5F5F5"), + Content = chipLabel, + VerticalOptions = LayoutOptions.Center, + }; + + var chipTap = new TapGestureRecognizer(); + var capturedAmount = amount; + chipTap.Tapped += (_, _) => + { + _currentAmount = capturedAmount; + _amountEntry.Text = _currentAmount.ToString(); + }; + chip.GestureRecognizers.Add(chipTap); + + _chipsRow.Children.Add(chip); + } + } + + private void OnAmountTextChanged(object? sender, TextChangedEventArgs e) + { + if (int.TryParse(e.NewTextValue, out var amount) && amount >= 0) + _currentAmount = amount; + } + + private async void OnSupportTapped(object? sender, TappedEventArgs e) + { + if (string.IsNullOrWhiteSpace(Username)) return; + + // Fire event with collected data + SupportRequested?.Invoke(this, new SupportRequestedEventArgs + { + Username = Username, + Amount = _currentAmount, + Name = string.IsNullOrWhiteSpace(_nameEntry.Text) ? null : _nameEntry.Text, + Message = string.IsNullOrWhiteSpace(_messageEditor.Text) ? null : _messageEditor.Text, + IsMonthly = _monthlyCheckBox.IsChecked, + }); + + // Animate + await _supportButton.ScaleToAsync(0.95, 80, Easing.CubicIn); + await _supportButton.ScaleToAsync(1.0, 80, Easing.CubicOut); + + // Open browser + await Launcher.Default.OpenAsync(new Uri($"{BmcConstants.BaseUrl}{Username}")); + } + + private async void OnFooterTapped(object? sender, TappedEventArgs e) + { + if (string.IsNullOrWhiteSpace(Username)) return; + await Launcher.Default.OpenAsync(new Uri($"{BmcConstants.BaseUrl}{Username}")); + } + + #region Property Changed Handlers + + private static void OnUsernameChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is BuyMeACoffeeWidget widget) + widget._footerLabel.Text = $"buymeacoffee.com/{newValue}"; + } + + private static void OnDisplayNameChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is BuyMeACoffeeWidget widget) + { + var name = (string)newValue; + widget._headerLabel.Text = string.IsNullOrWhiteSpace(name) ? "Support" : $"Support {name}"; + } + } + + private static void OnChipsChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is BuyMeACoffeeWidget widget) + widget.BuildChips(); + } + + private static void OnDefaultAmountChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is BuyMeACoffeeWidget widget) + { + widget._currentAmount = (int)newValue; + widget._amountEntry.Text = widget._currentAmount.ToString(); + } + } + + private static void OnAccentChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is BuyMeACoffeeWidget widget) + widget._supportButton.BackgroundColor = (Color)newValue; + } + + private static void OnShowMonthlyChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is BuyMeACoffeeWidget widget) + widget._monthlyRow.IsVisible = (bool)newValue; + } + + #endregion +} diff --git a/BuyMeCofee.Maui/Enums/BmcButtonTheme.cs b/BuyMeCofee.Maui/Enums/BmcButtonTheme.cs new file mode 100644 index 0000000..f5df41c --- /dev/null +++ b/BuyMeCofee.Maui/Enums/BmcButtonTheme.cs @@ -0,0 +1,14 @@ +namespace BuyMeCofee.Maui.Enums; + +public enum BmcButtonTheme +{ + Yellow, + Black, + White, + Blue, + Violet, + Orange, + Red, + Green, + Custom +} diff --git a/BuyMeCofee.Maui/Helpers/BmcBrandAssets.cs b/BuyMeCofee.Maui/Helpers/BmcBrandAssets.cs new file mode 100644 index 0000000..a10f61f --- /dev/null +++ b/BuyMeCofee.Maui/Helpers/BmcBrandAssets.cs @@ -0,0 +1,18 @@ +namespace BuyMeCofee.Maui.Helpers; + +internal static class BmcBrandAssets +{ + private const string LogoResourceName = "BuyMeCofee.Maui.Resources.Images.bmc_logo.png"; + + internal static ImageSource GetCupLogo() + { + var assembly = typeof(BmcBrandAssets).Assembly; + return ImageSource.FromStream(() => assembly.GetManifestResourceStream(LogoResourceName)!); + } + + internal static Stream GetCupLogoStream() + { + var assembly = typeof(BmcBrandAssets).Assembly; + return assembly.GetManifestResourceStream(LogoResourceName)!; + } +} diff --git a/BuyMeCofee.Maui/Helpers/BmcThemeResolver.cs b/BuyMeCofee.Maui/Helpers/BmcThemeResolver.cs new file mode 100644 index 0000000..f1c2b28 --- /dev/null +++ b/BuyMeCofee.Maui/Helpers/BmcThemeResolver.cs @@ -0,0 +1,23 @@ +using BuyMeCofee.Maui.Constants; +using BuyMeCofee.Maui.Enums; +using BuyMeCofee.Maui.Models; + +namespace BuyMeCofee.Maui.Helpers; + +public static class BmcThemeResolver +{ + public static BmcThemeInfo Resolve(BmcButtonTheme theme) => theme switch + { + BmcButtonTheme.Yellow => new(Color.FromArgb(BmcColors.YellowBg), Color.FromArgb(BmcColors.BrandDark), Colors.Transparent), + BmcButtonTheme.Black => new(Color.FromArgb(BmcColors.BlackBg), Colors.White, Colors.Transparent), + BmcButtonTheme.White => new(Color.FromArgb(BmcColors.WhiteBg), Color.FromArgb(BmcColors.BrandDark), Color.FromArgb(BmcColors.WhiteStroke)), + BmcButtonTheme.Blue => new(Color.FromArgb(BmcColors.BlueBg), Colors.White, Colors.Transparent), + BmcButtonTheme.Violet => new(Color.FromArgb(BmcColors.VioletBg), Colors.White, Colors.Transparent), + BmcButtonTheme.Orange => new(Color.FromArgb(BmcColors.OrangeBg), Colors.White, Colors.Transparent), + BmcButtonTheme.Red => new(Color.FromArgb(BmcColors.RedBg), Colors.White, Colors.Transparent), + BmcButtonTheme.Green => new(Color.FromArgb(BmcColors.GreenBg), Colors.White, Colors.Transparent), + BmcButtonTheme.Custom => throw new InvalidOperationException( + "Custom theme requires setting CustomBackgroundColor and CustomTextColor properties directly."), + _ => throw new ArgumentOutOfRangeException(nameof(theme)) + }; +} diff --git a/BuyMeCofee.Maui/Models/BmcThemeInfo.cs b/BuyMeCofee.Maui/Models/BmcThemeInfo.cs new file mode 100644 index 0000000..343b3ce --- /dev/null +++ b/BuyMeCofee.Maui/Models/BmcThemeInfo.cs @@ -0,0 +1,3 @@ +namespace BuyMeCofee.Maui.Models; + +public record BmcThemeInfo(Color Background, Color TextColor, Color StrokeColor); diff --git a/BuyMeCofee.Maui/Models/SupportRequestedEventArgs.cs b/BuyMeCofee.Maui/Models/SupportRequestedEventArgs.cs new file mode 100644 index 0000000..11aa600 --- /dev/null +++ b/BuyMeCofee.Maui/Models/SupportRequestedEventArgs.cs @@ -0,0 +1,10 @@ +namespace BuyMeCofee.Maui.Models; + +public class SupportRequestedEventArgs : EventArgs +{ + public string Username { get; init; } = string.Empty; + public int Amount { get; init; } + public string? Name { get; init; } + public string? Message { get; init; } + public bool IsMonthly { get; init; } +} diff --git a/BuyMeCofee.Maui/Resources/Images/bmc_logo.png b/BuyMeCofee.Maui/Resources/Images/bmc_logo.png new file mode 100644 index 0000000..a20c50c Binary files /dev/null and b/BuyMeCofee.Maui/Resources/Images/bmc_logo.png differ diff --git a/bmc.maui.slnx b/bmc.maui.slnx new file mode 100644 index 0000000..20a25c1 --- /dev/null +++ b/bmc.maui.slnx @@ -0,0 +1,3 @@ + + +