This commit is contained in:
2026-07-12 04:34:44 +08:00
parent e7ce0f7bae
commit 909fdf46d6
43 changed files with 4727 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
import '../vendor/stripe_sdk/lib/stripe_sdk.dart';
import '../vendor/stripe_sdk/lib/stripe_sdk_ui.dart';
import 'mini_stripe_api_handler.dart';
class MiniStripeApi {
static MiniStripeApi? _instance;
final MiniStripeApiHandler _apiHandler;
final String publishableKey;
final String apiVersion;
/// Create a new instance, which can be used with e.g. dependency injection.
/// Throws a [Exception] if an invalid [publishableKey] has been submitted.
///
/// [publishableKey] is your publishable key, beginning with "sk_".
/// Your can copy your key from https://dashboard.stripe.com/account/apikeys
///
/// [stripeAccount] is the id of a stripe customer and stats with "cus_".
/// This is a optional parameter.
MiniStripeApi(this.publishableKey, {this.apiVersion = defaultApiVersion, String? stripeAccount})
: _apiHandler = MiniStripeApiHandler(stripeAccount: stripeAccount) {
_validateKey(publishableKey);
_apiHandler.apiVersion = apiVersion;
}
/// Initialize the managed singleton instance of [StripeApi].
/// Afterwards you can use [StripeApi.instance] to access the created instance.
///
/// [publishableKey] is your publishable key, beginning with "sk_".
/// Your can copy your key from https://dashboard.stripe.com/account/apikeys
///
/// [stripeAccount] is the id of a stripe customer and stats with "cus_".
/// This is a optional parameter.
static void init(String publishableKey, {String apiVersion = defaultApiVersion, String? stripeAccount}) {
// _instance ??= MiniStripeApi(publishableKey, uuidString, apiVersion: apiVersion, stripeAccount: stripeAccount);
_instance = MiniStripeApi(publishableKey, apiVersion: apiVersion, stripeAccount: stripeAccount);
}
/// Access the singleton instance of [StripeApi].
/// Throws an [Exception] if [StripeApi.init] hasn't been called previously.
static MiniStripeApi get instance {
if (_instance == null) {
throw Exception('Attempted to get singleton instance of StripeApi without initialization');
}
return _instance!;
}
/// Create a stripe Token
/// https://stripe.com/docs/api/tokens
Future<Map<String, dynamic>> createToken(Map<String, dynamic> data) async {
final path = '/tokens';
return _apiHandler.request(RequestMethod.post, path, publishableKey, apiVersion, params: data);
}
/// Create a PaymentMethod.
/// https://stripe.com/docs/api/payment_methods/create
Future<Map<String, dynamic>> createPaymentMethod(Map<String, dynamic> data) async {
final path = '/payment_methods';
return _apiHandler.request(RequestMethod.post, path, publishableKey, apiVersion, params: data);
}
/// Create a PaymentMethod from a card.
/// This will only create a PaymentMethod with the minimum required properties.
/// To include additional properties such as billing details, use [StripeCard.toPaymentMethod], add additional details
/// and then use [createPaymentMethod].
Future<Map<String, dynamic>> createPaymentMethodFromCard(StripeCard card) async {
return createPaymentMethod(card.toPaymentMethod());
}
/// Create a new Source object.
/// https://stripe.com/docs/api/sources/create
Future<Map<String, dynamic>> createSource(Map<String, dynamic> data) async {
final path = '/sources';
return _apiHandler.request(RequestMethod.post, path, publishableKey, apiVersion, params: data);
}
/// Retrieve a PaymentIntent.
/// https://stripe.com/docs/api/payment_intents/retrieve
Future<Map<String, dynamic>> retrievePaymentIntent(String clientSecret, {String? apiVersion}) async {
final intentId = _parseIdFromClientSecret(clientSecret);
final path = '/payment_intents/$intentId';
final params = {'client_secret': clientSecret};
return _apiHandler.request(RequestMethod.get, path, publishableKey, apiVersion ?? this.apiVersion, params: params);
}
/// Confirm a PaymentIntent
/// https://stripe.com/docs/api/payment_intents/confirm
Future<Map<String, dynamic>> confirmPaymentIntent(String clientSecret, {Map<String, dynamic>? data, String? idempotencyKey}) async {
final params = data ?? {};
final intent = _parseIdFromClientSecret(clientSecret);
params['client_secret'] = clientSecret;
final path = '/payment_intents/$intent/confirm';
return _apiHandler.request(RequestMethod.post, path, publishableKey, apiVersion, params: params, idempotencyKey: idempotencyKey);
}
/// Retrieve a SetupIntent.
/// https://stripe.com/docs/api/setup_intents/retrieve
Future<Map<String, dynamic>> retrieveSetupIntent(String clientSecret, {String? apiVersion}) async {
final intentId = _parseIdFromClientSecret(clientSecret);
final path = '/setup_intents/$intentId';
final params = {'client_secret': clientSecret};
return _apiHandler.request(RequestMethod.get, path, publishableKey, apiVersion, params: params);
}
/// Confirm a SetupIntent
/// https://stripe.com/docs/api/setup_intents/confirm
Future<Map<String, dynamic>> confirmSetupIntent(String clientSecret, {Map<String, dynamic>? data}) async {
final params = data ?? {};
final intent = _parseIdFromClientSecret(clientSecret);
params['client_secret'] = clientSecret;
final path = '/setup_intents/$intent/confirm';
return _apiHandler.request(RequestMethod.post, path, publishableKey, apiVersion, params: params);
}
/// Validates the received [publishableKey] and throws a [Exception] if an
/// invalid key has been submitted.
static void _validateKey(String publishableKey) {
if (publishableKey == null || publishableKey.isEmpty) {
throw Exception('Invalid Publishable Key: '
'You must use a valid publishable key to create a token. '
'For more info, see https://stripe.com/docs/stripe.js.');
}
if (publishableKey.startsWith('sk_')) {
throw Exception('Invalid Publishable Key: '
'You are using a secret key to create a token, '
'instead of the publishable one. For more info, '
'see https://stripe.com/docs/stripe.js');
}
}
}
String _parseIdFromClientSecret(String clientSecret) {
return clientSecret.split('_secret')[0];
}

View File

@@ -0,0 +1,164 @@
import 'dart:async';
import 'dart:convert' show json;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../vendor/stripe_sdk/lib/stripe_sdk.dart';
class MiniStripeApiHandler {
static const String LIVE_API_BASE = 'https://api.stripe.com';
static const String LIVE_LOGGING_BASE = 'https://q.stripe.com';
static const String LOGGING_ENDPOINT = 'https://m.stripe.com/4';
static const String LIVE_API_PATH = LIVE_API_BASE + '/v1';
static const String CHARSET = 'UTF-8';
static const String CUSTOMERS = 'customers';
static const String TOKENS = 'tokens';
static const String SOURCES = 'sources';
static const String HEADER_KEY_REQUEST_ID = 'Request-Id';
static const String FIELD_ERROR = 'error';
static const String FIELD_SOURCE = 'source';
String apiVersion = defaultApiVersion;
static const String MALFORMED_RESPONSE_MESSAGE = 'An improperly formatted error response was found.';
final http.Client _client = http.Client();
final String? stripeAccount;
MiniStripeApiHandler({this.stripeAccount});
Future<Map<String, dynamic>> request(RequestMethod method, String path, String key, String? apiVersion,
{final Map<String, dynamic>? params, String? idempotencyKey}) {
final options = RequestOptions(key: key, apiVersion: apiVersion, stripeAccount: stripeAccount, idempotencyKey: idempotencyKey);
return _getStripeResponse(method, LIVE_API_PATH + path, options, params: params);
}
Future<Map<String, dynamic>> _getStripeResponse(RequestMethod method, final String url, final RequestOptions options,
{final Map<String, dynamic>? params}) async {
print('url: $url');
final headers = _headers(options: options);
print('headers: $headers');
http.Response response;
switch (method) {
case RequestMethod.get:
var fUrl = url;
if (params != null && params.isNotEmpty) {
fUrl = '$url?${_encodeMap(params)}';
}
response = await _client.get(Uri.parse(fUrl), headers: headers);
break;
case RequestMethod.post:
response = await _client.post(
Uri.parse(url),
headers: headers,
body: params != null ? _urlEncodeMap(params) : null,
);
break;
case RequestMethod.delete:
response = await _client.delete(Uri.parse(url), headers: headers);
break;
default:
throw Exception('Request Method: $method not implemented');
}
final requestId = response.headers[HEADER_KEY_REQUEST_ID];
final statusCode = response.statusCode;
Map<String, dynamic> resp;
try {
resp = json.decode(response.body);
} catch (error) {
final stripeError = StripeApiError(requestId, {StripeApiError.fieldMessage: MALFORMED_RESPONSE_MESSAGE});
throw StripeApiException(stripeError);
}
if (statusCode < 200 || statusCode >= 300) {
final Map<String, dynamic> errBody = resp[FIELD_ERROR];
final stripeError = StripeApiError(requestId, errBody);
throw StripeApiException(stripeError);
} else {
return resp;
}
}
///
///
///
static Map<String, String> _headers({RequestOptions? options}) {
final headers = <String, String>{};
headers['Accept-Charset'] = CHARSET;
headers['Accept'] = 'application/json';
headers['Content-Type'] = 'application/x-www-form-urlencoded';
headers['User-Agent'] = 'StripeSDK/v2';
if (options != null) {
headers['Authorization'] = 'Bearer ${options.key}';
}
// debug headers
final propertyMap = <String, String>{};
propertyMap['os.name'] = defaultTargetPlatform.toString();
propertyMap['lang'] = 'Dart';
propertyMap['publisher'] = 'lars.dahl@gmail.com';
headers['X-Stripe-Client-User-Agent'] = json.encode(propertyMap);
if (options != null) {
if (options.apiVersion != null) {
headers['Stripe-Version'] = options.apiVersion!;
}
if (options.stripeAccount != null) {
headers['Stripe-Account'] = options.stripeAccount!;
}
if (options.idempotencyKey != null) {
headers['Idempotency-Key'] = options.idempotencyKey!;
}
}
return headers;
}
static String _encodeMap(Map<String, dynamic> params) {
return params.keys
.map((key) => '${Uri.encodeComponent(key)}=${Uri.encodeComponent(params[key].toString())}')
.join('&');
}
static String _urlEncodeMap(dynamic data) {
final urlData = StringBuffer('');
var first = true;
void urlEncode(dynamic sub, String path) {
if (sub is List) {
for (var i = 0; i < sub.length; i++) {
urlEncode(sub[i], '$path%5B%5D');
}
} else if (sub is Map) {
sub.forEach((k, v) {
if (path == '') {
urlEncode(v, '${Uri.encodeQueryComponent(k)}');
} else {
urlEncode(v, '$path%5B${Uri.encodeQueryComponent(k)}%5D');
}
});
} else {
if (!first) {
urlData.write('&');
}
first = false;
urlData.write('$path=${Uri.encodeQueryComponent(sub.toString())}');
}
}
urlEncode(data, '');
return urlData.toString();
}
}

7
lib/vendor/countdown/countdown.dart vendored Normal file
View File

@@ -0,0 +1,7 @@
// Copyright (c) 2015, Benjamin NGUYEN. All rights reserved. Use of this source code
// is governed by a BSD-style license that can be found in the LICENSE file.
/// The countdown library.
library countdown;
export 'src/countdown_base.dart';

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2015, Benjamin NGUYEN. All rights reserved. Use of this source code
// is governed by a BSD-style license that can be found in the LICENSE file.
library countdown.base;
import "dart:async";
class CountDown {
/// reference point for start and resume
DateTime? _begin;
Timer? _timer;
Duration? _duration;
Duration? remainingTime;
bool isPaused = false;
StreamController<Duration>? _controller;
Duration? _refresh;
/// provide a way to send less data to the client but keep the data of the timer up to date
int _everyTick = 0;
int counter = 0;
/// once you instantiate the CountDown you need to register to receive information
CountDown(Duration duration,
{Duration refresh = const Duration(milliseconds: 10), int everyTick = 1}) {
_refresh = refresh;
_everyTick = everyTick;
this._duration = duration;
_controller = StreamController<Duration>(
onListen: _onListen,
onPause: _onPause,
onResume: _onResume,
onCancel: _onCancel);
}
Stream<Duration> get stream => _controller!.stream;
/// _onListen
/// invoke when the first subscriber has subscribe and not before to avoid leak of memory
_onListen() {
// reference point
_begin = DateTime.now();
_timer = Timer.periodic(_refresh!, _tick);
}
/// the remaining time is set at '_refresh' ms accurate
_onPause() {
isPaused = true;
_timer?.cancel();
_timer = null;
}
/// ...restart the timer with the duration
_onResume() {
_begin = DateTime.now();
_duration = this.remainingTime;
isPaused = false;
// lance le timer
_timer = Timer.periodic(_refresh!, _tick);
}
_onCancel() {
// on pause we already cancel the _timer
if (!isPaused) {
_timer?.cancel();
_timer = null;
}
// _controller.close(); // close automatically the "pipe" when the sub close it by sub.cancel()
}
void _tick(Timer? timer) {
counter++;
Duration alreadyConsumed = DateTime.now().difference(_begin!);
this.remainingTime = this._duration! - alreadyConsumed;
if (this.remainingTime!.isNegative) {
timer?.cancel();
timer = null;
// tell the onDone's subscriber that it's finish
_controller?.close();
} else {
// here we can control the frequency of sending data
if (counter % _everyTick == 0) {
_controller?.add(this.remainingTime!);
counter = 0;
}
}
}
}

View File

@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// Allows the user to close the app by double tapping the back-button.
///
/// You must specify a [SnackBar], so it can be shown when the user taps the
/// back-button. Notice that the value you set for [SnackBar.duration] is going
/// to be considered to decide whether the snack-bar is currently visible or
/// not.
///
/// Since the back-button is an Android feature, this Widget is going to be
/// nothing but the own [child] if the current platform is anything but Android.
class DoubleBackToCloseApp extends StatefulWidget {
/// The [SnackBar] shown when the user taps the back-button.
final SnackBar snackBar;
/// The widget below this widget in the tree.
final Widget child;
/// Creates a widget that allows the user to close the app by double tapping
/// the back-button.
const DoubleBackToCloseApp({
Key? key,
required this.snackBar,
required this.child,
}) : assert(snackBar != null),
assert(child != null),
super(key: key);
@override
_DoubleBackToCloseAppState createState() => _DoubleBackToCloseAppState();
}
class _DoubleBackToCloseAppState extends State<DoubleBackToCloseApp> {
/// The last time the user tapped Android's back-button.
DateTime? _lastTimeBackButtonWasTapped;
/// Returns whether the current platform is Android.
bool get _isAndroid => Theme.of(context).platform == TargetPlatform.android;
/// Returns whether the [DoubleBackToCloseApp.snackBar] is currently visible.
///
/// The snack-bar is going to be considered visible if the duration of the
/// snack-bar is greater than the difference from now to the
/// [_lastTimeBackButtonWasTapped].
///
/// This is not quite accurate since the snack-bar could've been dismissed by
/// the user, so this algorithm needs to be improved, as described in #2.
bool get _isSnackBarVisible =>
(_lastTimeBackButtonWasTapped != null) &&
(widget.snackBar.duration >
DateTime.now().difference(_lastTimeBackButtonWasTapped!));
/// Returns whether the next back navigation of this route will be handled
/// internally.
///
/// Returns true when there's a widget that inserted an entry into the
/// local-history of the current route, in order to handle pop. This is done
/// by [Drawer], for example, so it can close on pop.
bool get _willHandlePopInternally =>
ModalRoute.of(context)!.willHandlePopInternally;
@override
Widget build(BuildContext context) {
_ensureThatContextContainsScaffold();
if (_isAndroid) {
return WillPopScope(
onWillPop: _handleWillPop,
child: widget.child,
);
} else {
return widget.child;
}
}
/// Handles [WillPopScope.onWillPop].
Future<bool> _handleWillPop() async {
if (_isSnackBarVisible || _willHandlePopInternally) {
return true;
} else {
_lastTimeBackButtonWasTapped = DateTime.now();
// Scaffold.of(context).showSnackBar(widget.snackBar);
ScaffoldMessenger.of(context).showSnackBar(widget.snackBar);
return false;
}
}
/// Throws a [FlutterError] if this widget was not wrapped in a [Scaffold].
void _ensureThatContextContainsScaffold() {
if (Scaffold.maybeOf(context) == null) {
throw FlutterError(
'`DoubleBackToCloseApp` must be wrapped in a `Scaffold`.',
);
}
}
}

View File

@@ -0,0 +1,449 @@
library flappy_search_bar;
import 'dart:async';
import 'package:async/async.dart';
import 'scaled_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'search_bar_style.dart';
mixin _ControllerListener<T> on State<SearchBar<T>> {
void onListChanged(List<T> items) {}
void onLoading() {}
void onClear() {}
void onError(Error error) {}
}
class SearchBarController<T> {
final List<T> _list = [];
final List<T> _filteredList = [];
final List<T> _sortedList = [];
TextEditingController? _searchQueryController;
String? _lastSearchedText;
Future<List<T>> Function(String text)? _lastSearchFunction;
_ControllerListener? _controllerListener;
int Function(T a, T b)? _lastSorting;
CancelableOperation? _cancelableOperation;
int? minimumChars;
void setTextController(TextEditingController _searchQueryController, minimunChars) {
this._searchQueryController = _searchQueryController;
this.minimumChars = minimunChars;
}
void setListener(_ControllerListener _controllerListener) {
this._controllerListener = _controllerListener;
}
void clear() {
_controllerListener?.onClear();
}
void _search(
String text, Future<List<T>> Function(String text) onSearch) async {
_controllerListener?.onLoading();
try {
if (_cancelableOperation != null &&
(!_cancelableOperation!.isCompleted ||
!_cancelableOperation!.isCanceled)) {
_cancelableOperation!.cancel();
}
_cancelableOperation = CancelableOperation.fromFuture(
onSearch(text),
onCancel: () => {},
);
final List<T> items = await _cancelableOperation!.value;
_lastSearchFunction = onSearch;
_lastSearchedText = text;
_list.clear();
_filteredList.clear();
_sortedList.clear();
_lastSorting = null;
_list.addAll(items);
_controllerListener?.onListChanged(_list);
} catch (error) {
_controllerListener?.onError(error as Error);
}
}
void injectSearch(
String searchText, Future<List<T>> Function(String text) onSearch) {
if (searchText != null && searchText.length >= minimumChars!) {
_searchQueryController?.text = searchText;
_search(searchText, onSearch);
}
}
void replayLastSearch() {
if (_lastSearchFunction != null && _lastSearchedText != null) {
_search(_lastSearchedText!, _lastSearchFunction!);
}
}
void removeFilter() {
_filteredList.clear();
if (_lastSorting == null) {
_controllerListener?.onListChanged(_list);
} else {
_sortedList.clear();
_sortedList.addAll(List<T>.from(_list));
_sortedList.sort(_lastSorting);
_controllerListener?.onListChanged(_sortedList);
}
}
void removeSort() {
_sortedList.clear();
_lastSorting = null;
_controllerListener
?.onListChanged(_filteredList.isEmpty ? _list : _filteredList);
}
void sortList(int Function(T a, T b) sorting) {
_lastSorting = sorting;
_sortedList.clear();
_sortedList
.addAll(List<T>.from(_filteredList.isEmpty ? _list : _filteredList));
_sortedList.sort(sorting);
_controllerListener?.onListChanged(_sortedList);
}
void filterList(bool Function(T item) filter) {
_filteredList.clear();
_filteredList.addAll(_sortedList.isEmpty
? _list.where(filter).toList()
: _sortedList.where(filter).toList());
_controllerListener?.onListChanged(_filteredList);
}
}
/// Signature for a function that creates [ScaledTile] for a given index.
typedef ScaledTile IndexedScaledTileBuilder(int index);
class SearchBar<T> extends StatefulWidget {
/// Future returning searched items
final Future<List<T>> Function(String text) onSearch;
/// List of items showed by default
final List<T> suggestions;
/// Callback returning the widget corresponding to a Suggestion item
final Widget Function(T item, int index)? buildSuggestion;
/// Minimum number of chars required for a search
final int minimumChars;
/// Callback returning the widget corresponding to an item found
final Widget Function(T item, int index) onItemFound;
/// Callback returning the widget corresponding to an Error while searching
final Widget Function(Error error)? onError;
/// Cooldown between each call to avoid too many
final Duration debounceDuration;
/// Widget to show when loading
final Widget loader;
/// Widget to show when no item were found
final Widget emptyWidget;
/// Widget to show by default
final Widget? placeHolder;
/// Widget showed on left of the search bar
final Widget icon;
/// Widget placed between the search bar and the results
final Widget? header;
/// Hint text of the search bar
final String hintText;
/// TextStyle of the hint text
final TextStyle hintStyle;
/// Color of the icon when search bar is active
final Color iconActiveColor;
/// Text style of the text in the search bar
final TextStyle textStyle;
/// Widget shown for cancellation
final Widget cancellationWidget;
/// Callback when cancel button is triggered
final VoidCallback? onCancelled;
/// Controller used to be able to sort, filter or replay the search
final SearchBarController? searchBarController;
/// Enable to edit the style of the search bar
final SearchBarStyle searchBarStyle;
/// Number of items displayed on cross axis
final int crossAxisCount;
/// Weather the list should take the minimum place or not
final bool shrinkWrap;
/// Called to get the tile at the specified index for the
/// [SliverGridStaggeredTileLayout].
final IndexedScaledTileBuilder? indexedScaledTileBuilder;
/// Set the scrollDirection
final Axis scrollDirection;
/// Spacing between tiles on main axis
final double mainAxisSpacing;
/// Spacing between tiles on cross axis
final double crossAxisSpacing;
/// Set a padding on the search bar
final EdgeInsetsGeometry searchBarPadding;
/// Set a padding on the header
final EdgeInsetsGeometry headerPadding;
/// Set a padding on the list
final EdgeInsetsGeometry listPadding;
SearchBar({
Key? key,
required this.onSearch,
required this.onItemFound,
this.searchBarController,
this.minimumChars = 3,
this.debounceDuration = const Duration(milliseconds: 500),
this.loader = const Center(child: CircularProgressIndicator()),
this.onError,
this.emptyWidget = const SizedBox.shrink(),
this.header,
this.placeHolder,
this.icon = const Icon(Icons.search),
this.hintText = "",
this.hintStyle = const TextStyle(color: Color.fromRGBO(142, 142, 147, 1)),
this.iconActiveColor = Colors.black,
this.textStyle = const TextStyle(color: Colors.black),
this.cancellationWidget = const Text("Cancel"),
this.onCancelled,
this.suggestions = const [],
this.buildSuggestion,
this.searchBarStyle = const SearchBarStyle(),
this.crossAxisCount = 1,
this.shrinkWrap = false,
this.indexedScaledTileBuilder,
this.scrollDirection = Axis.vertical,
this.mainAxisSpacing = 0.0,
this.crossAxisSpacing = 0.0,
this.listPadding = const EdgeInsets.all(0),
this.searchBarPadding = const EdgeInsets.all(0),
this.headerPadding = const EdgeInsets.all(0),
}) : super(key: key);
@override
_SearchBarState createState() => _SearchBarState<T>();
}
class _SearchBarState<T> extends State<SearchBar<T>>
with TickerProviderStateMixin, _ControllerListener<T> {
bool _loading = false;
Widget? _error;
final _searchQueryController = TextEditingController();
Timer? _debounce;
bool _animate = false;
List<T> _list = [];
SearchBarController? searchBarController;
@override
void initState() {
super.initState();
searchBarController =
widget.searchBarController ?? SearchBarController<T>();
searchBarController?.setListener(this);
searchBarController?.setTextController(_searchQueryController, widget.minimumChars);
}
@override
void onListChanged(List<T> items) {
setState(() {
_loading = false;
_list = items;
});
}
@override
void onLoading() {
setState(() {
_loading = true;
_error = null;
_animate = true;
});
}
@override
void onClear() {
_cancel();
}
@override
void onError(Error error) {
setState(() {
_loading = false;
_error = widget.onError != null ? widget.onError!(error) : Text("error");
});
}
_onTextChanged(String newText) async {
if (_debounce?.isActive ?? false) {
_debounce?.cancel();
}
_debounce = Timer(widget.debounceDuration, () async {
if (newText.length >= widget.minimumChars && widget.onSearch != null) {
searchBarController?._search(newText, widget.onSearch);
} else {
setState(() {
_list.clear();
_error = null;
_loading = false;
_animate = false;
});
}
});
}
void _cancel() {
if (widget.onCancelled != null) {
widget.onCancelled!();
}
setState(() {
_searchQueryController.clear();
_list.clear();
_error = null;
_loading = false;
_animate = false;
});
}
Widget _buildListView(
List<T> items, Widget Function(T item, int index) builder) {
return Padding(
padding: widget.listPadding,
child: StaggeredGridView.countBuilder(
crossAxisCount: widget.crossAxisCount,
itemCount: items.length,
shrinkWrap: widget.shrinkWrap,
staggeredTileBuilder:
widget.indexedScaledTileBuilder ?? (int index) => ScaledTile.fit(1),
scrollDirection: widget.scrollDirection,
mainAxisSpacing: widget.mainAxisSpacing,
crossAxisSpacing: widget.crossAxisSpacing,
addAutomaticKeepAlives: true,
itemBuilder: (BuildContext context, int index) {
return builder(items[index], index);
},
),
);
}
Widget? _buildContent(BuildContext context) {
if (_error != null) {
return _error;
} else if (_loading) {
return widget.loader;
} else if (_searchQueryController.text.length < widget.minimumChars) {
if (widget.placeHolder != null) return widget.placeHolder;
return _buildListView(
widget.suggestions, widget.buildSuggestion ?? widget.onItemFound);
} else if (_list.isNotEmpty) {
return _buildListView(_list, widget.onItemFound);
} else {
return widget.emptyWidget;
}
}
@override
Widget build(BuildContext context) {
final widthMax = MediaQuery.of(context).size.width;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: widget.searchBarPadding,
child: Container(
height: 80,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Flexible(
child: AnimatedContainer(
duration: Duration(milliseconds: 200),
width: _animate ? widthMax * .8 : widthMax,
decoration: BoxDecoration(
borderRadius: widget.searchBarStyle.borderRadius,
color: widget.searchBarStyle.backgroundColor,
),
child: Padding(
padding: widget.searchBarStyle.padding,
child: Theme(
child: TextField(
controller: _searchQueryController,
onChanged: _onTextChanged,
style: widget.textStyle,
decoration: InputDecoration(
icon: widget.icon,
border: InputBorder.none,
hintText: widget.hintText,
hintStyle: widget.hintStyle,
),
),
data: Theme.of(context).copyWith(
primaryColor: widget.iconActiveColor,
),
),
),
),
),
GestureDetector(
onTap: _cancel,
child: AnimatedOpacity(
opacity: _animate ? 1.0 : 0,
curve: Curves.easeIn,
duration: Duration(milliseconds: _animate ? 1000 : 0),
child: AnimatedContainer(
duration: Duration(milliseconds: 200),
width:
_animate ? MediaQuery.of(context).size.width * .2 : 0,
child: Container(
color: Colors.transparent,
child: Center(
child: widget.cancellationWidget,
),
),
),
),
),
],
),
),
),
Padding(
padding: widget.headerPadding,
child: widget.header ?? Container(),
),
Expanded(
child: _buildContent(context) ?? SizedBox.shrink(),
),
],
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
/// Class only use in order to give access to StaggeredTile to users
class ScaledTile extends StaggeredTile {
/// Creates a [ScaledTile] with the given [crossAxisCellCount] that
/// fit its main axis extent to its content.
///
/// This tile will have a fixed main axis extent.
ScaledTile.fit(
int crossAxisCellCount,
) : super.fit(crossAxisCellCount);
/// Creates a [ScaledTile] with the given [crossAxisCellCount] and
/// [mainAxisExtent].
///
/// This tile will have a fixed main axis extent.
ScaledTile.extent(
int crossAxisCellCount,
double mainAxisExtent,
) : super.extent(crossAxisCellCount, mainAxisExtent);
/// Creates a [ScaledTile] with the given [crossAxisCellCount] and
/// [mainAxisCellCount].
///
/// The main axis extent of this tile will be the length of
/// [mainAxisCellCount] cells (inner spacings included).
ScaledTile.count(
int crossAxisCellCount,
num mainAxisCellCount,
) : super.count(crossAxisCellCount, mainAxisCellCount as double);
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
class SearchBarStyle {
final Color backgroundColor;
final EdgeInsetsGeometry padding;
final BorderRadius borderRadius;
const SearchBarStyle(
{this.backgroundColor = const Color.fromRGBO(142, 142, 147, .15),
this.padding = const EdgeInsets.all(5.0),
this.borderRadius = const BorderRadius.all(Radius.circular(5.0))});
}

View File

@@ -0,0 +1,20 @@
name: Publish
on:
push:
branches:
- master
- workflow
jobs:
build:
name: Publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Dart and Flutter Package Publisher
uses: k-paxian/dart-package-publisher@v1.2
with:
skiptests: false
dryRunOnly: false
credentialJson: ${{ secrets.CREDENTIAL_JSON }}

140
lib/vendor/stripe_sdk/.gitignore vendored Normal file
View File

@@ -0,0 +1,140 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
build/
# Android related
**/android/**/gradle-wrapper.jar
**/android/.gradle
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
# iOS/XCode related
**/ios/**/*.mode1v3
**/ios/**/*.mode2v3
**/ios/**/*.moved-aside
**/ios/**/*.pbxuser
**/ios/**/*.perspectivev3
**/ios/**/*sync/
**/ios/**/.sconsign.dblite
**/ios/**/.tags*
**/ios/**/.vagrant/
**/ios/**/DerivedData/
**/ios/**/Icon?
**/ios/**/Pods/
**/ios/**/.symlinks/
**/ios/**/profile
**/ios/**/xcuserdata
**/ios/.generated/
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/app.flx
**/ios/Flutter/app.zip
**/ios/Flutter/flutter_assets/
**/ios/Flutter/flutter_export_environment.sh
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!**/ios/**/default.mode1v3
!**/ios/**/default.mode2v3
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
### JetBrains ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
.gradle/
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser

74
lib/vendor/stripe_sdk/.gitignore.old vendored Normal file
View File

@@ -0,0 +1,74 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
build/
# Android related
**/android/**/gradle-wrapper.jar
**/android/.gradle
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
# iOS/XCode related
**/ios/**/*.mode1v3
**/ios/**/*.mode2v3
**/ios/**/*.moved-aside
**/ios/**/*.pbxuser
**/ios/**/*.perspectivev3
**/ios/**/*sync/
**/ios/**/.sconsign.dblite
**/ios/**/.tags*
**/ios/**/.vagrant/
**/ios/**/DerivedData/
**/ios/**/Icon?
**/ios/**/Pods/
**/ios/**/.symlinks/
**/ios/**/profile
**/ios/**/xcuserdata
**/ios/.generated/
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/app.flx
**/ios/Flutter/app.zip
**/ios/Flutter/flutter_assets/
**/ios/Flutter/flutter_export_environment.sh
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!**/ios/**/default.mode1v3
!**/ios/**/default.mode2v3
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3

10
lib/vendor/stripe_sdk/.metadata vendored Normal file
View File

@@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: 8874f21e79d7ec66d0457c7ab338348e31b17f1d
channel: stable
project_type: package

290
lib/vendor/stripe_sdk/CHANGELOG.md vendored Normal file
View File

@@ -0,0 +1,290 @@
# Stripe SDK Changelog
## 5.0.0-nullsafety.1
## 4.0.2
* Fix a bug with date validation
## 4.0.1
* Fix assertion for webReturnPath
## 4.0.0
* Refactor, simplify and improve credit card validation in `StripeCard`
* Add support for postal code in `AddPaymentMethodScreen` and `StripeCard`
* Removed experimental status of `AddPaymentMethodScreen` and `PaymentMethodsScreen`
* List up to 100 payment methods in `PaymentMethodsScreen`, up from 10
* Add support for additional request parameters in `CustomerSession.listPaymentMethods`
* Misc UI improvements on `AddPaymentMethodScreen`, e.g proper autofill hints and more.
* Add new optional parameter `webReturnPath` to all SCA-capable methods. This allow specifying a custom return_url when compiled for web.
* Fix issue with iOS not closing webview after SCA.
### Breaking changes
This update removes many utility methods that were public, but not strictly related to Stripe or this library.
In most cases this will not affect you, but if you relied on any of the removed methods I recommend you copy the methods
into your project, or find other replacements.
* Removed many unused and unnecessary methods and constants on `StripeCard`
* Removed file `card_utils.dart` and all it's contents.
## 3.2.0
* Add new method: `StripeApi.createSource()`
## 3.1.0
* `CustomerSession` now implements `ChangeNotifier`, notifying listeners when the session is ended.
* `CustomerSession` will throw an error if attempted used after the session has ended.
* Added `CustomerSession.endSession()` instance method.
* `PaymentMethodStore` will now be cleared, listeners notified and finally disposed when the related `CustomerSession` is ended.
### Deprecated
* `CustomerSession.endCustomerSession()` has been deprecated and replaced by `CustomerSession.instance.endSession`.
## 3.0.1+2
* Update dependencies
* Fix misc warnings and lint issues
## 3.0.1+1
* Fix lint issues
## 3.0.1
* Fix unhandled exception due to unknown parameter: exp_mont
## 3.0.0
* Added Support for custom decorations in card form
* Updated default stripe api version to `2020-03-02`
### Breaking changes
* Removed `Card.toMap()`
* Removed support for non-card properties on Card (billing details)
* See https://github.com/ezet/stripe-sdk/issues/61#issuecomment-662657924
* Rename `confirmSetupIntentWithPaymentMethod` to `confirmSetupIntent` as this is the default, general case.
* Rename `confirmSetupIntent` to `authenticateSetupIntent` so it aligns more with `authenticatePaymentIntent`, as they share similar behavior.
* Make `returnUrlForSca` a required parameter for Stripe
#### Removed following deprecated functions and classes
* class: CardNumberFormatter
* constructor: CustomerSession
* Stripe.getReturnUrl
* Stripe.handlePaymentIntent
* Stripe.handleSetupIntent
## 2.8.2
* Updated readme
* Updated uni_links
* Added additional API documentation
* Fixed validation for expiration month
## 2.8.1
Please provide feedback on experimental features and examples.
* Fix misc minor bugs
* Fix misc lint issues
### Experimental: PaymentMethodsScreen
* Major improvements to UI and functionality
* Use a shared cache to avoid loading all payment methods repeatedly
* Create setup intent immediately upon entering add PM screen
### Experimental: CheckoutScreen
* Basic implementation of a checkout screen
### Example
* Misc changes related to PaymentMethodsScreen
* Started example implementation of CheckoutScreen
## 2.8.0
* Expose StripeApiException and StripeApiError
* Deprecated Stripe.handlePaymentIntent. Contact me if you used this.
* Deprecated Stripe.handleSetupIntent. Contact me if you used this.
* Deprecated `CustomerSession()` constructor. 3.0 will enforce the singleton pattern.
* Removed experimental parameter `nextAction` from `Stripe.authenticatePayment()`
* Added `Stripe.authenticatePaymentWithNextAction()`
### Experimental: PaymentMethodsScreen
A complete UI screen that lets a user view, delete and add stripe payment methods.
* Slide right to display delete option
* Press `+` to open `AddPaymentMethodScreen`
### Example
* Re-organize example code
## 2.7.0
* Use explicit type for CreateSetupIntent return.
* Added optional [nextAction] parameter to Stripe.authenticatePayment.
* Make [paymentMethodId] of `Stripe.confirmPayment` optional
* Fix bug which prevented ephemeral keys from refreshing correctly.
### Example
* Add [Customer Details]
* Add [Payments] with automatic and manual confirmation
## 2.6.0
* Add visual card widget in CardForm
* Fix misc bugs in CardForm
## 2.5.3
* Add focus handling for CardForm.
* Give card number focus by default.
* Enable tapping "next/arrow" on keyboard to move to next field.
## 2.5.2
* Added a complete demo application, available in /examples/app.
* Display, add and remove payment methods.
* Add payment methods with and without Setup Intent.
* Quickly test pre-made Stripe test cards, with SCA and more.
* Made the `StripeApi` instance on `Stripe` objects public. This avoids having to create
a separate StripeApi instance.
* Fixed bug which prevented ephemeral keys from refreshing correctly.
### Experimental:
* Added "AddPaymentMethod" screen, which handles the complete flow of adding a payment card.
* This is still WIP and in beta stage, meaning the API might change.
## 2.5.1
Minor breaking change.
* Rename returnUrlForSCA to returnUrlForSca.
## 2.5.0
* Add support for custom return url scheme
* Update documentation
## 2.4.5
* Add links to complete examples of the stripe SDK and supporting backend.
## 2.4.4
* Fix bug which caused initial expiry date not to be set
* Add runnable example in example/app
## 2.4.3
* Add support for custom text style on card form and form fields
* Added examples for card form and form fields
## 2.4.2
* Allow custom form field error text
* Fix bug where card number input field would allow more than 16 digits
## 2.4.1
* Add support for custom input decorators on card form and form fields
## 2.4.0
* Minor breaking change: Split package into two separate sub-libraries:
* stripe_sdk: API related functionality
* stripe_sdk_ui: UI widgets and utilities
* Replaced ListView with Column inside the CardForm
* Updated dependencies
## 2.3.0
* Add CardForm widget, which can be used to add or edit credit cards.
* Complete validation for card number, expiration date and CVC
* Individual FormField widgets can be used to create a custom form
* Fix bug in Stripe.authenticatePayment
## 2.2.0
* Add support for connected accounts
* Add optional constructor parameter `stripeAccount` for all APIs
## 2.1.1
* Remove unused stripeAccount property on `StripeApi`
## 2.1.0
* Add Stripe.confirmSetupIntentWithPaymentMethod
## 2.0.0
* Rewrite of internal API
* Fixed several issues and bugs
* Restructured public API with breaking changes
* Split `Stripe` into `StripeApi` and `Stripe`
* SCA related features moved to `Stripe`
* Moved basic Stripe API requests to `StripeApi`
See README and examples for further details details.
## 1.1.1
* Misc minor updates and fixes
## 1.1.0
* Complete support for SetupIntent with SCA
* Fix bug with confirmPayment
* Internal refactoring
## 1.0.1
* Support multiple simultaneous authentication flows
* Improve documentation and examples
* Allow specifying apiVersion for CustomerSession
* Allow multiple instances of Stripe and CustomerSession
## 1.0.0+1
* Improve examples
## 1.0.0
* Improve API for SCA-related features
* Improve examples
## 0.0.2
* Remove typed models
* Add some support for payment intents
* Complete support for payment methods
* Complete support for tokens
* Add examples
* Major cleanup
## 0.0.1+2
* Add analysis_options.yaml
* Fix dartanalyzer issues
* Fix other misc packaging issues
## 0.0.1+1
* Initial release

26
lib/vendor/stripe_sdk/LICENSE vendored Normal file
View File

@@ -0,0 +1,26 @@
Copyright (c) 2020, Lars-Kristian Dahl
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
The views and conclusions contained in the software and documentation are those
of the authors and should not be interpreted as representing official policies,
either expressed or implied, of the Stripe SDK project.

247
lib/vendor/stripe_sdk/README.md vendored Normal file
View File

@@ -0,0 +1,247 @@
[![pub package](https://img.shields.io/pub/v/stripe_sdk.svg)](https://pub.dev/packages/stripe_sdk)
# Flutter Stripe SDK
A native dart package for Stripe. There are various other flutter plugins that wrap existing Stripe libraries,
but this package uses a different approach.
It does not wrap existing Stripe libraries, but instead accesses the Stripe API directly.
Flutter support:
* [x] iOS
* [x] Android
* [x] Web
See *example/main.dart* for additional short examples.
See <https://github.com/ezet/stripe-sdk/tree/master/example> for a complete demo application,
with a working example backend.
Demo backend: <https://github.com/ezet/stripe-sdk-demo-api>
## Features
* Supports all types of SCA, including 3DS, 3DS2, BankID and others.
* Handle payments with complete SCA support.
* Add, remove and update payment methods, sources and cards, optionally with SCA.
* Manage customer information.
* Create all types of Stripe tokens.
* Forms, widgets and utilities to use directly, or create your own UI!
### Experimental
* Managed UI flow for adding payment methods with SCA (using SetupIntent).
### Supported APIs
- PaymentIntent, with SCA
- SetupIntent, with SCA
- PaymentMethod
- Customer
- Cards
- Sources
- Tokens
### Planned features
- Offer managed UI flow for checkout
## Demo application
There is a complete demo application available at <https://github.com/ezet/stripe-sdk/tree/master/example/app>.
<img src="https://raw.githubusercontent.com/ezet/stripe-sdk/master/doc/demo.png" width="300">
## Overview
- The return type for each function is `Future<Map<String, dynamic>>`, where the value depends on the stripe API version.
The library has three classes to access the Stripe API:
- `Stripe` for generic, non-customer specific APIs, using publishable keys.
- `CustomerSession` for customer-specific APIs, using stripe ephemeral keys.
- `StripeApi` enables raw REST calls against the Stripe API.
### Stripe
- <https://stripe.dev/stripe-android/index.html?com/stripe/android/Stripe.html>
Aims to provide high-level functionality similar to the official mobile Stripe SDKs.
### CustomerSession
_Requires a Stripe ephemeral key._
- <https://stripe.com/docs/mobile/android/customer-information#customer-session-no-ui>
- <https://stripe.com/docs/mobile/android/standard#creating-ephemeral-keys>
Provides functionality similar to CustomerSession in the Stripe Android SDK.
### StripeApi
- <https://stripe.com/docs/api>
Provides basic low-level methods to access the Stripe REST API.
- Limited to the APIs that can be used with a public key or ephemeral key.
- Library methods map to a Stripe API call with the same name.
- Additional parameters can be provided as an optional argument.
_`Stripe` and `CustomerSession` use this internally._
## Initialization
All classes offer a singleton instance that can be initiated by calling the `init(...)` methods and then accessed through `.instance`.
Regular instances can also be created using the constructor, which allows them to be managed by e.g. dependency injection instead.
### Stripe
```dart
Stripe.init('pk_xxx');
// or, to manage your own instance, or multiple instances
final stripe = Stripe('pk_xxx');
```
### CustomerSession
The function that retrieves the ephemeral key must return the JSON response as a plain string.
```dart
CustomerSession.init((apiVersion) => server.getEphemeralKeyFromServer(apiVersion));
// or, to manage your own instances
final session = CustomerSession((apiVersion) => server.getEphemeralKeyFromServer(apiVersion));
```
### StripeApi
```dart
StripeApi.init('pk_xxx');
// or, to manage your own instances
final stripeApi = StripeApi('pk_xxx');
```
## UI
Use `CardForm` to add or edit credit card details, or build your own form using the pre-built FormFields.
```dart
final formKey = GlobalKey<FormState>();
final card = StripeCard();
final form = CardForm(card: card, formKey: formKey);
onPressed: () {
if (formKey.currentState.validate()) {
formKey.currentState.save();
}
}
```
<img src="https://raw.githubusercontent.com/ezet/stripe-sdk/master/doc/cardform.png" width="300">
## SCA/PSD2
The library offers complete support for SCA on iOS and Android.
It handles all types of SCA, including 3DS, 3DS2, BankID and others.
It handles SCA by launching the authentication flow in a web browser, and returns the result to the app.
The `returnUrlForSca` parameter must match the configuration of your `AndroidManifest.xml` and `Info.plist` as shown in the next steps.
```dart
Stripe.init('pk_xxx', returnUrlForSca: 'stripesdk://3ds.stripesdk.io');
final clientSecret = await server.createPaymentIntent(Stripe.instance.getReturnUrlForSca());
final paymentIntent = await Stripe.instance.confirmPayment(clientSecret, paymentMethodId: 'pm_card_visa');
```
### Android
You need to declare the following intent filter in `android/app/src/main/AndroidManifest.xml`.
This example is for the url `stripesdk://3ds.stripesdk.io`:
```xml
<manifest ...>
<!-- ... other tags -->
<application ...>
<activity ...>
<!-- The launchMode should be singleTop or singleTask,
to avoid launching a new instance of the app when SCA has been completed. -->
android:launchMode="singleTop"
<!-- ... other tags -->
<!-- Deep Links -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="stripesdk"
android:host="3ds.stripesdk.io" />
</intent-filter>
</activity>
</application>
</manifest>
```
### IOS
For iOS you need to declare the scheme in `ios/Runner/Info.plist` (or through Xcode's Target Info editor,
under URL Types). This example is for the url `stripesdk://3ds.stripesdk.io`:
```xml
<!-- ... other tags -->
<plist>
<dict>
<!-- ... other tags -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>3ds.stripesdk.io</string>
<key>CFBundleURLSchemes</key>
<array>
<string>stripesdk</string>
</array>
</dict>
</array>
<!-- ... other tags -->
</dict>
</plist>
```
## Experimental
Experimental features are marked as `deprecated` and the API is subject to change until it is deemed stable.
Feel free to use these features but be aware that breaking changes might be introduced in minor updates.
### Add Payment Method
Use `AddPaymentMethodScreen.withSetupIntent(...)` to launch a managed UI flow for adding a payment method.
This will also handle SCA if required.
### Payment Methods
`PaymentMethodsScreen` offers a prebuilt UI that can:
* List all current payment methods
* Add new payment methods, using setup intents
* Delete existing payment methods
## Additional examples
### Glappen
This is a complete application, with a mobile client and a backend API.
Documentation is lacking, but it can serve as an example for more advanced use.
App: <https://github.com/ezet/glappen-client>
Backend: <https://github.com/ezet/glappen-firebase-api>

View File

@@ -0,0 +1,16 @@
include: package:flutter_lints/flutter.yaml
# For lint rules and documentation, see http://dart-lang.github.io/linter/lints.
# Uncomment to specify additional rules.
#linter:
# rules:
# prefer_single_quotes: true
# deprecated_member_use_from_same_package: false
# missing_required_param: false
analyzer:
# errors:
# omit_local_variable_types: ignore
strong-mode:
implicit-casts: true
implicit-dynamic: true

View File

@@ -0,0 +1,148 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'ephemeral_key_manager.dart';
import 'stripe_api_handler.dart';
class CustomerSession extends ChangeNotifier {
static const int keyRefreshBufferInSeconds = 30;
static CustomerSession? _instance;
final StripeApiHandler _apiHandler;
final EphemeralKeyManager _keyManager;
final String apiVersion;
bool isDisposed = false;
/// Create a new CustomerSession instance. Use this if you prefer to manage your own instances.
CustomerSession._(EphemeralKeyProvider provider, {this.apiVersion = defaultApiVersion, String? stripeAccount})
: _keyManager = EphemeralKeyManager(provider, keyRefreshBufferInSeconds),
_apiHandler = StripeApiHandler(stripeAccount: stripeAccount) {
_apiHandler.apiVersion = apiVersion;
_instance = this;
}
/// Initiate the customer session singleton instance.
/// If [prefetchKey] is true, fetch the ephemeral key immediately.
static void initCustomerSession(EphemeralKeyProvider provider,
{String apiVersion = defaultApiVersion, String? stripeAccount, bool prefetchKey = true}) {
CustomerSession._(provider, apiVersion: apiVersion, stripeAccount: stripeAccount);
if (prefetchKey) {
_instance!._keyManager.retrieveEphemeralKey();
}
}
/// End the managed singleton customer session.
/// Call this when the current user logs out.
@Deprecated('Use CustomerSession.instance.endSession instead.')
static void endCustomerSession() {
_instance?.endSession();
}
/// End the managed singleton customer session.
/// Call this when the current user logs out.
void endSession() {
notifyListeners();
dispose();
isDisposed = true;
if (identical(_instance, this)) {
_instance = null;
}
}
/// Get the current customer session
static CustomerSession get instance {
if (_instance == null) {
throw Exception(
'Attempted to get instance of CustomerSession before initialization. Please initialize a new session using [CustomerSession.initCustomerSession() first.]');
}
assert(_instance!._assertNotDisposed());
return _instance!;
}
/// Retrieves the details for the current customer.
/// https://stripe.com/docs/api/customers/retrieve
Future<Map<String, dynamic>> retrieveCurrentCustomer() async {
assert(_assertNotDisposed());
final EphemeralKey key = await (_keyManager.retrieveEphemeralKey());
final path = '/customers/${key.customerId}';
return _apiHandler.request(RequestMethod.get, path, key.secret, apiVersion);
}
/// List a Customer's PaymentMethods.
/// https://stripe.com/docs/api/payment_methods/list
Future<Map<String, dynamic>> listPaymentMethods(
{type = 'card', int? limit, String? endingBefore, String? startingAfter}) async {
assert(_assertNotDisposed());
final EphemeralKey key = await (_keyManager.retrieveEphemeralKey());
const path = '/payment_methods';
final params = {'customer': key.customerId, 'type': type};
if (limit != null) params['limit'] = limit;
if (startingAfter != null) params['starting_after'] = startingAfter;
if (endingBefore != null) params['ending_before'] = endingBefore;
return _apiHandler.request(RequestMethod.get, path, key.secret, apiVersion, params: params);
}
/// Attach a PaymentMethod.
/// https://stripe.com/docs/api/payment_methods/attach
Future<Map<String, dynamic>> attachPaymentMethod(String? paymentMethodId) async {
assert(_assertNotDisposed());
final EphemeralKey key = await (_keyManager.retrieveEphemeralKey());
final path = '/payment_methods/$paymentMethodId/attach';
final params = {'customer': key.customerId};
return _apiHandler.request(RequestMethod.post, path, key.secret, apiVersion, params: params);
}
/// Detach a PaymentMethod.
/// https://stripe.com/docs/api/payment_methods/detach
Future<Map<String, dynamic>> detachPaymentMethod(String? paymentMethodId) async {
assert(_assertNotDisposed());
final EphemeralKey key = await (_keyManager.retrieveEphemeralKey());
final path = '/payment_methods/$paymentMethodId/detach';
return _apiHandler.request(RequestMethod.post, path, key.secret, apiVersion);
}
/// Attaches a Source object to the Customer.
/// The source must be in a chargeable or pending state.
/// https://stripe.com/docs/api/sources/attach
Future<Map<String, dynamic>> attachSource(String sourceId) async {
assert(_assertNotDisposed());
final EphemeralKey key = await (_keyManager.retrieveEphemeralKey());
final path = '/customers/${key.customerId}/sources';
final params = {'source': sourceId};
return _apiHandler.request(RequestMethod.post, path, key.secret, apiVersion, params: params);
}
/// Detaches a Source object from a Customer.
/// The status of a source is changed to consumed when it is detached and it can no longer be used to create a charge.
/// https://stripe.com/docs/api/sources/detach
Future<Map<String, dynamic>> detachSource(String sourceId) async {
assert(_assertNotDisposed());
final EphemeralKey key = await (_keyManager.retrieveEphemeralKey());
final path = '/customers/${key.customerId}/sources/$sourceId';
return _apiHandler.request(RequestMethod.delete, path, key.secret, apiVersion);
}
/// Updates the specified customer by setting the values of the parameters passed.
/// https://stripe.com/docs/api/customers/update
Future<Map<String, dynamic>> updateCustomer(Map<String, dynamic> data) async {
assert(_assertNotDisposed());
final EphemeralKey key = await (_keyManager.retrieveEphemeralKey());
final path = '/customers/${key.customerId}';
return _apiHandler.request(RequestMethod.post, path, key.secret, apiVersion, params: data);
}
bool _assertNotDisposed() {
assert(() {
if (isDisposed) {
throw FlutterError('A $runtimeType was used after being disposed.\n'
'Once you have called dispose() on a $runtimeType, it can no longer be used.');
}
return true;
}());
return true;
}
}

View File

@@ -0,0 +1,119 @@
import 'dart:async';
import 'dart:convert' show json;
import 'dart:developer';
import 'stripe_api_handler.dart';
import 'stripe_error.dart';
import 'util/stripe_json_utils.dart';
/// Function that takes a apiVersion and returns a Stripe ephemeral key response
typedef EphemeralKeyProvider = Future<String> Function(String apiVersion);
/// Represents a Stripe Ephemeral Key
class EphemeralKey {
static const String fieldCreated = 'created';
static const String fieldExpires = 'expires';
static const String fieldSecret = 'secret';
static const String fieldLiveMode = 'livemode';
static const String fieldObject = 'object';
static const String fieldId = 'id';
static const String fieldAssociatedObjects = 'associated_objects';
static const String fieldType = 'type';
static const String fieldNull = 'null';
late String _id;
late int _created;
late int _expires;
late bool _liveMode;
late String _customerId;
late String _object;
late String _secret;
late String _type;
late DateTime _createdAt;
late DateTime _expiresAt;
EphemeralKey.fromJson(Map<String, dynamic> json) {
// TODO might throw an error if ephemeralKey doesn't provide all fields.
_id = optString(json, fieldId)!;
_created = optInteger(json, fieldCreated)!;
_expires = optInteger(json, fieldExpires)!;
_liveMode = optBoolean(json, fieldLiveMode)!;
_customerId = json[fieldAssociatedObjects][0][fieldId];
_type = json[fieldAssociatedObjects][0][fieldType];
_object = optString(json, fieldObject)!;
_secret = optString(json, fieldSecret)!;
_createdAt = DateTime.fromMillisecondsSinceEpoch(_created * 1000);
_expiresAt = DateTime.fromMillisecondsSinceEpoch(_expires * 1000);
}
String get id => _id;
int get created => _created;
int get expires => _expires;
String get customerId => _customerId;
bool get liveMode => _liveMode;
String get object => _object;
String get secret => _secret;
String get type => _type;
DateTime get createdAt => _createdAt;
DateTime get expiresAt => _expiresAt;
}
class EphemeralKeyManager {
EphemeralKey? _ephemeralKey;
final EphemeralKeyProvider ephemeralKeyProvider;
final int timeBufferInSeconds;
EphemeralKeyManager(this.ephemeralKeyProvider, this.timeBufferInSeconds);
/// Retrieve a ephemeral key.
/// Will fetch a new one using [EphemeralKeyProvider] if required.
Future<EphemeralKey> retrieveEphemeralKey() async {
if (_shouldRefreshKey()) {
String key;
try {
key = await ephemeralKeyProvider(defaultApiVersion);
} catch (error) {
log(error.toString());
rethrow;
}
try {
Map<String, dynamic> decodedKey = json.decode(key);
_ephemeralKey = EphemeralKey.fromJson(decodedKey);
} catch (error) {
log(error.toString());
final e = StripeApiError(null, {
StripeApiError.fieldMessage:
'Failed to parse Ephemeral Key, Please return the response as it is as you received from stripe server',
});
throw StripeApiException(e);
}
return _ephemeralKey!;
} else {
return _ephemeralKey!;
}
}
bool _shouldRefreshKey() {
if (_ephemeralKey == null) {
return true;
}
final now = DateTime.now();
final diff = _ephemeralKey!.expiresAt.difference(now);
return diff.inSeconds < timeBufferInSeconds;
}
}

View File

@@ -0,0 +1,93 @@
import 'package:credit_card_validator/credit_card_validator.dart';
class StripeCard {
final _ccValidator = CreditCardValidator();
String? number;
String? cvc;
int? expMonth;
int? expYear;
String? last4;
String? postalCode;
StripeCard({
this.number,
this.cvc,
this.expMonth,
this.expYear,
this.last4,
});
/// Checks whether or not the {@link #number} field is valid.
///
/// @return {@code true} if valid, {@code false} otherwise.
bool isPostalCodeValid() {
return (postalCode != null && postalCode!.isNotEmpty)
? int.tryParse(postalCode!) != null
: false;
}
/// Checks whether or not the {@link #number} field is valid.
///
/// @return {@code true} if valid, {@code false} otherwise.
bool validateNumber() {
return number != null ? _ccValidator.validateCCNum(number!).isValid : false;
}
/// Checks whether or not the {@link #expMonth} and {@link #expYear} fields represent a valid
/// expiry date.
///
/// @return {@code true} if valid, {@code false} otherwise
bool validateDate() {
return _ccValidator
.validateExpDate(
'${expMonth.toString().padLeft(2, '0')}/${expYear.toString().padLeft(2, '0')}')
.isValid;
}
/// Checks whether or not the {@link #cvc} field is valid.
///
/// @return {@code true} if valid, {@code false} otherwise
bool validateCVC() {
if (cvc == null) return false;
return _ccValidator.validateCVV(cvc!, _ccValidator.validateCCNum(number!).ccType).isValid;
}
/// Returns a stripe hash that represents this card.
/// It only sets the type and card details. In order to add additional details such as name and address,
/// you need to insert these keys into the hash before submitting it.
Map<String, Object> toPaymentMethod() {
final Map<String, Object> map = {
'type': 'card',
'card': {
'number': number,
'cvc': cvc,
'exp_month': expMonth,
'exp_year': expYear,
},
'billing_details': {
'address': {'postal_code': postalCode}
}
};
_removeNullAndEmptyParams(map);
return map;
}
static void _removeNullAndEmptyParams(Map<String, Object?> mapToEdit) {
// Remove all null values; they cause validation errors
final List<String> keys = mapToEdit.keys.toList(growable: false);
for (String key in keys) {
final Object? value = mapToEdit[key];
if (value == null) {
mapToEdit.remove(key);
} else if (value is String) {
if (value.isEmpty) {
mapToEdit.remove(key);
}
} else if (value is Map) {
_removeNullAndEmptyParams(value as Map<String, Object?>);
}
}
}
}

View File

@@ -0,0 +1,268 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'ui/stripe_web_view.dart';
import "package:universal_html/html.dart" as html;
import 'package:url_launcher/url_launcher.dart';
import 'stripe_api.dart';
import 'ui/stripe_ui.dart';
class Stripe {
/// Creates a new [Stripe] object. Use this constructor if you wish to handle the instance of this class by yourself.
/// Alternatively, use [Stripe.init] to create a singleton and access it through [Stripe.instance].
///
/// [publishableKey] is your publishable key, beginning with "pk_".
/// Your can copy your key from https://dashboard.stripe.com/account/apikeys
///
/// [stripeAccount] is the id of a stripe customer and stats with "cus_".
/// This is a optional parameter.
///
/// [returnUrlForSca] should be used to specify a unique return url for
/// Strong Customer Authentication (SCA) such as 3DS, 3DS2, BankID and others.
/// It is required to use your own app specific url scheme and host. This
/// parameter must match your "android/app/src/main/AndroidManifest.xml"
/// and "ios/Runner/Info.plist" configuration.
Stripe(String publishableKey, {String? stripeAccount})
: api = StripeApi(publishableKey, stripeAccount: stripeAccount);
final StripeApi api;
static Stripe? _instance;
/// Access the instance of Stripe by calling [Stripe.instance].
/// Throws an [Exception] if [Stripe.init] hasn't been called previously.
static Stripe get instance {
if (_instance == null) {
throw Exception('Attempted to get singleton instance of Stripe without initialization');
}
return _instance!;
}
/// Initializes the singleton instance of [Stripe]. Afterwards you can
/// use [Stripe.instance] to access the created instance.
///
/// [publishableKey] is your publishable key, beginning with "pk_".
/// Your can copy your key from https://dashboard.stripe.com/account/apikeys
///
/// [stripeAccount] is the id of a stripe customer and stats with "cus_".
/// This is a optional parameter.
///
/// [returnUrlForSca] should be used to specify a unique return url for
/// Strong Customer Authentication (SCA) such as 3DS, 3DS2, BankID and others.
/// It is required to use your own app specific url scheme and host. This
/// parameter must match your "android/app/src/main/AndroidManifest.xml"
/// and "ios/Runner/Info.plist" configuration.
static void init(String publishableKey, {String? stripeAccount}) {
_instance = Stripe(publishableKey, stripeAccount: stripeAccount);
StripeApi.init(publishableKey, stripeAccount: stripeAccount);
}
/// Creates a return URL that can be used to authenticate a single PaymentIntent.
/// This should be set on the intent before attempting to authenticate it.
String getReturnUrlForSca({String? webReturnUrl}) {
if (kIsWeb) {
return webReturnUrl ?? StripeUiOptions.defaultWebReturnUrl;
} else {
final requestId = Random.secure().nextInt(99999999);
return '${StripeUiOptions.defaultMobileReturnUrl}?requestId=$requestId';
}
}
/// Authenticate a SetupIntent
/// https://stripe.com/docs/api/setup_intents/confirm
Future<Map<String, dynamic>> authenticateSetupIntent(String clientSecret,
{String? webReturnPath, required BuildContext context}) async {
final Map<String, dynamic> intent = await api.confirmSetupIntent(
clientSecret,
data: {'return_url': getReturnUrlForSca(webReturnUrl: webReturnPath)},
);
return _handleSetupIntent(intent, context);
}
/// Confirm and authenticate a SetupIntent
/// https://stripe.com/docs/api/setup_intents/confirm
Future<Map<String, dynamic>> confirmSetupIntent(String clientSecret, String paymentMethod,
{String? webReturnPath, required BuildContext context}) async {
var returnUrlForSca = getReturnUrlForSca(webReturnUrl: webReturnPath);
final Map<String, dynamic> intent = await api.confirmSetupIntent(
clientSecret,
data: {
'return_url': returnUrlForSca,
'payment_method': paymentMethod,
},
);
return _handleSetupIntent(intent, context);
}
/// Confirm and authenticate a payment.
/// Returns the PaymentIntent.
/// https://stripe.com/docs/payments/payment-intents/android
Future<Map<String, dynamic>> confirmPayment(String paymentIntentClientSecret, BuildContext context,
{String? paymentMethodId}) async {
final data = {'return_url': getReturnUrlForSca()};
if (paymentMethodId != null) data['payment_method'] = paymentMethodId;
final Map<String, dynamic> paymentIntent = await api.confirmPaymentIntent(
paymentIntentClientSecret,
data: data,
);
return _handlePaymentIntent(paymentIntent, context);
}
/// Confirm and authenticate a payment with Google Pay.
/// /// [paymentResult] must be the result of requesting a Google Pay payment with the `pay` library.
/// Returns the PaymentIntent.
Future<Map<String, dynamic>> confirmPaymentWithGooglePay(BuildContext context,
{required String paymentIntentClientSecret, required Map<String, dynamic> paymentResult}) async {
final data = <String, dynamic>{'return_url': getReturnUrlForSca()};
final String token = paymentResult['paymentMethodData']['tokenizationData']['token'] as String;
final tokenJson = jsonDecode(token) as Map<String, dynamic>;
final tokenId = tokenJson['id'] as String;
data['payment_method_data'] = {
'type': 'card',
"card": {"token": tokenId}
};
final Map<String, dynamic> paymentIntent = await api.confirmPaymentIntent(
paymentIntentClientSecret,
data: data,
);
return _handlePaymentIntent(paymentIntent, context);
}
/// Confirm and authenticate a payment with Apple Pay.
/// [paymentResult] must be the result of requesting a Apple Pay paymet with the `pay` library.
/// Returns the PaymentIntent.
Future<Map<String, dynamic>> confirmPaymentWithApplePay(BuildContext context,
{required String paymentIntentClientSecret, required Map<String, dynamic> paymentResult}) async {
final tokenPayload = <String, dynamic>{};
tokenPayload['pk_token'] = paymentResult['token'];
final paymentMethod = _getPaymentMethod(paymentResult['paymentMethod']);
tokenPayload['pk_token_instrument_name'] = paymentMethod['displayName'];
tokenPayload['pk_token_payment_network'] = paymentMethod['network'];
tokenPayload['card'] = <String, dynamic>{};
tokenPayload['pk_token_transaction_id'] = paymentResult['transactionIdentifier'];
if (tokenPayload['pk_token_transaction_id'] == "Simulated Identifier") {
tokenPayload['pk_token_transaction_id'] =
"ApplePayStubs~4242424242424242~200~USD~${DateTime.now().millisecondsSinceEpoch}";
}
final stripeToken = await api.createToken(tokenPayload);
final tokenId = stripeToken['id'] as String;
final data = <String, dynamic>{'return_url': getReturnUrlForSca()};
data['payment_method_data'] = {
'type': 'card',
"card": {"token": tokenId}
};
final Map<String, dynamic> paymentIntent = await api.confirmPaymentIntent(
paymentIntentClientSecret,
data: data,
);
return _handlePaymentIntent(paymentIntent, context);
}
Map<String, dynamic> _getPaymentMethod(dynamic paymentMethod) {
if (paymentMethod is String) {
return jsonDecode(paymentMethod) as Map<String, dynamic>;
} else {
return paymentMethod as Map<String, dynamic>;
}
}
/// Authenticate a payment.
/// Returns the PaymentIntent.
/// https://stripe.com/docs/payments/payment-intents/android-manual
Future<Map<String, dynamic>> authenticatePayment(String paymentIntentClientSecret, BuildContext context) async {
final Map<String, dynamic> paymentIntent = await api.retrievePaymentIntent(paymentIntentClientSecret);
return _handlePaymentIntent(paymentIntent, context);
}
/// Authenticate a payment with [paymentIntent].
/// This is similar to [authenticatePayment] but is slightly more efficient,
/// as it avoids the request to the Stripe API to retrieve the action.
/// To use this, return the complete [paymentIntent] from your server.
Future<Map<String, dynamic>> _handlePaymentIntent(Map<String, dynamic> paymentIntent, BuildContext context) async {
return _authenticateIntent(paymentIntent, context, api.retrievePaymentIntent);
}
/// Launch 3DS in a new browser window.
/// Returns a [Future] with the Stripe SetupIntent when the user completes or cancels authentication.
Future<Map<String, dynamic>> _handleSetupIntent(Map<String, dynamic> setupIntent, BuildContext context) async {
return _authenticateIntent(setupIntent, context, api.retrieveSetupIntent);
}
Future<Map<String, dynamic>> _authenticateIntent(Map<String, dynamic> intent, BuildContext context,
Future<Map<String, dynamic>> Function(String clientSecret) getIntentFunction) async {
if (intent['status'] != 'requires_action') return intent;
final clientSecret = intent['client_secret'];
final action = intent['next_action'];
final String url = action['redirect_to_url']['url'];
final returnUri = Uri.parse(action['redirect_to_url']['return_url']);
if (kIsWeb) {
return _authenticateWithBrowser(context, url, returnUri, getIntentFunction, clientSecret);
} else {
await _authenticateWithWebView(context, url, returnUri);
return getIntentFunction(clientSecret);
}
}
Future<Map<String, dynamic>> _authenticateWithBrowser(BuildContext context, String url, Uri returnUri,
Future<Map<String, dynamic>> Function(String clientSecret) getIntentFunction, String clientSecret) async {
final completer = Completer<Map<String, dynamic>>();
late StreamSubscription<html.Event> subscription;
subscription = html.window.onFocus.listen((event) async {
final intent = await getIntentFunction(clientSecret);
if (intent['status'] != 'requires_action') {
Navigator.of(context).pop();
subscription.cancel();
await Future.delayed(const Duration(seconds: 1));
completer.complete(intent);
return;
}
});
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => SimpleDialog(
title: const Text("Awaiting authentication, please complete authentication in the opened window."),
children: [
SimpleDialogOption(
child: const Text("Cancel"),
onPressed: () {
Navigator.of(context).pop();
subscription.cancel();
completer.complete(getIntentFunction(clientSecret));
},
),
SimpleDialogOption(
child: const Text("Open new window"),
onPressed: () {
Navigator.of(context).pop();
launchUrl(Uri.parse(url));
},
)
],
),
);
await launchUrl(Uri.parse(url));
return completer.future;
}
Future<bool?> _authenticateWithWebView(BuildContext context, String url, Uri returnUri) async {
return Navigator.push<bool?>(
context,
MaterialPageRoute(
builder: (context) => StripeWebView(
uri: url,
returnUri: returnUri,
),
));
}
}

View File

@@ -0,0 +1,139 @@
import 'dart:async';
import 'models/card.dart';
import 'stripe_api_handler.dart';
typedef IntentProvider = Future<Map<String, dynamic>> Function(Uri uri);
class StripeApi {
static StripeApi? _instance;
final StripeApiHandler _apiHandler;
final String publishableKey;
final String apiVersion;
/// Create a new instance, which can be used with e.g. dependency injection.
/// Throws a [Exception] if an invalid [publishableKey] has been submitted.
///
/// [publishableKey] is your publishable key, beginning with "pk_".
/// Your can copy your key from https://dashboard.stripe.com/account/apikeys
///
/// [stripeAccount] is the id of a stripe customer and stats with "cus_".
/// This is a optional parameter.
StripeApi(this.publishableKey, {this.apiVersion = defaultApiVersion, String? stripeAccount})
: _apiHandler = StripeApiHandler(stripeAccount: stripeAccount) {
_validateKey(publishableKey);
_apiHandler.apiVersion = apiVersion;
}
/// Initialize the managed singleton instance of [StripeApi].
/// Afterwards you can use [StripeApi.instance] to access the created instance.
///
/// [publishableKey] is your publishable key, beginning with "pk_".
/// Your can copy your key from https://dashboard.stripe.com/account/apikeys
///
/// [stripeAccount] is the id of a stripe customer and stats with "cus_".
/// This is a optional parameter.
static void init(String publishableKey, {String apiVersion = defaultApiVersion, String? stripeAccount}) {
_instance ??= StripeApi(publishableKey, apiVersion: apiVersion, stripeAccount: stripeAccount);
}
/// Access the singleton instance of [StripeApi].
/// Throws an [Exception] if [StripeApi.init] hasn't been called previously.
static StripeApi get instance {
if (_instance == null) {
throw Exception('Attempted to get singleton instance of StripeApi without initialization');
}
return _instance!;
}
/// Create a stripe Token
/// https://stripe.com/docs/api/tokens
Future<Map<String, dynamic>> createToken(Map<String, dynamic> data) async {
const path = '/tokens';
return _apiHandler.request(RequestMethod.post, path, publishableKey, apiVersion, params: data);
}
/// Create a PaymentMethod.
/// https://stripe.com/docs/api/payment_methods/create
Future<Map<String, dynamic>> createPaymentMethod(Map<String, dynamic> data) async {
const path = '/payment_methods';
return _apiHandler.request(RequestMethod.post, path, publishableKey, apiVersion, params: data);
}
/// Create a PaymentMethod from a card.
/// This will only create a PaymentMethod with the minimum required properties.
/// To include additional properties such as billing details, use [StripeCard.toPaymentMethod], add additional details
/// and then use [createPaymentMethod].
Future<Map<String, dynamic>> createPaymentMethodFromCard(StripeCard card) async {
return createPaymentMethod(card.toPaymentMethod());
}
/// Create a new Source object.
/// https://stripe.com/docs/api/sources/create
Future<Map<String, dynamic>> createSource(Map<String, dynamic> data) async {
const path = '/sources';
return _apiHandler.request(RequestMethod.post, path, publishableKey, apiVersion, params: data);
}
/// Retrieve a PaymentIntent.
/// https://stripe.com/docs/api/payment_intents/retrieve
Future<Map<String, dynamic>> retrievePaymentIntent(String clientSecret, {String? apiVersion}) async {
final intentId = _parseIdFromClientSecret(clientSecret);
final path = '/payment_intents/$intentId';
final params = {'client_secret': clientSecret};
return _apiHandler.request(RequestMethod.get, path, publishableKey, apiVersion, params: params);
}
/// Confirm a PaymentIntent
/// https://stripe.com/docs/api/payment_intents/confirm
Future<Map<String, dynamic>> confirmPaymentIntent(String clientSecret, {Map<String, dynamic>? data}) async {
data ??= {};
final intent = _parseIdFromClientSecret(clientSecret);
data['client_secret'] = clientSecret;
final path = '/payment_intents/$intent/confirm';
return _apiHandler.request(RequestMethod.post, path, publishableKey, apiVersion, params: data);
}
/// Retrieve a SetupIntent.
/// https://stripe.com/docs/api/setup_intents/retrieve
Future<Map<String, dynamic>> retrieveSetupIntent(String clientSecret, {String? apiVersion}) async {
final intentId = _parseIdFromClientSecret(clientSecret);
final path = '/setup_intents/$intentId';
final params = {'client_secret': clientSecret};
return _apiHandler.request(RequestMethod.get, path, publishableKey, apiVersion, params: params);
}
/// Confirm a SetupIntent
/// https://stripe.com/docs/api/setup_intents/confirm
Future<Map<String, dynamic>> confirmSetupIntent(String clientSecret, {Map<String, dynamic>? data}) async {
data ??= {};
final intent = _parseIdFromClientSecret(clientSecret);
data['client_secret'] = clientSecret;
final path = '/setup_intents/$intent/confirm';
return _apiHandler.request(RequestMethod.post, path, publishableKey, apiVersion, params: data);
}
/// Validates the received [publishableKey] and throws a [Exception] if an
/// invalid key has been submitted.
static void _validateKey(String publishableKey) {
if (publishableKey.isEmpty) {
throw Exception('Invalid Publishable Key: '
'You must use a valid publishable key to create a token. '
'For more info, see https://stripe.com/docs/stripe.js.');
}
if (publishableKey.startsWith('sk_')) {
throw Exception('Invalid Publishable Key: '
'You are using a secret key to create a token, '
'instead of the publishable one. For more info, '
'see https://stripe.com/docs/stripe.js');
}
}
}
String _parseIdFromClientSecret(String clientSecret) {
return clientSecret.split('_secret')[0];
}

View File

@@ -0,0 +1,185 @@
import 'dart:async';
import 'dart:convert' show json;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'stripe_error.dart';
const String defaultApiVersion = '2020-03-02';
enum RequestMethod { get, post, put, delete, option }
class StripeApiHandler {
static const String liveApiBase = 'https://api.stripe.com';
static const String liveLogginBase = 'https://q.stripe.com';
static const String liveLoggingEndpoint = 'https://m.stripe.com/4';
static const String liveApiPath = liveApiBase + '/v1';
static const String charset = 'UTF-8';
static const String customers = 'customers';
static const String tokens = 'tokens';
static const String sources = 'sources';
static const String headerKeyRequestId = 'Request-Id';
static const String fieldError = 'error';
static const String fieldSource = 'source';
String apiVersion = defaultApiVersion;
static const String malformedResponseMessage = 'An improperly formatted error response was found.';
final http.Client _client = http.Client();
final String? stripeAccount;
StripeApiHandler({this.stripeAccount});
Future<Map<String, dynamic>> request(RequestMethod method, String path, String key, String? apiVersion,
{final Map<String, dynamic>? params}) {
final options = RequestOptions(key: key, apiVersion: apiVersion, stripeAccount: stripeAccount);
return _getStripeResponse(method, liveApiPath + path, options, params: params);
}
Future<Map<String, dynamic>> _getStripeResponse(RequestMethod method, final String url, final RequestOptions options,
{final Map<String, dynamic>? params}) async {
final Map<String, String> headers = _headers(options: options);
http.Response response;
switch (method) {
case RequestMethod.get:
var fUrl = url;
if (params != null && params.isNotEmpty) {
fUrl = '$url?${_encodeMap(params)}';
}
response = await _client.get(Uri.parse(fUrl), headers: headers);
break;
case RequestMethod.post:
response = await _client.post(
Uri.parse(url),
headers: headers,
body: params != null ? _urlEncodeMap(params) : null,
);
break;
case RequestMethod.delete:
response = await _client.delete(Uri.parse(url), headers: headers);
break;
default:
throw Exception('Request Method: $method not implemented');
}
final requestId = response.headers[headerKeyRequestId];
final statusCode = response.statusCode;
late Map<String, dynamic> resp;
try {
resp = json.decode(response.body);
} catch (error) {
final stripeError = StripeApiError(requestId, {StripeApiError.fieldMessage: malformedResponseMessage});
throw StripeApiException(stripeError);
}
if (statusCode < 200 || statusCode >= 300) {
final Map<String, dynamic> errBody = resp[fieldError];
final stripeError = StripeApiError(requestId, errBody);
throw StripeApiException(stripeError);
} else {
return resp;
}
}
///
///
///
static Map<String, String> _headers({RequestOptions? options}) {
final Map<String, String> headers = {};
headers['Accept-Charset'] = charset;
headers['Accept'] = 'application/json';
headers['Content-Type'] = 'application/x-www-form-urlencoded';
headers['User-Agent'] = 'StripeSDK/v2';
if (options != null) {
headers['Authorization'] = 'Bearer ${options.key}';
if (options.apiVersion != null) {
headers['Stripe-Version'] = options.apiVersion!;
}
if (options.stripeAccount != null) {
headers['Stripe-Account'] = options.stripeAccount!;
}
if (options.idempotencyKey != null) {
headers['Idempotency-Key'] = options.idempotencyKey!;
}
}
// debug headers
final propertyMap = <String, String>{};
propertyMap['os.name'] = defaultTargetPlatform.toString();
propertyMap['lang'] = 'Dart';
propertyMap['publisher'] = 'lars.dahl@gmail.com';
headers['X-Stripe-Client-User-Agent'] = json.encode(propertyMap);
return headers;
}
static String _encodeMap(Map<String, dynamic> params) {
return params.keys
.map((key) => '${Uri.encodeComponent(key)}=${Uri.encodeComponent(params[key].toString())}')
.join('&');
}
static String _urlEncodeMap(dynamic data) {
final urlData = StringBuffer();
var first = true;
void urlEncode(dynamic sub, String path) {
if (sub is List) {
for (var i = 0; i < sub.length; i++) {
urlEncode(sub[i], '$path%5B%5D');
}
} else if (sub is Map) {
sub.forEach((k, v) {
if (path == '') {
urlEncode(v, Uri.encodeQueryComponent(k));
} else {
urlEncode(v, '$path%5B${Uri.encodeQueryComponent(k)}%5D');
}
});
} else {
if (!first) {
urlData.write('&');
}
first = false;
urlData.write('$path=${Uri.encodeQueryComponent(sub.toString())}');
}
}
urlEncode(data, '');
return urlData.toString();
}
}
class RequestOptions {
static const String typeQuery = 'source';
static const String typeJson = 'json_data';
final String? apiVersion; // TODO might have no usage
final String? guid;
final String? idempotencyKey;
final String key;
final String? requestType; // TODO might have no usage
final String? stripeAccount;
RequestOptions({
required this.apiVersion,
this.guid,
this.idempotencyKey,
required this.key,
this.requestType,
this.stripeAccount,
});
}

View File

@@ -0,0 +1,67 @@
import 'util/stripe_json_utils.dart';
class StripeApiError {
static const String fieldType = 'type';
static const String fieldCharge = 'charge';
static const String fieldCode = 'code';
static const String fieldDeclineCode = 'decline_code';
static const String fieldDocUrl = 'doc_url';
static const String fieldMessage = 'message';
static const String fieldParam = 'param';
final String? requestId;
final String? type;
final String? charge;
final String? code;
final String? declineCode;
final String? docUrl;
final String? message;
final String? param;
StripeApiError._internal(
this.requestId,
this.type,
this.charge,
this.code,
this.declineCode,
this.docUrl,
this.message,
this.param,
);
factory StripeApiError(String? requestId, Map<String, dynamic> json) {
final type = optString(json, fieldType);
final charge = optString(json, fieldCharge);
final code = optString(json, fieldCode);
final declineCode = optString(json, fieldDeclineCode);
final docUrl = optString(json, fieldDocUrl);
final message = optString(json, fieldMessage);
final param = optString(json, fieldParam);
return StripeApiError._internal(
requestId,
type,
charge,
code,
declineCode,
docUrl,
message,
param,
);
}
}
class StripeApiException implements Exception {
final StripeApiError error;
final String? requestId;
final String message;
StripeApiException(this.error)
: requestId = error.requestId,
message = error.message!;
@override
String toString() {
return message;
}
}

View File

@@ -0,0 +1,121 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../stripe_error.dart';
import '../ui/stripe_ui.dart';
import '../stripe.dart';
import 'models.dart';
import 'progress_bar.dart';
import 'stores/payment_method_store.dart';
import 'widgets/payment_method_selector.dart';
class CheckoutPage extends StatefulWidget {
final Future<IntentClientSecret> Function() createPaymentIntent;
final void Function(BuildContext context, Map<String, dynamic> paymentIntent) onPaymentSuccess;
final void Function(BuildContext context, Map<String, dynamic> paymentIntent) onPaymentFailed;
final void Function(BuildContext context, StripeApiException e) onPaymentError;
CheckoutPage({
Key? key,
required this.createPaymentIntent,
void Function(BuildContext context, Map<String, dynamic> paymentIntent)? onPaymentSuccess,
void Function(BuildContext context, Map<String, dynamic> paymentIntent)? onPaymentFailed,
void Function(BuildContext, StripeApiException)? onPaymentError,
}) : onPaymentSuccess = onPaymentSuccess ?? StripeUiOptions.onPaymentSuccess,
onPaymentFailed = onPaymentFailed ?? StripeUiOptions.onPaymentFailed,
onPaymentError = onPaymentError ?? StripeUiOptions.onPaymentError,
super(key: key);
@override
_CheckoutPageState createState() => _CheckoutPageState();
}
// ignore: deprecated_member_use_from_same_package
class _CheckoutPageState extends State<CheckoutPage> {
final PaymentMethodStore paymentMethodStore = PaymentMethodStore.instance;
String? _selectedPaymentMethod;
late Future<IntentClientSecret> _clientSecretFuture;
late Future<Map<String, dynamic>> paymentIntentFuture;
@override
void initState() {
_clientSecretFuture = widget.createPaymentIntent();
paymentIntentFuture =
_clientSecretFuture.then((value) => Stripe.instance.api.retrievePaymentIntent(value.clientSecret));
super.initState();
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Center(
child: PaymentMethodSelector(
paymentMethodStore: paymentMethodStore,
initialPaymentMethodId: null,
onChanged: (value) => setState(() {
_selectedPaymentMethod = value;
debugPrint("Selected paymentMethod changed: $value");
})),
),
FutureBuilder<Map<String, dynamic>>(
future: paymentIntentFuture,
builder: (context, snapshot) {
if (snapshot.hasError) {
debugPrint(snapshot.error.toString());
}
if (snapshot.hasData) {
return Align(
alignment: Alignment.bottomCenter,
child: StripeUiOptions.payButtonBuilder(
context,
snapshot.data!,
_selectedPaymentMethod == null
? null
: _createAttemptPaymentFunction(context, _clientSecretFuture)),
);
} else {
return _selectedPaymentMethod == null
? const SizedBox.shrink()
: const Center(
child: CircularProgressIndicator(),
);
}
})
],
),
);
}
void Function() _createAttemptPaymentFunction(BuildContext context, Future<IntentClientSecret> paymentIntentFuture) {
return () async {
showProgressDialog(context);
final initialPaymentIntent = await paymentIntentFuture.catchError((error){
hideProgressDialog(context);
});
try {
final confirmedPaymentIntent = await Stripe.instance
.confirmPayment(initialPaymentIntent.clientSecret, context, paymentMethodId: _selectedPaymentMethod);
if (confirmedPaymentIntent['status'] == 'succeeded') {
widget.onPaymentSuccess(context, confirmedPaymentIntent);
hideProgressDialog(context);
} else {
widget.onPaymentFailed(context, confirmedPaymentIntent);
hideProgressDialog(context);
}
} catch (e) {
hideProgressDialog(context);
debugPrint(e.toString());
if (e is StripeApiException) {
widget.onPaymentError(context, e);
} else {
rethrow;
}
}
};
}
}

View File

@@ -0,0 +1,6 @@
class IntentClientSecret {
final String clientSecret;
final String? status;
IntentClientSecret(this.status, this.clientSecret);
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
void hideProgressDialog(BuildContext context) {
Navigator.of(context, rootNavigator: true).pop();
}
Future<bool?> showProgressDialog(BuildContext context) {
return showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()));
}

View File

@@ -0,0 +1,144 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../stores/payment_method_store.dart';
import '../../models/card.dart';
import '../../stripe.dart';
import '../models.dart';
import '../progress_bar.dart';
import '../stripe_ui.dart';
import '../widgets/card_form.dart';
/// A screen that collects, creates and attaches a payment method to a stripe customer.
///
/// Payment methods can be created with and without a Setup Intent. Using a Setup Intent is highly recommended.
///
class AddPaymentMethodScreen extends StatefulWidget {
final Stripe stripe;
/// Used to create a setup intent when required.
final createSetupIntent = StripeUiOptions.createSetupIntent;
/// The payment method store used to manage payment methods.
final PaymentMethodStore paymentMethodStore;
/// Custom Title for the screen
final String title;
static const String _defaultTitle = 'Add payment method';
static Route<String?> route(
{PaymentMethodStore? paymentMethodStore, Stripe? stripe, CardForm? form, String title = _defaultTitle}) {
return MaterialPageRoute(
builder: (context) => AddPaymentMethodScreen(
paymentMethodStore: paymentMethodStore ?? PaymentMethodStore.instance,
stripe: stripe ?? Stripe.instance,
title: title,
),
);
}
/// Add a payment method using a Stripe Setup Intent
AddPaymentMethodScreen({Key? key, required this.paymentMethodStore, required this.stripe, this.title = _defaultTitle})
: super(key: key);
@override
_AddPaymentMethodScreenState createState() => _AddPaymentMethodScreenState();
}
class _AddPaymentMethodScreenState extends State<AddPaymentMethodScreen> {
Future<IntentClientSecret>? setupIntentFuture;
final form = CardForm();
@override
void initState() {
if (widget.createSetupIntent != null) setupIntentFuture = widget.createSetupIntent!();
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.check),
onPressed: () async {
final formState = form.formKey.currentState;
if (formState?.validate() ?? false) {
formState!.save();
await _tryCreatePaymentMethod(context, form.card);
}
},
)
],
),
body: SingleChildScrollView(
child: Column(
children: [
form,
if (StripeUiOptions.showTestPaymentMethods)
Padding(
padding: const EdgeInsets.all(8.0),
child: Wrap(
children: [
_createTestCardButton("4242424242424242"),
_createTestCardButton("4000000000003220"),
_createTestCardButton("4000000000003063"),
_createTestCardButton("4000008400001629"),
_createTestCardButton("4000008400001280"),
_createTestCardButton("4000000000003055"),
_createTestCardButton("4000000000003097"),
_createTestCardButton("378282246310005"),
],
),
)
],
),
));
}
Widget _createTestCardButton(String number) {
return OutlinedButton(
child: Text(number.substring(number.length - 4)),
onPressed: () =>
_tryCreatePaymentMethod(context, StripeCard(number: number, cvc: "123", expMonth: 1, expYear: 2030)));
}
Future<void> _tryCreatePaymentMethod(BuildContext context, StripeCard cardData) async {
FocusManager.instance.primaryFocus!.unfocus();
showProgressDialog(context);
try {
await _createPaymentMethod(cardData, context);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
Future<void> _createPaymentMethod(StripeCard cardData, BuildContext context) async {
var paymentMethod = await widget.stripe.api.createPaymentMethodFromCard(cardData);
if (setupIntentFuture != null) {
final initialSetupIntent =
await setupIntentFuture!.timeout(const Duration(seconds: 10)).whenComplete(() => hideProgressDialog(context));
final confirmedSetupIntent = await widget.stripe
.confirmSetupIntent(initialSetupIntent.clientSecret, paymentMethod['id'], context: context);
if (confirmedSetupIntent['status'] == 'succeeded') {
/// A new payment method has been attached, so refresh the store.
await widget.paymentMethodStore.refresh();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("Payment method successfully added."),
));
Navigator.pop(context, paymentMethod['id']);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text("Authentication failed, please try again.")));
}
} else {
paymentMethod = await (widget.paymentMethodStore.attachPaymentMethod(paymentMethod['id']))
.whenComplete(() => hideProgressDialog(context));
Navigator.pop(context, paymentMethod['id']);
}
}
}

View File

@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import '../../stripe.dart';
import '../../ui/stores/payment_method_store.dart';
import '../progress_bar.dart';
import 'add_payment_method_screen.dart';
class PaymentMethodsScreen extends StatelessWidget {
final String title;
/// The payment method store to use.
final PaymentMethodStore _paymentMethodStore;
static Route<void> route({String title = '', PaymentMethodStore? paymentMethodStore}) {
return MaterialPageRoute(
builder: (context) => PaymentMethodsScreen(
title: title,
paymentMethodStore: paymentMethodStore,
));
}
PaymentMethodsScreen({Key? key, this.title = 'Payment Methods', PaymentMethodStore? paymentMethodStore})
: _paymentMethodStore = paymentMethodStore ?? PaymentMethodStore.instance,
super(key: key);
@override
Widget build(BuildContext context) {
final stripe = Stripe.instance;
return Scaffold(
appBar: AppBar(
title: Text(title),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.add),
onPressed: () async {
await Navigator.push(
context, AddPaymentMethodScreen.route(paymentMethodStore: _paymentMethodStore, stripe: stripe));
},
)
],
),
body: PaymentMethodsList(
paymentMethodStore: _paymentMethodStore,
),
);
}
}
class PaymentMethod {
final String id;
final String last4;
final String brand;
final DateTime expirationDate;
const PaymentMethod(this.id, this.last4, this.brand, this.expirationDate);
String getExpirationAsString() {
return '${expirationDate.month}/${expirationDate.year}';
}
@override
int get hashCode {
return id.hashCode;
}
@override
bool operator ==(Object other) {
if (other is PaymentMethod) return id == other.id;
return false;
}
}
class PaymentMethodsList extends StatefulWidget {
final PaymentMethodStore paymentMethodStore;
const PaymentMethodsList({Key? key, required this.paymentMethodStore}) : super(key: key);
@override
_PaymentMethodsListState createState() => _PaymentMethodsListState();
}
class _PaymentMethodsListState extends State<PaymentMethodsList> {
List<PaymentMethod> paymentMethods = [];
@override
void initState() {
super.initState();
widget.paymentMethodStore.addListener(paymentMethodStoreListener);
setState(() => paymentMethods = widget.paymentMethodStore.paymentMethods);
}
@override
void didUpdateWidget(PaymentMethodsList oldWidget) {
widget.paymentMethodStore.addListener(paymentMethodStoreListener);
widget.paymentMethodStore.removeListener(paymentMethodStoreListener);
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
widget.paymentMethodStore.removeListener(paymentMethodStoreListener);
super.dispose();
}
void paymentMethodStoreListener() {
if (mounted) {
setState(() => paymentMethods = widget.paymentMethodStore.paymentMethods);
}
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () async => await widget.paymentMethodStore.refresh(),
child: buildListView(paymentMethods, widget.paymentMethodStore, context),
);
}
Widget buildListView(List<PaymentMethod> listData, PaymentMethodStore paymentMethods, BuildContext rootContext) {
if (paymentMethods.isLoading) {
return const Center(child: CircularProgressIndicator());
} else {
return ListView.builder(
itemCount: listData.length,
itemBuilder: (BuildContext context, int index) {
final card = listData[index];
return Card(
child: ListTile(
onLongPress: () async {},
onTap: () => _displayDeletePaymentMethodDialog(rootContext, card, paymentMethods),
title: Text(card.last4),
subtitle: Text(card.brand.toUpperCase()),
leading: const Icon(Icons.credit_card),
// trailing: card.id == defaultPaymentMethod.paymentMethodId ? Icon(Icons.check_circle) : null,
trailing: IconButton(
onPressed: () => _displayDeletePaymentMethodDialog(rootContext, card, paymentMethods),
icon: const Icon(Icons.cancel),
),
),
);
});
}
}
Future<void> _displayDeletePaymentMethodDialog(
BuildContext context, PaymentMethod card, PaymentMethodStore paymentMethods) async {
showDialog(
context: context,
builder: (BuildContext alertDialogContext) {
return AlertDialog(
title: const Text('Delete payment method'),
content: const Text('Are you sure you want to delete this payment method?'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(alertDialogContext, rootNavigator: true).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.of(alertDialogContext, rootNavigator: true).pop();
showProgressDialog(context);
await widget.paymentMethodStore
.detachPaymentMethod(card.id)
.whenComplete(() => hideProgressDialog(context));
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(const SnackBar(
content: Text('Payment method successfully deleted.'),
));
},
child: const Text('Delete'),
),
],
);
});
}
}

View File

@@ -0,0 +1,89 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import '../../customer_session.dart';
import '../screens/payment_methods_screen.dart';
/// A managed repository for payment methods.
/// This is the preferred way to work with payment methods when using Flutter.
/// The store will only refresh itself if there are active listeners.
class PaymentMethodStore extends ChangeNotifier {
final List<PaymentMethod> paymentMethods = [];
bool isLoading = false;
bool isInitialized = false;
/// The customer session the store operates on.
final CustomerSession _customerSession;
static PaymentMethodStore? _instance;
/// Access the singleton instance of [PaymentMethodStore].
static PaymentMethodStore get instance {
_instance ??= PaymentMethodStore();
return _instance!;
}
PaymentMethodStore({CustomerSession? customerSession})
: _customerSession = customerSession ?? CustomerSession.instance {
_customerSession.addListener(_customerSessionListener);
}
void _customerSessionListener() => dispose();
/// Refreshes data from the API when the first listener is added.
@override
void addListener(VoidCallback listener) {
super.addListener(listener);
if (!isInitialized) {
isInitialized = true;
refresh();
}
}
/// Attach a payment method and refresh the store if there are any active listeners.
Future<Map<String, dynamic>> attachPaymentMethod(String paymentMethodId) async {
final paymentMethodFuture = await _customerSession.attachPaymentMethod(paymentMethodId);
await refresh();
return paymentMethodFuture;
}
/// Detach a payment method and refresh the store if there are any active listeners.
Future<Map> detachPaymentMethod(String paymentMethodId) async {
final paymentMethodFuture = await _customerSession.detachPaymentMethod(paymentMethodId);
await refresh();
return paymentMethodFuture;
}
/// Refresh the store if there are any active listeners.
Future<void> refresh() async {
final paymentMethodFuture = _customerSession.listPaymentMethods(limit: 100);
isLoading = true;
notifyListeners();
return paymentMethodFuture.then((value) {
final List listData = value['data'] ?? <PaymentMethod>[];
paymentMethods.clear();
if (listData.isNotEmpty) {
paymentMethods.addAll(listData
.map((item) => PaymentMethod(item['id'], item['card']['last4'], item['card']['brand'],
DateTime(item['card']['exp_year'], item['card']['exp_month'])))
.toList());
}
}).whenComplete(() {
isLoading = false;
notifyListeners();
});
}
/// Clear the store, notify all active listeners and dispose the ChangeNotifier.
@override
void dispose() {
_customerSession.removeListener(_customerSessionListener);
paymentMethods.clear();
notifyListeners();
if (identical(this, _instance)) {
_instance = null;
}
super.dispose();
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import '../stripe_error.dart';
import 'models.dart';
typedef CreateSetupIntent = Future<IntentClientSecret> Function();
typedef CreatePaymentIntent = Future<IntentClientSecret> Function(int amount);
class StripeUiOptions {
static CreateSetupIntent? createSetupIntent;
static CreatePaymentIntent? createPaymentIntent;
static String Function(BuildContext context, String currency, int amount) formatCurrency = _defaultFormatCurrency;
static Widget Function(BuildContext context, Map<String, dynamic> paymentIntent, void Function()? onPressed)
payButtonBuilder = _defaultPayButtonBuilder;
static void Function(BuildContext, Map<String, dynamic>) onPaymentFailed = _defaultOnPaymentFailed;
static void Function(BuildContext, Map<String, dynamic>) onPaymentSuccess = _defaultOnPaymentSuccess;
static void Function(BuildContext, StripeApiException) onPaymentError = _defaultOnPaymentError;
static String defaultWebReturnUrl = "/";
static String defaultMobileReturnUrl = "stripesdk://3ds.stripesdk";
static bool showTestPaymentMethods = false;
}
String _defaultFormatCurrency(BuildContext context, String currency, int amount) {
return "${currency.toUpperCase()}${(amount / 100).toStringAsFixed(2)}";
}
Widget _defaultPayButtonBuilder(BuildContext context, Map<String, dynamic> paymentIntent, void Function()? onPressed) {
return ElevatedButton(
onPressed: onPressed,
child: Text('Pay ${StripeUiOptions.formatCurrency(context, paymentIntent['currency'], paymentIntent['amount'])}'),
);
}
void _defaultOnPaymentSuccess(BuildContext context, Map<String, dynamic> paymentIntent) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(const SnackBar(content: Text("Payment successfully completed")));
}
void _defaultOnPaymentFailed(BuildContext context, Map<String, dynamic> paymentIntent) {
final lastIntent = paymentIntent['last_payment_error'];
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(lastIntent != null ? lastIntent('message') : "Payment aborted")));
}
void _defaultOnPaymentError(BuildContext context, StripeApiException e) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(e.message)));
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class StripeWebView extends StatelessWidget {
const StripeWebView({Key? key, required this.uri, required this.returnUri}) : super(key: key);
final String uri;
final Uri returnUri;
@override
Widget build(BuildContext context) {
return WebView(
initialUrl: uri,
javascriptMode: JavascriptMode.unrestricted,
navigationDelegate: (navigation) {
final uri = Uri.parse(navigation.url);
if (uri.scheme == returnUri.scheme &&
uri.host == returnUri.host &&
uri.queryParameters['requestId'] == returnUri.queryParameters['requestId']) {
Navigator.pop(context, true);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
});
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
/// Form field to edit a credit card CVC code, with validation
class CardCvcFormField extends StatefulWidget {
const CardCvcFormField(
{Key? key,
this.initialValue,
required this.onSaved,
required this.validator,
required this.onChanged,
this.decoration = defaultDecoration,
this.textStyle = defaultTextStyle})
: super(key: key);
final String? initialValue;
final InputDecoration decoration;
final TextStyle textStyle;
final void Function(String?) onSaved;
final void Function(String) onChanged;
final String? Function(String?) validator;
static const defaultErrorText = 'Invalid CVV';
static const defaultDecoration = InputDecoration(border: OutlineInputBorder(), labelText: 'CVV', hintText: 'XXX');
static const defaultTextStyle = TextStyle(color: Colors.black);
@override
_CardCvcFormFieldState createState() => _CardCvcFormFieldState();
}
class _CardCvcFormFieldState extends State<CardCvcFormField> {
final maskFormatter = MaskTextInputFormatter(mask: '####');
@override
Widget build(BuildContext context) {
return TextFormField(
initialValue: widget.initialValue,
autofillHints: const [AutofillHints.creditCardSecurityCode],
inputFormatters: [maskFormatter],
onChanged: widget.onChanged,
validator: widget.validator,
onSaved: widget.onSaved,
style: widget.textStyle,
decoration: widget.decoration,
textInputAction: TextInputAction.next,
// Use TextInputType.datetime instead of TextInputType.number to fix the numeric keyboard issue on
// iOS devices running iOS12 and lower. See: https://github.com/flutter/flutter/issues/58510
keyboardType: TextInputType.datetime);
}
}

View File

@@ -0,0 +1,83 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
/// Form field to edit a credit card expiration date, with validation.
class CardExpiryFormField extends StatefulWidget {
const CardExpiryFormField(
{Key? key,
this.initialMonth,
this.initialYear,
required this.onSaved,
required this.validator,
required this.onChanged,
this.decoration = defaultDecoration,
this.textStyle = defaultTextStyle})
: super(key: key);
final int? initialMonth;
final int? initialYear;
final InputDecoration decoration;
final TextStyle textStyle;
final void Function(int?, int?) onSaved;
final void Function(int?, int?) onChanged;
final String? Function(String?) validator;
static const defaultLabelText = 'Expiration Date';
static const defaultHintText = 'MM/YY';
static const defaultErrorText = 'Invalid expiration date';
static const defaultMonthMask = '##';
static const defaultYearMask = '##';
static const defaultDecoration =
InputDecoration(border: OutlineInputBorder(), labelText: defaultLabelText, hintText: defaultHintText);
static const defaultTextStyle = TextStyle(color: Colors.black);
@override
_CardExpiryFormFieldState createState() => _CardExpiryFormFieldState();
}
class _CardExpiryFormFieldState extends State<CardExpiryFormField> {
final maskFormatter =
MaskTextInputFormatter(mask: '${CardExpiryFormField.defaultMonthMask}/${CardExpiryFormField.defaultYearMask}');
@override
Widget build(BuildContext context) {
final month = widget.initialMonth?.toString().padLeft(CardExpiryFormField.defaultMonthMask.length, '0');
final year = widget.initialYear?.toString().substring(widget.initialYear.toString().length -
min(CardExpiryFormField.defaultYearMask.length, widget.initialYear.toString().length));
final initial = (month ?? '') + (year ?? '');
final initialMaskFormatter =
MaskTextInputFormatter(mask: '${CardExpiryFormField.defaultMonthMask}/${CardExpiryFormField.defaultYearMask}');
return TextFormField(
validator: widget.validator,
initialValue:
initialMaskFormatter.formatEditUpdate(const TextEditingValue(), TextEditingValue(text: initial)).text,
autofillHints: const [AutofillHints.creditCardExpirationDate],
onChanged: (text) {
final arr = text.split('/');
final month = int.tryParse(arr[0]);
int? year;
if (arr.length == 2) {
year = int.tryParse(arr[1]);
}
widget.onChanged(month, year);
},
onSaved: (text) {
final arr = text!.split('/');
final month = int.tryParse(arr[0]);
final year = int.tryParse(arr[1]);
widget.onSaved(month, year);
},
inputFormatters: [maskFormatter],
style: widget.textStyle,
decoration: widget.decoration,
textInputAction: TextInputAction.next,
// Use TextInputType.datetime instead of TextInputType.number to fix the numeric keyboard issue on
// iOS devices running iOS12 and lower. See: https://github.com/flutter/flutter/issues/58510
keyboardType: TextInputType.datetime);
}
}

View File

@@ -0,0 +1,186 @@
import 'package:awesome_card/credit_card.dart';
import 'package:awesome_card/style/card_background.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../models/card.dart';
import 'card_cvc_form_field.dart';
import 'card_expiry_form_field.dart';
import 'card_number_form_field.dart';
/// Basic form to add or edit a credit card, with complete validation.
class CardForm extends StatefulWidget {
CardForm(
{Key? key,
formKey,
card,
this.cardNumberDecoration,
this.cardNumberTextStyle,
this.cardExpiryDecoration,
this.cardExpiryTextStyle,
this.cardCvcDecoration,
this.cardCvcTextStyle,
this.cardNumberErrorText,
this.cardExpiryErrorText,
this.cardCvcErrorText,
this.cardDecoration,
this.postalCodeDecoration,
this.postalCodeTextStyle,
this.postalCodeErrorText,
this.displayAnimatedCard = !kIsWeb && false,
this.displayPostalCode = true})
: card = card ?? StripeCard(),
formKey = formKey ?? GlobalKey(),
super(key: key);
final GlobalKey<FormState> formKey;
final StripeCard card;
final bool displayAnimatedCard;
final bool displayPostalCode;
final InputDecoration? cardNumberDecoration;
final TextStyle? cardNumberTextStyle;
final InputDecoration? cardExpiryDecoration;
final TextStyle? cardExpiryTextStyle;
final InputDecoration? cardCvcDecoration;
final TextStyle? cardCvcTextStyle;
final InputDecoration? postalCodeDecoration;
final TextStyle? postalCodeTextStyle;
final String? cardNumberErrorText;
final String? postalCodeErrorText;
final String? cardExpiryErrorText;
final String? cardCvcErrorText;
final Decoration? cardDecoration;
@override
_CardFormState createState() => _CardFormState();
}
class _CardFormState extends State<CardForm> {
final StripeCard _validationModel = StripeCard();
bool cvcHasFocus = false;
@override
Widget build(BuildContext context) {
var cardExpiry = 'MM/YY';
if (_validationModel.expMonth != null) {
cardExpiry = "${_validationModel.expMonth}/${_validationModel.expYear ?? 'YY'}";
}
return SingleChildScrollView(
child:
Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [
if (widget.displayAnimatedCard) _getCreditCardView(cardExpiry),
Form(
key: widget.formKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
margin: const EdgeInsets.only(top: 16),
child: CardNumberFormField(
initialValue: _validationModel.number ?? widget.card.number,
onChanged: (number) {
setState(() {
_validationModel.number = number;
});
},
validator: (text) => _validationModel.validateNumber()
? null
: widget.cardNumberErrorText ?? CardNumberFormField.defaultErrorText,
textStyle: widget.cardNumberTextStyle ?? CardNumberFormField.defaultTextStyle,
onSaved: (text) => widget.card.number = text,
decoration: widget.cardNumberDecoration ?? CardNumberFormField.defaultDecoration,
),
),
Container(
padding: const EdgeInsets.symmetric(vertical: 8.0),
margin: const EdgeInsets.only(top: 8),
child: CardExpiryFormField(
initialMonth: _validationModel.expMonth ?? widget.card.expMonth,
initialYear: _validationModel.expYear ?? widget.card.expYear,
onChanged: (int? month, int? year) {
setState(() {
_validationModel.expMonth = month;
_validationModel.expYear = year;
});
},
onSaved: (int? month, int? year) {
widget.card.expMonth = month;
widget.card.expYear = year;
},
validator: (text) => _validationModel.validateDate()
? null
: widget.cardExpiryErrorText ?? CardExpiryFormField.defaultErrorText,
textStyle: widget.cardExpiryTextStyle ?? CardExpiryFormField.defaultTextStyle,
decoration: widget.cardExpiryDecoration ?? CardExpiryFormField.defaultDecoration,
)),
Container(
padding: const EdgeInsets.symmetric(vertical: 8.0),
margin: const EdgeInsets.only(top: 8),
child: Focus(
onFocusChange: (value) => setState(() => cvcHasFocus = value),
child: CardCvcFormField(
initialValue: _validationModel.cvc ?? widget.card.cvc,
onChanged: (text) => setState(() => _validationModel.cvc = text),
onSaved: (text) => widget.card.cvc = text,
validator: (text) => _validationModel.validateCVC()
? null
: widget.cardCvcErrorText ?? CardCvcFormField.defaultErrorText,
textStyle: widget.cardCvcTextStyle ?? CardCvcFormField.defaultTextStyle,
decoration: widget.cardCvcDecoration ?? CardCvcFormField.defaultDecoration,
),
),
),
if (widget.displayPostalCode) _getPostalCodeField(),
],
),
),
),
]),
);
}
Widget _getPostalCodeField() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8.0),
margin: const EdgeInsets.only(top: 8),
child: TextFormField(
textInputAction: TextInputAction.done,
initialValue: _validationModel.postalCode ?? widget.card.postalCode,
onChanged: (text) => setState(() => _validationModel.postalCode = text),
onSaved: (text) => widget.card.postalCode = text,
autofillHints: const [AutofillHints.postalCode],
validator: (text) =>
_validationModel.isPostalCodeValid() ? null : widget.postalCodeErrorText ?? 'Invalid postal code',
style: widget.postalCodeTextStyle ?? const TextStyle(color: Colors.black),
decoration: widget.postalCodeDecoration ??
const InputDecoration(border: OutlineInputBorder(), labelText: 'Postal code'),
// Use TextInputType.datetime instead of TextInputType.number to fix the numeric keyboard issue on
// iOS devices running iOS12 and lower. See: https://github.com/flutter/flutter/issues/58510
keyboardType: TextInputType.streetAddress),
);
}
Widget _getCreditCardView(String cardExpiry) {
return Padding(
padding: const EdgeInsets.only(top: 16.0),
child: CreditCard(
cardNumber: _validationModel.number ?? '',
cardExpiry: cardExpiry,
cvv: _validationModel.cvc ?? '',
frontBackground: widget.cardDecoration != null
? Container(
width: double.maxFinite,
height: double.maxFinite,
decoration: widget.cardDecoration,
)
: CardBackgrounds.black,
backBackground: CardBackgrounds.white,
showBackSide: cvcHasFocus,
showShadow: true,
),
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
/// Form field to edit a credit card number, with validation.
class CardNumberFormField extends StatefulWidget {
const CardNumberFormField(
{Key? key,
this.initialValue,
required this.onSaved,
required this.validator,
this.onChanged,
this.decoration = defaultDecoration,
this.textStyle = defaultTextStyle,
this.textEditingController})
: super(key: key);
final void Function(String?) onSaved;
final void Function(String)? onChanged;
final String? Function(String?) validator;
final String? initialValue;
final InputDecoration decoration;
final TextStyle textStyle;
final TextEditingController? textEditingController;
static const defaultLabelText = 'Card number';
static const defaultHintText = 'XXXX XXXX XXXX XXXX';
static const defaultErrorText = 'Invalid card number';
static const defaultDecoration = InputDecoration(
border: OutlineInputBorder(),
labelText: defaultLabelText,
hintText: defaultHintText,
);
static const defaultTextStyle = TextStyle(color: Colors.black);
@override
_CardNumberFormFieldState createState() => _CardNumberFormFieldState();
}
class _CardNumberFormFieldState extends State<CardNumberFormField> {
final maskFormatter = MaskTextInputFormatter(mask: '#### #### #### ####');
@override
Widget build(BuildContext context) {
return TextFormField(
initialValue: widget.initialValue,
controller: widget.textEditingController,
inputFormatters: [maskFormatter],
autofocus: true,
autofillHints: const [AutofillHints.creditCardNumber],
onSaved: widget.onSaved,
validator: widget.validator,
onChanged: widget.onChanged,
decoration: widget.decoration,
style: widget.textStyle,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
);
}
}

View File

@@ -0,0 +1,174 @@
import 'package:collection/collection.dart' show IterableExtension;
import 'package:flutter/material.dart';
import '../../../stripe_sdk_ui.dart';
typedef OnPaymentMethodSelected = void Function(String?);
enum SelectorType { radioButton, dropdownButton }
class PaymentMethodSelector extends StatefulWidget {
PaymentMethodSelector({
required this.onChanged,
PaymentMethodStore? paymentMethodStore,
this.initialPaymentMethodId,
this.selectorType = SelectorType.radioButton,
Key? key,
this.selectFirstByDefault = false,
}) : _paymentMethodStore = paymentMethodStore ?? PaymentMethodStore.instance,
super(key: key);
final String? initialPaymentMethodId;
final OnPaymentMethodSelected onChanged;
final PaymentMethodStore _paymentMethodStore;
final bool selectFirstByDefault;
final SelectorType selectorType;
@override
_PaymentMethodSelectorState createState() => _PaymentMethodSelectorState();
}
class _PaymentMethodSelectorState extends State<PaymentMethodSelector> {
List<PaymentMethod>? _paymentMethods;
PaymentMethod? _selectedPaymentMethod;
bool _isLoading = false;
@override
void initState() {
widget._paymentMethodStore.addListener(_updateState);
_updateState();
super.initState();
}
@override
void dispose() {
widget._paymentMethodStore.removeListener(_updateState);
super.dispose();
}
void _updateState() {
if (mounted) {
setState(() {
_paymentMethods = widget._paymentMethodStore.paymentMethods;
_isLoading = widget._paymentMethodStore.isLoading;
if (!_paymentMethods!.contains(_selectedPaymentMethod) || _selectedPaymentMethod == null) {
if (widget.selectFirstByDefault && _selectedPaymentMethod == null) {
_selectedPaymentMethod = _paymentMethods?.firstOrNull;
} else {
_selectedPaymentMethod = null;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onChanged(_selectedPaymentMethod?.id);
});
}
});
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
if (!_isLoading) _buildSelector() else _buildLoadingIndicator(),
const SizedBox(
height: 16,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
OutlinedButton(
onPressed: () async {
final id = await Navigator.push(
context, AddPaymentMethodScreen.route(paymentMethodStore: widget._paymentMethodStore));
if (id != null) {
await widget._paymentMethodStore.refresh();
setState(() {
_selectedPaymentMethod = _getPaymentMethodById(id);
});
}
},
child: const Text('+ Add card')),
OutlinedButton(
onPressed: () async {
final _ = await Navigator.push(
context,
PaymentMethodsScreen.route(
title: 'Payment methods', paymentMethodStore: widget._paymentMethodStore));
},
child: const Text('Manage cards')),
],
)
],
);
}
Widget _buildSelector() {
switch (widget.selectorType) {
case SelectorType.radioButton:
return _buildRadioListSelector();
case SelectorType.dropdownButton:
return _buildDropdownSelector();
}
}
Widget _buildRadioListSelector() {
return ListView.builder(
itemCount: _paymentMethods?.length ?? 0,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemBuilder: (context, index) {
final item = _paymentMethods![index];
return RadioListTile<String?>(
title: Text(item.brand.toUpperCase()),
secondary: Text(item.last4),
subtitle: Text(item.getExpirationAsString()),
value: item.id,
groupValue: _selectedPaymentMethod?.id,
onChanged: (value) => setState(() {
_selectedPaymentMethod = _getPaymentMethodById(value);
widget.onChanged(_selectedPaymentMethod?.id);
}));
},
);
}
Widget _buildDropdownSelector() {
if (_paymentMethods?.isEmpty == true) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).colorScheme.onBackground),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: DropdownButton<String>(
underline: const SizedBox.shrink(),
value: _selectedPaymentMethod?.id,
items: _paymentMethods
?.map((item) => DropdownMenuItem(
value: item.id,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text('${item.brand.toUpperCase()} **** **** **** ${item.last4}'),
),
))
.toList(),
onChanged: (value) {
setState(() {
_selectedPaymentMethod = _getPaymentMethodById(value);
});
},
),
),
);
}
PaymentMethod? _getPaymentMethodById(String? paymentMethodId) {
return _paymentMethods?.singleWhereOrNull((item) => item.id == paymentMethodId);
}
Widget _buildLoadingIndicator() {
return const CircularProgressIndicator();
}
}

View File

@@ -0,0 +1,47 @@
import '../util/stripe_text_utils.dart';
class ModelUtils {
/// Check to see whether the input string is a whole, positive number.
///
/// @param value the input string to test
/// @return {@code true} if the input value consists entirely of integers
static bool isWholePositiveNumber(String? value) {
return value != null && isDigitsOnly(value);
}
/// Determines whether the input year-month pair has passed.
///
/// @param year the input year, as a two or four-digit integer
/// @param month the input month
/// @param now the current time
/// @return {@code true} if the input time has passed the specified current time,
/// {@code false} otherwise.
static bool hasMonthPassed(int year, int month, DateTime now) {
if (hasYearPassed(year, now)) {
return true;
}
// Expires at end of specified month
return normalizeYear(year, now) == now.year && month < now.month;
}
/// Determines whether or not the input year has already passed.
///
/// @param year the input year, as a two or four-digit integer
/// @param now, the current time
/// @return {@code true} if the input year has passed the year of the specified current time
/// {@code false} otherwise.
static bool hasYearPassed(int year, DateTime now) {
final normalized = normalizeYear(year, now);
return normalized < now.year;
}
static int normalizeYear(int year, DateTime now) {
if (year < 100 && year >= 0) {
final currentYear = now.year.toString();
final prefix = currentYear.substring(0, currentYear.length - 2);
year = int.parse('$prefix$year');
}
return year;
}
}

View File

@@ -0,0 +1,67 @@
const String nullValue = 'null';
/// Calls through to {@link JSONObject#optString(String)} while safely
/// converting the raw string "null" and the empty string to {@code null}. Will not throw
/// an exception if the field isn't found.
///
/// @param jsonObject the input object
/// @param fieldName the optional field name
/// @return the value stored in the field, or {@code null} if the field isn't present
String? optString(Map<String, dynamic> json, String fieldName) {
return nullIfNullOrEmpty(json[fieldName] ?? '');
}
/// Calls through to {@link JSONObject#optInt(String)} only in the case that the
/// key exists. This returns {@code null} if the key is not in the object.
///
/// @param jsonObject the input object
/// @param fieldName the required field name
/// @return the value stored in the requested field, or {@code null} if the key is not present
bool? optBoolean(Map<String, dynamic> json, String fieldName) {
return json[fieldName];
}
/// Calls through to {@link JSONObject#optInt(String)} only in the case that the
/// key exists. This returns {@code null} if the key is not in the object.
///
/// @param jsonObject the input object
/// @param fieldName the required field name
/// @return the value stored in the requested field, or {@code null} if the key is not present
int? optInteger(Map<String, dynamic> json, String fieldName) {
return json[fieldName];
}
/// Calls through to {@link JSONObject#optString(String)} while safely converting
/// the raw string "null" and the empty string to {@code null}, along with any value that isn't
/// a two-character string.
/// @param jsonObject the object from which to retrieve the country code
/// @param fieldName the name of the field in which the country code is stored
/// @return a two-letter country code if one is found, or {@code null}
String? optCountryCode(Map<String, dynamic> json, String fieldName) {
final value = optString(json, fieldName);
if (value != null && value.length == 2) {
return value;
}
return null;
}
/// Calls through to {@link JSONObject#optString(String)} while safely converting
/// the raw string "null" and the empty string to {@code null}, along with any value that isn't
/// a three-character string.
/// @param jsonObject the object from which to retrieve the currency code
/// @param fieldName the name of the field in which the currency code is stored
/// @return a three-letter currency code if one is found, or {@code null}
String? optCurrency(Map<String, dynamic> json, String fieldName) {
final value = optString(json, fieldName);
if (value != null && value.length == 3) {
return value;
}
return null;
}
///
String? nullIfNullOrEmpty(String possibleNull) {
return ((nullValue == possibleNull) || (possibleNull.isEmpty)) ? null : possibleNull;
}

View File

@@ -0,0 +1,78 @@
/// Util Array for converting bytes to a hex string.
/// {@url http://stackoverflow.com/questions/9655181/convert-from-byte-array-to-hex-string-in-java}
const String hexArray = '0123456789ABCDEF';
///Swap {@code null} for blank text values.
///
/// @param value an input string that may or may not be entirely whitespace
/// @return {@code null} if the string is entirely whitespace, otherwise the input value
///
String? nullIfBlank(String? value) {
if (isBlank(value)) {
return null;
}
return value;
}
/// A checker for whether or not the input value is entirely whitespace. This is slightly more
/// aggressive than the android TextUtils#isEmpty method, which only returns true for
/// {@code null} or {@code ""}.
///
/// @param value a possibly blank input string value
/// @return {@code true} if and only if the value is all whitespace, {@code null}, or empty
bool isBlank(String? value) {
return value == null || value.trim().isEmpty;
}
/// Converts a card number that may have spaces between the numbers into one without any spaces.
/// Note: method does not check that all characters are digits or spaces.
///
/// @param cardNumberWithSpaces a card number, for instance "4242 4242 4242 4242"
/// @return the input number minus any spaces, for instance "4242424242424242".
/// Returns {@code null} if the input was {@code null} or all spaces.
String? removeSpacesAndHyphens(String? cardNumberWithSpaces) {
if (isBlank(cardNumberWithSpaces)) {
return null;
}
return cardNumberWithSpaces!.replaceAll(RegExp(r'\s+|\-+'), '');
}
/// Check to see if the input number has any of the given prefixes.
///
/// @param number the number to test
/// @param prefixes the prefixes to test against
/// @return {@code true} if number begins with any of the input prefixes
bool hasAnyPrefix(String? number, List<String> prefixes) {
if (number == null) {
return false;
}
for (var prefix in prefixes) {
if (number.startsWith(prefix)) {
return true;
}
}
return false;
}
// TODO function isn't used and the same as isDigitsOnly() -> remove?
bool isDigit(String? s) {
if (s == null) {
return false;
}
return int.tryParse(s) != null;
}
bool isDigitsOnly(String? s) {
if (s == null) {
return false;
}
return int.tryParse(s) != null;
}
int? getNumericValue(String s) {
return int.tryParse(s);
}

View File

@@ -0,0 +1,8 @@
/// A library to communicate with the Stripe API from Dart.
library stripe_sdk;
export 'src/customer_session.dart';
export 'src/stripe.dart';
export 'src/stripe_api.dart';
export 'src/stripe_api_handler.dart';
export 'src/stripe_error.dart';

View File

@@ -0,0 +1,15 @@
/// UI widgets, screens and helpers for Stripe and Stripe SDK.
library stripe_sdk_ui;
export 'src/models/card.dart';
export 'src/ui/checkout_page.dart';
export 'src/ui/models.dart';
export 'src/ui/screens/add_payment_method_screen.dart';
export 'src/ui/screens/payment_methods_screen.dart';
export 'src/ui/stores/payment_method_store.dart';
export 'src/ui/stripe_ui.dart';
export 'src/ui/widgets/card_cvc_form_field.dart';
export 'src/ui/widgets/card_expiry_form_field.dart';
export 'src/ui/widgets/card_form.dart';
export 'src/ui/widgets/card_number_form_field.dart';
export 'src/ui/widgets/payment_method_selector.dart';

426
lib/vendor/stripe_sdk/pubspec.lock vendored Normal file
View File

@@ -0,0 +1,426 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
awesome_card:
dependency: "direct main"
description:
name: awesome_card
sha256: "6455e99492b70c089b1b9d5a0da64e07f5bdd9cb51e5daaca5a1df340f2253d1"
url: "https://pub.dev"
source: hosted
version: "1.1.7"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
characters:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306
url: "https://pub.dev"
source: hosted
version: "1.3.1"
clock:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
url: "https://pub.dev"
source: hosted
version: "1.1.1"
collection:
dependency: "direct main"
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.18.0"
credit_card_type_detector:
dependency: transitive
description:
name: credit_card_type_detector
sha256: e49d8ce0e76969093982ed9c920f2204bd34e41ddbcaf4589d999af6dc00a3e4
url: "https://pub.dev"
source: hosted
version: "2.0.0"
credit_card_validator:
dependency: "direct main"
description:
name: credit_card_validator
sha256: "90a38c5764dee7b4323cbed55118e09d0fb7bd72db3db5ccade4ac07bf475f2d"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c
url: "https://pub.dev"
source: hosted
version: "3.0.1"
csslib:
dependency: transitive
description:
name: csslib
sha256: f857285c8dc0b4f2f77b49a1c083ff8c75223a7549de20f3e607df58cf497a43
url: "https://pub.dev"
source: hosted
version: "0.17.0"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: b543301ad291598523947dc534aaddc5aaad597b709d2426d3a0e0d44c5cb493
url: "https://pub.dev"
source: hosted
version: "1.0.4"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
html:
dependency: transitive
description:
name: html
sha256: bfef906cbd4e78ef49ae511d9074aebd1d2251482ef601a280973e8b58b51bbf
url: "https://pub.dev"
source: hosted
version: "0.15.0"
http:
dependency: "direct main"
description:
name: http
sha256: "6aa2946395183537c8b880962d935877325d6a09a2867c3970c05c0fed6ac482"
url: "https://pub.dev"
source: hosted
version: "0.13.5"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: e362d639ba3bc07d5a71faebb98cde68c05bfbcfbbb444b60b6f60bb67719185
url: "https://pub.dev"
source: hosted
version: "4.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
url: "https://pub.dev"
source: hosted
version: "10.0.4"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
lints:
dependency: transitive
description:
name: lints
sha256: a2c3d198cb5ea2e179926622d433331d8b58374ab8f29cdda6e863bd62fd369c
url: "https://pub.dev"
source: hosted
version: "1.0.1"
mask_text_input_formatter:
dependency: "direct main"
description:
name: mask_text_input_formatter
sha256: "19bb7809c3c2559277e95521b3ee421e1409eb2cc85efd2feb191696c92490f4"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
meta:
dependency: transitive
description:
name: meta
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
path:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a
url: "https://pub.dev"
source: hosted
version: "2.1.3"
simple_animations:
dependency: "direct main"
description:
name: simple_animations
sha256: "79acf025f196001d4680280f67b4d77ca828459b23498606d2fb130a4961046e"
url: "https://pub.dev"
source: hosted
version: "4.0.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
source_span:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.2"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: "53bdf7e979cfbf3e28987552fd72f637e63f3c8724c9e56d9246942dc2fa36ee"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
universal_html:
dependency: "direct main"
description:
name: universal_html
sha256: "5ff50b7c14d201421cf5230ec389a0591c4deb5c817c9d7ccca3b26fe5f31e34"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: "79f78ddad839ee3aae3ec7c01eb4575faf0d5c860f8e5223bc9f9c17f7f03cef"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: "568176fc8ab5ac1d88ff0db8ff28659d103851670dda55e83b485664c2309299"
url: "https://pub.dev"
source: hosted
version: "6.1.6"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "2ec66333093cf5dd5103f089c3a7842fcd5581023a571839d3d176ff10244582"
url: "https://pub.dev"
source: hosted
version: "6.0.14"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "0d6b0b9dce05986462e8ba08e51f1909ac63e2e59ebde829c46ad66423f03dee"
url: "https://pub.dev"
source: hosted
version: "6.0.14"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "0d06961fca3c1ff122057247c598bfb938b3712923d98671d2df5021fe924567"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: fc3fca6327a51638007b179b5102b4b5c3d23875816f37ae394b37b99a558f4c
url: "https://pub.dev"
source: hosted
version: "2.0.1"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "4eae912628763eb48fc214522e58e942fd16ce195407dbf45638239523c759a6"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: a2bc64b513fd1d8fee7646c7a8330afc897b0973c902d004907fa010e50d323e
url: "https://pub.dev"
source: hosted
version: "2.0.4"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "4e24aac2a2960fb9a70a07992e1ba69cb99fbcee48fdf17abe280ce867bfcea2"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
url: "https://pub.dev"
source: hosted
version: "14.2.1"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: "392c1d83b70fe2495de3ea2c84531268d5b8de2de3f01086a53334d8b6030a88"
url: "https://pub.dev"
source: hosted
version: "3.0.4"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: "74df4f2310165977cbab22c05c5f5c98ce402de96069f53d80e72ecbd73ec3e0"
url: "https://pub.dev"
source: hosted
version: "2.8.2"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "2da815ee8c8618441b2d8dccd276f24a9a3ccf089445ad9e434d72289576125e"
url: "https://pub.dev"
source: hosted
version: "1.8.0"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "87c56a34f69867f3aaf0b66df5399a4fc18898b1df8c738b835e62b73059d66f"
url: "https://pub.dev"
source: hosted
version: "2.7.1"
sdks:
dart: ">=3.3.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

70
lib/vendor/stripe_sdk/pubspec.yaml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: stripe_sdk
description: The only native Stripe library for Flutter. Has complete support for SCA/PSD2, payment intents and the newest Stripe features.
version: 5.0.0-nullsafety.1
homepage: https://github.com/ezet/stripe_sdk
environment:
sdk: '>=2.13.0 <4.0.0'
flutter: "^3.0.0"
dependencies:
flutter:
sdk: flutter
http: ^0.13.5
url_launcher: ^6.1.6
mask_text_input_formatter: ^2.4.0
# flutter_slidable: ^1.2.0
simple_animations: ^4.0.1
awesome_card: ^1.1.7
credit_card_validator: ^2.0.1
collection: ^1.16.0
webview_flutter: ^3.0.4
universal_html: ^2.0.8
dev_dependencies:
flutter_lints: ^1.0.4
flutter_test:
sdk: flutter
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter.
flutter:
uses-material-design: true
# To add assets to your package, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
#
# For details regarding assets in packages, see
# https://flutter.dev/assets-and-images/#from-packages
#
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware.
# To add custom fonts to your package, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts in packages, see
# https://flutter.dev/custom-fonts/#from-packages
false_secrets:
- example/**