mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: cache workspace subscription + minor fixes (#5705)
This commit is contained in:
parent
00551bd1d2
commit
0802651546
@ -1,14 +1,20 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/user/application/user_service.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:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'settings_billing_bloc.freezed.dart';
|
||||
@ -25,25 +31,19 @@ class SettingsBillingBloc
|
||||
started: () async {
|
||||
emit(const SettingsBillingState.loading());
|
||||
|
||||
final snapshots = await Future.wait([
|
||||
UserBackendService.getWorkspaceSubscriptions(),
|
||||
_service.getBillingPortal(),
|
||||
]);
|
||||
|
||||
FlowyError? error;
|
||||
|
||||
final subscription = snapshots.first.fold(
|
||||
final subscription =
|
||||
(await UserBackendService.getWorkspaceSubscriptions()).fold(
|
||||
(s) =>
|
||||
(s as RepeatedWorkspaceSubscriptionPB)
|
||||
.items
|
||||
.firstWhereOrNull((i) => i.workspaceId == workspaceId) ??
|
||||
s.items.firstWhereOrNull((i) => i.workspaceId == workspaceId) ??
|
||||
WorkspaceSubscriptionPB(
|
||||
workspaceId: workspaceId,
|
||||
subscriptionPlan: SubscriptionPlanPB.None,
|
||||
isActive: true,
|
||||
),
|
||||
(e) {
|
||||
// Not a Cjstomer yet
|
||||
// Not a Customer yet
|
||||
if (e.code == ErrorCode.InvalidParams) {
|
||||
return WorkspaceSubscriptionPB(
|
||||
workspaceId: workspaceId,
|
||||
@ -57,46 +57,88 @@ class SettingsBillingBloc
|
||||
},
|
||||
);
|
||||
|
||||
final billingPortalResult = snapshots.last;
|
||||
final billingPortal = billingPortalResult.fold(
|
||||
(s) => s as BillingPortalPB,
|
||||
(e) {
|
||||
// Not a customer yet
|
||||
if (e.code == ErrorCode.InvalidParams) {
|
||||
return BillingPortalPB();
|
||||
}
|
||||
|
||||
error = e;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
if (subscription == null || billingPortal == null || error != null) {
|
||||
if (subscription == null || error != null) {
|
||||
return emit(SettingsBillingState.error(error: error));
|
||||
}
|
||||
|
||||
if (!_billingPortalCompleter.isCompleted) {
|
||||
unawaited(_fetchBillingPortal());
|
||||
unawaited(
|
||||
_billingPortalCompleter.future.then(
|
||||
(result) {
|
||||
result.fold(
|
||||
(portal) {
|
||||
_billingPortal = portal;
|
||||
add(
|
||||
SettingsBillingEvent.billingPortalFetched(
|
||||
billingPortal: portal,
|
||||
),
|
||||
);
|
||||
},
|
||||
(e) => Log.error('Error fetching billing portal: $e'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
emit(
|
||||
SettingsBillingState.ready(
|
||||
subscription: subscription,
|
||||
billingPortal: billingPortal,
|
||||
billingPortal: _billingPortal,
|
||||
),
|
||||
);
|
||||
},
|
||||
billingPortalFetched: (billingPortal) {
|
||||
state.maybeWhen(
|
||||
orElse: () {},
|
||||
ready: (subscription, _) => emit(
|
||||
SettingsBillingState.ready(
|
||||
subscription: subscription,
|
||||
billingPortal: billingPortal,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
openCustomerPortal: () async {
|
||||
if (_billingPortalCompleter.isCompleted && _billingPortal != null) {
|
||||
await afLaunchUrlString(_billingPortal!.url);
|
||||
}
|
||||
await _billingPortalCompleter.future;
|
||||
if (_billingPortal != null) {
|
||||
await afLaunchUrlString(_billingPortal!.url);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
late final String workspaceId;
|
||||
late final WorkspaceService _service;
|
||||
final _billingPortalCompleter =
|
||||
Completer<FlowyResult<BillingPortalPB, FlowyError>>();
|
||||
|
||||
BillingPortalPB? _billingPortal;
|
||||
|
||||
Future<void> _fetchBillingPortal() async {
|
||||
final billingPortalResult = await _service.getBillingPortal();
|
||||
_billingPortalCompleter.complete(billingPortalResult);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SettingsBillingEvent with _$SettingsBillingEvent {
|
||||
const factory SettingsBillingEvent.started() = _Started;
|
||||
const factory SettingsBillingEvent.billingPortalFetched({
|
||||
required BillingPortalPB billingPortal,
|
||||
}) = _BillingPortalFetched;
|
||||
const factory SettingsBillingEvent.openCustomerPortal() = _OpenCustomerPortal;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SettingsBillingState with _$SettingsBillingState {
|
||||
class SettingsBillingState extends Equatable with _$SettingsBillingState {
|
||||
const SettingsBillingState._();
|
||||
|
||||
const factory SettingsBillingState.initial() = _Initial;
|
||||
|
||||
const factory SettingsBillingState.loading() = _Loading;
|
||||
@ -109,4 +151,11 @@ class SettingsBillingState with _$SettingsBillingState {
|
||||
required WorkspaceSubscriptionPB subscription,
|
||||
required BillingPortalPB? billingPortal,
|
||||
}) = _Ready;
|
||||
|
||||
@override
|
||||
List<Object?> get props => maybeWhen(
|
||||
orElse: () => const [],
|
||||
error: (error) => [error],
|
||||
ready: (subscription, billingPortal) => [subscription, billingPortal],
|
||||
);
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ 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';
|
||||
@ -36,7 +35,6 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
final snapshots = await Future.wait([
|
||||
_service.getWorkspaceUsage(),
|
||||
UserBackendService.getWorkspaceSubscriptions(),
|
||||
_service.getBillingPortal(),
|
||||
]);
|
||||
|
||||
FlowyError? error;
|
||||
@ -65,24 +63,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
},
|
||||
);
|
||||
|
||||
final billingPortalResult = snapshots.last;
|
||||
final billingPortal = billingPortalResult.fold(
|
||||
(s) => s as BillingPortalPB,
|
||||
(e) {
|
||||
// Not a customer yet
|
||||
if (e.code == ErrorCode.InvalidParams) {
|
||||
return BillingPortalPB();
|
||||
}
|
||||
|
||||
error = e;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
if (usageResult == null ||
|
||||
subscription == null ||
|
||||
billingPortal == null ||
|
||||
error != null) {
|
||||
if (usageResult == null || subscription == null || error != null) {
|
||||
return emit(SettingsPlanState.error(error: error));
|
||||
}
|
||||
|
||||
@ -90,7 +71,6 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
SettingsPlanState.ready(
|
||||
workspaceUsage: usageResult,
|
||||
subscription: subscription,
|
||||
billingPortal: billingPortal,
|
||||
showSuccessDialog: withShowSuccessful,
|
||||
),
|
||||
);
|
||||
@ -100,7 +80,6 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
SettingsPlanState.ready(
|
||||
workspaceUsage: usageResult,
|
||||
subscription: subscription,
|
||||
billingPortal: billingPortal,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -176,7 +155,6 @@ class SettingsPlanState with _$SettingsPlanState {
|
||||
const factory SettingsPlanState.ready({
|
||||
required WorkspaceUsagePB workspaceUsage,
|
||||
required WorkspaceSubscriptionPB subscription,
|
||||
required BillingPortalPB? billingPortal,
|
||||
@Default(false) bool showSuccessDialog,
|
||||
@Default(false) bool downgradeProcessing,
|
||||
}) = _Ready;
|
||||
|
@ -58,8 +58,7 @@ class SettingsBillingView extends StatelessWidget {
|
||||
return ErrorWidget.withDetails(message: 'Something went wrong!');
|
||||
},
|
||||
ready: (state) {
|
||||
final billingPortalEnabled = state.billingPortal != null &&
|
||||
state.billingPortal!.url.isNotEmpty &&
|
||||
final billingPortalEnabled =
|
||||
state.subscription.subscriptionPlan !=
|
||||
SubscriptionPlanPB.None;
|
||||
|
||||
@ -85,8 +84,11 @@ class SettingsBillingView extends StatelessWidget {
|
||||
),
|
||||
if (billingPortalEnabled)
|
||||
SingleSettingAction(
|
||||
onPressed: () =>
|
||||
afLaunchUrlString(state.billingPortal!.url),
|
||||
onPressed: () => context
|
||||
.read<SettingsBillingBloc>()
|
||||
.add(
|
||||
const SettingsBillingEvent.openCustomerPortal(),
|
||||
),
|
||||
label: LocaleKeys
|
||||
.settings_billingPage_plan_billingPeriod
|
||||
.tr(),
|
||||
@ -105,8 +107,11 @@ class SettingsBillingView extends StatelessWidget {
|
||||
.tr(),
|
||||
children: [
|
||||
SingleSettingAction(
|
||||
onPressed: () =>
|
||||
afLaunchUrlString(state.billingPortal!.url),
|
||||
onPressed: () => context
|
||||
.read<SettingsBillingBloc>()
|
||||
.add(
|
||||
const SettingsBillingEvent.openCustomerPortal(),
|
||||
),
|
||||
label: LocaleKeys
|
||||
.settings_billingPage_paymentDetails_methodLabel
|
||||
.tr(),
|
||||
@ -119,24 +124,34 @@ class SettingsBillingView extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
// TODO(Mathias): Implement the business logic for AI Add-ons
|
||||
const SettingsCategory(
|
||||
title: 'Add-ons',
|
||||
SettingsCategory(
|
||||
title: LocaleKeys.settings_billingPage_addons_title.tr(),
|
||||
children: [
|
||||
SingleSettingAction(
|
||||
buttonType: SingleSettingsButtonType.highlight,
|
||||
label: 'AppFlowy AI Max',
|
||||
description:
|
||||
"\$8 /user per month billed annually or \$10 billed monthly",
|
||||
buttonLabel: 'Add AI Max',
|
||||
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,
|
||||
),
|
||||
SingleSettingAction(
|
||||
buttonType: SingleSettingsButtonType.highlight,
|
||||
label: 'AppFlowy AI Offline',
|
||||
description:
|
||||
"\$8 /user per month billed annually or \$10 billed monthly",
|
||||
buttonLabel: 'Add AI Offline',
|
||||
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,
|
||||
),
|
||||
|
@ -57,16 +57,6 @@ class _SettingsPlanComparisonDialogState
|
||||
|
||||
if (readyState.showSuccessDialog) {
|
||||
SettingsAlertDialog(
|
||||
icon: Center(
|
||||
child: SizedBox(
|
||||
height: 90,
|
||||
width: 90,
|
||||
child: FlowySvg(
|
||||
FlowySvgs.check_circle_s,
|
||||
color: AFThemeExtension.of(context).success,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: LocaleKeys.settings_comparePlanDialog_paymentSuccess_title
|
||||
.tr(args: [readyState.subscription.label]),
|
||||
subtitle: LocaleKeys
|
||||
@ -588,21 +578,28 @@ class _Heading extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 175,
|
||||
width: 185,
|
||||
height: height,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyText.semibold(
|
||||
title,
|
||||
fontSize: 24,
|
||||
color: isPrimary
|
||||
? AFThemeExtension.of(context).strongText
|
||||
: Theme.of(context).isLightMode
|
||||
? const Color(0xFF5C3699)
|
||||
: const Color(0xFFC49BEC),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FlowyText.semibold(
|
||||
title,
|
||||
fontSize: 24,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: isPrimary
|
||||
? AFThemeExtension.of(context).strongText
|
||||
: Theme.of(context).isLightMode
|
||||
? const Color(0xFF5C3699)
|
||||
: const Color(0xFFC49BEC),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (description != null && description!.isNotEmpty) ...[
|
||||
const VSpace(4),
|
||||
@ -636,24 +633,20 @@ final _planLabels = [
|
||||
),
|
||||
_PlanItem(
|
||||
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemThree.tr(),
|
||||
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipThree.tr(),
|
||||
),
|
||||
_PlanItem(
|
||||
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFour.tr(),
|
||||
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipFour.tr(),
|
||||
),
|
||||
_PlanItem(
|
||||
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFive.tr(),
|
||||
),
|
||||
_PlanItem(
|
||||
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSix.tr(),
|
||||
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSix.tr(),
|
||||
),
|
||||
_PlanItem(
|
||||
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSeven.tr(),
|
||||
),
|
||||
_PlanItem(
|
||||
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemEight.tr(),
|
||||
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipEight.tr(),
|
||||
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSeven.tr(),
|
||||
),
|
||||
];
|
||||
|
||||
@ -676,20 +669,17 @@ final List<_CellItem> _freeLabels = [
|
||||
),
|
||||
_CellItem(
|
||||
LocaleKeys.settings_comparePlanDialog_freeLabels_itemFour.tr(),
|
||||
icon: FlowySvgs.check_m,
|
||||
),
|
||||
_CellItem(
|
||||
LocaleKeys.settings_comparePlanDialog_freeLabels_itemFive.tr(),
|
||||
icon: FlowySvgs.check_m,
|
||||
),
|
||||
_CellItem(
|
||||
LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(),
|
||||
icon: FlowySvgs.check_m,
|
||||
),
|
||||
_CellItem(
|
||||
LocaleKeys.settings_comparePlanDialog_freeLabels_itemSeven.tr(),
|
||||
icon: FlowySvgs.check_m,
|
||||
),
|
||||
_CellItem(
|
||||
LocaleKeys.settings_comparePlanDialog_freeLabels_itemEight.tr(),
|
||||
),
|
||||
];
|
||||
|
||||
@ -705,19 +695,17 @@ final List<_CellItem> _proLabels = [
|
||||
),
|
||||
_CellItem(
|
||||
LocaleKeys.settings_comparePlanDialog_proLabels_itemFour.tr(),
|
||||
icon: FlowySvgs.check_m,
|
||||
),
|
||||
_CellItem(
|
||||
LocaleKeys.settings_comparePlanDialog_proLabels_itemFive.tr(),
|
||||
icon: FlowySvgs.check_m,
|
||||
),
|
||||
_CellItem(
|
||||
LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(),
|
||||
icon: FlowySvgs.check_m,
|
||||
),
|
||||
_CellItem(
|
||||
LocaleKeys.settings_comparePlanDialog_proLabels_itemSeven.tr(),
|
||||
icon: FlowySvgs.check_m,
|
||||
),
|
||||
_CellItem(
|
||||
LocaleKeys.settings_comparePlanDialog_proLabels_itemEight.tr(),
|
||||
),
|
||||
];
|
||||
|
@ -69,7 +69,6 @@ class SettingsPlanView extends StatelessWidget {
|
||||
_PlanUsageSummary(
|
||||
usage: state.workspaceUsage,
|
||||
subscription: state.subscription,
|
||||
billingPortal: state.billingPortal,
|
||||
),
|
||||
const VSpace(16),
|
||||
_CurrentPlanBox(subscription: state.subscription),
|
||||
@ -78,7 +77,7 @@ class SettingsPlanView extends StatelessWidget {
|
||||
FlowyText(
|
||||
'Add-ons',
|
||||
fontSize: 18,
|
||||
color: AFThemeExtension.of(context).secondaryTextColor,
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const VSpace(8),
|
||||
@ -88,8 +87,8 @@ class SettingsPlanView extends StatelessWidget {
|
||||
child: _AddOnBox(
|
||||
title: "AI Max",
|
||||
description:
|
||||
"Unlimited AI responses with access to the latest advanced AI models.",
|
||||
price: "\$8",
|
||||
"Unlimited AI models and access to advanced models",
|
||||
price: "US\$8",
|
||||
priceInfo: "billed annually or \$10 billed monthly",
|
||||
buttonText: "Add AI Max",
|
||||
),
|
||||
@ -99,8 +98,8 @@ class SettingsPlanView extends StatelessWidget {
|
||||
child: _AddOnBox(
|
||||
title: "AI Offline",
|
||||
description:
|
||||
"Run AI locally on your device for maximum privacy.",
|
||||
price: "\$8",
|
||||
"Local AI on your own hardware for ultimate privacy",
|
||||
price: "US\$8",
|
||||
priceInfo: "billed annually or \$10 billed monthly",
|
||||
buttonText: "Add AI Offline",
|
||||
),
|
||||
@ -247,8 +246,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
|
||||
final appearance = context.read<AppearanceSettingsCubit>().state;
|
||||
return appearance.dateFormat.formatDate(
|
||||
widget.subscription.canceledAt.toDateTime(),
|
||||
true,
|
||||
appearance.timeFormat,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
@ -273,12 +271,10 @@ class _PlanUsageSummary extends StatelessWidget {
|
||||
const _PlanUsageSummary({
|
||||
required this.usage,
|
||||
required this.subscription,
|
||||
this.billingPortal,
|
||||
});
|
||||
|
||||
final WorkspaceUsagePB usage;
|
||||
final WorkspaceSubscriptionPB subscription;
|
||||
final BillingPortalPB? billingPortal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -342,19 +338,17 @@ class _PlanUsageSummary extends StatelessWidget {
|
||||
subscription: subscription,
|
||||
badgeLabel:
|
||||
LocaleKeys.settings_planPage_planUsage_proBadge.tr(),
|
||||
onTap: billingPortal?.url == null
|
||||
? null
|
||||
: () async {
|
||||
context.read<SettingsPlanBloc>().add(
|
||||
const SettingsPlanEvent.addSubscription(
|
||||
SubscriptionPlanPB.Pro,
|
||||
),
|
||||
);
|
||||
await Future.delayed(
|
||||
const Duration(seconds: 2),
|
||||
() {},
|
||||
);
|
||||
},
|
||||
onTap: () async {
|
||||
context.read<SettingsPlanBloc>().add(
|
||||
const SettingsPlanEvent.addSubscription(
|
||||
SubscriptionPlanPB.Pro,
|
||||
),
|
||||
);
|
||||
await Future.delayed(
|
||||
const Duration(seconds: 2),
|
||||
() {},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
@ -553,7 +547,7 @@ class _AddOnBox extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 200,
|
||||
height: 220,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
@ -571,7 +565,6 @@ class _AddOnBox extends StatelessWidget {
|
||||
color: AFThemeExtension.of(context).secondaryTextColor,
|
||||
),
|
||||
const VSpace(4),
|
||||
const VSpace(4),
|
||||
FlowyText.regular(
|
||||
description,
|
||||
fontSize: 11,
|
||||
@ -579,24 +572,17 @@ class _AddOnBox extends StatelessWidget {
|
||||
maxLines: 4,
|
||||
),
|
||||
const VSpace(4),
|
||||
Row(
|
||||
children: [
|
||||
FlowyText(
|
||||
price,
|
||||
fontSize: 24,
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
),
|
||||
const HSpace(4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: FlowyText(
|
||||
'/user per month',
|
||||
fontSize: 11,
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
),
|
||||
),
|
||||
],
|
||||
FlowyText(
|
||||
price,
|
||||
fontSize: 24,
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
),
|
||||
FlowyText(
|
||||
'/user per month',
|
||||
fontSize: 11,
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
),
|
||||
const VSpace(6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@ -617,6 +603,7 @@ class _AddOnBox extends StatelessWidget {
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
|
||||
fillColor: Colors.transparent,
|
||||
constraints: const BoxConstraints(minWidth: 115),
|
||||
radius: Corners.s16Border,
|
||||
hoverColor: const Color(0xFF5C3699),
|
||||
fontColor: const Color(0xFF5C3699),
|
||||
|
@ -631,13 +631,13 @@
|
||||
"title": "Plan usage summary",
|
||||
"storageLabel": "Storage",
|
||||
"storageUsage": "{} of {} GB",
|
||||
"collaboratorsLabel": "Collaborators",
|
||||
"collaboratorsLabel": "Members",
|
||||
"collaboratorsUsage": "{} of {}",
|
||||
"aiResponseLabel": "AI Responses",
|
||||
"aiResponseUsage": "{} of {}",
|
||||
"proBadge": "Pro",
|
||||
"memberProToggle": "10 members and unlimited AI responses",
|
||||
"storageUnlimited": "Unlimited storage with your Pro Plan",
|
||||
"memberProToggle": "More members & unlimited AI",
|
||||
"storageUnlimited": "Unlimited storage with Pro Plan",
|
||||
"aiCredit": {
|
||||
"title": "Add @:appName AI Credit",
|
||||
"price": "5$",
|
||||
@ -655,7 +655,7 @@
|
||||
"freeInfo": "Perfect for individuals or small teams up to 3 members.",
|
||||
"proInfo": "Perfect for small and medium teams up to 10 members.",
|
||||
"teamInfo": "Perfect for all productive and well-organized teams..",
|
||||
"upgrade": "Upgrade plan",
|
||||
"upgrade": "Change plan",
|
||||
"canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}."
|
||||
},
|
||||
"deal": {
|
||||
@ -681,6 +681,19 @@
|
||||
"title": "Payment details",
|
||||
"methodLabel": "Payment method",
|
||||
"methodButtonLabel": "Edit method"
|
||||
},
|
||||
"addons": {
|
||||
"title": "Add-ons",
|
||||
"aiMax": {
|
||||
"label": "AI Max",
|
||||
"description": "US$8 /user per month billed annually or US$10 billed monthly",
|
||||
"buttonLabel": "Add AI Max"
|
||||
},
|
||||
"aiOnDevice": {
|
||||
"label": "AI On-device",
|
||||
"description": "US$8 /user per month billed annually or US$10 billed monthly",
|
||||
"buttonLabel": "Add AI On-device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"comparePlanDialog": {
|
||||
@ -695,48 +708,44 @@
|
||||
},
|
||||
"freePlan": {
|
||||
"title": "Free",
|
||||
"description": "For organizing every corner of your work & life.",
|
||||
"price": "$0",
|
||||
"description": "For individuals and small groups to organize everything",
|
||||
"price": "US$0",
|
||||
"priceInfo": "free forever"
|
||||
},
|
||||
"proPlan": {
|
||||
"title": "Professional",
|
||||
"description": "A place for small groups to plan & get organized.",
|
||||
"price": "$10 /month",
|
||||
"priceInfo": "billed annually"
|
||||
"description": "For small teams to manage projects and team knowledge",
|
||||
"price": "US$12.5",
|
||||
"priceInfo": "billed monthly"
|
||||
},
|
||||
"planLabels": {
|
||||
"itemOne": "Workspaces",
|
||||
"itemTwo": "Members",
|
||||
"itemThree": "Guests",
|
||||
"tooltipThree": "Guests have read-only permission to the specifically shared content",
|
||||
"itemFour": "Guest collaborators",
|
||||
"tooltipFour": "Guest collaborators are billed as one seat",
|
||||
"itemFive": "Storage",
|
||||
"itemSix": "Real-time collaboration",
|
||||
"itemSeven": "Mobile app",
|
||||
"itemEight": "AI Responses",
|
||||
"tooltipEight": "Lifetime means the number of responses never reset"
|
||||
"itemThree": "Storage",
|
||||
"itemFour": "Real-time collaboration",
|
||||
"itemFive": "Mobile app",
|
||||
"itemSix": "AI Responses",
|
||||
"tooltipSix": "Lifetime means the number of responses never reset",
|
||||
"itemSeven": "Custom namespace",
|
||||
"tooltipSeven": "Allows you to customize part of the URL for your workspace"
|
||||
},
|
||||
"freeLabels": {
|
||||
"itemOne": "charged per workspace",
|
||||
"itemTwo": "3",
|
||||
"itemThree": "",
|
||||
"itemFour": "0",
|
||||
"itemFive": "5 GB",
|
||||
"itemSix": "yes",
|
||||
"itemSeven": "yes",
|
||||
"itemEight": "1,000 lifetime"
|
||||
"itemTwo": "up to 3",
|
||||
"itemThree": "5 GB",
|
||||
"itemFour": "yes",
|
||||
"itemFive": "yes",
|
||||
"itemSix": "100 lifetime",
|
||||
"itemSeven": ""
|
||||
},
|
||||
"proLabels": {
|
||||
"itemOne": "charged per workspace",
|
||||
"itemTwo": "up to 10",
|
||||
"itemThree": "",
|
||||
"itemFour": "10 guests billed as one seat",
|
||||
"itemFive": "unlimited",
|
||||
"itemSix": "yes",
|
||||
"itemSeven": "yes",
|
||||
"itemEight": "10,000 monthly"
|
||||
"itemThree": "unlimited",
|
||||
"itemFour": "yes",
|
||||
"itemFive": "yes",
|
||||
"itemSix": "unlimited",
|
||||
"itemSeven": ""
|
||||
},
|
||||
"paymentSuccess": {
|
||||
"title": "You are now on the {} plan!",
|
||||
|
@ -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, SignInUrlPB, SignInUrlPayloadPB,
|
||||
SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB,
|
||||
UserWorkspaceIdPB, UserWorkspacePB,
|
||||
OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, RepeatedWorkspaceSubscriptionPB,
|
||||
SignInUrlPB, SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB,
|
||||
UpdateUserProfilePayloadPB, UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB,
|
||||
};
|
||||
use flowy_user::errors::{FlowyError, FlowyResult};
|
||||
use flowy_user::event_map::UserEvent;
|
||||
@ -315,6 +315,14 @@ 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,3 +240,18 @@ 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);
|
||||
}
|
||||
|
@ -739,6 +739,7 @@ fn to_workspace_subscription(s: WorkspaceSubscriptionStatus) -> WorkspaceSubscri
|
||||
},
|
||||
},
|
||||
is_active: matches!(s.subscription_status, SubscriptionStatus::Active),
|
||||
has_canceled: s.canceled_at.is_some(),
|
||||
canceled_at: s.canceled_at,
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
drop table workspace_subscriptions_table;
|
@ -0,0 +1,11 @@
|
||||
-- Your SQL goes here
|
||||
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,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (workspace_id)
|
||||
);
|
@ -114,6 +114,18 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
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>,
|
||||
updated_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
chat_local_setting_table,
|
||||
chat_message_table,
|
||||
@ -125,4 +137,5 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
user_table,
|
||||
user_workspace_table,
|
||||
workspace_members_table,
|
||||
workspace_subscriptions_table,
|
||||
);
|
||||
|
@ -453,22 +453,65 @@ pub struct WorkspaceInvitation {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RecurringInterval {
|
||||
Month,
|
||||
Year,
|
||||
}
|
||||
|
||||
impl Into<i64> for RecurringInterval {
|
||||
fn into(self) -> i64 {
|
||||
match self {
|
||||
RecurringInterval::Month => 0,
|
||||
RecurringInterval::Year => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for RecurringInterval {
|
||||
fn from(value: i64) -> Self {
|
||||
match value {
|
||||
0 => RecurringInterval::Month,
|
||||
1 => RecurringInterval::Year,
|
||||
_ => RecurringInterval::Month,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SubscriptionPlan {
|
||||
None,
|
||||
Pro,
|
||||
Team,
|
||||
}
|
||||
|
||||
impl Into<i64> for SubscriptionPlan {
|
||||
fn into(self) -> i64 {
|
||||
match self {
|
||||
SubscriptionPlan::None => 0,
|
||||
SubscriptionPlan::Pro => 1,
|
||||
SubscriptionPlan::Team => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for SubscriptionPlan {
|
||||
fn from(value: i64) -> Self {
|
||||
match value {
|
||||
0 => SubscriptionPlan::None,
|
||||
1 => SubscriptionPlan::Pro,
|
||||
2 => SubscriptionPlan::Team,
|
||||
_ => SubscriptionPlan::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WorkspaceSubscription {
|
||||
pub workspace_id: String,
|
||||
pub subscription_plan: SubscriptionPlan,
|
||||
pub recurring_interval: RecurringInterval,
|
||||
pub is_active: bool,
|
||||
pub has_canceled: bool,
|
||||
pub canceled_at: Option<i64>,
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,14 @@
|
||||
use chrono::{TimeZone, Utc};
|
||||
use diesel::insert_into;
|
||||
use diesel::{RunQueryDsl, SqliteConnection};
|
||||
use flowy_error::FlowyError;
|
||||
use flowy_error::{FlowyError, FlowyResult};
|
||||
use flowy_sqlite::schema::user_workspace_table;
|
||||
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;
|
||||
use flowy_user_pub::entities::WorkspaceSubscription;
|
||||
use std::convert::TryFrom;
|
||||
|
||||
#[derive(Clone, Default, Queryable, Identifiable, Insertable)]
|
||||
@ -18,6 +22,19 @@ pub struct UserWorkspaceTable {
|
||||
pub icon: String,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Insertable, AsChangeset, Debug)]
|
||||
#[diesel(table_name = workspace_subscriptions_table)]
|
||||
#[diesel(primary_key(workspace_id))]
|
||||
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 updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
pub fn get_user_workspace_op(workspace_id: &str, mut conn: DBConnection) -> Option<UserWorkspace> {
|
||||
user_workspace_table::dsl::user_workspace_table
|
||||
.filter(user_workspace_table::id.eq(workspace_id))
|
||||
@ -74,6 +91,46 @@ pub fn insert_new_workspaces_op(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn select_workspace_subscription(
|
||||
mut conn: DBConnection,
|
||||
workspace_id: &str,
|
||||
) -> FlowyResult<WorkspaceSubscriptionsTable> {
|
||||
let subscription = dsl::workspace_subscriptions_table
|
||||
.filter(workspace_subscriptions_table::workspace_id.eq(workspace_id))
|
||||
.first::<WorkspaceSubscriptionsTable>(&mut conn)?;
|
||||
|
||||
Ok(subscription)
|
||||
}
|
||||
|
||||
pub fn upsert_workspace_subscription<T: Into<WorkspaceSubscriptionsTable>>(
|
||||
mut conn: DBConnection,
|
||||
subscription: T,
|
||||
) -> FlowyResult<()> {
|
||||
let subscription = subscription.into();
|
||||
|
||||
insert_into(workspace_subscriptions_table::table)
|
||||
.values(&subscription)
|
||||
.on_conflict((workspace_subscriptions_table::workspace_id,))
|
||||
.do_update()
|
||||
.set(&subscription)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl From<WorkspaceSubscriptionsTable> for WorkspaceSubscription {
|
||||
fn from(value: WorkspaceSubscriptionsTable) -> Self {
|
||||
Self {
|
||||
workspace_id: value.workspace_id,
|
||||
subscription_plan: value.subscription_plan.into(),
|
||||
recurring_interval: value.recurring_interval.into(),
|
||||
is_active: value.is_active,
|
||||
has_canceled: value.has_canceled,
|
||||
canceled_at: value.canceled_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<(i64, &UserWorkspace)> for UserWorkspaceTable {
|
||||
type Error = FlowyError;
|
||||
|
||||
|
@ -11,8 +11,9 @@ use flowy_folder_pub::entities::{AppFlowyData, ImportData};
|
||||
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, WorkspaceUsage,
|
||||
RecurringInterval, Role, SubscriptionPlan, UpdateUserProfileParams, UserWorkspace,
|
||||
WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, WorkspaceSubscription,
|
||||
WorkspaceUsage,
|
||||
};
|
||||
use lib_dispatch::prelude::af_spawn;
|
||||
|
||||
@ -30,7 +31,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, UserWorkspaceTable,
|
||||
get_all_user_workspace_op, get_user_workspace_op, insert_new_workspaces_op,
|
||||
select_workspace_subscription, upsert_workspace_subscription, UserWorkspaceTable,
|
||||
WorkspaceSubscriptionsTable,
|
||||
};
|
||||
use crate::user_manager::{upsert_user_profile_change, UserManager};
|
||||
use flowy_user_pub::session::Session;
|
||||
@ -447,12 +450,62 @@ impl UserManager {
|
||||
|
||||
#[instrument(level = "info", skip(self), err)]
|
||||
pub async fn get_workspace_subscriptions(&self) -> FlowyResult<Vec<WorkspaceSubscription>> {
|
||||
let res = self
|
||||
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.workspace_id,
|
||||
subscription_plan: subscription.subscription_plan.into(),
|
||||
recurring_interval: subscription.recurring_interval.into(),
|
||||
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)
|
||||
}
|
||||
|
||||
async fn get_workspace_subscriptions_from_remote(
|
||||
&self,
|
||||
uid: i64,
|
||||
) -> FlowyResult<Vec<WorkspaceSubscription>> {
|
||||
let subscriptions = self
|
||||
.cloud_services
|
||||
.get_user_service()?
|
||||
.get_workspace_subscriptions()
|
||||
.await?;
|
||||
Ok(res)
|
||||
|
||||
for subscription in &subscriptions {
|
||||
let db = self.authenticate_user.get_sqlite_connection(uid)?;
|
||||
let record = WorkspaceSubscriptionsTable {
|
||||
workspace_id: subscription.workspace_id.clone(),
|
||||
subscription_plan: <SubscriptionPlan as Into<i64>>::into(
|
||||
subscription.subscription_plan.clone(),
|
||||
),
|
||||
recurring_interval: <RecurringInterval as Into<i64>>::into(
|
||||
subscription.recurring_interval.clone(),
|
||||
),
|
||||
is_active: subscription.is_active,
|
||||
has_canceled: subscription.has_canceled,
|
||||
canceled_at: subscription.canceled_at.clone().into(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
upsert_workspace_subscription(db, record)?;
|
||||
}
|
||||
|
||||
Ok(subscriptions)
|
||||
}
|
||||
|
||||
#[instrument(level = "info", skip(self), err)]
|
||||
|
Loading…
Reference in New Issue
Block a user