feat: display user's subscription end/refresh date

This commit is contained in:
ggurdin 2025-11-11 13:45:22 -05:00
parent a7fea620b3
commit 0ab91ccb68
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
12 changed files with 155 additions and 89 deletions

View file

@ -5321,5 +5321,9 @@
"inviteFriends": "Invite friends",
"activityStatsButtonTooltip": "Activity info",
"allow": "Allow",
"deny": "Deny"
"deny": "Deny",
"enabledRenewal": "Enable Subscription Renewal",
"subscriptionEndsOn": "Subscription Ends On",
"subscriptionRenewsOn": "Subscription Renews On",
"waitForSubscriptionChanges": "Changes to your subscription may take a moment to reflect in the app."
}

View file

@ -525,7 +525,6 @@ class ChatListController extends State<ChatList>
//#Pangea
StreamSubscription? _invitedSpaceSubscription;
StreamSubscription? _subscriptionStatusStream;
StreamSubscription? _roomCapacitySubscription;
//Pangea#
@ -613,13 +612,8 @@ class ChatListController extends State<ChatList>
}
});
_subscriptionStatusStream ??= MatrixState
.pangeaController.subscriptionController.subscriptionStream.stream
.listen((event) {
if (mounted) {
showSubscribedSnackbar(context);
}
});
MatrixState.pangeaController.subscriptionController.subscriptionNotifier
.addListener(_onSubscribe);
// listen for space child updates for any space that is not the active space
// so that when the user navigates to the space that was updated, it will
@ -673,6 +667,10 @@ class ChatListController extends State<ChatList>
}
// #Pangea
void _onSubscribe() {
if (mounted) showSubscribedSnackbar(context);
}
Future<void> _joinInvitedSpaces() async {
final invitedSpaces = Matrix.of(context).client.rooms.where(
(r) => r.isSpace && r.membership == Membership.invite,
@ -691,8 +689,9 @@ class ChatListController extends State<ChatList>
_intentUriStreamSubscription?.cancel();
//#Pangea
_invitedSpaceSubscription?.cancel();
_subscriptionStatusStream?.cancel();
_roomCapacitySubscription?.cancel();
MatrixState.pangeaController.subscriptionController.subscriptionNotifier
.removeListener(_onSubscribe);
//Pangea#
scrollController.removeListener(_onScroll);
super.dispose();

View file

@ -4,6 +4,7 @@ class PLocalKey {
static const String beganWebPayment = "beganWebPayment";
static const String dismissedPaywall = 'dismissedPaywall';
static const String paywallBackoff = 'paywallBackoff';
static const String clickedCancelSubscription = 'clickedCancelSubscription';
static const String messagesSinceUpdate = 'messagesSinceLastUpdate';
static const String completedActivities = 'completedActivities';
static const String justInputtedCode = 'justInputtedCode';

View file

@ -12,7 +12,6 @@ import 'package:url_launcher/url_launcher_string.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/controllers/base_controller.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/network/urls.dart';
@ -35,13 +34,13 @@ enum SubscriptionStatus {
shouldShowPaywall,
}
class SubscriptionController extends BaseController {
class SubscriptionController with ChangeNotifier {
late PangeaController _pangeaController;
CurrentSubscriptionInfo? currentSubscriptionInfo;
AvailableSubscriptionsInfo? availableSubscriptionInfo;
final StreamController subscriptionStream = StreamController.broadcast();
final ValueNotifier<bool> subscriptionNotifier = ValueNotifier<bool>(false);
SubscriptionController(PangeaController pangeaController) : super() {
_pangeaController = pangeaController;
@ -119,22 +118,20 @@ class SubscriptionController extends BaseController {
(CustomerInfo info) async {
final bool? wasSubscribed = isSubscribed;
await updateCustomerInfo();
if (wasSubscribed != null &&
!wasSubscribed &&
(isSubscribed != null && isSubscribed!)) {
subscriptionStream.add(true);
if (wasSubscribed == false && isSubscribed == true) {
subscriptionNotifier.value = true;
}
},
);
} else {
if (SubscriptionManagementRepo.getBeganWebPayment()) {
await SubscriptionManagementRepo.removeBeganWebPayment();
if (isSubscribed != null && isSubscribed!) {
subscriptionStream.add(true);
if (isSubscribed == true) {
subscriptionNotifier.value = true;
}
}
}
setState(null);
notifyListeners();
} catch (e, s) {
debugPrint("Failed to initialize subscription controller");
ErrorHandler.logError(
@ -197,7 +194,6 @@ class SubscriptionController extends BaseController {
isPromo: isPromo,
);
await SubscriptionManagementRepo.setBeganWebPayment();
setState(null);
launchUrlString(
paymentLink,
webOnlyWindowName: "_self",
@ -234,7 +230,7 @@ class SubscriptionController extends BaseController {
Future<void> updateCustomerInfo() async {
await currentSubscriptionInfo?.setCurrentSubscription();
setState(null);
notifyListeners();
}
/// if the user is subscribed, returns subscribed

View file

@ -11,6 +11,7 @@ class CurrentSubscriptionInfo {
final AvailableSubscriptionsInfo availableSubscriptionInfo;
DateTime? expirationDate;
DateTime? unsubscribeDetectedAt;
String? currentSubscriptionId;
CurrentSubscriptionInfo({
@ -59,6 +60,9 @@ class CurrentSubscriptionInfo {
(currentSubscription?.appId ==
availableSubscriptionInfo.appIds?.currentAppId);
DateTime? get subscriptionEndDate =>
unsubscribeDetectedAt == null ? null : expirationDate;
void resetSubscription() => currentSubscriptionId = null;
Future<void> setCurrentSubscription() async {}
}

View file

@ -104,10 +104,10 @@ class MobileSubscriptionInfo extends CurrentSubscriptionInfo {
expirationDate = activeEntitlement.expirationDate != null
? DateTime.parse(activeEntitlement.expirationDate!)
: null;
unsubscribeDetectedAt = activeEntitlement.unsubscribeDetectedAt != null
? DateTime.parse(activeEntitlement.unsubscribeDetectedAt!)
: null;
if (activeEntitlement.periodType == PeriodType.trial) {
// We dont use actual trials as it would require adding a CC on devices
}
if (currentSubscriptionId != null && currentSubscription == null) {
Sentry.addBreadcrumb(
Breadcrumb(message: "mismatch of productIds and currentSubscriptionID"),

View file

@ -21,7 +21,16 @@ class WebSubscriptionInfo extends CurrentSubscriptionInfo {
);
currentSubscriptionId = rcResponse.currentSubscriptionId;
expirationDate = rcResponse.expirationDate;
final currentSubscription =
rcResponse.allSubscriptions?[currentSubscriptionId];
if (currentSubscription != null) {
expirationDate = DateTime.tryParse(currentSubscription.expiresDate);
unsubscribeDetectedAt =
currentSubscription.unsubscribeDetectedAt != null
? DateTime.parse(currentSubscription.unsubscribeDetectedAt!)
: null;
}
} catch (err) {
currentSubscriptionId = AppConfig.errorSubscriptionId;
}

View file

@ -171,21 +171,20 @@ class ChangeSubscription extends StatelessWidget {
ElevatedButton(
onPressed: () => controller
.submitChange(subscription),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
controller.loading
? const CircularProgressIndicator
.adaptive()
: Text(
child: controller.loading
? const LinearProgressIndicator()
: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Text(
subscription.isTrial
? L10n.of(context)
.activateTrial
: L10n.of(context).pay,
),
],
),
],
),
),
],
),

View file

@ -2,16 +2,16 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/subscription/pages/settings_subscription_view.dart';
import 'package:fluffychat/pangea/subscription/repo/subscription_management_repo.dart';
import 'package:fluffychat/pangea/subscription/utils/subscription_app_id.dart';
import 'package:fluffychat/pangea/subscription/widgets/subscription_snackbar.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class SubscriptionManagement extends StatefulWidget {
@ -27,37 +27,25 @@ class SubscriptionManagementController extends State<SubscriptionManagement> {
MatrixState.pangeaController.subscriptionController;
SubscriptionDetails? selectedSubscription;
StreamSubscription? _subscriptionStatusStream;
bool loading = false;
late StreamSubscription _settingsSubscription;
@override
void initState() {
if (!subscriptionController.initCompleter.isCompleted) {
subscriptionController.initialize().then((_) => setState(() {}));
}
_settingsSubscription = subscriptionController.stateStream.listen((event) {
debugPrint("stateStream event in subscription settings");
setState(() {});
});
_subscriptionStatusStream ??=
subscriptionController.subscriptionStream.stream.listen((_) {
showSubscribedSnackbar(context);
context.go('/rooms');
});
subscriptionController.addListener(_onSubscriptionUpdate);
subscriptionController.subscriptionNotifier.addListener(_onSubscribe);
subscriptionController.updateCustomerInfo();
super.initState();
}
@override
void dispose() {
subscriptionController.subscriptionNotifier.removeListener(_onSubscribe);
subscriptionController.removeListener(_onSubscriptionUpdate);
super.dispose();
_settingsSubscription.cancel();
_subscriptionStatusStream?.cancel();
}
bool get subscriptionsAvailable =>
@ -106,29 +94,46 @@ class SubscriptionManagementController extends State<SubscriptionManagement> {
.currentSubscriptionInfo!.currentPlatformMatchesPurchasePlatform;
}
DateTime? get expirationDate =>
subscriptionController.currentSubscriptionInfo?.expirationDate;
DateTime? get subscriptionEndDate =>
subscriptionController.currentSubscriptionInfo?.subscriptionEndDate;
void _onSubscriptionUpdate() => setState(() {});
void _onSubscribe() => showSubscribedSnackbar(context);
Future<void> submitChange(
SubscriptionDetails subscription, {
bool isPromo = false,
}) async {
setState(() => loading = true);
await showFutureLoadingDialog(
context: context,
future: () async => subscriptionController.submitSubscriptionChange(
try {
await subscriptionController.submitSubscriptionChange(
subscription,
context,
isPromo: isPromo,
),
onError: (error, s) {
setState(() => loading = false);
return null;
},
);
if (mounted && loading) {
setState(() => loading = false);
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"subscription_id": subscription.id,
"is_promo": isPromo,
},
);
} finally {
if (mounted) setState(() => loading = false);
}
}
Future<void> onClickCancelSubscription() async {
await SubscriptionManagementRepo.setClickedCancelSubscription();
await launchMangementUrl(ManagementOption.cancel);
if (mounted) setState(() {});
}
Future<void> launchMangementUrl(ManagementOption option) async {
String managementUrl = Environment.stripeManagementUrl;
final String? email =

View file

@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/subscription/pages/change_subscription.dart';
import 'package:fluffychat/pangea/subscription/pages/settings_subscription.dart';
import 'package:fluffychat/pangea/subscription/repo/subscription_management_repo.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
class SettingsSubscriptionView extends StatelessWidget {
@ -25,12 +26,18 @@ class SettingsSubscriptionView extends StatelessWidget {
Column(
children: [
ListTile(
title: Text(L10n.of(context).cancelSubscription),
enabled: controller.showManagementOptions,
onTap: () => controller.launchMangementUrl(
ManagementOption.cancel,
title: Text(
controller.subscriptionEndDate == null
? L10n.of(context).cancelSubscription
: L10n.of(context).enabledRenewal,
),
enabled: controller.showManagementOptions,
onTap: controller.onClickCancelSubscription,
trailing: Icon(
controller.subscriptionEndDate == null
? Icons.cancel_outlined
: Icons.refresh_outlined,
),
trailing: const Icon(Icons.cancel_outlined),
),
const Divider(height: 1),
ListTile(
@ -49,6 +56,42 @@ class SettingsSubscriptionView extends StatelessWidget {
),
enabled: controller.showManagementOptions,
),
if (controller.expirationDate != null) ...[
const Divider(height: 1),
ListTile(
title: Text(
controller.subscriptionEndDate != null
? L10n.of(context).subscriptionEndsOn
: L10n.of(context).subscriptionRenewsOn,
),
subtitle: Text(
DateFormat.yMMMMd().format(
controller.expirationDate!.toLocal(),
),
),
),
if (SubscriptionManagementRepo.getClickedCancelSubscription())
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.info_outline,
size: 20,
),
Flexible(
child: Text(
L10n.of(context).waitForSubscriptionChanges,
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic),
),
),
],
),
),
],
],
),
];
@ -68,16 +111,15 @@ class SettingsSubscriptionView extends StatelessWidget {
child: Column(
children: [
if (isSubscribed == null)
const Center(child: CircularProgressIndicator.adaptive()),
if (isSubscribed != null &&
isSubscribed &&
!controller.showManagementOptions)
const Center(child: CircularProgressIndicator.adaptive())
else if (isSubscribed && !controller.showManagementOptions)
ManagementNotAvailableWarning(
controller: controller,
),
if (isSubscribed != null && !isSubscribed)
)
else if (isSubscribed && controller.showManagementOptions)
...managementButtons
else
ChangeSubscription(controller: controller),
if (controller.showManagementOptions) ...managementButtons,
],
),
),

View file

@ -69,4 +69,22 @@ class SubscriptionManagementRepo {
final int backoff = _getPaywallBackoff() + 1;
await _cache.write(PLocalKey.paywallBackoff, backoff);
}
static Future<void> setClickedCancelSubscription() async {
await _cache.write(
PLocalKey.clickedCancelSubscription,
DateTime.now().toIso8601String(),
);
}
static bool getClickedCancelSubscription() {
final entry = _cache.read(PLocalKey.clickedCancelSubscription);
if (entry == null) return false;
final val = DateTime.tryParse(entry);
return val != null && DateTime.now().difference(val).inSeconds < 60;
}
static Future<void> removeClickedCancelSubscription() async {
await _cache.remove(PLocalKey.clickedCancelSubscription);
}
}

View file

@ -151,7 +151,6 @@ class RCProductsResponseModel {
class RCSubscriptionResponseModel {
String? currentSubscriptionId;
SubscriptionDetails? currentSubscription;
DateTime? expirationDate;
List<String>? allEntitlements;
Map<String, RCSubscription>? allSubscriptions;
@ -159,7 +158,6 @@ class RCSubscriptionResponseModel {
this.currentSubscriptionId,
this.currentSubscription,
this.allEntitlements,
this.expirationDate,
this.allSubscriptions,
});
@ -188,14 +186,6 @@ class RCSubscriptionResponseModel {
}
final String currentSubscriptionId = activeEntitlements[0];
final Map<String, dynamic> currentSubscriptionMetadata =
json['subscriptions'][currentSubscriptionId];
final DateTime expirationDate = DateTime.parse(
currentSubscriptionMetadata['expires_date'],
);
final SubscriptionDetails? currentSubscription =
allProducts?.firstWhereOrNull(
(SubscriptionDetails sub) =>
@ -206,7 +196,6 @@ class RCSubscriptionResponseModel {
return RCSubscriptionResponseModel(
currentSubscription: currentSubscription,
currentSubscriptionId: currentSubscriptionId,
expirationDate: expirationDate,
allEntitlements: activeEntitlements,
allSubscriptions: history,
);