mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 10:38:41 +00:00
567 lines
19 KiB
Dart
567 lines
19 KiB
Dart
// ignore_for_file: inference_failure_on_instance_creation
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:twonly/globals.dart';
|
|
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
|
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
|
|
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart';
|
|
import 'package:twonly/src/providers/connection.provider.dart';
|
|
import 'package:twonly/src/utils/log.dart';
|
|
import 'package:twonly/src/utils/misc.dart';
|
|
import 'package:twonly/src/utils/storage.dart';
|
|
import 'package:twonly/src/views/components/better_list_title.dart';
|
|
import 'package:twonly/src/views/settings/subscription/additional_users.view.dart';
|
|
import 'package:twonly/src/views/settings/subscription/checkout.view.dart';
|
|
import 'package:twonly/src/views/settings/subscription/manage_subscription.view.dart';
|
|
import 'package:twonly/src/views/settings/subscription/transaction.view.dart';
|
|
import 'package:twonly/src/views/settings/subscription/voucher.view.dart';
|
|
|
|
String localePrizing(BuildContext context, int cents) {
|
|
final myLocale = Localizations.localeOf(context);
|
|
final euros = cents / 100;
|
|
|
|
if (euros == euros.toInt()) {
|
|
return '${euros.toInt()}€';
|
|
}
|
|
|
|
return NumberFormat.currency(
|
|
locale: myLocale.toString(),
|
|
symbol: '€',
|
|
decimalDigits: 2,
|
|
).format(cents / 100);
|
|
}
|
|
|
|
Future<Response_PlanBallance?> loadPlanBalance({bool useCache = true}) async {
|
|
final ballance = await apiService.getPlanBallance();
|
|
if (ballance != null) {
|
|
await updateUserdata((u) {
|
|
u.lastPlanBallance = ballance.writeToJson();
|
|
return u;
|
|
});
|
|
return ballance;
|
|
}
|
|
final user = await getUser();
|
|
if (user != null && user.lastPlanBallance != null && useCache) {
|
|
try {
|
|
return Response_PlanBallance.fromJson(
|
|
user.lastPlanBallance!,
|
|
);
|
|
} catch (e) {
|
|
Log.error('from json: $e');
|
|
}
|
|
}
|
|
return ballance;
|
|
}
|
|
|
|
// ignore: constant_identifier_names
|
|
const int MONTHLY_PAYMENT_DAYS = 30;
|
|
// ignore: constant_identifier_names
|
|
const int YEARLY_PAYMENT_DAYS = 365;
|
|
|
|
int calculateRefund(Response_PlanBallance current) {
|
|
var refund = getPlanPrice('Pro', paidMonthly: true);
|
|
|
|
if (current.paymentPeriodDays == YEARLY_PAYMENT_DAYS) {
|
|
final elapsedDays = DateTime.now()
|
|
.difference(DateTime.fromMillisecondsSinceEpoch(
|
|
current.lastPaymentDoneUnixTimestamp.toInt() * 1000))
|
|
.inDays;
|
|
if (elapsedDays < current.paymentPeriodDays.toInt()) {
|
|
// User has yearly plan with 10€
|
|
// used it half a year and wants now to upgrade => gets 5€ discount...
|
|
// math.ceil(((365-(365/2))/365)*10)
|
|
// => 5€
|
|
|
|
refund = (((YEARLY_PAYMENT_DAYS - elapsedDays) / YEARLY_PAYMENT_DAYS) *
|
|
getPlanPrice('Pro', paidMonthly: false) /
|
|
100)
|
|
.ceil() *
|
|
100;
|
|
}
|
|
} else {
|
|
final elapsedDays = DateTime.now()
|
|
.difference(DateTime.fromMillisecondsSinceEpoch(
|
|
current.lastPaymentDoneUnixTimestamp.toInt() * 1000))
|
|
.inDays;
|
|
if (elapsedDays > 14) {
|
|
refund = 0;
|
|
}
|
|
}
|
|
return refund;
|
|
}
|
|
|
|
class SubscriptionView extends StatefulWidget {
|
|
const SubscriptionView({super.key, this.redirectError});
|
|
|
|
final ErrorCode? redirectError;
|
|
|
|
@override
|
|
State<SubscriptionView> createState() => _SubscriptionViewState();
|
|
}
|
|
|
|
class _SubscriptionViewState extends State<SubscriptionView> {
|
|
bool loaded = false;
|
|
bool testerRequested = true;
|
|
Response_PlanBallance? ballance;
|
|
String? additionalOwnerName;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
initAsync();
|
|
}
|
|
|
|
Future<void> initAsync() async {
|
|
ballance = await loadPlanBalance();
|
|
if (ballance != null && ballance!.hasAdditionalAccountOwnerId()) {
|
|
final ownerId = ballance!.additionalAccountOwnerId.toInt();
|
|
final contact = await twonlyDB.contactsDao
|
|
.getContactByUserId(ownerId)
|
|
.getSingleOrNull();
|
|
if (contact != null) {
|
|
additionalOwnerName = getContactDisplayName(contact);
|
|
} else {
|
|
additionalOwnerName = ownerId.toString();
|
|
}
|
|
}
|
|
setState(() {});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final myLocale = Localizations.localeOf(context);
|
|
String? formattedBalance;
|
|
DateTime? nextPayment;
|
|
final currentPlan = context.read<CustomChangeProvider>().plan;
|
|
final isPayingUser = currentPlan == 'Family' || currentPlan == 'Pro';
|
|
|
|
if (ballance != null) {
|
|
final lastPaymentDateTime = DateTime.fromMillisecondsSinceEpoch(
|
|
ballance!.lastPaymentDoneUnixTimestamp.toInt() * 1000);
|
|
if (isPayingUser) {
|
|
nextPayment = lastPaymentDateTime
|
|
.add(Duration(days: ballance!.paymentPeriodDays.toInt()));
|
|
}
|
|
final ballanceInCents =
|
|
ballance!.transactions.map((a) => a.depositCents.toInt()).sum;
|
|
formattedBalance = NumberFormat.currency(
|
|
locale: myLocale.toString(),
|
|
symbol: '€',
|
|
decimalDigits: 2,
|
|
).format(ballanceInCents / 100);
|
|
}
|
|
|
|
var refund = 0;
|
|
if (currentPlan == 'Pro' && ballance != null) {
|
|
refund = calculateRefund(ballance!);
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(context.lang.settingsSubscription),
|
|
),
|
|
body: ListView(
|
|
children: [
|
|
if (widget.redirectError != null)
|
|
Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
margin: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orangeAccent,
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
child: Text(
|
|
(widget.redirectError == ErrorCode.PlanLimitReached)
|
|
? context.lang.planLimitReached
|
|
: context.lang.planNotAllowed,
|
|
style: const TextStyle(color: Colors.black),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Center(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: context.color.primary,
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
child: Text(
|
|
currentPlan,
|
|
style: TextStyle(
|
|
fontSize: 32,
|
|
fontWeight: FontWeight.bold,
|
|
color: isDarkMode(context) ? Colors.black : Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (additionalOwnerName != null)
|
|
Center(
|
|
child: Text(
|
|
context.lang.partOfPaidPlanOf(additionalOwnerName!),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Colors.orange),
|
|
),
|
|
),
|
|
if (currentPlan != 'Family' && currentPlan != 'Pro')
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Text(
|
|
context.lang.upgradeToPaidPlan,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 18),
|
|
),
|
|
),
|
|
),
|
|
if (currentPlan != 'Family' && currentPlan != 'Pro')
|
|
PlanCard(
|
|
planId: 'Pro',
|
|
onTap: () async {
|
|
await Navigator.push(context,
|
|
MaterialPageRoute(builder: (context) {
|
|
return const CheckoutView(
|
|
planId: 'Pro',
|
|
);
|
|
}));
|
|
await initAsync();
|
|
},
|
|
),
|
|
if (currentPlan != 'Family')
|
|
PlanCard(
|
|
planId: 'Family',
|
|
refund: refund,
|
|
onTap: () async {
|
|
await Navigator.push(context,
|
|
MaterialPageRoute(builder: (context) {
|
|
return CheckoutView(
|
|
planId: 'Family',
|
|
refund: (refund > 0) ? refund : null,
|
|
disableMonthlyOption: currentPlan == 'Pro' &&
|
|
ballance!.paymentPeriodDays.toInt() ==
|
|
YEARLY_PAYMENT_DAYS,
|
|
);
|
|
}));
|
|
await initAsync();
|
|
},
|
|
),
|
|
if (!isPayingUser) ...[
|
|
const SizedBox(height: 10),
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Text(
|
|
context.lang.redeemUserInviteCode,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 18),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
PlanCard(
|
|
planId: 'Plus',
|
|
onTap: () async {
|
|
await redeemUserInviteCode(context, 'Plus');
|
|
await initAsync();
|
|
},
|
|
),
|
|
],
|
|
const SizedBox(height: 10),
|
|
if (currentPlan != 'Family') const Divider(),
|
|
BetterListTile(
|
|
icon: FontAwesomeIcons.gears,
|
|
text: context.lang.manageSubscription,
|
|
subtitle: (nextPayment != null)
|
|
? Text(
|
|
'${context.lang.nextPayment}: ${DateFormat.yMMMMd(myLocale.toString()).format(nextPayment)}')
|
|
: null,
|
|
onTap: () async {
|
|
await Navigator.push(context,
|
|
MaterialPageRoute(builder: (context) {
|
|
return ManageSubscriptionView(
|
|
ballance: ballance,
|
|
nextPayment: nextPayment,
|
|
);
|
|
}));
|
|
await initAsync();
|
|
},
|
|
),
|
|
BetterListTile(
|
|
icon: FontAwesomeIcons.moneyBillTransfer,
|
|
text: context.lang.transactionHistory,
|
|
subtitle: (formattedBalance != null)
|
|
? Text('${context.lang.currentBalance}: $formattedBalance')
|
|
: null,
|
|
onTap: () {
|
|
if (formattedBalance == null) return;
|
|
Navigator.push(context, MaterialPageRoute(builder: (context) {
|
|
return TransactionView(
|
|
transactions: ballance?.transactions,
|
|
formattedBalance: formattedBalance!,
|
|
);
|
|
}));
|
|
},
|
|
),
|
|
if (isPayingUser)
|
|
BetterListTile(
|
|
icon: FontAwesomeIcons.userPlus,
|
|
text: context.lang.manageAdditionalUsers,
|
|
subtitle: loaded ? Text('${context.lang.open}: 3') : null,
|
|
onTap: () async {
|
|
await Navigator.push(context,
|
|
MaterialPageRoute(builder: (context) {
|
|
return AdditionalUsersView(
|
|
ballance: ballance,
|
|
);
|
|
}));
|
|
await initAsync();
|
|
},
|
|
),
|
|
BetterListTile(
|
|
icon: FontAwesomeIcons.ticket,
|
|
text: context.lang.createOrRedeemVoucher,
|
|
onTap: () async {
|
|
await Navigator.push(context,
|
|
MaterialPageRoute(builder: (context) {
|
|
return const VoucherView();
|
|
}));
|
|
await initAsync();
|
|
},
|
|
),
|
|
const SizedBox(height: 30)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
int getPlanPrice(String planId, {required bool paidMonthly}) {
|
|
switch (planId) {
|
|
case 'Pro':
|
|
return paidMonthly ? 100 : 1000;
|
|
case 'Family':
|
|
return paidMonthly ? 200 : 2000;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
class PlanCard extends StatelessWidget {
|
|
const PlanCard({
|
|
required this.planId,
|
|
super.key,
|
|
this.refund,
|
|
this.onTap,
|
|
this.paidMonthly,
|
|
});
|
|
final String planId;
|
|
final void Function()? onTap;
|
|
final int? refund;
|
|
final bool? paidMonthly;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final yearlyPrice = getPlanPrice(planId, paidMonthly: false);
|
|
final monthlyPrice = getPlanPrice(planId, paidMonthly: true);
|
|
var features = <String>[];
|
|
|
|
switch (planId) {
|
|
case 'Free':
|
|
features = [context.lang.freeFeature1];
|
|
case 'Plus':
|
|
features = [context.lang.plusFeature1, context.lang.plusFeature2];
|
|
case 'Tester':
|
|
case 'Pro':
|
|
features = [
|
|
context.lang.proFeature1,
|
|
context.lang.proFeature2,
|
|
context.lang.proFeature3,
|
|
context.lang.proFeature4,
|
|
];
|
|
case 'Family':
|
|
features = [
|
|
context.lang.familyFeature1,
|
|
context.lang.familyFeature2,
|
|
];
|
|
default:
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 16, right: 16),
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: Card(
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
Text(
|
|
planId,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
if (yearlyPrice != 0) const SizedBox(height: 10),
|
|
if (yearlyPrice != 0 && paidMonthly == null)
|
|
Column(
|
|
children: [
|
|
if (paidMonthly == null || paidMonthly!)
|
|
Text(
|
|
'${localePrizing(context, yearlyPrice)}/${context.lang.year}',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
if (paidMonthly == null || !paidMonthly!)
|
|
Text(
|
|
'${localePrizing(context, monthlyPrice)}/${context.lang.month}',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (paidMonthly != null)
|
|
Text(
|
|
(paidMonthly!)
|
|
? '${localePrizing(context, monthlyPrice)}/${context.lang.month}'
|
|
: '${localePrizing(context, yearlyPrice)}/${context.lang.year}',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
...features.map(
|
|
(feature) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Text(
|
|
feature,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
if (refund != null && refund! > 0)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 7),
|
|
child: Text(
|
|
context.lang
|
|
.subscriptionRefund(localePrizing(context, refund!)),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: context.color.primary,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
if (onTap != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 10),
|
|
child: FilledButton.icon(
|
|
onPressed: onTap,
|
|
label: (planId == 'Free' || planId == 'Plus')
|
|
? Text(context.lang.redeemUserInviteCodeTitle)
|
|
: Text(context.lang.upgradeToPaidPlanButton(planId)),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> redeemUserInviteCode(BuildContext context, String newPlan) async {
|
|
var inviteCode = '';
|
|
// ignore: inference_failure_on_function_invocation
|
|
await showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(context.lang.redeemUserInviteCodeTitle),
|
|
content: StatefulBuilder(
|
|
builder: (BuildContext context, StateSetter setState) {
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: TextField(
|
|
onChanged: (value) => setState(() {
|
|
inviteCode = value.toUpperCase();
|
|
}),
|
|
decoration: InputDecoration(
|
|
labelText: context.lang.registerTwonlyCodeLabel,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
textCapitalization: TextCapitalization.characters,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: Text(context.lang.cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
final res = await apiService.redeemUserInviteCode(inviteCode);
|
|
if (!context.mounted) return;
|
|
if (res.isSuccess) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.lang.redeemUserInviteCodeSuccess),
|
|
),
|
|
);
|
|
// reconnect to load new plan.
|
|
await apiService.close(() {});
|
|
await apiService.connect(force: true);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
errorCodeToText(context, res.error as ErrorCode))),
|
|
);
|
|
}
|
|
if (!context.mounted) return;
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: Text(context.lang.ok),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|