From e1512560ef33903fbb571fa2fb07364878ad8b5f Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 11 Aug 2022 00:45:01 +0800 Subject: [PATCH] Add donation page --- app/lib/bloc/donation.dart | 133 +++++++++++ app/lib/l10n/app_en.arb | 10 + app/lib/widget/changelog.dart | 10 + app/lib/widget/donation.dart | 230 +++++++++++++++++++ app/lib/widget/handler/purchase_handler.dart | 123 ++++++++++ app/lib/widget/my_app.dart | 13 ++ app/lib/widget/settings.dart | 8 + app/lib/widget/splash.dart | 2 + app/lib/widget/splash/bloc.dart | 15 ++ app/pubspec.lock | 32 +++ app/pubspec.yaml | 2 + 11 files changed, 578 insertions(+) create mode 100644 app/lib/bloc/donation.dart create mode 100644 app/lib/widget/donation.dart create mode 100644 app/lib/widget/handler/purchase_handler.dart diff --git a/app/lib/bloc/donation.dart b/app/lib/bloc/donation.dart new file mode 100644 index 00000000..90edbd15 --- /dev/null +++ b/app/lib/bloc/donation.dart @@ -0,0 +1,133 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:logging/logging.dart'; +import 'package:np_collection/np_collection.dart'; +import 'package:np_platform_util/np_platform_util.dart'; + +class StoreProduct { + const StoreProduct(this.details); + + static StoreProduct? of(ProductDetails details) { + if (!_products.keys.contains(details.id)) { + _log.severe("[of] Unknown product ID: ${details.id}"); + return null; + } else { + return StoreProduct(details); + } + } + + String get title => _products[details.id]!["title"]!; + String get price => details.price; + + final ProductDetails details; + + static const _products = {}; + + static final _log = Logger("bloc.donation.StoreProduct"); +} + +class StoreException implements Exception { + static final storeName = + getRawPlatform() == NpPlatform.android ? "Play Store" : "App Store"; + + const StoreException([this.message]); + + @override + toString() { + if (message == null) { + return "StoreException"; + } else { + return "StoreException: $message"; + } + } + + final Object? message; +} + +abstract class DonationBlocEvent { + const DonationBlocEvent(); +} + +class DonationBlocQuery extends DonationBlocEvent { + const DonationBlocQuery(); +} + +abstract class DonationBlocState { + const DonationBlocState(this.products); + + @override + toString() => "$runtimeType {" + "products: List {length: ${products.length}}, " + "}"; + + final List products; +} + +class DonationBlocInit extends DonationBlocState { + const DonationBlocInit() : super(const []); +} + +class DonationBlocLoading extends DonationBlocState { + const DonationBlocLoading(super.products); +} + +class DonationBlocSuccess extends DonationBlocState { + const DonationBlocSuccess(super.products); +} + +class DonationBlocFailure extends DonationBlocState { + const DonationBlocFailure(super.products, this.exception); + + @override + toString() => "$runtimeType {" + "super: ${super.toString()}, " + "exception: $exception, " + "}"; + + final Object exception; +} + +class DonationBloc extends Bloc { + DonationBloc() : super(const DonationBlocInit()) { + on(_onEvent); + } + + Future _onEvent( + DonationBlocEvent event, Emitter emit) async { + _log.info("[_onEvent] $event"); + if (event is DonationBlocQuery) { + await _onEventQuery(event, emit); + } + } + + Future _onEventQuery( + DonationBlocQuery ev, Emitter emit) async { + try { + emit(DonationBlocLoading(state.products)); + if (!await InAppPurchase.instance.isAvailable()) { + emit(DonationBlocFailure( + [], StoreException("${StoreException.storeName} is unavilable"))); + return; + } + final response = await InAppPurchase.instance + .queryProductDetails(StoreProduct._products.keys.toSet()); + if (response.notFoundIDs.isNotEmpty) { + _log.warning( + "[_onEventQuery] Product IDs not found: ${response.notFoundIDs.toReadableString()}"); + } + final productMap = Map.fromEntries(response.productDetails + .map((e) => MapEntry(e.id, StoreProduct.of(e)))); + final products = StoreProduct._products.keys + .map((id) => productMap[id]) + .whereNotNull() + .toList(); + emit(DonationBlocSuccess(products)); + } catch (e, stackTrace) { + _log.severe("[_onEventQuery] Exception while request", e, stackTrace); + emit(DonationBlocFailure([], e)); + } + } + + static final _log = Logger("bloc.donation.DonationBloc"); +} diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 6524266d..211dccaf 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1532,6 +1532,16 @@ }, "fileNotFound": "File not found", + "donationTitle": "Buy me a coffee", + "donationButtonLabel": "BUY ME A COFFEE", + "donationShortMessage": "Want to help?", + "donationThankYouMessage": "First of all, thank you for buying the app!", + "donationLongMessage": "Photos is a personal project with a lack of funding. If you like this app, please consider donating to support the project. Your donations will ensure this project to remain active and sustainable.", + "donationBottomMessage": "Donations are 100% voluntary. NO features will ever require a donation.", + "donationSuccessMessage": "Thank you for your support! ☕", + "donationFailureMessage": "Payment declined", + "donationPendingMessage": "Payment is pending", + "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { "description": "Error message when server responds with HTTP401" diff --git a/app/lib/widget/changelog.dart b/app/lib/widget/changelog.dart index eb02337f..759b34d9 100644 --- a/app/lib/widget/changelog.dart +++ b/app/lib/widget/changelog.dart @@ -3,6 +3,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/help_utils.dart' as help_util; import 'package:nc_photos/url_launcher_util.dart'; +import 'package:nc_photos/widget/donation.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_ui/np_ui.dart'; @@ -74,6 +75,15 @@ class Changelog extends StatelessWidget { Widget _buildContent(BuildContext context) { return Column( children: [ + _ChangelogBanner( + title: Text(L10n.global().donationShortMessage), + action: TextButton( + onPressed: () { + Navigator.of(context).pushNamed(Donation.routeName); + }, + child: Text(L10n.global().donationButtonLabel), + ), + ), Expanded( child: ListView.builder( itemCount: _changelogs.length, diff --git a/app/lib/widget/donation.dart b/app/lib/widget/donation.dart new file mode 100644 index 00000000..02c5dbc5 --- /dev/null +++ b/app/lib/widget/donation.dart @@ -0,0 +1,230 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc/donation.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/widget/handler/purchase_handler.dart'; +import 'package:nc_photos/widget/processing_dialog.dart'; + +class Donation extends StatefulWidget { + static const routeName = "/donation"; + + static Route buildRoute() => MaterialPageRoute( + builder: (context) => const Donation(), + ); + + const Donation({Key? key}) : super(key: key); + + @override + createState() => _DonationState(); +} + +class _DonationState extends State { + @override + initState() { + super.initState(); + _initPurchase(); + _initBloc(); + } + + @override + dispose() { + PurchaseHandler() + ..popOnSuccessListener() + ..popOnFailureListener() + ..popOnPurchaseUpdatedListener(); + super.dispose(); + } + + @override + build(BuildContext context) { + return Scaffold( + appBar: _buildAppBar(context), + extendBodyBehindAppBar: true, + body: BlocListener( + bloc: _bloc, + listener: (context, state) => _onStateChange(context, state), + child: BlocBuilder( + bloc: _bloc, + builder: (context, state) => _buildContent(context, state), + ), + ), + ); + } + + void _initPurchase() { + PurchaseHandler() + ..pushOnSuccessListener(_showSuccessDialog) + ..pushOnFailureListener(_showFailureDialog) + ..pushOnPurchaseUpdatedListener(_onPurchaseUpdated); + } + + void _initBloc() { + _log.info("[_initBloc] Initialize bloc"); + _reqQuery(); + } + + PreferredSizeWidget _buildAppBar(BuildContext context) { + return AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + ); + } + + Widget _buildContent(BuildContext context, DonationBlocState state) { + return Column( + children: [ + const SizedBox(height: 40), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: Text("☕", style: TextStyle(fontSize: 56)), + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: Text( + L10n.global().donationTitle, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w300, + ), + ), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: Text(L10n.global().donationThankYouMessage), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + L10n.global().donationLongMessage, + style: const TextStyle(fontSize: 14), + ), + ), + const SizedBox(height: 16), + const Divider(thickness: 1, height: 1), + Expanded( + child: ListView.separated( + padding: EdgeInsets.zero, + itemCount: _products.length, + itemBuilder: _buildItem, + separatorBuilder: (_, __) => const Divider(height: 1), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text( + L10n.global().donationBottomMessage, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ), + ], + ); + } + + Widget _buildItem(BuildContext context, int index) { + final p = _products[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(p.title), + trailing: Text(p.price), + onTap: () { + final param = PurchaseParam(productDetails: p.details); + InAppPurchase.instance.buyConsumable(purchaseParam: param); + }, + ); + } + + void _onStateChange(BuildContext context, DonationBlocState state) { + if (state is DonationBlocInit) { + _products = []; + } else if (state is DonationBlocSuccess || state is DonationBlocLoading) { + _products = state.products; + } else if (state is DonationBlocFailure) { + _products = state.products; + SnackBarManager().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(state.exception)), + duration: k.snackBarDurationNormal, + )); + } + } + + Future _onPurchaseUpdated( + Map> details) async { + if (details[PurchaseStatus.pending]?.isNotEmpty == true) { + if (!_isPendingDialogVisible) { + _isPendingDialogVisible = true; + unawaited(showDialog( + context: context, + barrierDismissible: false, + builder: (_) => + ProcessingDialog(text: L10n.global().donationPendingMessage), + )); + } + } else { + if (_isPendingDialogVisible) { + _isPendingDialogVisible = false; + Navigator.of(context).pop(); + } + } + } + + Future _showSuccessDialog(List successes) { + return showDialog( + context: context, + builder: (_) => AlertDialog( + content: Text(L10n.global().donationSuccessMessage), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ), + ); + } + + Future _showFailureDialog(List failures) { + return showDialog( + context: context, + builder: (_) => AlertDialog( + content: Text(L10n.global().donationFailureMessage), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ), + ); + } + + void _reqQuery() { + _bloc.add(const DonationBlocQuery()); + } + + late final _bloc = DonationBloc(); + var _products = []; + + var _isPendingDialogVisible = false; + + static final _log = Logger("widget.donation._DonationState"); +} diff --git a/app/lib/widget/handler/purchase_handler.dart b/app/lib/widget/handler/purchase_handler.dart new file mode 100644 index 00000000..e4dde985 --- /dev/null +++ b/app/lib/widget/handler/purchase_handler.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:logging/logging.dart'; +import 'package:np_collection/np_collection.dart'; +import 'package:np_platform_util/np_platform_util.dart'; + +class PurchaseHandler { + factory PurchaseHandler() => _inst; + + PurchaseHandler._() { + _subscription = InAppPurchase.instance.purchaseStream.listen( + _onPurchaseUpdated, + onDone: () { + _subscription?.cancel(); + }, + onError: (e) { + _log.severe("[PurchaseHandler] purchaseStream erred", e); + }, + ); + Future.delayed(const Duration(seconds: 10)).then((_) async { + await InAppPurchase.instance.restorePurchases(); + }); + } + + void pushOnSuccessListener(void Function(List details) l) { + _onSuccessListeners.add(l); + } + + void popOnSuccessListener() { + _onSuccessListeners.removeLast(); + } + + void pushOnFailureListener(void Function(List details) l) { + _onFailureListeners.add(l); + } + + void popOnFailureListener() { + _onFailureListeners.removeLast(); + } + + /// PurchaseUpdatedListeners are called after other status listeners + void pushOnPurchaseUpdatedListener( + void Function(Map> details) l) { + _onPurchaseUpdatedListeners.add(l); + } + + void popOnPurchaseUpdatedListener() { + _onPurchaseUpdatedListeners.removeLast(); + } + + Future _onPurchaseUpdated(List details) async { + _log.info( + "[_onPurchaseUpdated] ${details.map((d) => d.toStringEx()).toReadableString()}"); + final statusMap = details.groupBy(key: (d) => d.status); + if (statusMap[PurchaseStatus.error]?.isNotEmpty == true) { + final failures = statusMap[PurchaseStatus.error]!; + _log.warning( + "[_onPurchaseUpdated] Error: ${failures.map((e) => "${e.error!.code}: ${e.error!.message}").toReadableString()}"); + try { + _onFailureListeners.lastOrNull?.call(failures); + } catch (e, stackTrace) { + _log.severe("[_onPurchaseUpdated] Uncaught exception", e, stackTrace); + } + } + if (statusMap[PurchaseStatus.purchased]?.isNotEmpty == true || + statusMap[PurchaseStatus.restored]?.isNotEmpty == true) { + final successes = (statusMap[PurchaseStatus.purchased] ?? []) + + (statusMap[PurchaseStatus.restored] ?? []); + try { + _onSuccessListeners.lastOrNull?.call(successes); + } catch (e, stackTrace) { + _log.severe("[_onPurchaseUpdated] Uncaught exception", e, stackTrace); + } + } + + for (final d in details.where((d) => + d.pendingCompletePurchase || d.status == PurchaseStatus.restored)) { + try { + _log.info("[_onPurchaseUpdated] Complete purchase: ${d.toStringEx()}"); + await InAppPurchase.instance.completePurchase(d); + if (d.status == PurchaseStatus.restored) { + // restored purchases are not automatically consumed + _log.info( + "[_onPurchaseUpdated] Consume restored purchase: ${d.purchaseID}"); + if (getRawPlatform() == NpPlatform.android) { + final addition = InAppPurchase.instance + .getPlatformAddition(); + await addition.consumePurchase(d); + } + } + } catch (e, stackTrace) { + _log.severe("[_onPurchaseUpdated] Failed while completePurchase", e, + stackTrace); + } + } + + for (final l in _onPurchaseUpdatedListeners) { + l(statusMap); + } + } + + StreamSubscription>? _subscription; + final _onSuccessListeners = )>[]; + final _onFailureListeners = )>[]; + final _onPurchaseUpdatedListeners = + >)>[]; + + static final _inst = PurchaseHandler._(); + + static final _log = Logger("widget.handler.purchase_handler.PurchaseHandler"); +} + +extension on PurchaseDetails { + toStringEx() => "PurchaseDetails {" + "purchaseID: $purchaseID, " + "productID: $productID, " + "transactionDate: $transactionDate, " + "status: ${status.name}, " + "}"; +} diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index c809637b..49e31e81 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -31,6 +31,7 @@ import 'package:nc_photos/widget/changelog.dart'; import 'package:nc_photos/widget/collection_browser.dart'; import 'package:nc_photos/widget/collection_picker.dart'; import 'package:nc_photos/widget/connect.dart'; +import 'package:nc_photos/widget/donation.dart'; import 'package:nc_photos/widget/enhanced_photo_browser.dart'; import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/image_editor.dart'; @@ -242,6 +243,7 @@ class _WrappedAppState extends State<_WrappedApp> route ??= _handleCollectionBrowserRoute(settings); route ??= _handleAccountSettingsRoute(settings); route ??= _handlePlacePickerRoute(settings); + route ??= _handleDonationRoute(settings); return route; } @@ -563,6 +565,17 @@ class _WrappedAppState extends State<_WrappedApp> return null; } + Route? _handleDonationRoute(RouteSettings settings) { + try { + if (settings.name == Donation.routeName) { + return Donation.buildRoute(); + } + } catch (e) { + _log.severe("[_handleDonationRoute] Failed while handling route", e); + } + return null; + } + late final _bloc = context.read<_Bloc>(); final _scaffoldMessengerKey = GlobalKey(); final _navigatorKey = GlobalKey(); diff --git a/app/lib/widget/settings.dart b/app/lib/widget/settings.dart index c416387f..0c1854c1 100644 --- a/app/lib/widget/settings.dart +++ b/app/lib/widget/settings.dart @@ -14,6 +14,7 @@ import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/platform/notification.dart'; import 'package:nc_photos/stream_util.dart'; import 'package:nc_photos/url_launcher_util.dart'; +import 'package:nc_photos/widget/donation.dart'; import 'package:nc_photos/widget/list_tile_center_leading.dart'; import 'package:nc_photos/widget/settings/collection_settings.dart'; import 'package:nc_photos/widget/settings/developer_settings.dart'; @@ -138,6 +139,13 @@ class _SettingsState extends State { } }, ), + ListTile( + leading: const Icon(Icons.coffee_outlined), + title: Text(L10n.global().donationTitle), + onTap: () { + Navigator.of(context).pushNamed(Donation.routeName); + }, + ), ListTile( title: const Text("Rate on Google Play"), onTap: () { diff --git a/app/lib/widget/splash.dart b/app/lib/widget/splash.dart index 6773f644..5e7e538a 100644 --- a/app/lib/widget/splash.dart +++ b/app/lib/widget/splash.dart @@ -13,11 +13,13 @@ import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/mobile/android/activity.dart'; import 'package:nc_photos/mobile/android/permission_util.dart'; import 'package:nc_photos/protected_page_handler.dart'; +import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/use_case/compat/v29.dart'; import 'package:nc_photos/use_case/compat/v46.dart'; import 'package:nc_photos/use_case/compat/v55.dart'; import 'package:nc_photos/widget/app_intermediate_circular_progress_indicator.dart'; import 'package:nc_photos/widget/changelog.dart'; +import 'package:nc_photos/widget/handler/purchase_handler.dart'; import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/setup.dart'; import 'package:nc_photos/widget/sign_in.dart'; diff --git a/app/lib/widget/splash/bloc.dart b/app/lib/widget/splash/bloc.dart index 071f69e9..82853566 100644 --- a/app/lib/widget/splash/bloc.dart +++ b/app/lib/widget/splash/bloc.dart @@ -8,6 +8,21 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { }) : super(_State.init()) { on<_Init>(_onInit); on<_ChangelogDismissed>(_onChangelogDismissed); + + // TODO: this does NOT belong here + PurchaseHandler() + ..pushOnSuccessListener((details) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().donationSuccessMessage), + duration: k.snackBarDurationNormal, + )); + }) + ..pushOnFailureListener((details) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().donationFailureMessage), + duration: k.snackBarDurationNormal, + )); + }); } @override diff --git a/app/pubspec.lock b/app/pubspec.lock index 8a8c9a69..ddf8b429 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -805,6 +805,38 @@ packages: url: "https://gitlab.com/nc-photos/dart_image_size_getter" source: git version: "1.0.0" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + sha256: "960f26a08d9351fb8f89f08901f8a829d41b04d45a694b8f776121d9e41dcad6" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + in_app_purchase_android: + dependency: transitive + description: + name: in_app_purchase_android + sha256: "3d84d1a001fa138bed09e0cd95e9dbce268dce8b6f63bda7cf69cbf9135fbfbb" + url: "https://pub.dev" + source: hosted + version: "0.3.6" + in_app_purchase_platform_interface: + dependency: transitive + description: + name: in_app_purchase_platform_interface + sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + in_app_purchase_storekit: + dependency: transitive + description: + name: in_app_purchase_storekit + sha256: "3eea5e173fca0a59ab2fcec5201bea14bb808dfad01004d2b1d7bfdb9244c5e6" + url: "https://pub.dev" + source: hosted + version: "0.3.16" intl: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 52784faa..1067add3 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -162,6 +162,8 @@ dependencies: wakelock_plus: ^1.1.1 woozy_search: ^2.0.3 + in_app_purchase: 3.2.0 + dependency_overrides: video_player: git: