Limited
2
0
Files
bmc.maui/BuyMeCofee.Maui/Controls/BuyMeACoffeeWidget.cs
logikonline 66c8ec8ecf
Some checks failed
Release / Verify Apple Build (push) Successful in 14s
Release / Build & Pack (push) Successful in 8h59m34s
Release / Publish (push) Failing after 16s
feat(ci): add supporter verification and auto-hide functionality
Implement BmcSupporterService to verify supporters via BMC API with configurable caching. Add SupporterEmail and HideIfSupporter properties to all controls to automatically hide donation prompts for existing supporters. Replace PNG logo with SVG for crisp rendering at all scales. Add BmcOptions and BmcConfiguration for library-wide settings. Bump version to 1.1.0.
2026-03-04 00:35:05 -05:00

476 lines
16 KiB
C#

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);
public static readonly BindableProperty SupporterEmailProperty =
BindableProperty.Create(nameof(SupporterEmail), typeof(string), typeof(BuyMeACoffeeWidget),
null, propertyChanged: OnSupporterPropertyChanged);
public static readonly BindableProperty HideIfSupporterProperty =
BindableProperty.Create(nameof(HideIfSupporter), typeof(bool), typeof(BuyMeACoffeeWidget),
false, propertyChanged: OnSupporterPropertyChanged);
#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);
}
/// <summary>
/// Email address to check against your BMC supporters list.
/// Requires AccessToken configured via UseBuyMeACoffee(options => ...).
/// </summary>
public string? SupporterEmail
{
get => (string?)GetValue(SupporterEmailProperty);
set => SetValue(SupporterEmailProperty, value);
}
/// <summary>
/// When true, the control auto-hides if SupporterEmail is a verified supporter. Default: false.
/// </summary>
public bool HideIfSupporter
{
get => (bool)GetValue(HideIfSupporterProperty);
set => SetValue(HideIfSupporterProperty, 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;
}
private static void OnSupporterPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is BuyMeACoffeeWidget widget)
BmcSupporterCheck.CheckAndHide(widget, widget.SupporterEmail, widget.HideIfSupporter);
}
#endregion
}