diff --git a/lib/application/donations/donations_bloc.dart b/lib/application/donations/donations_bloc.dart index d3394cd06..e139d8bf7 100644 --- a/lib/application/donations/donations_bloc.dart +++ b/lib/application/donations/donations_bloc.dart @@ -1,5 +1,6 @@ import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:shiori/domain/extensions/string_extensions.dart'; import 'package:shiori/domain/models/models.dart'; import 'package:shiori/domain/services/network_service.dart'; import 'package:shiori/domain/services/purchase_service.dart'; @@ -14,17 +15,20 @@ class DonationsBloc extends Bloc { static int maxUserIdLength = 20; + //The user id must be something like 12345_xyz + static String appUserIdRegex = '([0-9]{5,10}[_][A-Za-z])'; + DonationsBloc(this._purchaseService, this._networkService) : super(const DonationsState.loading()); @override Stream mapEventToState(DonationsEvent event) async* { if (!await _networkService.isInternetAvailable()) { - yield const DonationsState.initial(packages: [], isInitialized: false, noInternetConnection: true); + yield const DonationsState.initial(packages: [], isInitialized: false, noInternetConnection: true, canMakePurchases: false); return; } if (!await _purchaseService.isPlatformSupported()) { - yield const DonationsState.initial(packages: [], isInitialized: false, noInternetConnection: false); + yield const DonationsState.initial(packages: [], isInitialized: false, noInternetConnection: false, canMakePurchases: false); return; } @@ -32,7 +36,8 @@ class DonationsBloc extends Bloc { await _purchaseService.init(); } - final currentState = state; + yield const DonationsState.loading(); + final s = await event.map( init: (_) => _init(), restorePurchases: (e) => _restorePurchases(e.userId), @@ -40,23 +45,63 @@ class DonationsBloc extends Bloc { ); yield s; - - if ((s is _PurchaseCompleted && s.error) || (s is _RestoreCompleted && s.error)) { - yield currentState; - } + yield await s.maybeMap( + purchaseCompleted: (state) async { + if (state.error) { + return _init(); + } + return state; + }, + restoreCompleted: (state) async { + if (state.error) { + return _init(); + } + return state; + }, + orElse: () async => s, + ); } Future _init() async { + final canMakePurchases = await _purchaseService.canMakePurchases(); + if (!canMakePurchases) { + return DonationsState.initial( + packages: [], + isInitialized: _purchaseService.isInitialized, + noInternetConnection: false, + canMakePurchases: false, + ); + } final packages = await _purchaseService.getInAppPurchases(); - return DonationsState.initial(packages: packages, isInitialized: _purchaseService.isInitialized, noInternetConnection: false); + return DonationsState.initial( + packages: packages, + isInitialized: _purchaseService.isInitialized, + noInternetConnection: false, + canMakePurchases: canMakePurchases, + ); } Future _restorePurchases(String userId) async { + if (!RegExp(appUserIdRegex).hasMatch(userId)) { + throw Exception('AppUserId is not valid'); + } final restored = await _purchaseService.restorePurchases(userId); return DonationsState.restoreCompleted(error: !restored); } Future _purchase(_Purchase e) async { + if (!RegExp(appUserIdRegex).hasMatch(e.userId)) { + throw Exception('AppUserId is not valid'); + } + + if (e.identifier.isNullEmptyOrWhitespace) { + throw Exception('Invalid package identifier'); + } + + if (e.offeringIdentifier.isNullEmptyOrWhitespace) { + throw Exception('Invalid offering identifier'); + } + final succeed = await _purchaseService.purchase(e.userId, e.identifier, e.offeringIdentifier); return DonationsState.purchaseCompleted(error: !succeed); } diff --git a/lib/application/donations/donations_state.dart b/lib/application/donations/donations_state.dart index 4dc1e8a3b..6248acb04 100644 --- a/lib/application/donations/donations_state.dart +++ b/lib/application/donations/donations_state.dart @@ -8,6 +8,7 @@ class DonationsState with _$DonationsState { required List packages, required bool isInitialized, required bool noInternetConnection, + required bool canMakePurchases, }) = _InitialState; const factory DonationsState.purchaseCompleted({ diff --git a/lib/domain/enums/app_unlocked_feature.dart b/lib/domain/enums/app_unlocked_feature.dart new file mode 100644 index 000000000..3373774d9 --- /dev/null +++ b/lib/domain/enums/app_unlocked_feature.dart @@ -0,0 +1,3 @@ +enum AppUnlockedFeature { + darkAmoledTheme, +} diff --git a/lib/domain/enums/enums.dart b/lib/domain/enums/enums.dart index 7af9b48f2..9beefcd80 100644 --- a/lib/domain/enums/enums.dart +++ b/lib/domain/enums/enums.dart @@ -4,6 +4,7 @@ export 'app_notification_item_type.dart'; export 'app_notification_type.dart'; export 'app_server_reset_time_type.dart'; export 'app_theme_type.dart'; +export 'app_unlocked_feature.dart'; export 'artifact_farming_time_type.dart'; export 'artifact_filter_type.dart'; export 'artifact_type.dart'; diff --git a/lib/domain/services/purchase_service.dart b/lib/domain/services/purchase_service.dart index e40e84619..0ca34ccb6 100644 --- a/lib/domain/services/purchase_service.dart +++ b/lib/domain/services/purchase_service.dart @@ -1,3 +1,4 @@ +import 'package:shiori/domain/enums/enums.dart'; import 'package:shiori/domain/models/models.dart'; abstract class PurchaseService { @@ -7,6 +8,8 @@ abstract class PurchaseService { Future isPlatformSupported(); + Future canMakePurchases(); + Future logIn(String userId); Future> getInAppPurchases(); @@ -14,4 +17,6 @@ abstract class PurchaseService { Future purchase(String userId, String identifier, String offeringIdentifier); Future restorePurchases(String userId, {String? entitlementIdentifier}); + + Future> getUnlockedFeatures(); } diff --git a/lib/infrastructure/purchase_service.dart b/lib/infrastructure/purchase_service.dart index 1cc2e25e8..94e7d86a2 100644 --- a/lib/infrastructure/purchase_service.dart +++ b/lib/infrastructure/purchase_service.dart @@ -4,6 +4,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:purchases_flutter/purchases_flutter.dart'; +import 'package:shiori/domain/enums/enums.dart'; +import 'package:shiori/domain/extensions/string_extensions.dart'; import 'package:shiori/domain/models/models.dart'; import 'package:shiori/domain/services/logging_service.dart'; import 'package:shiori/domain/services/purchase_service.dart'; @@ -58,6 +60,16 @@ class PurchaseServiceImpl implements PurchaseService { return Future.value(false); } + @override + Future canMakePurchases() async { + try { + return await Purchases.canMakePayments(); + } catch (e, s) { + _handleError('canMakePurchases', e, s); + return false; + } + } + @override Future logIn(String userId) async { try { @@ -117,19 +129,48 @@ class PurchaseServiceImpl implements PurchaseService { } try { - final transactions = await Purchases.restoreTransactions(); - if (entitlementIdentifier == null) { - return transactions.entitlements.active.isNotEmpty; - } - - final entitlement = transactions.entitlements.active.values.firstWhereOrNull((el) => el.identifier == entitlementIdentifier); - return entitlement != null; + final features = await _getUnlockedFeatures(entitlementIdentifier: entitlementIdentifier); + return features.isNotEmpty; } catch (e, s) { _handleError('restorePurchases', e, s); return false; } } + @override + Future> getUnlockedFeatures() async { + try { + if (!await isPlatformSupported()) { + return []; + } + + if (await Purchases.isAnonymous) { + return []; + } + + final features = await _getUnlockedFeatures(); + return features; + } catch (e, s) { + _handleError('getUnlockedFeatures', e, s); + return []; + } + } + + Future> _getUnlockedFeatures({String? entitlementIdentifier}) async { + try { + final transactions = await Purchases.restoreTransactions(); + if (entitlementIdentifier.isNotNullEmptyOrWhitespace) { + final activeEntitlements = transactions.entitlements.active.values.any((el) => el.isActive); + return activeEntitlements ? AppUnlockedFeature.values : []; + } + + final entitlement = transactions.entitlements.active.values.firstWhereOrNull((el) => el.identifier == entitlementIdentifier && el.isActive); + return entitlement != null ? AppUnlockedFeature.values : []; + } catch (e) { + rethrow; + } + } + void _handleError(String methodName, dynamic e, StackTrace s) { if (e is PlatformException) { final errorCode = PurchasesErrorHelper.getErrorCode(e); diff --git a/lib/presentation/donations/donations_bottom_sheet.dart b/lib/presentation/donations/donations_bottom_sheet.dart index 691d18f93..704e9e6ba 100644 --- a/lib/presentation/donations/donations_bottom_sheet.dart +++ b/lib/presentation/donations/donations_bottom_sheet.dart @@ -10,6 +10,7 @@ import 'package:shiori/presentation/shared/bullet_list.dart'; import 'package:shiori/presentation/shared/dialogs/text_dialog.dart'; import 'package:shiori/presentation/shared/loading.dart'; import 'package:shiori/presentation/shared/nothing_found_column.dart'; +import 'package:shiori/presentation/shared/shiori_icons.dart'; import 'package:shiori/presentation/shared/styles.dart'; import 'package:shiori/presentation/shared/utils/toast_utils.dart'; @@ -22,7 +23,7 @@ class DonationsBottomSheet extends StatelessWidget { return BlocProvider( create: (ctx) => Injection.donationsBloc..add(const DonationsEvent.init()), child: CommonBottomSheet( - titleIcon: Icons.heart_broken, + titleIcon: Shiori.heart, title: s.donations, showCancelButton: false, showOkButton: false, @@ -49,67 +50,58 @@ class _BodyState extends State<_Body> { return BlocConsumer( listener: (ctx, state) { state.maybeMap( - purchaseCompleted: (state) { - final toast = ToastUtils.of(context); - if (!state.error) { - ToastUtils.showSucceedToast(toast, s.paymentSucceed); - Navigator.pop(context); - } else { - ToastUtils.showWarningToast(toast, s.paymentError); - } - }, + purchaseCompleted: (state) => _handlePurchaseOrRestoreCompleted(true, state.error, context), + restoreCompleted: (state) => _handlePurchaseOrRestoreCompleted(false, state.error, context), orElse: () {}, ); }, builder: (ctx, state) => state.maybeMap( - initial: (state) => state.noInternetConnection - ? NothingFoundColumn(msg: s.noInternetConnection) - : !state.isInitialized - ? NothingFoundColumn(msg: s.unknownError) - : Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text( - s.donationMsg, - textAlign: TextAlign.justify, - style: Theme.of(context).textTheme.subtitle1!.copyWith(fontWeight: FontWeight.bold), + initial: (state) => state.noInternetConnection || !state.isInitialized || !state.canMakePurchases + ? _Error(noInternetConnection: state.noInternetConnection, isInitialized: state.isInitialized, canMakePurchases: state.canMakePurchases) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + s.donationMsg, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.subtitle1!.copyWith(fontWeight: FontWeight.bold), + ), + ...state.packages.map( + (e) => _DonationItem( + item: e, + isSelected: _selected == e, + onTap: () => setState(() => _selected = e), + ), + ), + BulletList( + iconSize: 16, + addTooltip: false, + items: [ + s.donationMsgA, + s.donationMsgB, + ], + ), + CommonButtonBar( + children: [ + OutlinedButton( + onPressed: () => Navigator.pop(context), + child: Text(s.cancel, style: TextStyle(color: theme.primaryColor)), ), - ...state.packages.map( - (e) => _DonationItem( - item: e, - isSelected: _selected == e, - onTap: () => setState(() => _selected = e), + if (state.packages.isNotEmpty && state.isInitialized) + OutlinedButton( + onPressed: () => _handleRestore(context), + child: Text(s.restorePurchases, style: TextStyle(color: theme.primaryColor)), ), - ), - BulletList( - iconSize: 16, - addTooltip: false, - items: [ - s.donationMsgA, - s.donationMsgB, - ], - ), - CommonButtonBar( - children: [ - OutlinedButton( - onPressed: () => Navigator.pop(context), - child: Text(s.cancel, style: TextStyle(color: theme.primaryColor)), - ), - if (state.isInitialized) - OutlinedButton( - onPressed: () => _handleRestore(context), - child: Text(s.restorePurchases, style: TextStyle(color: theme.primaryColor)), - ), - if (state.isInitialized && _selected != null) - ElevatedButton( - onPressed: () => _handleConfirm(context), - child: Text(s.confirm), - ) - ], - ), + if (state.packages.isNotEmpty && state.isInitialized && _selected != null) + ElevatedButton( + onPressed: () => _handleConfirm(context), + child: Text(s.confirm), + ) ], ), + ], + ), orElse: () => const Loading(useScaffold: false), ), ); @@ -120,9 +112,10 @@ class _BodyState extends State<_Body> { return showDialog( context: context, builder: (_) => TextDialog.create( - title: s.uid, + title: s.purchase, hintText: s.uid, maxLength: DonationsBloc.maxUserIdLength, + regexPattern: DonationsBloc.appUserIdRegex, child: BulletList( iconSize: 16, addTooltip: false, @@ -140,9 +133,10 @@ class _BodyState extends State<_Body> { return showDialog( context: context, builder: (_) => TextDialog.create( - title: s.uid, + title: s.restorePurchases, hintText: s.uid, maxLength: DonationsBloc.maxUserIdLength, + regexPattern: DonationsBloc.appUserIdRegex, child: BulletList( iconSize: 16, addTooltip: false, @@ -152,6 +146,24 @@ class _BodyState extends State<_Body> { ), ); } + + void _handlePurchaseOrRestoreCompleted(bool isPurchase, bool error, BuildContext context) { + final s = S.of(context); + final toast = ToastUtils.of(context); + String msg = ''; + if (isPurchase) { + msg = error ? s.paymentError : s.paymentSucceed; + } else { + msg = error ? s.restorePurchaseError : s.restorePurchaseSucceed; + } + + if (!error) { + ToastUtils.showSucceedToast(toast, msg); + Navigator.pop(context); + } else { + ToastUtils.showWarningToast(toast, msg); + } + } } class _DonationItem extends StatelessWidget { @@ -187,3 +199,27 @@ class _DonationItem extends StatelessWidget { ); } } + +class _Error extends StatelessWidget { + final bool noInternetConnection; + final bool isInitialized; + final bool canMakePurchases; + + const _Error({ + Key? key, + required this.noInternetConnection, + required this.isInitialized, + required this.canMakePurchases, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final s = S.of(context); + final msg = noInternetConnection + ? s.noInternetConnection + : !canMakePurchases + ? 'Device cannot make purchases' + : s.unknownError; + return NothingFoundColumn(msg: msg); + } +} diff --git a/lib/presentation/shared/dialogs/text_dialog.dart b/lib/presentation/shared/dialogs/text_dialog.dart index 2215cba73..d4c0afb2c 100644 --- a/lib/presentation/shared/dialogs/text_dialog.dart +++ b/lib/presentation/shared/dialogs/text_dialog.dart @@ -12,6 +12,7 @@ class TextDialog extends StatefulWidget { final Function(String) onSave; final bool isInEditMode; final Widget? child; + final String? regexPattern; const TextDialog.create({ Key? key, @@ -19,6 +20,7 @@ class TextDialog extends StatefulWidget { required this.hintText, required this.onSave, required this.maxLength, + this.regexPattern, this.child, }) : value = '', isInEditMode = false, @@ -31,6 +33,7 @@ class TextDialog extends StatefulWidget { required this.value, required this.maxLength, required this.onSave, + this.regexPattern, this.child, }) : isInEditMode = true, super(key: key); @@ -126,7 +129,10 @@ class _TextDialogState extends State { void _textChanged() { final newValue = _textEditingController.text; - final isValid = newValue.isNotNullEmptyOrWhitespace && newValue.length <= widget.maxLength; + bool isValid = newValue.isNotNullEmptyOrWhitespace && newValue.length <= widget.maxLength; + if (isValid && widget.regexPattern.isNotNullEmptyOrWhitespace) { + isValid = RegExp(widget.regexPattern!).hasMatch(newValue); + } final isDirty = newValue != _currentValue; _currentValue = newValue; setState(() {