mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-22 08:46:18 +01:00
Add donation page
This commit is contained in:
parent
40b168d660
commit
e1512560ef
11 changed files with 578 additions and 0 deletions
133
app/lib/bloc/donation.dart
Normal file
133
app/lib/bloc/donation.dart
Normal 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");
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
230
app/lib/widget/donation.dart
Normal file
230
app/lib/widget/donation.dart
Normal 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");
|
||||||
|
}
|
123
app/lib/widget/handler/purchase_handler.dart
Normal file
123
app/lib/widget/handler/purchase_handler.dart
Normal 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}, "
|
||||||
|
"}";
|
||||||
|
}
|
|
@ -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>();
|
||||||
|
|
|
@ -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: () {
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
Loading…
Reference in a new issue