Limited
2
0

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.
This commit is contained in:
2026-03-03 23:19:45 -05:00
commit 0e9fb32448
15 changed files with 1071 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
using SkiaSharp.Views.Maui.Controls.Hosting;
namespace BuyMeCofee.Maui;
public static class BuyMeACoffeeExtensions
{
/// <summary>
/// Registers Buy Me a Coffee controls and their dependencies (SkiaSharp).
/// Call this in your MauiProgram.cs CreateMauiApp() builder.
/// </summary>
public static MauiAppBuilder UseBuyMeACoffee(this MauiAppBuilder builder)
{
builder.UseSkiaSharp();
return builder;
}
}

View File

@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0-android</TargetFrameworks>
<TargetFrameworks Condition="!$([MSBuild]::IsOSPlatform('linux'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Enable XAML source generation for faster build times and improved performance.
This generates C# code from XAML at compile time instead of runtime inflation.
To disable, remove this line.
For individual files, you can override by setting Inflator metadata:
<MauiXaml Update="MyPage.xaml" Inflator="Default" /> (reverts to defaults: Runtime for Debug, XamlC for Release)
<MauiXaml Update="MyPage.xaml" Inflator="Runtime" /> (force runtime inflation) -->
<MauiXamlInflator>SourceGen</MauiXamlInflator>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="SkiaSharp.Views.Maui.Controls" Version="3.116.1" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\Images\bmc_logo.png"
LogicalName="BuyMeCofee.Maui.Resources.Images.bmc_logo.png" />
</ItemGroup>
</Project>

View File

@@ -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";
}

View File

@@ -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];
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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
/// <summary>Your Buy Me a Coffee username/slug.</summary>
public string Username
{
get => (string)GetValue(UsernameProperty);
set => SetValue(UsernameProperty, value);
}
/// <summary>Button label text. Default: "Buy me a coffee"</summary>
public string ButtonText
{
get => (string)GetValue(ButtonTextProperty);
set => SetValue(ButtonTextProperty, value);
}
/// <summary>Color theme preset. Default: Yellow (official BMC brand color).</summary>
public BmcButtonTheme Theme
{
get => (BmcButtonTheme)GetValue(ThemeProperty);
set => SetValue(ThemeProperty, value);
}
/// <summary>Custom background color. Only used when Theme is set to Custom.</summary>
public Color? CustomBackgroundColor
{
get => (Color?)GetValue(CustomBackgroundColorProperty);
set => SetValue(CustomBackgroundColorProperty, value);
}
/// <summary>Custom text color. Only used when Theme is set to Custom.</summary>
public Color? CustomTextColor
{
get => (Color?)GetValue(CustomTextColorProperty);
set => SetValue(CustomTextColorProperty, value);
}
/// <summary>Border corner radius. Default: 8</summary>
public double CornerRadius
{
get => (double)GetValue(CornerRadiusProperty);
set => SetValue(CornerRadiusProperty, value);
}
/// <summary>Font size for the button text. Default: 16</summary>
public double FontSize
{
get => (double)GetValue(FontSizeProperty);
set => SetValue(FontSizeProperty, value);
}
/// <summary>Cup logo image height. Default: 28</summary>
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();
}
}

View File

@@ -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;
/// <summary>
/// Generates and displays a QR code linking to a Buy Me a Coffee profile,
/// with the BMC coffee cup logo overlaid in the center.
/// </summary>
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
/// <summary>Your Buy Me a Coffee username/slug.</summary>
public string Username
{
get => (string)GetValue(UsernameProperty);
set => SetValue(UsernameProperty, value);
}
/// <summary>Width and height of the QR code control. Default: 200</summary>
public double Size
{
get => (double)GetValue(SizeProperty);
set => SetValue(SizeProperty, value);
}
/// <summary>QR code foreground (module) color. Default: Black</summary>
public Color ForegroundColor
{
get => (Color)GetValue(ForegroundColorProperty);
set => SetValue(ForegroundColorProperty, value);
}
/// <summary>QR code background color. Default: White</summary>
public new Color BackgroundColor
{
get => (Color)GetValue(BackgroundColorProperty);
set => SetValue(BackgroundColorProperty, value);
}
/// <summary>Logo size as fraction of QR code size (0.0 - 0.35). Default: 0.25</summary>
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
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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
/// <summary>Your Buy Me a Coffee username/slug.</summary>
public string Username
{
get => (string)GetValue(UsernameProperty);
set => SetValue(UsernameProperty, value);
}
/// <summary>Name displayed in "Support {DisplayName}" header.</summary>
public string DisplayName
{
get => (string)GetValue(DisplayNameProperty);
set => SetValue(DisplayNameProperty, value);
}
/// <summary>Preset amount chips. Default: [25, 50, 100]</summary>
public int[] SuggestedAmounts
{
get => (int[])GetValue(SuggestedAmountsProperty);
set => SetValue(SuggestedAmountsProperty, value);
}
/// <summary>Initial amount in the entry field. Default: 5</summary>
public int DefaultAmount
{
get => (int)GetValue(DefaultAmountProperty);
set => SetValue(DefaultAmountProperty, value);
}
/// <summary>Accent color for the Support button. Default: BMC purple (#6C5CE7)</summary>
public Color AccentColor
{
get => (Color)GetValue(AccentColorProperty);
set => SetValue(AccentColorProperty, value);
}
/// <summary>Whether to show the "Make this monthly" checkbox. Default: true</summary>
public bool ShowMonthlyOption
{
get => (bool)GetValue(ShowMonthlyOptionProperty);
set => SetValue(ShowMonthlyOptionProperty, value);
}
/// <summary>Text on the support button. Default: "Support"</summary>
public string SupportButtonText
{
get => (string)GetValue(SupportButtonTextProperty);
set => SetValue(SupportButtonTextProperty, value);
}
#endregion
/// <summary>Raised when the user taps the Support button, before opening the browser.</summary>
public event EventHandler<SupportRequestedEventArgs>? 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
}

View File

@@ -0,0 +1,14 @@
namespace BuyMeCofee.Maui.Enums;
public enum BmcButtonTheme
{
Yellow,
Black,
White,
Blue,
Violet,
Orange,
Red,
Green,
Custom
}

View File

@@ -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)!;
}
}

View File

@@ -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))
};
}

View File

@@ -0,0 +1,3 @@
namespace BuyMeCofee.Maui.Models;
public record BmcThemeInfo(Color Background, Color TextColor, Color StrokeColor);

View File

@@ -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; }
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB