This commit is contained in:
otsmr 2025-11-06 18:40:22 +01:00
parent a99f42c5c8
commit 0ab0303163
23 changed files with 281 additions and 104 deletions

View file

@ -6,6 +6,7 @@ import 'package:twonly/globals.dart';
import 'package:twonly/src/localization/generated/app_localizations.dart';
import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/providers/settings.provider.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/components/app_outdated.dart';
import 'package:twonly/src/views/home.view.dart';
@ -36,8 +37,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
await setUserPlan();
};
globalCallbackUpdatePlan = (String planId) async {
await context.read<CustomChangeProvider>().updatePlan(planId);
globalCallbackUpdatePlan = (SubscriptionPlan plan) async {
await context.read<CustomChangeProvider>().updatePlan(plan);
};
unawaited(initAsync());
@ -47,9 +48,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
final user = await getUser();
if (user != null && mounted) {
if (mounted) {
await context
.read<CustomChangeProvider>()
.updatePlan(user.subscriptionPlan);
await context.read<CustomChangeProvider>().updatePlan(
planFromString(user.subscriptionPlan),
);
}
}
}
@ -79,7 +80,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
void dispose() {
WidgetsBinding.instance.removeObserver(this);
globalCallbackConnectionState = ({required bool isConnected}) {};
globalCallbackUpdatePlan = (String planId) {};
globalCallbackUpdatePlan = (SubscriptionPlan planId) {};
super.dispose();
}

View file

@ -4,6 +4,7 @@ import 'package:camera/camera.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/services/api.service.dart';
import 'package:twonly/src/services/subscription.service.dart';
late ApiService apiService;
@ -26,7 +27,8 @@ void Function({required bool isConnected}) globalCallbackConnectionState = ({
}) {};
void Function() globalCallbackAppIsOutdated = () {};
void Function() globalCallbackNewDeviceRegistered = () {};
void Function(String planId) globalCallbackUpdatePlan = (String planId) {};
void Function(SubscriptionPlan plan) globalCallbackUpdatePlan =
(SubscriptionPlan plan) {};
Map<String, VoidCallback> globalUserDataChangedCallBack = {};

View file

@ -321,8 +321,8 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
if (updateFlame) {
flameCounter += 1;
lastFlameCounterChange = Value(timestamp);
if (flameCounter > maxFlameCounter) {
maxFlameCounter = flameCounter;
if ((flameCounter + 1) >= maxFlameCounter) {
maxFlameCounter = flameCounter + 1;
maxFlameCounterFrom = DateTime.now();
}
}

View file

@ -105,16 +105,6 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
..where((t) => t.receiptId.equals(receiptId)))
.getSingleOrNull() !=
null;
// try {
// return await (select()
// ..where(
// (t) => t.receiptId.equals(receiptId),
// ))
// .getSingleOrNull();
// } catch (e) {
// Log.error(e);
// return null;
// }
}
Future<void> gotReceipt(String receiptId) async {

View file

@ -411,7 +411,7 @@
"@proFeature1": {},
"proFeature2": "1 zusätzlicher Plus Benutzer",
"@proFeature2": {},
"proFeature3": "Zusatzfunktionen (coming-soon)",
"proFeature3": "Flammen wiederherstellen",
"@proFeature3": {},
"proFeature4": "Cloud-Backup verschlüsselt (coming-soon)",
"@proFeature4": {},

View file

@ -705,7 +705,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get proFeature2 => '1 zusätzlicher Plus Benutzer';
@override
String get proFeature3 => 'Zusatzfunktionen (coming-soon)';
String get proFeature3 => 'Flammen wiederherstellen';
@override
String get proFeature4 => 'Cloud-Backup verschlüsselt (coming-soon)';

View file

@ -1251,6 +1251,7 @@ class EncryptedContent_FlameSync extends $pb.GeneratedMessage {
$fixnum.Int64? flameCounter,
$fixnum.Int64? lastFlameCounterChange,
$core.bool? bestFriend,
$core.bool? forceUpdate,
}) {
final $result = create();
if (flameCounter != null) {
@ -1262,6 +1263,9 @@ class EncryptedContent_FlameSync extends $pb.GeneratedMessage {
if (bestFriend != null) {
$result.bestFriend = bestFriend;
}
if (forceUpdate != null) {
$result.forceUpdate = forceUpdate;
}
return $result;
}
EncryptedContent_FlameSync._() : super();
@ -1272,6 +1276,7 @@ class EncryptedContent_FlameSync extends $pb.GeneratedMessage {
..aInt64(1, _omitFieldNames ? '' : 'flameCounter', protoName: 'flameCounter')
..aInt64(2, _omitFieldNames ? '' : 'lastFlameCounterChange', protoName: 'lastFlameCounterChange')
..aOB(3, _omitFieldNames ? '' : 'bestFriend', protoName: 'bestFriend')
..aOB(4, _omitFieldNames ? '' : 'forceUpdate', protoName: 'forceUpdate')
..hasRequiredFields = false
;
@ -1322,6 +1327,15 @@ class EncryptedContent_FlameSync extends $pb.GeneratedMessage {
$core.bool hasBestFriend() => $_has(2);
@$pb.TagNumber(3)
void clearBestFriend() => clearField(3);
@$pb.TagNumber(4)
$core.bool get forceUpdate => $_getBF(3);
@$pb.TagNumber(4)
set forceUpdate($core.bool v) { $_setBool(3, v); }
@$pb.TagNumber(4)
$core.bool hasForceUpdate() => $_has(3);
@$pb.TagNumber(4)
void clearForceUpdate() => clearField(4);
}
class EncryptedContent extends $pb.GeneratedMessage {

View file

@ -365,6 +365,7 @@ const EncryptedContent_FlameSync$json = {
{'1': 'flameCounter', '3': 1, '4': 1, '5': 3, '10': 'flameCounter'},
{'1': 'lastFlameCounterChange', '3': 2, '4': 1, '5': 3, '10': 'lastFlameCounterChange'},
{'1': 'bestFriend', '3': 3, '4': 1, '5': 8, '10': 'bestFriend'},
{'1': 'forceUpdate', '3': 4, '4': 1, '5': 8, '10': 'forceUpdate'},
],
};
@ -434,12 +435,13 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode(
'J5cHRlZENvbnRlbnQuUHVzaEtleXMuVHlwZVIEdHlwZRIZCgVrZXlJZBgCIAEoA0gAUgVrZXlJ'
'ZIgBARIVCgNrZXkYAyABKAxIAVIDa2V5iAEBEiEKCWNyZWF0ZWRBdBgEIAEoA0gCUgljcmVhdG'
'VkQXSIAQEiHwoEVHlwZRILCgdSRVFVRVNUEAASCgoGVVBEQVRFEAFCCAoGX2tleUlkQgYKBF9r'
'ZXlCDAoKX2NyZWF0ZWRBdBqHAQoJRmxhbWVTeW5jEiIKDGZsYW1lQ291bnRlchgBIAEoA1IMZm'
'ZXlCDAoKX2NyZWF0ZWRBdBqpAQoJRmxhbWVTeW5jEiIKDGZsYW1lQ291bnRlchgBIAEoA1IMZm'
'xhbWVDb3VudGVyEjYKFmxhc3RGbGFtZUNvdW50ZXJDaGFuZ2UYAiABKANSFmxhc3RGbGFtZUNv'
'dW50ZXJDaGFuZ2USHgoKYmVzdEZyaWVuZBgDIAEoCFIKYmVzdEZyaWVuZEIKCghfZ3JvdXBJZE'
'IPCg1faXNEaXJlY3RDaGF0QhcKFV9zZW5kZXJQcm9maWxlQ291bnRlckIQCg5fbWVzc2FnZVVw'
'ZGF0ZUIICgZfbWVkaWFCDgoMX21lZGlhVXBkYXRlQhAKDl9jb250YWN0VXBkYXRlQhEKD19jb2'
'50YWN0UmVxdWVzdEIMCgpfZmxhbWVTeW5jQgsKCV9wdXNoS2V5c0ILCglfcmVhY3Rpb25CDgoM'
'X3RleHRNZXNzYWdlQg4KDF9ncm91cENyZWF0ZUIMCgpfZ3JvdXBKb2luQg4KDF9ncm91cFVwZG'
'F0ZUIXChVfcmVzZW5kR3JvdXBQdWJsaWNLZXk=');
'dW50ZXJDaGFuZ2USHgoKYmVzdEZyaWVuZBgDIAEoCFIKYmVzdEZyaWVuZBIgCgtmb3JjZVVwZG'
'F0ZRgEIAEoCFILZm9yY2VVcGRhdGVCCgoIX2dyb3VwSWRCDwoNX2lzRGlyZWN0Q2hhdEIXChVf'
'c2VuZGVyUHJvZmlsZUNvdW50ZXJCEAoOX21lc3NhZ2VVcGRhdGVCCAoGX21lZGlhQg4KDF9tZW'
'RpYVVwZGF0ZUIQCg5fY29udGFjdFVwZGF0ZUIRCg9fY29udGFjdFJlcXVlc3RCDAoKX2ZsYW1l'
'U3luY0ILCglfcHVzaEtleXNCCwoJX3JlYWN0aW9uQg4KDF90ZXh0TWVzc2FnZUIOCgxfZ3JvdX'
'BDcmVhdGVCDAoKX2dyb3VwSm9pbkIOCgxfZ3JvdXBVcGRhdGVCFwoVX3Jlc2VuZEdyb3VwUHVi'
'bGljS2V5');

View file

@ -169,6 +169,7 @@ message EncryptedContent {
int64 flameCounter = 1;
int64 lastFlameCounterChange = 2;
bool bestFriend = 3;
bool forceUpdate = 4;
}
}

View file

@ -1,15 +1,16 @@
import 'package:flutter/foundation.dart';
import 'package:twonly/src/services/subscription.service.dart';
class CustomChangeProvider with ChangeNotifier, DiagnosticableTreeMixin {
bool _isConnected = false;
bool get isConnected => _isConnected;
String plan = 'Free';
SubscriptionPlan plan = SubscriptionPlan.Free;
Future<void> updateConnectionState(bool update) async {
_isConnected = update;
notifyListeners();
}
Future<void> updatePlan(String newPlan) async {
Future<void> updatePlan(SubscriptionPlan newPlan) async {
plan = newPlan;
notifyListeners();
}

View file

@ -35,6 +35,7 @@ import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/services/signal/prekeys.signal.dart';
import 'package:twonly/src/services/signal/utils.signal.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
@ -384,7 +385,7 @@ class ApiService {
user.subscriptionPlan = authenticated.plan;
return user;
});
globalCallbackUpdatePlan(authenticated.plan);
globalCallbackUpdatePlan(planFromString(authenticated.plan));
}
Log.info('websocket is authenticated');
unawaited(onAuthenticated());

View file

@ -123,18 +123,24 @@ Future<void> handleFlameSync(
Log.info('Got a flameSync from $contactId');
final group = await twonlyDB.groupsDao.getDirectChat(contactId);
if (group == null || group.lastFlameCounterChange != null) return;
if (group == null || group.lastFlameCounterChange == null) return;
var updates = GroupsCompanion(
alsoBestFriend: Value(flameSync.bestFriend),
);
if (isToday(group.lastFlameCounterChange!) &&
isToday(fromTimestamp(flameSync.lastFlameCounterChange))) {
isToday(fromTimestamp(flameSync.lastFlameCounterChange)) ||
flameSync.forceUpdate) {
if (flameSync.flameCounter > group.flameCounter) {
updates = GroupsCompanion(
updates = updates.copyWith(
flameCounter: Value(flameSync.flameCounter.toInt()),
);
}
if (flameSync.flameCounter > group.maxFlameCounter) {
updates = updates.copyWith(
maxFlameCounter: Value(flameSync.flameCounter.toInt()),
);
}
}
await twonlyDB.groupsDao.updateGroup(group.groupId, updates);
}

View file

@ -9,7 +9,7 @@ import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
Future<void> syncFlameCounters() async {
Future<void> syncFlameCounters({String? forceForGroup}) async {
final groups = await twonlyDB.groupsDao.getAllDirectChats();
if (groups.isEmpty) return;
final maxMessageCounter = groups.map((x) => x.totalMediaCounter).max;
@ -26,9 +26,11 @@ Future<void> syncFlameCounters() async {
for (final group in groups) {
if (group.lastFlameCounterChange == null) continue;
if (!isToday(group.lastFlameCounterChange!)) continue;
if (forceForGroup == null || group.groupId != forceForGroup) {
if (group.lastFlameSync != null) {
if (isToday(group.lastFlameSync!)) continue;
}
}
final flameCounter = getFlameCounterFromGroup(group) - 1;
@ -49,6 +51,7 @@ Future<void> syncFlameCounters() async {
lastFlameCounterChange:
Int64(group.lastFlameCounterChange!.millisecondsSinceEpoch),
bestFriend: group.groupId == bestFriend.groupId,
forceUpdate: group.groupId == forceForGroup,
),
),
);

View file

@ -0,0 +1,35 @@
// ignore_for_file: constant_identifier_names
import 'package:twonly/globals.dart';
enum SubscriptionPlan {
Free,
Tester,
Family,
Pro,
Plus,
}
bool isAdditionalAccount(SubscriptionPlan plan) {
return plan == SubscriptionPlan.Free || plan == SubscriptionPlan.Plus;
}
bool isPayingUser(SubscriptionPlan plan) {
return plan == SubscriptionPlan.Family ||
plan == SubscriptionPlan.Pro ||
plan == SubscriptionPlan.Tester;
}
SubscriptionPlan planFromString(String value) {
final input = value.trim().toLowerCase();
for (final v in SubscriptionPlan.values) {
final name = v.name;
final compareName = name.toLowerCase();
if (compareName == input) return v;
}
return SubscriptionPlan.Free;
}
SubscriptionPlan getCurrentPlan() {
return planFromString(gUser.subscriptionPlan);
}

View file

@ -8,6 +8,7 @@ import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/log.dart';
Future<bool> isUserCreated() async {
@ -35,16 +36,19 @@ Future<UserData?> getUser() async {
}
}
Future<void> updateUsersPlan(BuildContext context, String planId) async {
context.read<CustomChangeProvider>().plan = planId;
Future<void> updateUsersPlan(
BuildContext context,
SubscriptionPlan plan,
) async {
context.read<CustomChangeProvider>().plan = plan;
await updateUserdata((user) {
user.subscriptionPlan = planId;
user.subscriptionPlan = plan.name;
return user;
});
if (!context.mounted) return;
await context.read<CustomChangeProvider>().updatePlan(planId);
await context.read<CustomChangeProvider>().updatePlan(plan);
}
Mutex updateProtection = Mutex();

View file

@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/chats/add_new_user.view.dart';
@ -104,7 +105,7 @@ class _ChatListViewState extends State<ChatListView> {
@override
Widget build(BuildContext context) {
final isConnected = context.watch<CustomChangeProvider>().isConnected;
final planId = context.watch<CustomChangeProvider>().plan;
final plan = context.watch<CustomChangeProvider>().plan;
return Scaffold(
appBar: AppBar(
title: Row(
@ -130,7 +131,7 @@ class _ChatListViewState extends State<ChatListView> {
),
const SizedBox(width: 10),
const Text('twonly '),
if (planId != 'Free')
if (plan != SubscriptionPlan.Free)
GestureDetector(
onTap: () {
Navigator.push(
@ -150,7 +151,7 @@ class _ChatListViewState extends State<ChatListView> {
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
child: Text(
planId,
plan.name,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,

View file

@ -82,14 +82,14 @@ class GroupContextMenu extends StatelessWidget {
context.lang.groupContextMenuDeleteGroup,
);
if (ok) {
// await twonlyDB.messagesDao.deleteMessagesByGroupId(group.groupId);
await twonlyDB.groupsDao.deleteGroup(group.groupId);
// await twonlyDB.groupsDao.updateGroup(
// group.groupId,
// const GroupsCompanion(
// deletedContent: Value(true),
// ),
// );
await twonlyDB.messagesDao.deleteMessagesByGroupId(group.groupId);
// await twonlyDB.groupsDao.deleteGroup(group.groupId);
await twonlyDB.groupsDao.updateGroup(
group.groupId,
const GroupsCompanion(
deletedContent: Value(true),
),
);
}
},
),

View file

@ -0,0 +1,102 @@
import 'dart:async';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/animate_icon.dart';
import 'package:twonly/src/views/components/better_list_title.dart';
import 'package:twonly/src/views/settings/subscription/subscription.view.dart';
class MaxFlameListTitle extends StatefulWidget {
const MaxFlameListTitle({
required this.contactId,
super.key,
});
final int contactId;
@override
State<MaxFlameListTitle> createState() => _MaxFlameListTitleState();
}
class _MaxFlameListTitleState extends State<MaxFlameListTitle> {
int _flameCounter = 0;
Group? _directChat;
late String _groupId;
late StreamSubscription<int> _flameCounterSub;
late StreamSubscription<Group?> _groupSub;
@override
void initState() {
_groupId = getUUIDforDirectChat(widget.contactId, gUser.userId);
final stream = twonlyDB.groupsDao.watchFlameCounter(_groupId);
_flameCounterSub = stream.listen((counter) {
if (mounted) {
setState(() {
_flameCounter = counter;
});
}
});
final stream2 = twonlyDB.groupsDao.watchGroup(_groupId);
_groupSub = stream2.listen((update) {
if (mounted) {
setState(() {
_directChat = update;
});
}
});
super.initState();
}
@override
void dispose() {
_flameCounterSub.cancel();
_groupSub.cancel();
super.dispose();
}
Future<void> _restoreFlames() async {
if (!isPayingUser(getCurrentPlan())) {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return const SubscriptionView();
},
),
);
return;
}
await twonlyDB.groupsDao.updateGroup(
_groupId,
GroupsCompanion(
flameCounter: Value(_directChat!.maxFlameCounter - 1),
lastFlameCounterChange: Value(DateTime.now()),
),
);
await syncFlameCounters(forceForGroup: _groupId);
}
@override
Widget build(BuildContext context) {
if (_directChat == null ||
_flameCounter >= (_directChat!.maxFlameCounter + 1) ||
_directChat!.lastFlameCounterChange!
.isBefore(DateTime.now().subtract(const Duration(days: 5)))) {
return Container();
}
return BetterListTile(
onTap: _restoreFlames,
leading: const SizedBox(
width: 24,
child: EmojiAnimation(
emoji: '🔥',
),
),
text: 'Restore your ${_directChat!.maxFlameCounter} lost flames',
);
}
}

View file

@ -10,6 +10,7 @@ import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/components/avatar_icon.component.dart';
import 'package:twonly/src/views/components/better_list_title.dart';
import 'package:twonly/src/views/components/flame.dart';
import 'package:twonly/src/views/components/max_flame_list_title.dart';
import 'package:twonly/src/views/components/select_chat_deletion_time.comp.dart';
import 'package:twonly/src/views/components/verified_shield.dart';
import 'package:twonly/src/views/contact/contact_verify.view.dart';
@ -146,6 +147,9 @@ class _ContactViewState extends State<ContactView> {
groupId: getUUIDforDirectChat(widget.userId, gUser.userId),
),
const Divider(),
MaxFlameListTitle(
contactId: widget.userId,
),
BetterListTile(
icon: FontAwesomeIcons.shieldHeart,
text: context.lang.contactVerifyNumberTitle,

View file

@ -1,17 +1,18 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/settings/subscription/select_payment.view.dart';
import 'package:twonly/src/views/settings/subscription/subscription.view.dart';
class CheckoutView extends StatefulWidget {
const CheckoutView({
required this.planId,
required this.plan,
super.key,
this.refund,
this.disableMonthlyOption,
});
final String planId;
final SubscriptionPlan plan;
final int? refund;
final bool? disableMonthlyOption;
@ -31,7 +32,7 @@ class _CheckoutViewState extends State<CheckoutView> {
}
void setCheckout({bool init = false}) {
checkoutInCents = getPlanPrice(widget.planId, paidMonthly: paidMonthly);
checkoutInCents = getPlanPrice(widget.plan, paidMonthly: paidMonthly);
if (!init) {
setState(() {});
}
@ -52,7 +53,7 @@ class _CheckoutViewState extends State<CheckoutView> {
Expanded(
child: ListView(
children: [
PlanCard(planId: widget.planId),
PlanCard(plan: widget.plan),
if (widget.disableMonthlyOption == null ||
!widget.disableMonthlyOption!)
Padding(
@ -129,7 +130,7 @@ class _CheckoutViewState extends State<CheckoutView> {
MaterialPageRoute(
builder: (context) {
return SelectPaymentView(
planId: widget.planId,
plan: widget.plan,
payMonthly: paidMonthly,
refund: widget.refund,
);

View file

@ -7,6 +7,7 @@ import 'package:twonly/globals.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/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/settings/subscription/subscription.view.dart';
@ -64,25 +65,24 @@ class _ManageSubscriptionViewState extends State<ManageSubscriptionView> {
@override
Widget build(BuildContext context) {
final planId = context.read<CustomChangeProvider>().plan;
final plan = context.read<CustomChangeProvider>().plan;
final myLocale = Localizations.localeOf(context);
final paidMonthly = ballance?.paymentPeriodDays == MONTHLY_PAYMENT_DAYS;
final isPayingUser = planId == 'Family' || planId == 'Pro';
return Scaffold(
appBar: AppBar(
title: Text(context.lang.manageSubscription),
),
body: ListView(
children: [
PlanCard(planId: planId, paidMonthly: paidMonthly),
if (isPayingUser) const SizedBox(height: 20),
if (widget.nextPayment != null && isPayingUser)
PlanCard(plan: plan, paidMonthly: paidMonthly),
if (isPayingUser(plan)) const SizedBox(height: 20),
if (widget.nextPayment != null && isPayingUser(plan))
ListTile(
title: Text(
'${context.lang.nextPayment}: ${DateFormat.yMMMMd(myLocale.toString()).format(widget.nextPayment!)}',
),
),
if (autoRenewal != null && isPayingUser)
if (autoRenewal != null && isPayingUser(plan))
ListTile(
title: Text(context.lang.autoRenewal),
subtitle: Text(

View file

@ -4,6 +4,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/model/protobuf/api/websocket/error.pbserver.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/settings/subscription/subscription.view.dart';
@ -13,13 +14,13 @@ import 'package:url_launcher/url_launcher.dart';
class SelectPaymentView extends StatefulWidget {
const SelectPaymentView({
super.key,
this.planId,
this.plan,
this.payMonthly,
this.valueInCents,
this.refund,
});
final String? planId;
final SubscriptionPlan? plan;
final bool? payMonthly;
final int? valueInCents;
final int? refund;
@ -62,9 +63,9 @@ class _SelectPaymentViewState extends State<SelectPaymentView> {
void setCheckout(bool init) {
if (widget.valueInCents != null && widget.valueInCents! > 0) {
checkoutInCents = widget.valueInCents!;
} else if (widget.planId != null) {
} else if (widget.plan != null) {
checkoutInCents =
getPlanPrice(widget.planId!, paidMonthly: widget.payMonthly!);
getPlanPrice(widget.plan!, paidMonthly: widget.payMonthly!);
} else {
/// Nothing to checkout for...
Navigator.pop(context);
@ -77,7 +78,7 @@ class _SelectPaymentViewState extends State<SelectPaymentView> {
@override
Widget build(BuildContext context) {
final totalPrice = (widget.planId != null && widget.payMonthly != null)
final totalPrice = (widget.plan != null && widget.payMonthly != null)
? '${localePrizing(context, checkoutInCents)}/${(widget.payMonthly!) ? context.lang.month : context.lang.year}'
: localePrizing(context, checkoutInCents);
final canPay = paymentMethods == PaymentMethods.twonlyCredit &&
@ -239,13 +240,13 @@ class _SelectPaymentViewState extends State<SelectPaymentView> {
onPressed: canPay
? () async {
final res = await apiService.switchToPayedPlan(
widget.planId!,
widget.plan!.name,
widget.payMonthly!,
tryAutoRenewal,
);
if (!context.mounted) return;
if (res.isSuccess) {
await updateUsersPlan(context, widget.planId!);
await updateUsersPlan(context, widget.plan!);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(

View file

@ -11,6 +11,7 @@ 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/services/subscription.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
@ -64,7 +65,7 @@ const int MONTHLY_PAYMENT_DAYS = 30;
const int YEARLY_PAYMENT_DAYS = 365;
int calculateRefund(Response_PlanBallance current) {
var refund = getPlanPrice('Pro', paidMonthly: true);
var refund = getPlanPrice(SubscriptionPlan.Pro, paidMonthly: true);
if (current.paymentPeriodDays == YEARLY_PAYMENT_DAYS) {
final elapsedDays = DateTime.now()
@ -81,7 +82,7 @@ int calculateRefund(Response_PlanBallance current) {
// => 5
refund = (((YEARLY_PAYMENT_DAYS - elapsedDays) / YEARLY_PAYMENT_DAYS) *
getPlanPrice('Pro', paidMonthly: false) /
getPlanPrice(SubscriptionPlan.Pro, paidMonthly: false) /
100)
.ceil() *
100;
@ -144,13 +145,12 @@ class _SubscriptionViewState extends State<SubscriptionView> {
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) {
if (isPayingUser(currentPlan)) {
nextPayment = lastPaymentDateTime
.add(Duration(days: ballance!.paymentPeriodDays.toInt()));
}
@ -164,7 +164,7 @@ class _SubscriptionViewState extends State<SubscriptionView> {
}
var refund = 0;
if (currentPlan == 'Pro' && ballance != null) {
if (currentPlan == SubscriptionPlan.Pro && ballance != null) {
refund = calculateRefund(ballance!);
}
@ -202,7 +202,7 @@ class _SubscriptionViewState extends State<SubscriptionView> {
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
child: Text(
currentPlan,
currentPlan.name,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
@ -220,7 +220,7 @@ class _SubscriptionViewState extends State<SubscriptionView> {
style: const TextStyle(color: Colors.orange),
),
),
if (currentPlan != 'Family' && currentPlan != 'Pro')
if (!isPayingUser(currentPlan))
Center(
child: Padding(
padding: const EdgeInsets.all(18),
@ -231,16 +231,17 @@ class _SubscriptionViewState extends State<SubscriptionView> {
),
),
),
if (currentPlan != 'Family' && currentPlan != 'Pro')
if (!isPayingUser(currentPlan) ||
currentPlan == SubscriptionPlan.Tester)
PlanCard(
planId: 'Pro',
plan: SubscriptionPlan.Pro,
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return const CheckoutView(
planId: 'Pro',
plan: SubscriptionPlan.Pro,
);
},
),
@ -248,9 +249,9 @@ class _SubscriptionViewState extends State<SubscriptionView> {
await initAsync();
},
),
if (currentPlan != 'Family')
if (currentPlan != SubscriptionPlan.Family)
PlanCard(
planId: 'Family',
plan: SubscriptionPlan.Family,
refund: refund,
onTap: () async {
await Navigator.push(
@ -258,9 +259,10 @@ class _SubscriptionViewState extends State<SubscriptionView> {
MaterialPageRoute(
builder: (context) {
return CheckoutView(
planId: 'Family',
plan: SubscriptionPlan.Family,
refund: (refund > 0) ? refund : null,
disableMonthlyOption: currentPlan == 'Pro' &&
disableMonthlyOption:
currentPlan == SubscriptionPlan.Pro &&
ballance!.paymentPeriodDays.toInt() ==
YEARLY_PAYMENT_DAYS,
);
@ -270,7 +272,7 @@ class _SubscriptionViewState extends State<SubscriptionView> {
await initAsync();
},
),
if (!isPayingUser) ...[
if (!isPayingUser(currentPlan)) ...[
const SizedBox(height: 10),
Center(
child: Padding(
@ -284,15 +286,15 @@ class _SubscriptionViewState extends State<SubscriptionView> {
),
const SizedBox(height: 10),
PlanCard(
planId: 'Plus',
plan: SubscriptionPlan.Plus,
onTap: () async {
await redeemUserInviteCode(context, 'Plus');
await redeemUserInviteCode(context, SubscriptionPlan.Plus.name);
await initAsync();
},
),
],
const SizedBox(height: 10),
if (currentPlan != 'Family') const Divider(),
if (currentPlan != SubscriptionPlan.Family) const Divider(),
BetterListTile(
icon: FontAwesomeIcons.gears,
text: context.lang.manageSubscription,
@ -337,7 +339,8 @@ class _SubscriptionViewState extends State<SubscriptionView> {
);
},
),
if (isPayingUser || currentPlan == 'Tester')
if (isPayingUser(currentPlan) ||
currentPlan == SubscriptionPlan.Tester)
BetterListTile(
icon: FontAwesomeIcons.userPlus,
text: context.lang.manageAdditionalUsers,
@ -378,36 +381,38 @@ class _SubscriptionViewState extends State<SubscriptionView> {
}
}
int getPlanPrice(String planId, {required bool paidMonthly}) {
switch (planId) {
case 'Pro':
int getPlanPrice(SubscriptionPlan plan, {required bool paidMonthly}) {
switch (plan) {
case SubscriptionPlan.Pro:
return paidMonthly ? 100 : 1000;
case 'Family':
case SubscriptionPlan.Family:
return paidMonthly ? 200 : 2000;
}
// ignore: no_default_cases
default:
return 0;
}
}
class PlanCard extends StatelessWidget {
const PlanCard({
required this.planId,
required this.plan,
super.key,
this.refund,
this.onTap,
this.paidMonthly,
});
final String planId;
final SubscriptionPlan plan;
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);
final yearlyPrice = getPlanPrice(plan, paidMonthly: false);
final monthlyPrice = getPlanPrice(plan, paidMonthly: true);
var features = <String>[];
switch (planId) {
switch (plan.name) {
case 'Free':
features = [context.lang.freeFeature1];
case 'Plus':
@ -447,7 +452,7 @@ class PlanCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text(
planId,
plan.name,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 24,
@ -519,9 +524,12 @@ class PlanCard extends StatelessWidget {
padding: const EdgeInsets.only(top: 10),
child: FilledButton.icon(
onPressed: onTap,
label: (planId == 'Free' || planId == 'Plus')
label: (plan == SubscriptionPlan.Free ||
plan == SubscriptionPlan.Plus)
? Text(context.lang.redeemUserInviteCodeTitle)
: Text(context.lang.upgradeToPaidPlanButton(planId)),
: Text(
context.lang.upgradeToPaidPlanButton(plan.name),
),
),
),
],