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.
476 lines
16 KiB
C#
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
|
|
}
|