Add donation page

This commit is contained in:
Ming Ming 2022-08-11 00:45:01 +08:00
parent 40b168d660
commit e1512560ef
11 changed files with 578 additions and 0 deletions

133
app/lib/bloc/donation.dart Normal file
View file

@ -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 = <String, Map>{};
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<StoreProduct> 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<DonationBlocEvent, DonationBlocState> {
DonationBloc() : super(const DonationBlocInit()) {
on<DonationBlocEvent>(_onEvent);
}
Future<void> _onEvent(
DonationBlocEvent event, Emitter<DonationBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is DonationBlocQuery) {
await _onEventQuery(event, emit);
}
}
Future<void> _onEventQuery(
DonationBlocQuery ev, Emitter<DonationBlocState> 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");
}

View file

@ -1532,6 +1532,16 @@
}, },
"fileNotFound": "File not found", "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": "Unauthenticated access. Please sign-in again if the problem continues",
"@errorUnauthenticated": { "@errorUnauthenticated": {
"description": "Error message when server responds with HTTP401" "description": "Error message when server responds with HTTP401"

View file

@ -3,6 +3,7 @@ import 'package:logging/logging.dart';
import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/help_utils.dart' as help_util; import 'package:nc_photos/help_utils.dart' as help_util;
import 'package:nc_photos/url_launcher_util.dart'; 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_codegen/np_codegen.dart';
import 'package:np_ui/np_ui.dart'; import 'package:np_ui/np_ui.dart';
@ -74,6 +75,15 @@ class Changelog extends StatelessWidget {
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
return Column( return Column(
children: [ children: [
_ChangelogBanner(
title: Text(L10n.global().donationShortMessage),
action: TextButton(
onPressed: () {
Navigator.of(context).pushNamed(Donation.routeName);
},
child: Text(L10n.global().donationButtonLabel),
),
),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: _changelogs.length, itemCount: _changelogs.length,

View file

@ -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<Donation> {
@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<DonationBloc, DonationBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<DonationBloc, DonationBlocState>(
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<void> _onPurchaseUpdated(
Map<PurchaseStatus, List<PurchaseDetails>> 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<void> _showSuccessDialog(List<PurchaseDetails> 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<void> _showFailureDialog(List<PurchaseDetails> 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 = <StoreProduct>[];
var _isPendingDialogVisible = false;
static final _log = Logger("widget.donation._DonationState");
}

View file

@ -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<PurchaseDetails> details) l) {
_onSuccessListeners.add(l);
}
void popOnSuccessListener() {
_onSuccessListeners.removeLast();
}
void pushOnFailureListener(void Function(List<PurchaseDetails> details) l) {
_onFailureListeners.add(l);
}
void popOnFailureListener() {
_onFailureListeners.removeLast();
}
/// PurchaseUpdatedListeners are called after other status listeners
void pushOnPurchaseUpdatedListener(
void Function(Map<PurchaseStatus, List<PurchaseDetails>> details) l) {
_onPurchaseUpdatedListeners.add(l);
}
void popOnPurchaseUpdatedListener() {
_onPurchaseUpdatedListeners.removeLast();
}
Future<void> _onPurchaseUpdated(List<PurchaseDetails> 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<InAppPurchaseAndroidPlatformAddition>();
await addition.consumePurchase(d);
}
}
} catch (e, stackTrace) {
_log.severe("[_onPurchaseUpdated] Failed while completePurchase", e,
stackTrace);
}
}
for (final l in _onPurchaseUpdatedListeners) {
l(statusMap);
}
}
StreamSubscription<List<PurchaseDetails>>? _subscription;
final _onSuccessListeners = <void Function(List<PurchaseDetails>)>[];
final _onFailureListeners = <void Function(List<PurchaseDetails>)>[];
final _onPurchaseUpdatedListeners =
<void Function(Map<PurchaseStatus, List<PurchaseDetails>>)>[];
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}, "
"}";
}

View file

@ -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_browser.dart';
import 'package:nc_photos/widget/collection_picker.dart'; import 'package:nc_photos/widget/collection_picker.dart';
import 'package:nc_photos/widget/connect.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/enhanced_photo_browser.dart';
import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/home.dart';
import 'package:nc_photos/widget/image_editor.dart'; import 'package:nc_photos/widget/image_editor.dart';
@ -242,6 +243,7 @@ class _WrappedAppState extends State<_WrappedApp>
route ??= _handleCollectionBrowserRoute(settings); route ??= _handleCollectionBrowserRoute(settings);
route ??= _handleAccountSettingsRoute(settings); route ??= _handleAccountSettingsRoute(settings);
route ??= _handlePlacePickerRoute(settings); route ??= _handlePlacePickerRoute(settings);
route ??= _handleDonationRoute(settings);
return route; return route;
} }
@ -563,6 +565,17 @@ class _WrappedAppState extends State<_WrappedApp>
return null; return null;
} }
Route<dynamic>? _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>(); late final _bloc = context.read<_Bloc>();
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
final _navigatorKey = GlobalKey<NavigatorState>(); final _navigatorKey = GlobalKey<NavigatorState>();

View file

@ -14,6 +14,7 @@ import 'package:nc_photos/platform/features.dart' as features;
import 'package:nc_photos/platform/notification.dart'; import 'package:nc_photos/platform/notification.dart';
import 'package:nc_photos/stream_util.dart'; import 'package:nc_photos/stream_util.dart';
import 'package:nc_photos/url_launcher_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/list_tile_center_leading.dart';
import 'package:nc_photos/widget/settings/collection_settings.dart'; import 'package:nc_photos/widget/settings/collection_settings.dart';
import 'package:nc_photos/widget/settings/developer_settings.dart'; import 'package:nc_photos/widget/settings/developer_settings.dart';
@ -138,6 +139,13 @@ class _SettingsState extends State<Settings> {
} }
}, },
), ),
ListTile(
leading: const Icon(Icons.coffee_outlined),
title: Text(L10n.global().donationTitle),
onTap: () {
Navigator.of(context).pushNamed(Donation.routeName);
},
),
ListTile( ListTile(
title: const Text("Rate on Google Play"), title: const Text("Rate on Google Play"),
onTap: () { onTap: () {

View file

@ -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/activity.dart';
import 'package:nc_photos/mobile/android/permission_util.dart'; import 'package:nc_photos/mobile/android/permission_util.dart';
import 'package:nc_photos/protected_page_handler.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/v29.dart';
import 'package:nc_photos/use_case/compat/v46.dart'; import 'package:nc_photos/use_case/compat/v46.dart';
import 'package:nc_photos/use_case/compat/v55.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/app_intermediate_circular_progress_indicator.dart';
import 'package:nc_photos/widget/changelog.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/home.dart';
import 'package:nc_photos/widget/setup.dart'; import 'package:nc_photos/widget/setup.dart';
import 'package:nc_photos/widget/sign_in.dart'; import 'package:nc_photos/widget/sign_in.dart';

View file

@ -8,6 +8,21 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
}) : super(_State.init()) { }) : super(_State.init()) {
on<_Init>(_onInit); on<_Init>(_onInit);
on<_ChangelogDismissed>(_onChangelogDismissed); 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 @override

View file

@ -805,6 +805,38 @@ packages:
url: "https://gitlab.com/nc-photos/dart_image_size_getter" url: "https://gitlab.com/nc-photos/dart_image_size_getter"
source: git source: git
version: "1.0.0" 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: intl:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -162,6 +162,8 @@ dependencies:
wakelock_plus: ^1.1.1 wakelock_plus: ^1.1.1
woozy_search: ^2.0.3 woozy_search: ^2.0.3
in_app_purchase: 3.2.0
dependency_overrides: dependency_overrides:
video_player: video_player:
git: git: