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:
442
BuyMeCofee.Maui/Controls/BuyMeACoffeeWidget.cs
Normal file
442
BuyMeCofee.Maui/Controls/BuyMeACoffeeWidget.cs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user