mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: refactor and start integrating AI plans
This commit is contained in:
parent
4c18b2bc9d
commit
792e6f1370
@ -95,7 +95,8 @@ class AppFlowyCloudDeepLink {
|
||||
}
|
||||
|
||||
if (_isPaymentSuccessUri(uri)) {
|
||||
return getIt<SubscriptionSuccessListenable>().onPaymentSuccess();
|
||||
final plan = uri.queryParameters['plan'];
|
||||
return getIt<SubscriptionSuccessListenable>().onPaymentSuccess(plan);
|
||||
}
|
||||
|
||||
return _isAuthCallbackDeepLink(uri).fold(
|
||||
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
|
||||
@ -10,7 +11,10 @@ import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
|
||||
abstract class IUserBackendService {
|
||||
Future<FlowyResult<void, FlowyError>> cancelSubscription(String workspaceId);
|
||||
Future<FlowyResult<void, FlowyError>> cancelSubscription(
|
||||
String workspaceId,
|
||||
SubscriptionPlanPB plan,
|
||||
);
|
||||
Future<FlowyResult<PaymentLinkPB, FlowyError>> createSubscription(
|
||||
String workspaceId,
|
||||
SubscriptionPlanPB plan,
|
||||
@ -228,9 +232,16 @@ class UserBackendService implements IUserBackendService {
|
||||
return UserEventLeaveWorkspace(data).send();
|
||||
}
|
||||
|
||||
static Future<FlowyResult<RepeatedWorkspaceSubscriptionPB, FlowyError>>
|
||||
getWorkspaceSubscriptions() {
|
||||
return UserEventGetWorkspaceSubscriptions().send();
|
||||
static Future<FlowyResult<WorkspaceSubscriptionInfoPB, FlowyError>>
|
||||
getWorkspaceSubscriptionInfo(String workspaceId) {
|
||||
final params = UserWorkspaceIdPB.create()..workspaceId = workspaceId;
|
||||
return UserEventGetWorkspaceSubscriptionInfo(params).send();
|
||||
}
|
||||
|
||||
static Future<FlowyResult<void, FlowyError>>
|
||||
invalidateWorkspaceSubscriptionCache(String workspaceId) {
|
||||
final params = UserWorkspaceIdPB.create()..workspaceId = workspaceId;
|
||||
return UserEventInvalidateWorkspaceSubscriptionInfoCache(params).send();
|
||||
}
|
||||
|
||||
Future<FlowyResult<WorkspaceMemberPB, FlowyError>>
|
||||
@ -250,15 +261,19 @@ class UserBackendService implements IUserBackendService {
|
||||
..recurringInterval = RecurringIntervalPB.Year
|
||||
..workspaceSubscriptionPlan = plan
|
||||
..successUrl =
|
||||
'${getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.base_url}/web/payment-success';
|
||||
'${getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.base_url}/web/payment-success?plan=${plan.toRecognizable()}';
|
||||
return UserEventSubscribeWorkspace(request).send();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<void, FlowyError>> cancelSubscription(
|
||||
String workspaceId,
|
||||
SubscriptionPlanPB plan,
|
||||
) {
|
||||
final request = UserWorkspaceIdPB()..workspaceId = workspaceId;
|
||||
final request = CancelWorkspaceSubscriptionPB()
|
||||
..workspaceId = workspaceId
|
||||
..plan = plan;
|
||||
|
||||
return UserEventCancelWorkspaceSubscription(request).send();
|
||||
}
|
||||
}
|
||||
|
@ -3,18 +3,19 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart';
|
||||
import 'package:appflowy/workspace/application/workspace/workspace_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'settings_billing_bloc.freezed.dart';
|
||||
@ -23,8 +24,12 @@ class SettingsBillingBloc
|
||||
extends Bloc<SettingsBillingEvent, SettingsBillingState> {
|
||||
SettingsBillingBloc({
|
||||
required this.workspaceId,
|
||||
required Int64 userId,
|
||||
}) : super(const _Initial()) {
|
||||
_userService = UserBackendService(userId: userId);
|
||||
_service = WorkspaceService(workspaceId: workspaceId);
|
||||
_successListenable = getIt<SubscriptionSuccessListenable>();
|
||||
_successListenable.addListener(_onPaymentSuccessful);
|
||||
|
||||
on<SettingsBillingEvent>((event, emit) async {
|
||||
await event.when(
|
||||
@ -33,31 +38,19 @@ class SettingsBillingBloc
|
||||
|
||||
FlowyError? error;
|
||||
|
||||
final subscription =
|
||||
(await UserBackendService.getWorkspaceSubscriptions()).fold(
|
||||
(s) =>
|
||||
s.items.firstWhereOrNull((i) => i.workspaceId == workspaceId) ??
|
||||
WorkspaceSubscriptionPB(
|
||||
workspaceId: workspaceId,
|
||||
subscriptionPlan: SubscriptionPlanPB.None,
|
||||
isActive: true,
|
||||
),
|
||||
final subscriptionInfo =
|
||||
(await UserBackendService.getWorkspaceSubscriptionInfo(
|
||||
workspaceId,
|
||||
))
|
||||
.fold(
|
||||
(s) => s,
|
||||
(e) {
|
||||
// Not a Customer yet
|
||||
if (e.code == ErrorCode.InvalidParams) {
|
||||
return WorkspaceSubscriptionPB(
|
||||
workspaceId: workspaceId,
|
||||
subscriptionPlan: SubscriptionPlanPB.None,
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
|
||||
error = e;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
if (subscription == null || error != null) {
|
||||
if (subscriptionInfo == null || error != null) {
|
||||
return emit(SettingsBillingState.error(error: error));
|
||||
}
|
||||
|
||||
@ -66,6 +59,8 @@ class SettingsBillingBloc
|
||||
unawaited(
|
||||
_billingPortalCompleter.future.then(
|
||||
(result) {
|
||||
if (isClosed) return;
|
||||
|
||||
result.fold(
|
||||
(portal) {
|
||||
_billingPortal = portal;
|
||||
@ -84,22 +79,20 @@ class SettingsBillingBloc
|
||||
|
||||
emit(
|
||||
SettingsBillingState.ready(
|
||||
subscription: subscription,
|
||||
subscriptionInfo: subscriptionInfo,
|
||||
billingPortal: _billingPortal,
|
||||
),
|
||||
);
|
||||
},
|
||||
billingPortalFetched: (billingPortal) {
|
||||
state.maybeWhen(
|
||||
billingPortalFetched: (billingPortal) async => state.maybeWhen(
|
||||
orElse: () {},
|
||||
ready: (subscription, _) => emit(
|
||||
ready: (subscriptionInfo, _) => emit(
|
||||
SettingsBillingState.ready(
|
||||
subscription: subscription,
|
||||
subscriptionInfo: subscriptionInfo,
|
||||
billingPortal: billingPortal,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
openCustomerPortal: () async {
|
||||
if (_billingPortalCompleter.isCompleted && _billingPortal != null) {
|
||||
await afLaunchUrlString(_billingPortal!.url);
|
||||
@ -109,21 +102,58 @@ class SettingsBillingBloc
|
||||
await afLaunchUrlString(_billingPortal!.url);
|
||||
}
|
||||
},
|
||||
addSubscription: (plan) async {
|
||||
final result =
|
||||
await _userService.createSubscription(workspaceId, plan);
|
||||
result.fold(
|
||||
(link) => afLaunchUrlString(link.paymentLink),
|
||||
(f) => Log.error(f.msg, f),
|
||||
);
|
||||
},
|
||||
cancelSubscription: (plan) async {
|
||||
await _userService.cancelSubscription(workspaceId, plan);
|
||||
await _onPaymentSuccessful();
|
||||
},
|
||||
paymentSuccessful: () async {
|
||||
final result = await UserBackendService.getWorkspaceSubscriptionInfo(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
final subscriptionInfo = result.toNullable();
|
||||
|
||||
if (subscriptionInfo != null) {
|
||||
emit(
|
||||
SettingsBillingState.ready(
|
||||
subscriptionInfo: subscriptionInfo,
|
||||
billingPortal: _billingPortal,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
late final String workspaceId;
|
||||
late final WorkspaceService _service;
|
||||
late final UserBackendService _userService;
|
||||
final _billingPortalCompleter =
|
||||
Completer<FlowyResult<BillingPortalPB, FlowyError>>();
|
||||
|
||||
BillingPortalPB? _billingPortal;
|
||||
late final SubscriptionSuccessListenable _successListenable;
|
||||
|
||||
Future<void> _fetchBillingPortal() async {
|
||||
final billingPortalResult = await _service.getBillingPortal();
|
||||
_billingPortalCompleter.complete(billingPortalResult);
|
||||
}
|
||||
|
||||
Future<void> _onPaymentSuccessful() async {
|
||||
// Invalidate cache for this workspace
|
||||
await UserBackendService.invalidateWorkspaceSubscriptionCache(workspaceId);
|
||||
|
||||
add(const SettingsBillingEvent.paymentSuccessful());
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@ -133,6 +163,12 @@ class SettingsBillingEvent with _$SettingsBillingEvent {
|
||||
required BillingPortalPB billingPortal,
|
||||
}) = _BillingPortalFetched;
|
||||
const factory SettingsBillingEvent.openCustomerPortal() = _OpenCustomerPortal;
|
||||
const factory SettingsBillingEvent.addSubscription(SubscriptionPlanPB plan) =
|
||||
_AddSubscription;
|
||||
const factory SettingsBillingEvent.cancelSubscription(
|
||||
SubscriptionPlanPB plan,
|
||||
) = _CancelSubscription;
|
||||
const factory SettingsBillingEvent.paymentSuccessful() = _PaymentSuccessful;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@ -148,7 +184,7 @@ class SettingsBillingState extends Equatable with _$SettingsBillingState {
|
||||
}) = _Error;
|
||||
|
||||
const factory SettingsBillingState.ready({
|
||||
required WorkspaceSubscriptionPB subscription,
|
||||
required WorkspaceSubscriptionInfoPB subscriptionInfo,
|
||||
required BillingPortalPB? billingPortal,
|
||||
}) = _Ready;
|
||||
|
||||
|
@ -11,7 +11,6 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
@ -34,7 +33,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
|
||||
final snapshots = await Future.wait([
|
||||
_service.getWorkspaceUsage(),
|
||||
UserBackendService.getWorkspaceSubscriptions(),
|
||||
UserBackendService.getWorkspaceSubscriptionInfo(workspaceId),
|
||||
]);
|
||||
|
||||
FlowyError? error;
|
||||
@ -47,30 +46,24 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
},
|
||||
);
|
||||
|
||||
final subscription = snapshots[1].fold(
|
||||
(s) =>
|
||||
(s as RepeatedWorkspaceSubscriptionPB)
|
||||
.items
|
||||
.firstWhereOrNull((i) => i.workspaceId == workspaceId) ??
|
||||
WorkspaceSubscriptionPB(
|
||||
workspaceId: workspaceId,
|
||||
subscriptionPlan: SubscriptionPlanPB.None,
|
||||
isActive: true,
|
||||
),
|
||||
final subscriptionInfo = snapshots[1].fold(
|
||||
(s) => s as WorkspaceSubscriptionInfoPB,
|
||||
(f) {
|
||||
error = f;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
if (usageResult == null || subscription == null || error != null) {
|
||||
if (usageResult == null ||
|
||||
subscriptionInfo == null ||
|
||||
error != null) {
|
||||
return emit(SettingsPlanState.error(error: error));
|
||||
}
|
||||
|
||||
emit(
|
||||
SettingsPlanState.ready(
|
||||
workspaceUsage: usageResult,
|
||||
subscription: subscription,
|
||||
subscriptionInfo: subscriptionInfo,
|
||||
showSuccessDialog: withShowSuccessful,
|
||||
),
|
||||
);
|
||||
@ -79,7 +72,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
emit(
|
||||
SettingsPlanState.ready(
|
||||
workspaceUsage: usageResult,
|
||||
subscription: subscription,
|
||||
subscriptionInfo: subscriptionInfo,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -100,7 +93,14 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
.mapOrNull(ready: (state) => state)
|
||||
?.copyWith(downgradeProcessing: true);
|
||||
emit(newState ?? state);
|
||||
await _userService.cancelSubscription(workspaceId);
|
||||
|
||||
// We can hardcode the subscription plan here because we cannot cancel addons
|
||||
// on the Plan page
|
||||
await _userService.cancelSubscription(
|
||||
workspaceId,
|
||||
SubscriptionPlanPB.Pro,
|
||||
);
|
||||
|
||||
add(const SettingsPlanEvent.started());
|
||||
},
|
||||
paymentSuccessful: () {
|
||||
@ -120,7 +120,10 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
late final IUserBackendService _userService;
|
||||
late final SubscriptionSuccessListenable _successListenable;
|
||||
|
||||
void _onPaymentSuccessful() {
|
||||
Future<void> _onPaymentSuccessful() async {
|
||||
// Invalidate cache for this workspace
|
||||
await UserBackendService.invalidateWorkspaceSubscriptionCache(workspaceId);
|
||||
|
||||
add(const SettingsPlanEvent.paymentSuccessful());
|
||||
}
|
||||
|
||||
@ -136,9 +139,12 @@ class SettingsPlanEvent with _$SettingsPlanEvent {
|
||||
const factory SettingsPlanEvent.started({
|
||||
@Default(false) bool withShowSuccessful,
|
||||
}) = _Started;
|
||||
|
||||
const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) =
|
||||
_AddSubscription;
|
||||
|
||||
const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription;
|
||||
|
||||
const factory SettingsPlanEvent.paymentSuccessful() = _PaymentSuccessful;
|
||||
}
|
||||
|
||||
@ -154,7 +160,7 @@ class SettingsPlanState with _$SettingsPlanState {
|
||||
|
||||
const factory SettingsPlanState.ready({
|
||||
required WorkspaceUsagePB workspaceUsage,
|
||||
required WorkspaceSubscriptionPB subscription,
|
||||
required WorkspaceSubscriptionInfoPB subscriptionInfo,
|
||||
@Default(false) bool showSuccessDialog,
|
||||
@Default(false) bool downgradeProcessing,
|
||||
}) = _Ready;
|
||||
|
@ -1,26 +1,52 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
extension SubscriptionLabels on WorkspaceSubscriptionPB {
|
||||
String get label => switch (subscriptionPlan) {
|
||||
SubscriptionPlanPB.None =>
|
||||
extension SubscriptionLabels on WorkspaceSubscriptionInfoPB {
|
||||
String get label => switch (plan) {
|
||||
WorkspacePlanPB.FreePlan =>
|
||||
LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(),
|
||||
SubscriptionPlanPB.Pro =>
|
||||
WorkspacePlanPB.ProPlan =>
|
||||
LocaleKeys.settings_planPage_planUsage_currentPlan_proTitle.tr(),
|
||||
SubscriptionPlanPB.Team =>
|
||||
WorkspacePlanPB.TeamPlan =>
|
||||
LocaleKeys.settings_planPage_planUsage_currentPlan_teamTitle.tr(),
|
||||
_ => 'N/A',
|
||||
};
|
||||
|
||||
String get info => switch (subscriptionPlan) {
|
||||
SubscriptionPlanPB.None =>
|
||||
String get info => switch (plan) {
|
||||
WorkspacePlanPB.FreePlan =>
|
||||
LocaleKeys.settings_planPage_planUsage_currentPlan_freeInfo.tr(),
|
||||
SubscriptionPlanPB.Pro =>
|
||||
WorkspacePlanPB.ProPlan =>
|
||||
LocaleKeys.settings_planPage_planUsage_currentPlan_proInfo.tr(),
|
||||
SubscriptionPlanPB.Team =>
|
||||
WorkspacePlanPB.TeamPlan =>
|
||||
LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(),
|
||||
_ => 'N/A',
|
||||
};
|
||||
}
|
||||
|
||||
extension WorkspaceSubscriptionStatusExt on WorkspaceSubscriptionInfoPB {
|
||||
bool get isCanceled =>
|
||||
planSubscription.status == WorkspaceSubscriptionStatusPB.Canceled;
|
||||
}
|
||||
|
||||
extension WorkspaceAddonsExt on WorkspaceSubscriptionInfoPB {
|
||||
bool get hasAIMax =>
|
||||
addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiMax);
|
||||
|
||||
bool get hasAIOnDevice =>
|
||||
addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiLocal);
|
||||
}
|
||||
|
||||
/// These have to match [SubscriptionSuccessListenable.subscribedPlan] labels
|
||||
extension ToRecognizable on SubscriptionPlanPB {
|
||||
String? toRecognizable() => switch (this) {
|
||||
SubscriptionPlanPB.None => 'free',
|
||||
SubscriptionPlanPB.Pro => 'pro',
|
||||
SubscriptionPlanPB.Team => 'team',
|
||||
SubscriptionPlanPB.AiMax => 'ai_max',
|
||||
SubscriptionPlanPB.AiLocal => 'ai_local',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ final _storageNumberFormat = NumberFormat()
|
||||
|
||||
extension PresentableUsage on WorkspaceUsagePB {
|
||||
String get totalBlobInGb =>
|
||||
(totalBlobBytesLimit.toInt() / 1024 / 1024 / 1024).round().toString();
|
||||
(storageBytesLimit.toInt() / 1024 / 1024 / 1024).round().toString();
|
||||
|
||||
/// We use [NumberFormat] to format the current blob in GB.
|
||||
///
|
||||
@ -16,5 +16,5 @@ extension PresentableUsage on WorkspaceUsagePB {
|
||||
/// And [NumberFormat.minimumFractionDigits] is set to 0.
|
||||
///
|
||||
String get currentBlobInGb =>
|
||||
_storageNumberFormat.format(totalBlobBytes.toInt() / 1024 / 1024 / 1024);
|
||||
_storageNumberFormat.format(storageBytes.toInt() / 1024 / 1024 / 1024);
|
||||
}
|
||||
|
@ -1,7 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
|
||||
class SubscriptionSuccessListenable extends ChangeNotifier {
|
||||
SubscriptionSuccessListenable();
|
||||
|
||||
void onPaymentSuccess() => notifyListeners();
|
||||
String? _plan;
|
||||
|
||||
SubscriptionPlanPB? get subscribedPlan => switch (_plan) {
|
||||
'free' => SubscriptionPlanPB.None,
|
||||
'pro' => SubscriptionPlanPB.Pro,
|
||||
'team' => SubscriptionPlanPB.Team,
|
||||
'ai_max' => SubscriptionPlanPB.AiMax,
|
||||
'ai_local' => SubscriptionPlanPB.AiLocal,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
void onPaymentSuccess(String? plan) {
|
||||
_plan = plan;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/workspace/application/settings/billing/settings_billing_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart';
|
||||
@ -9,6 +8,7 @@ import 'package:appflowy/workspace/presentation/settings/shared/settings_body.da
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||
@ -16,7 +16,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../generated/locale_keys.g.dart';
|
||||
|
||||
const _buttonsMinWidth = 116.0;
|
||||
const _buttonsMinWidth = 100.0;
|
||||
|
||||
class SettingsBillingView extends StatelessWidget {
|
||||
const SettingsBillingView({
|
||||
@ -31,8 +31,10 @@ class SettingsBillingView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<SettingsBillingBloc>(
|
||||
create: (context) => SettingsBillingBloc(workspaceId: workspaceId)
|
||||
..add(const SettingsBillingEvent.started()),
|
||||
create: (_) => SettingsBillingBloc(
|
||||
workspaceId: workspaceId,
|
||||
userId: user.id,
|
||||
)..add(const SettingsBillingEvent.started()),
|
||||
child: BlocBuilder<SettingsBillingBloc, SettingsBillingState>(
|
||||
builder: (context, state) {
|
||||
return state.map(
|
||||
@ -59,8 +61,7 @@ class SettingsBillingView extends StatelessWidget {
|
||||
},
|
||||
ready: (state) {
|
||||
final billingPortalEnabled =
|
||||
state.subscription.subscriptionPlan !=
|
||||
SubscriptionPlanPB.None;
|
||||
state.subscriptionInfo.plan != WorkspacePlanPB.FreePlan;
|
||||
|
||||
return SettingsBody(
|
||||
title: LocaleKeys.settings_billingPage_title.tr(),
|
||||
@ -73,10 +74,10 @@ class SettingsBillingView extends StatelessWidget {
|
||||
context,
|
||||
workspaceId,
|
||||
user.id,
|
||||
state.subscription,
|
||||
state.subscriptionInfo,
|
||||
),
|
||||
fontWeight: FontWeight.w500,
|
||||
label: state.subscription.label,
|
||||
label: state.subscriptionInfo.label,
|
||||
buttonLabel: LocaleKeys
|
||||
.settings_billingPage_plan_planButtonLabel
|
||||
.tr(),
|
||||
@ -127,33 +128,31 @@ class SettingsBillingView extends StatelessWidget {
|
||||
SettingsCategory(
|
||||
title: LocaleKeys.settings_billingPage_addons_title.tr(),
|
||||
children: [
|
||||
SingleSettingAction(
|
||||
buttonType: SingleSettingsButtonType.highlight,
|
||||
_AITile(
|
||||
plan: SubscriptionPlanPB.AiMax,
|
||||
label: LocaleKeys
|
||||
.settings_billingPage_addons_aiMax_label
|
||||
.tr(),
|
||||
description: LocaleKeys
|
||||
.settings_billingPage_addons_aiMax_description
|
||||
.tr(),
|
||||
buttonLabel: LocaleKeys
|
||||
.settings_billingPage_addons_aiMax_buttonLabel
|
||||
.tr(),
|
||||
fontWeight: FontWeight.w500,
|
||||
minWidth: _buttonsMinWidth,
|
||||
subscriptionInfo:
|
||||
state.subscriptionInfo.addOns.firstWhereOrNull(
|
||||
(a) => a.type == WorkspaceAddOnPBType.AddOnAiMax,
|
||||
),
|
||||
SingleSettingAction(
|
||||
buttonType: SingleSettingsButtonType.highlight,
|
||||
),
|
||||
_AITile(
|
||||
plan: SubscriptionPlanPB.AiLocal,
|
||||
label: LocaleKeys
|
||||
.settings_billingPage_addons_aiOnDevice_label
|
||||
.tr(),
|
||||
description: LocaleKeys
|
||||
.settings_billingPage_addons_aiOnDevice_description
|
||||
.tr(),
|
||||
buttonLabel: LocaleKeys
|
||||
.settings_billingPage_addons_aiOnDevice_buttonLabel
|
||||
.tr(),
|
||||
fontWeight: FontWeight.w500,
|
||||
minWidth: _buttonsMinWidth,
|
||||
subscriptionInfo:
|
||||
state.subscriptionInfo.addOns.firstWhereOrNull(
|
||||
(a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -170,7 +169,7 @@ class SettingsBillingView extends StatelessWidget {
|
||||
BuildContext context,
|
||||
String workspaceId,
|
||||
Int64 userId,
|
||||
WorkspaceSubscriptionPB subscription,
|
||||
WorkspaceSubscriptionInfoPB subscriptionInfo,
|
||||
) =>
|
||||
showDialog<bool?>(
|
||||
context: context,
|
||||
@ -180,7 +179,7 @@ class SettingsBillingView extends StatelessWidget {
|
||||
..add(const SettingsPlanEvent.started()),
|
||||
child: SettingsPlanComparisonDialog(
|
||||
workspaceId: workspaceId,
|
||||
subscription: subscription,
|
||||
subscriptionInfo: subscriptionInfo,
|
||||
),
|
||||
),
|
||||
).then((didChangePlan) {
|
||||
@ -191,3 +190,48 @@ class SettingsBillingView extends StatelessWidget {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class _AITile extends StatelessWidget {
|
||||
const _AITile({
|
||||
required this.label,
|
||||
required this.description,
|
||||
required this.plan,
|
||||
this.subscriptionInfo,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String description;
|
||||
final SubscriptionPlanPB plan;
|
||||
final WorkspaceAddOnPB? subscriptionInfo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCanceled = subscriptionInfo?.addOnSubscription.status ==
|
||||
WorkspaceSubscriptionStatusPB.Canceled;
|
||||
|
||||
return SingleSettingAction(
|
||||
label: label,
|
||||
description: description,
|
||||
buttonLabel: subscriptionInfo != null
|
||||
? isCanceled
|
||||
? LocaleKeys.settings_billingPage_addons_renewLabel.tr()
|
||||
: LocaleKeys.settings_billingPage_addons_removeLabel.tr()
|
||||
: LocaleKeys.settings_billingPage_addons_addLabel.tr(),
|
||||
fontWeight: FontWeight.w500,
|
||||
minWidth: _buttonsMinWidth,
|
||||
onPressed: () {
|
||||
if (subscriptionInfo != null && !isCanceled) {
|
||||
// Cancel the addon
|
||||
context
|
||||
.read<SettingsBillingBloc>()
|
||||
.add(SettingsBillingEvent.cancelSubscription(plan));
|
||||
} else {
|
||||
// Add/renew the addon
|
||||
context
|
||||
.read<SettingsBillingBloc>()
|
||||
.add(SettingsBillingEvent.addSubscription(plan));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,28 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../generated/locale_keys.g.dart';
|
||||
|
||||
class SettingsPlanComparisonDialog extends StatefulWidget {
|
||||
const SettingsPlanComparisonDialog({
|
||||
super.key,
|
||||
required this.workspaceId,
|
||||
required this.subscription,
|
||||
required this.subscriptionInfo,
|
||||
});
|
||||
|
||||
final String workspaceId;
|
||||
final WorkspaceSubscriptionPB subscription;
|
||||
final WorkspaceSubscriptionInfoPB subscriptionInfo;
|
||||
|
||||
@override
|
||||
State<SettingsPlanComparisonDialog> createState() =>
|
||||
@ -34,7 +35,7 @@ class _SettingsPlanComparisonDialogState
|
||||
final horizontalController = ScrollController();
|
||||
final verticalController = ScrollController();
|
||||
|
||||
late WorkspaceSubscriptionPB currentSubscription = widget.subscription;
|
||||
late WorkspaceSubscriptionInfoPB currentInfo = widget.subscriptionInfo;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@ -58,19 +59,17 @@ class _SettingsPlanComparisonDialogState
|
||||
if (readyState.showSuccessDialog) {
|
||||
SettingsAlertDialog(
|
||||
title: LocaleKeys.settings_comparePlanDialog_paymentSuccess_title
|
||||
.tr(args: [readyState.subscription.label]),
|
||||
.tr(args: [readyState.subscriptionInfo.label]),
|
||||
subtitle: LocaleKeys
|
||||
.settings_comparePlanDialog_paymentSuccess_description
|
||||
.tr(args: [readyState.subscription.label]),
|
||||
.tr(args: [readyState.subscriptionInfo.label]),
|
||||
hideCancelButton: true,
|
||||
confirm: Navigator.of(context).pop,
|
||||
confirmLabel: LocaleKeys.button_close.tr(),
|
||||
).show(context);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
currentSubscription = readyState.subscription;
|
||||
});
|
||||
setState(() => currentInfo = readyState.subscriptionInfo);
|
||||
},
|
||||
builder: (context, state) => FlowyDialog(
|
||||
constraints: const BoxConstraints(maxWidth: 784, minWidth: 674),
|
||||
@ -90,8 +89,7 @@ class _SettingsPlanComparisonDialogState
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(
|
||||
currentSubscription.subscriptionPlan !=
|
||||
widget.subscription.subscriptionPlan,
|
||||
currentInfo.plan != widget.subscriptionInfo.plan,
|
||||
),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
@ -170,32 +168,30 @@ class _SettingsPlanComparisonDialogState
|
||||
.settings_comparePlanDialog_freePlan_priceInfo
|
||||
.tr(),
|
||||
cells: _freeLabels,
|
||||
isCurrent: currentSubscription.subscriptionPlan ==
|
||||
SubscriptionPlanPB.None,
|
||||
isCurrent:
|
||||
currentInfo.plan == WorkspacePlanPB.FreePlan,
|
||||
canDowngrade:
|
||||
currentSubscription.subscriptionPlan !=
|
||||
SubscriptionPlanPB.None,
|
||||
currentCanceled: currentSubscription.hasCanceled ||
|
||||
currentInfo.plan != WorkspacePlanPB.FreePlan,
|
||||
currentCanceled: currentInfo.isCanceled ||
|
||||
(context
|
||||
.watch<SettingsPlanBloc>()
|
||||
.state
|
||||
.mapOrNull(
|
||||
loading: (_) => true,
|
||||
ready: (state) =>
|
||||
state.downgradeProcessing,
|
||||
ready: (s) => s.downgradeProcessing,
|
||||
) ??
|
||||
false),
|
||||
onSelected: () async {
|
||||
if (currentSubscription.subscriptionPlan ==
|
||||
SubscriptionPlanPB.None ||
|
||||
currentSubscription.hasCanceled) {
|
||||
if (currentInfo.plan ==
|
||||
WorkspacePlanPB.FreePlan ||
|
||||
currentInfo.isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await SettingsAlertDialog(
|
||||
title: LocaleKeys
|
||||
.settings_comparePlanDialog_downgradeDialog_title
|
||||
.tr(args: [currentSubscription.label]),
|
||||
.tr(args: [currentInfo.label]),
|
||||
subtitle: LocaleKeys
|
||||
.settings_comparePlanDialog_downgradeDialog_description
|
||||
.tr(),
|
||||
@ -228,11 +224,11 @@ class _SettingsPlanComparisonDialogState
|
||||
.settings_comparePlanDialog_proPlan_priceInfo
|
||||
.tr(),
|
||||
cells: _proLabels,
|
||||
isCurrent: currentSubscription.subscriptionPlan ==
|
||||
SubscriptionPlanPB.Pro,
|
||||
canUpgrade: currentSubscription.subscriptionPlan ==
|
||||
SubscriptionPlanPB.None,
|
||||
currentCanceled: currentSubscription.hasCanceled,
|
||||
isCurrent:
|
||||
currentInfo.plan == WorkspacePlanPB.ProPlan,
|
||||
canUpgrade:
|
||||
currentInfo.plan == WorkspacePlanPB.FreePlan,
|
||||
currentCanceled: currentInfo.isCanceled,
|
||||
onSelected: () =>
|
||||
context.read<SettingsPlanBloc>().add(
|
||||
const SettingsPlanEvent.addSubscription(
|
||||
|
@ -68,40 +68,74 @@ class SettingsPlanView extends StatelessWidget {
|
||||
children: [
|
||||
_PlanUsageSummary(
|
||||
usage: state.workspaceUsage,
|
||||
subscription: state.subscription,
|
||||
subscriptionInfo: state.subscriptionInfo,
|
||||
),
|
||||
const VSpace(16),
|
||||
_CurrentPlanBox(subscription: state.subscription),
|
||||
_CurrentPlanBox(subscriptionInfo: state.subscriptionInfo),
|
||||
const VSpace(16),
|
||||
// TODO(Mathias): Localize and add business logic
|
||||
FlowyText(
|
||||
'Add-ons',
|
||||
LocaleKeys.settings_planPage_planUsage_addons_title.tr(),
|
||||
fontSize: 18,
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const VSpace(8),
|
||||
const Row(
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: _AddOnBox(
|
||||
title: "AI Max",
|
||||
description:
|
||||
"Unlimited AI models and access to advanced models",
|
||||
price: "US\$8",
|
||||
priceInfo: "billed annually or \$10 billed monthly",
|
||||
buttonText: "Add AI Max",
|
||||
title: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiMax_title
|
||||
.tr(),
|
||||
description: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiMax_description
|
||||
.tr(),
|
||||
price: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiMax_price
|
||||
.tr(args: ['\$8']),
|
||||
priceInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiMax_priceInfo
|
||||
.tr(),
|
||||
billingInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiMax_billingInfo
|
||||
.tr(args: ['\$10']),
|
||||
buttonText: state.subscriptionInfo.hasAIMax
|
||||
? LocaleKeys
|
||||
.settings_planPage_planUsage_addons_activeLabel
|
||||
.tr()
|
||||
: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_addLabel
|
||||
.tr(),
|
||||
isActive: state.subscriptionInfo.hasAIMax,
|
||||
),
|
||||
),
|
||||
HSpace(8),
|
||||
const HSpace(8),
|
||||
Flexible(
|
||||
child: _AddOnBox(
|
||||
title: "AI Offline",
|
||||
description:
|
||||
"Local AI on your own hardware for ultimate privacy",
|
||||
price: "US\$8",
|
||||
priceInfo: "billed annually or \$10 billed monthly",
|
||||
buttonText: "Add AI Offline",
|
||||
title: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_title
|
||||
.tr(),
|
||||
description: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_description
|
||||
.tr(),
|
||||
price: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_price
|
||||
.tr(args: ['\$8']),
|
||||
priceInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_priceInfo
|
||||
.tr(),
|
||||
billingInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_billingInfo
|
||||
.tr(args: ['\$10']),
|
||||
buttonText: state.subscriptionInfo.hasAIOnDevice
|
||||
? LocaleKeys
|
||||
.settings_planPage_planUsage_addons_activeLabel
|
||||
.tr()
|
||||
: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_addLabel
|
||||
.tr(),
|
||||
isActive: state.subscriptionInfo.hasAIOnDevice,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -116,9 +150,9 @@ class SettingsPlanView extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _CurrentPlanBox extends StatefulWidget {
|
||||
const _CurrentPlanBox({required this.subscription});
|
||||
const _CurrentPlanBox({required this.subscriptionInfo});
|
||||
|
||||
final WorkspaceSubscriptionPB subscription;
|
||||
final WorkspaceSubscriptionInfoPB subscriptionInfo;
|
||||
|
||||
@override
|
||||
State<_CurrentPlanBox> createState() => _CurrentPlanBoxState();
|
||||
@ -161,13 +195,13 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
|
||||
children: [
|
||||
const VSpace(4),
|
||||
FlowyText.semibold(
|
||||
widget.subscription.label,
|
||||
widget.subscriptionInfo.label,
|
||||
fontSize: 24,
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
),
|
||||
const VSpace(8),
|
||||
FlowyText.regular(
|
||||
widget.subscription.info,
|
||||
widget.subscriptionInfo.info,
|
||||
fontSize: 16,
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
maxLines: 3,
|
||||
@ -189,7 +223,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
|
||||
onPressed: () => _openPricingDialog(
|
||||
context,
|
||||
context.read<SettingsPlanBloc>().workspaceId,
|
||||
widget.subscription,
|
||||
widget.subscriptionInfo,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -198,7 +232,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.subscription.hasCanceled) ...[
|
||||
if (widget.subscriptionInfo.isCanceled) ...[
|
||||
const VSpace(12),
|
||||
FlowyText(
|
||||
LocaleKeys
|
||||
@ -245,7 +279,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
|
||||
String _canceledDate(BuildContext context) {
|
||||
final appearance = context.read<AppearanceSettingsCubit>().state;
|
||||
return appearance.dateFormat.formatDate(
|
||||
widget.subscription.canceledAt.toDateTime(),
|
||||
widget.subscriptionInfo.planSubscription.endDate.toDateTime(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
@ -253,7 +287,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
|
||||
void _openPricingDialog(
|
||||
BuildContext context,
|
||||
String workspaceId,
|
||||
WorkspaceSubscriptionPB subscription,
|
||||
WorkspaceSubscriptionInfoPB subscriptionInfo,
|
||||
) =>
|
||||
showDialog(
|
||||
context: context,
|
||||
@ -261,7 +295,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
|
||||
value: planBloc,
|
||||
child: SettingsPlanComparisonDialog(
|
||||
workspaceId: workspaceId,
|
||||
subscription: subscription,
|
||||
subscriptionInfo: subscriptionInfo,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -270,11 +304,11 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
|
||||
class _PlanUsageSummary extends StatelessWidget {
|
||||
const _PlanUsageSummary({
|
||||
required this.usage,
|
||||
required this.subscription,
|
||||
required this.subscriptionInfo,
|
||||
});
|
||||
|
||||
final WorkspaceUsagePB usage;
|
||||
final WorkspaceSubscriptionPB subscription;
|
||||
final WorkspaceSubscriptionInfoPB subscriptionInfo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -294,8 +328,8 @@ class _PlanUsageSummary extends StatelessWidget {
|
||||
Expanded(
|
||||
child: _UsageBox(
|
||||
title: LocaleKeys.settings_planPage_planUsage_storageLabel.tr(),
|
||||
replacementText: subscription.subscriptionPlan ==
|
||||
SubscriptionPlanPB.Pro
|
||||
replacementText: subscriptionInfo.plan ==
|
||||
WorkspacePlanPB.ProPlan
|
||||
? LocaleKeys.settings_planPage_planUsage_storageUnlimited
|
||||
.tr()
|
||||
: null,
|
||||
@ -305,8 +339,8 @@ class _PlanUsageSummary extends StatelessWidget {
|
||||
usage.totalBlobInGb,
|
||||
],
|
||||
),
|
||||
value: usage.totalBlobBytes.toInt() /
|
||||
usage.totalBlobBytesLimit.toInt(),
|
||||
value: usage.storageBytes.toInt() /
|
||||
usage.storageBytesLimit.toInt(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
@ -330,12 +364,11 @@ class _PlanUsageSummary extends StatelessWidget {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (subscription.subscriptionPlan == SubscriptionPlanPB.None) ...[
|
||||
if (subscriptionInfo.plan == WorkspacePlanPB.FreePlan) ...[
|
||||
_ToggleMore(
|
||||
value: false,
|
||||
label:
|
||||
LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(),
|
||||
subscription: subscription,
|
||||
badgeLabel:
|
||||
LocaleKeys.settings_planPage_planUsage_proBadge.tr(),
|
||||
onTap: () async {
|
||||
@ -407,14 +440,12 @@ class _ToggleMore extends StatefulWidget {
|
||||
const _ToggleMore({
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.subscription,
|
||||
this.badgeLabel,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final String label;
|
||||
final WorkspaceSubscriptionPB subscription;
|
||||
final String? badgeLabel;
|
||||
final Future<void> Function()? onTap;
|
||||
|
||||
@ -535,14 +566,18 @@ class _AddOnBox extends StatelessWidget {
|
||||
required this.description,
|
||||
required this.price,
|
||||
required this.priceInfo,
|
||||
required this.billingInfo,
|
||||
required this.buttonText,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final String price;
|
||||
final String priceInfo;
|
||||
final String billingInfo;
|
||||
final String buttonText;
|
||||
final bool isActive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -553,7 +588,12 @@ class _AddOnBox extends StatelessWidget {
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFBDBDBD)),
|
||||
border: Border.all(
|
||||
color: isActive ? const Color(0xFF9C00FB) : const Color(0xFFBDBDBD),
|
||||
),
|
||||
color: isActive
|
||||
? const Color(0xFFF7F8FC).withOpacity(0.05)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
@ -578,7 +618,7 @@ class _AddOnBox extends StatelessWidget {
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
),
|
||||
FlowyText(
|
||||
'/user per month',
|
||||
priceInfo,
|
||||
fontSize: 11,
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
),
|
||||
@ -587,7 +627,7 @@ class _AddOnBox extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
priceInfo,
|
||||
billingInfo,
|
||||
color: AFThemeExtension.of(context).secondaryTextColor,
|
||||
fontSize: 11,
|
||||
maxLines: 2,
|
||||
@ -598,19 +638,28 @@ class _AddOnBox extends StatelessWidget {
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
FlowyTextButton(
|
||||
Expanded(
|
||||
child: FlowyTextButton(
|
||||
buttonText,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
|
||||
fillColor: Colors.transparent,
|
||||
fillColor:
|
||||
isActive ? const Color(0xFFE8E2EE) : Colors.transparent,
|
||||
constraints: const BoxConstraints(minWidth: 115),
|
||||
radius: Corners.s16Border,
|
||||
hoverColor: const Color(0xFF5C3699),
|
||||
hoverColor: isActive
|
||||
? const Color(0xFFE8E2EE)
|
||||
: const Color(0xFF5C3699),
|
||||
fontColor: const Color(0xFF5C3699),
|
||||
fontHoverColor: Colors.white,
|
||||
borderColor: const Color(0xFF5C3699),
|
||||
fontHoverColor:
|
||||
isActive ? const Color(0xFF5C3699) : Colors.white,
|
||||
borderColor: isActive
|
||||
? const Color(0xFFE8E2EE)
|
||||
: const Color(0xFF5C3699),
|
||||
fontSize: 12,
|
||||
onPressed: () {},
|
||||
onPressed: isActive ? null : () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -213,7 +213,7 @@ class FlowyTextButton extends StatelessWidget {
|
||||
child = ConstrainedBox(
|
||||
constraints: constraints,
|
||||
child: TextButton(
|
||||
onPressed: onPressed ?? () {},
|
||||
onPressed: onPressed,
|
||||
focusNode: FocusNode(skipTraversal: onPressed == null),
|
||||
style: ButtonStyle(
|
||||
overlayColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||
|
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.31665 11.5668L12.0166 6.86683L11.0833 5.9335L7.31665 9.70016L5.41665 7.80016L4.48331 8.7335L7.31665 11.5668ZM8.24998 15.1668C7.32776 15.1668 6.46109 14.9918 5.64998 14.6418C4.83887 14.2918 4.13331 13.8168 3.53331 13.2168C2.93331 12.6168 2.45831 11.9113 2.10831 11.1002C1.75831 10.2891 1.58331 9.42238 1.58331 8.50016C1.58331 7.57794 1.75831 6.71127 2.10831 5.90016C2.45831 5.08905 2.93331 4.3835 3.53331 3.7835C4.13331 3.1835 4.83887 2.7085 5.64998 2.3585C6.46109 2.0085 7.32776 1.8335 8.24998 1.8335C9.1722 1.8335 10.0389 2.0085 10.85 2.3585C11.6611 2.7085 12.3666 3.1835 12.9666 3.7835C13.5666 4.3835 14.0416 5.08905 14.3916 5.90016C14.7416 6.71127 14.9166 7.57794 14.9166 8.50016C14.9166 9.42238 14.7416 10.2891 14.3916 11.1002C14.0416 11.9113 13.5666 12.6168 12.9666 13.2168C12.3666 13.8168 11.6611 14.2918 10.85 14.6418C10.0389 14.9918 9.1722 15.1668 8.24998 15.1668ZM8.24998 13.8335C9.73887 13.8335 11 13.3168 12.0333 12.2835C13.0666 11.2502 13.5833 9.98905 13.5833 8.50016C13.5833 7.01127 13.0666 5.75016 12.0333 4.71683C11 3.6835 9.73887 3.16683 8.24998 3.16683C6.76109 3.16683 5.49998 3.6835 4.46665 4.71683C3.43331 5.75016 2.91665 7.01127 2.91665 8.50016C2.91665 9.98905 3.43331 11.2502 4.46665 12.2835C5.49998 13.3168 6.76109 13.8335 8.24998 13.8335Z" fill="#653E8C"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
@ -666,6 +666,25 @@
|
||||
"upgrade": "Change plan",
|
||||
"canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}."
|
||||
},
|
||||
"addons": {
|
||||
"title": "Add-ons",
|
||||
"addLabel": "Add",
|
||||
"activeLabel": "Added",
|
||||
"aiMax": {
|
||||
"title": "AI Max",
|
||||
"description": "Unlock unlimited AI",
|
||||
"price": "US{}",
|
||||
"priceInfo": "/user per month",
|
||||
"billingInfo": "billed annually or US{} biled monthly"
|
||||
},
|
||||
"aiOnDevice": {
|
||||
"title": "AI On-device",
|
||||
"description": "AI offline on your device",
|
||||
"price": "US{}",
|
||||
"priceInfo": "/user per month",
|
||||
"billingInfo": "billed annually or US{} biled monthly"
|
||||
}
|
||||
},
|
||||
"deal": {
|
||||
"bannerLabel": "New year deal!",
|
||||
"title": "Grow your team!",
|
||||
@ -692,15 +711,16 @@
|
||||
},
|
||||
"addons": {
|
||||
"title": "Add-ons",
|
||||
"addLabel": "Add",
|
||||
"removeLabel": "Remove",
|
||||
"renewLabel": "Renew",
|
||||
"aiMax": {
|
||||
"label": "AI Max",
|
||||
"description": "US$8 /user per month billed annually or US$10 billed monthly",
|
||||
"buttonLabel": "Add AI Max"
|
||||
"description": "US$8 /user per month billed annually or US$10 billed monthly"
|
||||
},
|
||||
"aiOnDevice": {
|
||||
"label": "AI On-device",
|
||||
"description": "US$8 /user per month billed annually or US$10 billed monthly",
|
||||
"buttonLabel": "Add AI On-device"
|
||||
"description": "US$8 /user per month billed annually or US$10 billed monthly"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -2033,7 +2053,6 @@
|
||||
"upgrade": "Update",
|
||||
"upgradeYourSpace": "Create multiple Spaces",
|
||||
"quicklySwitch": "Quickly switch to the next space",
|
||||
|
||||
"duplicate": "Duplicate Space",
|
||||
"movePageToSpace": "Move page to space",
|
||||
"switchSpace": "Switch space"
|
||||
|
@ -19,9 +19,9 @@ use flowy_server_pub::af_cloud_config::AFCloudConfiguration;
|
||||
use flowy_server_pub::AuthenticatorType;
|
||||
use flowy_user::entities::{
|
||||
AuthenticatorPB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB,
|
||||
OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, RepeatedWorkspaceSubscriptionPB,
|
||||
SignInUrlPB, SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB,
|
||||
UpdateUserProfilePayloadPB, UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB,
|
||||
OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB,
|
||||
SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB,
|
||||
UserWorkspaceIdPB, UserWorkspacePB,
|
||||
};
|
||||
use flowy_user::errors::{FlowyError, FlowyResult};
|
||||
use flowy_user::event_map::UserEvent;
|
||||
@ -315,14 +315,6 @@ impl EventIntegrationTest {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn get_workspace_subscriptions(&self) -> RepeatedWorkspaceSubscriptionPB {
|
||||
EventBuilder::new(self.clone())
|
||||
.event(UserEvent::GetWorkspaceSubscriptions)
|
||||
.async_send()
|
||||
.await
|
||||
.parse::<RepeatedWorkspaceSubscriptionPB>()
|
||||
}
|
||||
|
||||
pub async fn leave_workspace(&self, workspace_id: &str) {
|
||||
let payload = UserWorkspaceIdPB {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
|
@ -240,18 +240,3 @@ async fn af_cloud_different_open_same_workspace_test() {
|
||||
assert_eq!(views.len(), 1, "only get: {:?}", views); // Expecting two views.
|
||||
assert_eq!(views[0].name, "Getting started");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn af_cloud_get_workspace_subscriptions_test() {
|
||||
user_localhost_af_cloud().await;
|
||||
|
||||
let test = EventIntegrationTest::new().await;
|
||||
|
||||
let workspaces = test.get_all_workspaces().await.items;
|
||||
let first_workspace_id = workspaces[0].workspace_id.as_str();
|
||||
assert_eq!(workspaces.len(), 1);
|
||||
|
||||
let subscriptions = test.get_workspace_subscriptions().await;
|
||||
assert_eq!(subscriptions.items.len(), 1);
|
||||
assert_eq!(subscriptions.items[0].workspace_id, first_workspace_id);
|
||||
}
|
||||
|
@ -2,10 +2,9 @@
|
||||
CREATE TABLE workspace_subscriptions_table (
|
||||
workspace_id TEXT NOT NULL,
|
||||
subscription_plan INTEGER NOT NULL,
|
||||
recurring_interval INTEGER NOT NULL,
|
||||
is_active BOOLEAN NOT NULL,
|
||||
has_canceled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
canceled_at TIMESTAMP,
|
||||
workspace_status INTEGER NOT NULL,
|
||||
end_date TIMESTAMP,
|
||||
addons TEXT NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (workspace_id)
|
||||
);
|
@ -118,10 +118,9 @@ diesel::table! {
|
||||
workspace_subscriptions_table (workspace_id) {
|
||||
workspace_id -> Text,
|
||||
subscription_plan -> BigInt,
|
||||
recurring_interval -> BigInt,
|
||||
is_active -> Bool,
|
||||
has_canceled -> Bool,
|
||||
canceled_at -> Nullable<BigInt>,
|
||||
workspace_status -> BigInt,
|
||||
end_date -> BigInt,
|
||||
addons -> Text,
|
||||
updated_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
use chrono::Utc;
|
||||
use client_api::entity::billing_dto::{
|
||||
RecurringInterval, SubscriptionPlan, SubscriptionStatus, WorkspaceSubscriptionStatus,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
use validator::Validate;
|
||||
|
||||
@ -9,6 +11,8 @@ use flowy_user_pub::cloud::{AFWorkspaceSettings, AFWorkspaceSettingsChange};
|
||||
use flowy_user_pub::entities::{Role, WorkspaceInvitation, WorkspaceMember, WorkspaceSubscription};
|
||||
use lib_infra::validator_fn::required_not_empty_str;
|
||||
|
||||
use crate::services::sqlite_sql::workspace_sql::WorkspaceSubscriptionsTable;
|
||||
|
||||
#[derive(ProtoBuf, Default, Clone)]
|
||||
pub struct WorkspaceMemberPB {
|
||||
#[pb(index = 1)]
|
||||
@ -265,17 +269,28 @@ impl From<RecurringInterval> for RecurringIntervalPB {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ProtoBuf_Enum, Clone, Default, Debug)]
|
||||
#[derive(ProtoBuf_Enum, Clone, Default, Debug, Serialize, Deserialize)]
|
||||
pub enum SubscriptionPlanPB {
|
||||
#[default]
|
||||
None = 0,
|
||||
Pro = 1,
|
||||
Team = 2,
|
||||
|
||||
// Add-ons
|
||||
AiMax = 3,
|
||||
AiLocal = 4,
|
||||
}
|
||||
|
||||
impl From<WorkspacePlanPB> for SubscriptionPlanPB {
|
||||
fn from(value: WorkspacePlanPB) -> Self {
|
||||
match value {
|
||||
WorkspacePlanPB::FreePlan => SubscriptionPlanPB::None,
|
||||
WorkspacePlanPB::ProPlan => SubscriptionPlanPB::Pro,
|
||||
WorkspacePlanPB::TeamPlan => SubscriptionPlanPB::Team,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SubscriptionPlanPB> for SubscriptionPlan {
|
||||
fn from(value: SubscriptionPlanPB) -> Self {
|
||||
match value {
|
||||
@ -467,30 +482,55 @@ pub struct WorkspaceSubscriptionInfoPB {
|
||||
pub add_ons: Vec<WorkspaceAddOnPB>,
|
||||
}
|
||||
|
||||
impl WorkspaceSubscriptionInfoPB {
|
||||
pub fn default_from_workspace_id(workspace_id: String) -> Self {
|
||||
Self {
|
||||
plan: WorkspacePlanPB::FreePlan,
|
||||
plan_subscription: WorkspaceSubscriptionV2PB {
|
||||
workspace_id,
|
||||
subscription_plan: SubscriptionPlanPB::None,
|
||||
status: WorkspaceSubscriptionStatusPB::Active,
|
||||
end_date: 0,
|
||||
},
|
||||
add_ons: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<WorkspaceSubscriptionStatus>> for WorkspaceSubscriptionInfoPB {
|
||||
fn from(subs: Vec<WorkspaceSubscriptionStatus>) -> Self {
|
||||
let mut plan = WorkspacePlanPB::WorkspacePlanFree;
|
||||
let mut plan = WorkspacePlanPB::FreePlan;
|
||||
let mut plan_subscription = WorkspaceSubscriptionV2PB::default();
|
||||
let mut add_ons = Vec::new();
|
||||
for sub in subs {
|
||||
match sub.workspace_plan {
|
||||
SubscriptionPlan::Free => {
|
||||
plan = WorkspacePlanPB::WorkspacePlanFree;
|
||||
plan = WorkspacePlanPB::FreePlan;
|
||||
},
|
||||
SubscriptionPlan::Pro => {
|
||||
plan = WorkspacePlanPB::WorkspacePlanPro;
|
||||
plan = WorkspacePlanPB::ProPlan;
|
||||
plan_subscription = sub.into();
|
||||
},
|
||||
SubscriptionPlan::Team => {
|
||||
plan = WorkspacePlanPB::WorkspacePlanTeam;
|
||||
plan = WorkspacePlanPB::TeamPlan;
|
||||
},
|
||||
SubscriptionPlan::AiMax => {
|
||||
if plan_subscription.workspace_id.is_empty() {
|
||||
plan_subscription =
|
||||
WorkspaceSubscriptionV2PB::default_with_workspace_id(sub.workspace_id.clone());
|
||||
}
|
||||
|
||||
add_ons.push(WorkspaceAddOnPB {
|
||||
type_: WorkspaceAddOnPBType::AddOnAiMax,
|
||||
add_on_subscription: sub.into(),
|
||||
});
|
||||
},
|
||||
SubscriptionPlan::AiLocal => {
|
||||
if plan_subscription.workspace_id.is_empty() {
|
||||
plan_subscription =
|
||||
WorkspaceSubscriptionV2PB::default_with_workspace_id(sub.workspace_id.clone());
|
||||
}
|
||||
|
||||
add_ons.push(WorkspaceAddOnPB {
|
||||
type_: WorkspaceAddOnPBType::AddOnAiLocal,
|
||||
add_on_subscription: sub.into(),
|
||||
@ -498,6 +538,7 @@ impl From<Vec<WorkspaceSubscriptionStatus>> for WorkspaceSubscriptionInfoPB {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
WorkspaceSubscriptionInfoPB {
|
||||
plan,
|
||||
plan_subscription,
|
||||
@ -506,15 +547,45 @@ impl From<Vec<WorkspaceSubscriptionStatus>> for WorkspaceSubscriptionInfoPB {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkspaceSubscriptionInfoPB> for WorkspaceSubscriptionsTable {
|
||||
fn from(value: WorkspaceSubscriptionInfoPB) -> Self {
|
||||
WorkspaceSubscriptionsTable {
|
||||
workspace_id: value.plan_subscription.workspace_id,
|
||||
subscription_plan: value.plan.into(),
|
||||
workspace_status: value.plan_subscription.status.into(),
|
||||
end_date: value.plan_subscription.end_date,
|
||||
addons: serde_json::to_string(&value.add_ons).unwrap_or_default(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default)]
|
||||
pub enum WorkspacePlanPB {
|
||||
#[default]
|
||||
WorkspacePlanFree = 0,
|
||||
WorkspacePlanPro = 1,
|
||||
WorkspacePlanTeam = 2,
|
||||
FreePlan = 0,
|
||||
ProPlan = 1,
|
||||
TeamPlan = 2,
|
||||
}
|
||||
|
||||
#[derive(Debug, ProtoBuf, Default, Clone)]
|
||||
impl Into<i64> for WorkspacePlanPB {
|
||||
fn into(self) -> i64 {
|
||||
self as i64
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for WorkspacePlanPB {
|
||||
fn from(value: i64) -> Self {
|
||||
match value {
|
||||
0 => WorkspacePlanPB::FreePlan,
|
||||
1 => WorkspacePlanPB::ProPlan,
|
||||
2 => WorkspacePlanPB::TeamPlan,
|
||||
_ => WorkspacePlanPB::FreePlan,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, ProtoBuf, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceAddOnPB {
|
||||
#[pb(index = 1)]
|
||||
type_: WorkspaceAddOnPBType,
|
||||
@ -522,14 +593,14 @@ pub struct WorkspaceAddOnPB {
|
||||
add_on_subscription: WorkspaceSubscriptionV2PB,
|
||||
}
|
||||
|
||||
#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default)]
|
||||
#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub enum WorkspaceAddOnPBType {
|
||||
#[default]
|
||||
AddOnAiLocal = 0,
|
||||
AddOnAiMax = 1,
|
||||
}
|
||||
|
||||
#[derive(Debug, ProtoBuf, Default, Clone)]
|
||||
#[derive(Debug, ProtoBuf, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceSubscriptionV2PB {
|
||||
#[pb(index = 1)]
|
||||
pub workspace_id: String,
|
||||
@ -544,6 +615,17 @@ pub struct WorkspaceSubscriptionV2PB {
|
||||
pub end_date: i64,
|
||||
}
|
||||
|
||||
impl WorkspaceSubscriptionV2PB {
|
||||
pub fn default_with_workspace_id(workspace_id: String) -> Self {
|
||||
Self {
|
||||
workspace_id,
|
||||
subscription_plan: SubscriptionPlanPB::None,
|
||||
status: WorkspaceSubscriptionStatusPB::Active,
|
||||
end_date: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkspaceSubscriptionStatus> for WorkspaceSubscriptionV2PB {
|
||||
fn from(sub: WorkspaceSubscriptionStatus) -> Self {
|
||||
Self {
|
||||
@ -555,13 +637,28 @@ impl From<WorkspaceSubscriptionStatus> for WorkspaceSubscriptionV2PB {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default)]
|
||||
#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub enum WorkspaceSubscriptionStatusPB {
|
||||
#[default]
|
||||
Active = 0,
|
||||
Canceled = 1,
|
||||
}
|
||||
|
||||
impl Into<i64> for WorkspaceSubscriptionStatusPB {
|
||||
fn into(self) -> i64 {
|
||||
self as i64
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for WorkspaceSubscriptionStatusPB {
|
||||
fn from(value: i64) -> Self {
|
||||
match value {
|
||||
0 => WorkspaceSubscriptionStatusPB::Active,
|
||||
_ => WorkspaceSubscriptionStatusPB::Canceled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SubscriptionStatus> for WorkspaceSubscriptionStatusPB {
|
||||
fn from(status: SubscriptionStatus) -> Self {
|
||||
match status {
|
||||
|
@ -773,20 +773,6 @@ pub async fn subscribe_workspace_handler(
|
||||
data_result_ok(PaymentLinkPB { payment_link })
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all, err)]
|
||||
pub async fn get_workspace_subscriptions_handler(
|
||||
manager: AFPluginState<Weak<UserManager>>,
|
||||
) -> DataResult<RepeatedWorkspaceSubscriptionPB, FlowyError> {
|
||||
let manager = upgrade_manager(manager)?;
|
||||
let subs = manager
|
||||
.get_workspace_subscriptions()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(WorkspaceSubscriptionPB::from)
|
||||
.collect::<Vec<_>>();
|
||||
data_result_ok(RepeatedWorkspaceSubscriptionPB { items: subs })
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all, err)]
|
||||
pub async fn get_workspace_subscription_info_handler(
|
||||
params: AFPluginData<UserWorkspaceIdPB>,
|
||||
@ -843,6 +829,18 @@ pub async fn get_billing_portal_handler(
|
||||
data_result_ok(BillingPortalPB { url })
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all, err)]
|
||||
pub async fn invalidate_workspace_subscription_info_cache_handler(
|
||||
params: AFPluginData<UserWorkspaceIdPB>,
|
||||
manager: AFPluginState<Weak<UserManager>>,
|
||||
) -> FlowyResult<()> {
|
||||
let params = params.try_into_inner().unwrap();
|
||||
let manager = upgrade_manager(manager).unwrap();
|
||||
manager
|
||||
.invalidate_workspace_subscription_info_cache(params.workspace_id)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all, err)]
|
||||
pub async fn get_workspace_member_info(
|
||||
param: AFPluginData<WorkspaceMemberIdPB>,
|
||||
|
@ -70,15 +70,14 @@ pub fn init(user_manager: Weak<UserManager>) -> AFPlugin {
|
||||
.event(UserEvent::AcceptWorkspaceInvitation, accept_workspace_invitations_handler)
|
||||
// Billing
|
||||
.event(UserEvent::SubscribeWorkspace, subscribe_workspace_handler)
|
||||
.event(UserEvent::GetWorkspaceSubscriptions, get_workspace_subscriptions_handler)
|
||||
.event(UserEvent::GetWorkspaceSubscriptionInfo, get_workspace_subscription_info_handler)
|
||||
.event(UserEvent::CancelWorkspaceSubscription, cancel_workspace_subscription_handler)
|
||||
.event(UserEvent::GetWorkspaceUsage, get_workspace_usage_handler)
|
||||
.event(UserEvent::GetBillingPortal, get_billing_portal_handler)
|
||||
.event(UserEvent::InvalidateWorkspaceSubscriptionInfoCache, invalidate_workspace_subscription_info_cache_handler)
|
||||
// Workspace Setting
|
||||
.event(UserEvent::UpdateWorkspaceSetting, update_workspace_setting)
|
||||
.event(UserEvent::GetWorkspaceSetting, get_workspace_setting)
|
||||
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)]
|
||||
@ -243,10 +242,7 @@ pub enum UserEvent {
|
||||
#[event(input = "SubscribeWorkspacePB", output = "PaymentLinkPB")]
|
||||
SubscribeWorkspace = 51,
|
||||
|
||||
#[event(output = "RepeatedWorkspaceSubscriptionPB")]
|
||||
GetWorkspaceSubscriptions = 52,
|
||||
|
||||
#[event(input = "UserWorkspaceIdPB")]
|
||||
#[event(input = "CancelWorkspaceSubscriptionPB")]
|
||||
CancelWorkspaceSubscription = 53,
|
||||
|
||||
#[event(input = "UserWorkspaceIdPB", output = "WorkspaceUsagePB")]
|
||||
@ -266,6 +262,9 @@ pub enum UserEvent {
|
||||
|
||||
#[event(input = "UserWorkspaceIdPB", output = "WorkspaceSubscriptionInfoPB")]
|
||||
GetWorkspaceSubscriptionInfo = 59,
|
||||
|
||||
#[event(input = "UserWorkspaceIdPB")]
|
||||
InvalidateWorkspaceSubscriptionInfoCache = 60,
|
||||
}
|
||||
|
||||
pub trait UserStatusCallback: Send + Sync + 'static {
|
||||
|
@ -1,6 +1,5 @@
|
||||
use chrono::{TimeZone, Utc};
|
||||
use client_api::entity::billing_dto::{RecurringInterval, SubscriptionPlan};
|
||||
use diesel::insert_into;
|
||||
use diesel::{delete, insert_into};
|
||||
use diesel::{RunQueryDsl, SqliteConnection};
|
||||
use flowy_error::{FlowyError, FlowyResult};
|
||||
use flowy_sqlite::schema::user_workspace_table;
|
||||
@ -8,9 +7,13 @@ use flowy_sqlite::schema::workspace_subscriptions_table;
|
||||
use flowy_sqlite::schema::workspace_subscriptions_table::dsl;
|
||||
use flowy_sqlite::DBConnection;
|
||||
use flowy_sqlite::{query_dsl::*, ExpressionMethods};
|
||||
use flowy_user_pub::entities::{UserWorkspace, WorkspaceSubscription};
|
||||
use flowy_user_pub::entities::UserWorkspace;
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use crate::entities::{
|
||||
SubscriptionPlanPB, WorkspacePlanPB, WorkspaceSubscriptionInfoPB, WorkspaceSubscriptionV2PB,
|
||||
};
|
||||
|
||||
#[derive(Clone, Default, Queryable, Identifiable, Insertable)]
|
||||
#[diesel(table_name = user_workspace_table)]
|
||||
pub struct UserWorkspaceTable {
|
||||
@ -28,10 +31,9 @@ pub struct UserWorkspaceTable {
|
||||
pub struct WorkspaceSubscriptionsTable {
|
||||
pub workspace_id: String,
|
||||
pub subscription_plan: i64,
|
||||
pub recurring_interval: i64,
|
||||
pub is_active: bool,
|
||||
pub has_canceled: bool,
|
||||
pub canceled_at: Option<i64>,
|
||||
pub workspace_status: i64,
|
||||
pub end_date: i64,
|
||||
pub addons: String,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
@ -118,17 +120,33 @@ pub fn upsert_workspace_subscription<T: Into<WorkspaceSubscriptionsTable>>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl TryFrom<WorkspaceSubscriptionsTable> for WorkspaceSubscription {
|
||||
type Error = FlowyError;
|
||||
fn try_from(value: WorkspaceSubscriptionsTable) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
workspace_id: value.workspace_id,
|
||||
subscription_plan: SubscriptionPlan::try_from(value.subscription_plan as i16)?,
|
||||
recurring_interval: RecurringInterval::try_from(value.recurring_interval as i16)?,
|
||||
is_active: value.is_active,
|
||||
has_canceled: value.has_canceled,
|
||||
canceled_at: value.canceled_at,
|
||||
})
|
||||
pub fn delete_workspace_subscription_from_cache(
|
||||
mut conn: DBConnection,
|
||||
workspace_id: &str,
|
||||
) -> FlowyResult<()> {
|
||||
let delete = delete(
|
||||
dsl::workspace_subscriptions_table
|
||||
.filter(workspace_subscriptions_table::workspace_id.eq(workspace_id)),
|
||||
);
|
||||
|
||||
delete.execute(&mut conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Into<WorkspaceSubscriptionInfoPB> for WorkspaceSubscriptionsTable {
|
||||
fn into(self) -> WorkspaceSubscriptionInfoPB {
|
||||
WorkspaceSubscriptionInfoPB {
|
||||
plan: self.subscription_plan.into(),
|
||||
plan_subscription: WorkspaceSubscriptionV2PB {
|
||||
workspace_id: self.workspace_id,
|
||||
subscription_plan: SubscriptionPlanPB::from(WorkspacePlanPB::from(self.subscription_plan)),
|
||||
status: self.workspace_status.into(),
|
||||
end_date: self.end_date,
|
||||
},
|
||||
// Deserialize
|
||||
add_ons: serde_json::from_str(&self.addons).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
use chrono::{Duration, NaiveDateTime, Utc};
|
||||
use client_api::entity::billing_dto::{
|
||||
RecurringInterval, SubscriptionPlan, WorkspaceSubscriptionStatus, WorkspaceUsageAndLimit,
|
||||
};
|
||||
use client_api::entity::billing_dto::{SubscriptionPlan, WorkspaceUsageAndLimit};
|
||||
|
||||
use std::convert::TryFrom;
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -15,13 +14,13 @@ use flowy_sqlite::schema::user_workspace_table;
|
||||
use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods};
|
||||
use flowy_user_pub::entities::{
|
||||
Role, UpdateUserProfileParams, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus,
|
||||
WorkspaceMember, WorkspaceSubscription,
|
||||
WorkspaceMember,
|
||||
};
|
||||
use lib_dispatch::prelude::af_spawn;
|
||||
|
||||
use crate::entities::{
|
||||
RepeatedUserWorkspacePB, ResetWorkspacePB, SubscribeWorkspacePB, UpdateUserWorkspaceSettingPB,
|
||||
UseAISettingPB, UserWorkspacePB,
|
||||
UseAISettingPB, UserWorkspacePB, WorkspaceSubscriptionInfoPB,
|
||||
};
|
||||
use crate::migrations::AnonUser;
|
||||
use crate::notification::{send_notification, UserNotification};
|
||||
@ -33,9 +32,9 @@ use crate::services::sqlite_sql::member_sql::{
|
||||
};
|
||||
use crate::services::sqlite_sql::user_sql::UserTableChangeset;
|
||||
use crate::services::sqlite_sql::workspace_sql::{
|
||||
get_all_user_workspace_op, get_user_workspace_op, insert_new_workspaces_op,
|
||||
select_workspace_subscription, upsert_workspace_subscription, UserWorkspaceTable,
|
||||
WorkspaceSubscriptionsTable,
|
||||
delete_workspace_subscription_from_cache, get_all_user_workspace_op, get_user_workspace_op,
|
||||
insert_new_workspaces_op, select_workspace_subscription, upsert_workspace_subscription,
|
||||
UserWorkspaceTable,
|
||||
};
|
||||
use crate::user_manager::{upsert_user_profile_change, UserManager};
|
||||
use flowy_user_pub::session::Session;
|
||||
@ -450,75 +449,54 @@ impl UserManager {
|
||||
Ok(payment_link)
|
||||
}
|
||||
|
||||
#[instrument(level = "info", skip(self), err)]
|
||||
pub async fn get_workspace_subscriptions(&self) -> FlowyResult<Vec<WorkspaceSubscription>> {
|
||||
let session = self.get_session()?;
|
||||
let uid = session.user_id;
|
||||
let workspace_id = session.user_workspace.id.clone();
|
||||
let db = self.authenticate_user.get_sqlite_connection(uid)?;
|
||||
|
||||
// We check if we can use the cache from local sqlite db
|
||||
if let Ok(subscription) = select_workspace_subscription(db, &workspace_id) {
|
||||
if is_older_than_n_minutes(subscription.updated_at, 10) {
|
||||
self.get_workspace_subscriptions_from_remote(uid).await?;
|
||||
}
|
||||
|
||||
return Ok(vec![WorkspaceSubscription {
|
||||
workspace_id,
|
||||
subscription_plan: SubscriptionPlan::try_from(subscription.subscription_plan as i16)?,
|
||||
recurring_interval: RecurringInterval::try_from(subscription.recurring_interval as i16)?,
|
||||
is_active: subscription.is_active,
|
||||
has_canceled: subscription.has_canceled,
|
||||
canceled_at: subscription.canceled_at,
|
||||
}]);
|
||||
}
|
||||
|
||||
let subscriptions = self.get_workspace_subscriptions_from_remote(uid).await?;
|
||||
|
||||
Ok(subscriptions)
|
||||
}
|
||||
|
||||
#[instrument(level = "info", skip(self), err)]
|
||||
pub async fn get_workspace_subscription_info(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
) -> FlowyResult<Vec<WorkspaceSubscriptionStatus>> {
|
||||
) -> FlowyResult<WorkspaceSubscriptionInfoPB> {
|
||||
let session = self.get_session()?;
|
||||
let uid = session.user_id;
|
||||
let db = self.authenticate_user.get_sqlite_connection(uid)?;
|
||||
|
||||
if let Ok(subscription) = select_workspace_subscription(db, &workspace_id) {
|
||||
if is_older_than_n_minutes(subscription.updated_at, 10) {
|
||||
self
|
||||
.cloud_services
|
||||
.get_user_service()?
|
||||
.get_workspace_subscriptions()
|
||||
.await
|
||||
.get_workspace_subscription_info_from_remote(uid, workspace_id)
|
||||
.await?;
|
||||
}
|
||||
|
||||
async fn get_workspace_subscriptions_from_remote(
|
||||
return Ok(subscription.into());
|
||||
}
|
||||
|
||||
let info = self
|
||||
.get_workspace_subscription_info_from_remote(uid, workspace_id)
|
||||
.await?;
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
async fn get_workspace_subscription_info_from_remote(
|
||||
&self,
|
||||
uid: i64,
|
||||
) -> FlowyResult<Vec<WorkspaceSubscription>> {
|
||||
let subscriptions: Vec<WorkspaceSubscription> = self
|
||||
workspace_id: String,
|
||||
) -> FlowyResult<WorkspaceSubscriptionInfoPB> {
|
||||
let subscriptions = self
|
||||
.cloud_services
|
||||
.get_user_service()?
|
||||
.get_workspace_subscriptions()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(WorkspaceSubscription::from)
|
||||
.collect();
|
||||
.get_workspace_subscription_one(workspace_id.clone())
|
||||
.await?;
|
||||
|
||||
for subscription in &subscriptions {
|
||||
let db = self.authenticate_user.get_sqlite_connection(uid)?;
|
||||
let record = WorkspaceSubscriptionsTable {
|
||||
workspace_id: subscription.workspace_id.clone().into(),
|
||||
subscription_plan: subscription.subscription_plan.clone() as i64,
|
||||
recurring_interval: subscription.recurring_interval.clone() as i64,
|
||||
is_active: subscription.canceled_at.is_none(),
|
||||
has_canceled: subscription.canceled_at.is_some(),
|
||||
canceled_at: subscription.canceled_at.into(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
let info = WorkspaceSubscriptionInfoPB::from(subscriptions);
|
||||
let record = if info.plan_subscription.workspace_id.is_empty() {
|
||||
WorkspaceSubscriptionInfoPB::default_from_workspace_id(workspace_id)
|
||||
} else {
|
||||
info.clone()
|
||||
};
|
||||
|
||||
let db = self.authenticate_user.get_sqlite_connection(uid)?;
|
||||
upsert_workspace_subscription(db, record)?;
|
||||
}
|
||||
|
||||
Ok(subscriptions)
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[instrument(level = "info", skip(self), err)]
|
||||
@ -535,6 +513,16 @@ impl UserManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(level = "info", skip(self), err)]
|
||||
pub async fn invalidate_workspace_subscription_info_cache(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
) -> FlowyResult<()> {
|
||||
let uid = self.user_id()?;
|
||||
let db = self.authenticate_user.get_sqlite_connection(uid)?;
|
||||
delete_workspace_subscription_from_cache(db, &workspace_id)
|
||||
}
|
||||
|
||||
#[instrument(level = "info", skip(self), err)]
|
||||
pub async fn get_workspace_usage(
|
||||
&self,
|
||||
|
Loading…
Reference in New Issue
Block a user