feat: plan+billing (#5518)

* feat: billing client

* feat: subscribe workspace default impl

* feat: added create subscription

* feat: add get workspace subs

* feat: added subscription cancellation

* feat: add workspace limits api

* fix: update client api

* feat: user billing portal

* feat: billing UI (#5455)

* feat: plan ui

* feat: billing ui

* feat: settings plan comparison dialog

* feat: complete plan+billing ui

* feat: backend integration

* chore: cleaning

* chore: fixes after merge

* fix: dependency issue

* feat: added subscription plan cancellation information

* feat: subscription callback + canceled date

* feat: put behind feature flag

* feat: downgrade/upgrade dialogs

* feat: update limit error codes

* fix: billing refresh + downgrade dialog

* fix: some minor improvements to settings

* chore: use patch for client-api in tauri

* fix: add shared-entity to patch

* fix: compile

* ci: try to add back maximize build space step

* test: increase timeout in failing test

---------

Co-authored-by: Zack Fu Zi Xiang <speed2exe@live.com.sg>
This commit is contained in:
Mathias Mogensen 2024-06-12 17:08:55 +02:00 committed by GitHub
parent 3d7a500550
commit 4708c0f779
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 2769 additions and 83 deletions

View File

@ -32,14 +32,14 @@ jobs:
# swap-size-mb: 1024
# remove-dotnet: 'true'
# # the following step is required to avoid running out of space
# - name: Maximize build space
# run: |
# sudo rm -rf /usr/share/dotnet
# sudo rm -rf /opt/ghc
# sudo rm -rf "/usr/local/share/boost"
# sudo rm -rf "$AGENT_TOOLSDIRECTORY"
# sudo docker image prune --all --force
# the following step is required to avoid running out of space
- name: Maximize build space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
sudo docker image prune --all --force
- name: Checkout source code
uses: actions/checkout@v4

View File

@ -1,9 +1,10 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:string_validator/string_validator.dart';
import 'package:url_launcher/url_launcher.dart' as launcher;

View File

@ -32,6 +32,9 @@ enum FeatureFlag {
// used for the search feature
search,
// used for controlling whether to show plan+billing options in settings
planBilling,
// used for ignore the conflicted feature flag
unknown;
@ -104,6 +107,7 @@ enum FeatureFlag {
switch (this) {
case FeatureFlag.collaborativeWorkspace:
case FeatureFlag.membersSettings:
case FeatureFlag.planBilling:
case FeatureFlag.unknown:
return false;
case FeatureFlag.search:
@ -125,6 +129,8 @@ enum FeatureFlag {
return 'if it\'s on, the collaborators will show in the database';
case FeatureFlag.search:
return 'if it\'s on, the command palette and search button will be available';
case FeatureFlag.planBilling:
return 'if it\'s on, plan and billing pages will be available in Settings';
case FeatureFlag.unknown:
return '';
}

View File

@ -27,6 +27,7 @@ import 'package:appflowy/workspace/application/settings/appearance/desktop_appea
import 'package:appflowy/workspace/application/settings/appearance/mobile_appearance.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart';
import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
@ -168,6 +169,9 @@ void _resolveUserDeps(GetIt getIt, IntegrationMode mode) {
getIt.registerFactory<SplashBloc>(() => SplashBloc());
getIt.registerLazySingleton<NetworkListener>(() => NetworkListener());
getIt.registerLazySingleton<CachedRecentService>(() => CachedRecentService());
getIt.registerLazySingleton<SubscriptionSuccessListenable>(
() => SubscriptionSuccessListenable(),
);
}
void _resolveHomeDeps(GetIt getIt) {

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:app_links/app_links.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/startup/startup.dart';
@ -10,6 +12,7 @@ import 'package:appflowy/user/application/auth/auth_error.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/auth/device_id.dart';
import 'package:appflowy/user/application/user_auth_listener.dart';
import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
@ -17,7 +20,6 @@ import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/material.dart';
import 'package:url_protocol/url_protocol.dart';
class AppFlowyCloudDeepLink {
@ -92,6 +94,10 @@ class AppFlowyCloudDeepLink {
return;
}
if (_isPaymentSuccessUri(uri)) {
return getIt<SubscriptionSuccessListenable>().onPaymentSuccess();
}
return _isAuthCallbackDeepLink(uri).fold(
(_) async {
final deviceId = await getDeviceId();
@ -160,6 +166,10 @@ class AppFlowyCloudDeepLink {
..msg = uri.path,
);
}
bool _isPaymentSuccessUri(Uri uri) {
return uri.host == 'payment-success';
}
}
class InitAppFlowyCloudTask extends LaunchTask {

View File

@ -1,5 +1,7 @@
import 'dart:async';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/startup/startup.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';
@ -8,9 +10,7 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:fixnum/fixnum.dart';
class UserBackendService {
UserBackendService({
required this.userId,
});
UserBackendService({required this.userId});
final Int64 userId;
@ -219,4 +219,29 @@ class UserBackendService {
final data = UserWorkspaceIdPB.create()..workspaceId = workspaceId;
return UserEventLeaveWorkspace(data).send();
}
static Future<FlowyResult<RepeatedWorkspaceSubscriptionPB, FlowyError>>
getWorkspaceSubscriptions() {
return UserEventGetWorkspaceSubscriptions().send();
}
static Future<FlowyResult<PaymentLinkPB, FlowyError>> createSubscription(
String workspaceId,
SubscriptionPlanPB plan,
) {
final request = SubscribeWorkspacePB()
..workspaceId = workspaceId
..recurringInterval = RecurringIntervalPB.Month
..workspaceSubscriptionPlan = plan
..successUrl =
'${getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.base_url}/web/payment-success';
return UserEventSubscribeWorkspace(request).send();
}
static Future<FlowyResult<void, FlowyError>> cancelSubscription(
String workspaceId,
) {
final request = UserWorkspaceIdPB()..workspaceId = workspaceId;
return UserEventCancelWorkspaceSubscription(request).send();
}
}

View File

@ -0,0 +1,112 @@
import 'package:flutter/foundation.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/workspace/workspace_service.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:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'settings_billing_bloc.freezed.dart';
class SettingsBillingBloc
extends Bloc<SettingsBillingEvent, SettingsBillingState> {
SettingsBillingBloc({
required this.workspaceId,
}) : super(const _Initial()) {
_service = WorkspaceService(workspaceId: workspaceId);
on<SettingsBillingEvent>((event, emit) async {
await event.when(
started: () async {
emit(const SettingsBillingState.loading());
final snapshots = await Future.wait([
UserBackendService.getWorkspaceSubscriptions(),
_service.getBillingPortal(),
]);
FlowyError? error;
final subscription = snapshots.first.fold(
(s) =>
(s as RepeatedWorkspaceSubscriptionPB)
.items
.firstWhereOrNull((i) => i.workspaceId == workspaceId) ??
WorkspaceSubscriptionPB(
workspaceId: workspaceId,
subscriptionPlan: SubscriptionPlanPB.None,
isActive: true,
),
(e) {
// Not a Cjstomer yet
if (e.code == ErrorCode.InvalidParams) {
return WorkspaceSubscriptionPB(
workspaceId: workspaceId,
subscriptionPlan: SubscriptionPlanPB.None,
isActive: true,
);
}
error = e;
return null;
},
);
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) {
return emit(SettingsBillingState.error(error: error));
}
emit(
SettingsBillingState.ready(
subscription: subscription,
billingPortal: billingPortal,
),
);
},
);
});
}
late final String workspaceId;
late final WorkspaceService _service;
}
@freezed
class SettingsBillingEvent with _$SettingsBillingEvent {
const factory SettingsBillingEvent.started() = _Started;
}
@freezed
class SettingsBillingState with _$SettingsBillingState {
const factory SettingsBillingState.initial() = _Initial;
const factory SettingsBillingState.loading() = _Loading;
const factory SettingsBillingState.error({
@Default(null) FlowyError? error,
}) = _Error;
const factory SettingsBillingState.ready({
required WorkspaceSubscriptionPB subscription,
required BillingPortalPB? billingPortal,
}) = _Ready;
}

View File

@ -0,0 +1,174 @@
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:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'settings_plan_bloc.freezed.dart';
class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
SettingsPlanBloc({
required this.workspaceId,
}) : super(const _Initial()) {
_service = WorkspaceService(workspaceId: workspaceId);
_successListenable = getIt<SubscriptionSuccessListenable>();
_successListenable.addListener(_onPaymentSuccessful);
on<SettingsPlanEvent>((event, emit) async {
await event.when(
started: (withShowSuccessful) async {
emit(const SettingsPlanState.loading());
final snapshots = await Future.wait([
_service.getWorkspaceUsage(),
UserBackendService.getWorkspaceSubscriptions(),
_service.getBillingPortal(),
]);
FlowyError? error;
final usageResult = snapshots.first.fold(
(s) => s as WorkspaceUsagePB,
(f) {
error = f;
return null;
},
);
final subscription = snapshots[1].fold(
(s) =>
(s as RepeatedWorkspaceSubscriptionPB)
.items
.firstWhereOrNull((i) => i.workspaceId == workspaceId) ??
WorkspaceSubscriptionPB(
workspaceId: workspaceId,
subscriptionPlan: SubscriptionPlanPB.None,
isActive: true,
),
(f) {
error = f;
return null;
},
);
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) {
return emit(SettingsPlanState.error(error: error));
}
emit(
SettingsPlanState.ready(
workspaceUsage: usageResult,
subscription: subscription,
billingPortal: billingPortal,
showSuccessDialog: withShowSuccessful,
),
);
if (withShowSuccessful) {
emit(
SettingsPlanState.ready(
workspaceUsage: usageResult,
subscription: subscription,
billingPortal: billingPortal,
),
);
}
},
addSubscription: (plan) async {
final result = await UserBackendService.createSubscription(
workspaceId,
SubscriptionPlanPB.Pro,
);
result.fold(
(pl) => afLaunchUrlString(pl.paymentLink),
(f) => Log.error(f.msg, f),
);
},
cancelSubscription: () async {
await UserBackendService.cancelSubscription(workspaceId);
add(const SettingsPlanEvent.started());
},
paymentSuccessful: () {
final readyState = state.mapOrNull(ready: (state) => state);
if (readyState == null) {
return;
}
add(const SettingsPlanEvent.started(withShowSuccessful: true));
},
);
});
}
late final String workspaceId;
late final WorkspaceService _service;
late final SubscriptionSuccessListenable _successListenable;
void _onPaymentSuccessful() {
add(const SettingsPlanEvent.paymentSuccessful());
}
@override
Future<void> close() async {
_successListenable.removeListener(_onPaymentSuccessful);
return super.close();
}
}
@freezed
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;
}
@freezed
class SettingsPlanState with _$SettingsPlanState {
const factory SettingsPlanState.initial() = _Initial;
const factory SettingsPlanState.loading() = _Loading;
const factory SettingsPlanState.error({
@Default(null) FlowyError? error,
}) = _Error;
const factory SettingsPlanState.ready({
required WorkspaceUsagePB workspaceUsage,
required WorkspaceSubscriptionPB subscription,
required BillingPortalPB? billingPortal,
@Default(false) bool showSuccessDialog,
}) = _Ready;
}

View File

@ -0,0 +1,16 @@
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:easy_localization/easy_localization.dart';
extension SubscriptionLabels on WorkspaceSubscriptionPB {
String get label => switch (subscriptionPlan) {
SubscriptionPlanPB.None =>
LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(),
SubscriptionPlanPB.Pro =>
LocaleKeys.settings_planPage_planUsage_currentPlan_proTitle.tr(),
SubscriptionPlanPB.Team =>
LocaleKeys.settings_planPage_planUsage_currentPlan_teamTitle.tr(),
_ => 'N/A',
};
}

View File

@ -0,0 +1,8 @@
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
extension PresentableUsage on WorkspaceUsagePB {
String get totalBlobInGb =>
(totalBlobBytesLimit.toInt() / 1024 / 1024 / 1024).round().toString();
String get currentBlobInGb =>
(totalBlobBytes.toInt() / 1024 / 1024 / 1024).round().toString();
}

View File

@ -13,6 +13,8 @@ enum SettingsPage {
account,
workspace,
manageData,
plan,
billing,
// OLD
notifications,
cloud,
@ -81,14 +83,12 @@ class SettingsDialogEvent with _$SettingsDialogEvent {
class SettingsDialogState with _$SettingsDialogState {
const factory SettingsDialogState({
required UserProfilePB userProfile,
required FlowyResult<void, String> successOrFailure,
required SettingsPage page,
}) = _SettingsDialogState;
factory SettingsDialogState.initial(UserProfilePB userProfile) =>
SettingsDialogState(
userProfile: userProfile,
successOrFailure: FlowyResult.success(null),
page: SettingsPage.account,
);
}

View File

@ -0,0 +1,7 @@
import 'package:flutter/foundation.dart';
class SubscriptionSuccessListenable extends ChangeNotifier {
SubscriptionSuccessListenable();
void onPaymentSuccess() => notifyListeners();
}

View File

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
class WorkspaceService {
@ -70,4 +71,13 @@ class WorkspaceService {
return FolderEventMoveView(payload).send();
}
Future<FlowyResult<WorkspaceUsagePB, FlowyError>> getWorkspaceUsage() {
final payload = UserWorkspaceIdPB(workspaceId: workspaceId);
return UserEventGetWorkspaceUsage(payload).send();
}
Future<FlowyResult<BillingPortalPB, FlowyError>> getBillingPortal() {
return UserEventGetBillingPortal().send();
}
}

View File

@ -79,6 +79,11 @@ void showSettingsDialog(BuildContext context, UserProfilePB userProfile) {
],
child: SettingsDialog(
userProfile,
workspaceId: context
.read<UserWorkspaceBloc>()
.state
.currentWorkspace!
.workspaceId,
didLogout: () async {
// Pop the dialog using the dialog context
Navigator.of(dialogContext).pop();

View File

@ -436,7 +436,7 @@ class _UserProfileSettingState extends State<UserProfileSetting> {
iconUrl: widget.iconUrl,
name: widget.name,
size: 48,
fontSize: 24,
fontSize: 20,
isHovering: isHovering,
),
),
@ -445,7 +445,7 @@ class _UserProfileSettingState extends State<UserProfileSetting> {
const HSpace(16),
if (!isEditing) ...[
Padding(
padding: const EdgeInsets.only(top: 20),
padding: const EdgeInsets.only(top: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [

View File

@ -0,0 +1,138 @@
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';
import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart';
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:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../generated/locale_keys.g.dart';
class SettingsBillingView extends StatelessWidget {
const SettingsBillingView({super.key, required this.workspaceId});
final String workspaceId;
@override
Widget build(BuildContext context) {
return BlocProvider<SettingsBillingBloc>(
create: (context) => SettingsBillingBloc(workspaceId: workspaceId)
..add(const SettingsBillingEvent.started()),
child: BlocBuilder<SettingsBillingBloc, SettingsBillingState>(
builder: (context, state) {
return state.map(
initial: (_) => const SizedBox.shrink(),
loading: (_) => const Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator.adaptive(strokeWidth: 3),
),
),
error: (state) {
if (state.error != null) {
return Padding(
padding: const EdgeInsets.all(16),
child: FlowyErrorPage.message(
state.error!.msg,
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
);
}
return ErrorWidget.withDetails(message: 'Something went wrong!');
},
ready: (state) {
final billingPortalEnabled = state.billingPortal != null &&
state.billingPortal!.url.isNotEmpty;
return SettingsBody(
title: LocaleKeys.settings_billingPage_title.tr(),
children: [
SettingsCategory(
title: LocaleKeys.settings_billingPage_plan_title.tr(),
children: [
SingleSettingAction(
onPressed: () => _openPricingDialog(
context,
workspaceId,
state.subscription,
),
fontWeight: FontWeight.w500,
label: state.subscription.label,
buttonLabel: LocaleKeys
.settings_billingPage_plan_planButtonLabel
.tr(),
),
if (billingPortalEnabled)
SingleSettingAction(
onPressed: () =>
afLaunchUrlString(state.billingPortal!.url),
label: LocaleKeys
.settings_billingPage_plan_billingPeriod
.tr(),
fontWeight: FontWeight.w500,
buttonLabel: LocaleKeys
.settings_billingPage_plan_periodButtonLabel
.tr(),
),
],
),
if (billingPortalEnabled)
SettingsCategory(
title: LocaleKeys
.settings_billingPage_paymentDetails_title
.tr(),
children: [
SingleSettingAction(
onPressed: () =>
afLaunchUrlString(state.billingPortal!.url),
label: LocaleKeys
.settings_billingPage_paymentDetails_methodLabel
.tr(),
fontWeight: FontWeight.w500,
buttonLabel: LocaleKeys
.settings_billingPage_paymentDetails_methodButtonLabel
.tr(),
),
],
),
],
);
},
);
},
),
);
}
void _openPricingDialog(
BuildContext context,
String workspaceId,
WorkspaceSubscriptionPB subscription,
) =>
showDialog<bool?>(
context: context,
builder: (_) => BlocProvider<SettingsPlanBloc>(
create: (_) => SettingsPlanBloc(workspaceId: workspaceId)
..add(const SettingsPlanEvent.started()),
child: SettingsPlanComparisonDialog(
workspaceId: workspaceId,
subscription: subscription,
),
),
).then((didChangePlan) {
if (didChangePlan == true) {
context
.read<SettingsBillingBloc>()
.add(const SettingsBillingEvent.started());
}
});
}

View File

@ -59,7 +59,10 @@ class SettingsManageDataView extends StatelessWidget {
tooltip: LocaleKeys
.settings_manageDataPage_dataStorage_actions_resetTooltip
.tr(),
icon: const FlowySvg(FlowySvgs.restore_s),
icon: const FlowySvg(
FlowySvgs.restore_s,
size: Size.square(20),
),
label: LocaleKeys.settings_common_reset.tr(),
onPressed: () => SettingsAlertDialog(
title: LocaleKeys
@ -492,7 +495,7 @@ class _DataPathActions extends StatelessWidget {
.tr(),
label:
LocaleKeys.settings_manageDataPage_dataStorage_actions_open.tr(),
icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(16)),
icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(20)),
onPressed: () => afLaunchUrlString('file://$currentPath'),
),
],

View File

@ -0,0 +1,594 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.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: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';
class SettingsPlanComparisonDialog extends StatefulWidget {
const SettingsPlanComparisonDialog({
super.key,
required this.workspaceId,
required this.subscription,
});
final String workspaceId;
final WorkspaceSubscriptionPB subscription;
@override
State<SettingsPlanComparisonDialog> createState() =>
_SettingsPlanComparisonDialogState();
}
class _SettingsPlanComparisonDialogState
extends State<SettingsPlanComparisonDialog> {
final horizontalController = ScrollController();
final verticalController = ScrollController();
late WorkspaceSubscriptionPB currentSubscription = widget.subscription;
@override
void dispose() {
horizontalController.dispose();
verticalController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<SettingsPlanBloc, SettingsPlanState>(
listener: (context, state) {
final readyState = state.mapOrNull(ready: (state) => state);
if (readyState == null) {
return;
}
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
.settings_comparePlanDialog_paymentSuccess_description
.tr(
args: [readyState.subscription.label],
),
hideCancelButton: true,
confirm: Navigator.of(context).pop,
confirmLabel: LocaleKeys.button_close.tr(),
).show(context);
}
setState(() {
currentSubscription = readyState.subscription;
});
},
child: FlowyDialog(
constraints: const BoxConstraints(maxWidth: 784, minWidth: 674),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 24, left: 24, right: 24),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.semibold(
LocaleKeys.settings_comparePlanDialog_title.tr(),
fontSize: 24,
),
const Spacer(),
GestureDetector(
onTap: () => Navigator.of(context).pop(
currentSubscription.subscriptionPlan !=
widget.subscription.subscriptionPlan,
),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: FlowySvg(
FlowySvgs.m_close_m,
size: const Size.square(20),
color: Theme.of(context).colorScheme.outline,
),
),
),
],
),
),
Flexible(
child: SingleChildScrollView(
controller: horizontalController,
scrollDirection: Axis.horizontal,
child: SingleChildScrollView(
controller: verticalController,
padding:
const EdgeInsets.only(left: 24, right: 24, bottom: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 250,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(22),
SizedBox(
height: 100,
child: FlowyText.semibold(
LocaleKeys
.settings_comparePlanDialog_planFeatures
.tr(),
fontSize: 24,
maxLines: 2,
color: const Color(0xFF5C3699),
),
),
const SizedBox(height: 64),
const SizedBox(height: 56),
..._planLabels.map(
(e) => _ComparisonCell(
label: e.label,
tooltip: e.tooltip,
),
),
],
),
),
_PlanTable(
title: LocaleKeys
.settings_comparePlanDialog_freePlan_title
.tr(),
description: LocaleKeys
.settings_comparePlanDialog_freePlan_description
.tr(),
price: LocaleKeys
.settings_comparePlanDialog_freePlan_price
.tr(),
priceInfo: LocaleKeys
.settings_comparePlanDialog_freePlan_priceInfo
.tr(),
cells: _freeLabels,
isCurrent: currentSubscription.subscriptionPlan ==
SubscriptionPlanPB.None,
canDowngrade:
currentSubscription.subscriptionPlan !=
SubscriptionPlanPB.None,
onSelected: () async {
if (currentSubscription.subscriptionPlan ==
SubscriptionPlanPB.None ||
currentSubscription.hasCanceled) {
return;
}
await SettingsAlertDialog(
title: LocaleKeys
.settings_comparePlanDialog_downgradeDialog_title
.tr(args: [currentSubscription.label]),
subtitle: LocaleKeys
.settings_comparePlanDialog_downgradeDialog_description
.tr(),
isDangerous: true,
confirm: () {
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent
.cancelSubscription(),
);
Navigator.of(context).pop();
},
confirmLabel: LocaleKeys
.settings_comparePlanDialog_downgradeDialog_downgradeLabel
.tr(),
).show(context);
},
),
_PlanTable(
title: LocaleKeys
.settings_comparePlanDialog_proPlan_title
.tr(),
description: LocaleKeys
.settings_comparePlanDialog_proPlan_description
.tr(),
price: LocaleKeys
.settings_comparePlanDialog_proPlan_price
.tr(),
priceInfo: LocaleKeys
.settings_comparePlanDialog_proPlan_priceInfo
.tr(),
cells: _proLabels,
isCurrent: currentSubscription.subscriptionPlan ==
SubscriptionPlanPB.Pro,
canUpgrade: currentSubscription.subscriptionPlan ==
SubscriptionPlanPB.None,
onSelected: () =>
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
SubscriptionPlanPB.Pro,
),
),
),
],
),
],
),
),
),
),
],
),
),
);
}
}
class _PlanTable extends StatelessWidget {
const _PlanTable({
required this.title,
required this.description,
required this.price,
required this.priceInfo,
required this.cells,
required this.isCurrent,
required this.onSelected,
this.canUpgrade = false,
this.canDowngrade = false,
});
final String title;
final String description;
final String price;
final String priceInfo;
final List<String> cells;
final bool isCurrent;
final VoidCallback onSelected;
final bool canUpgrade;
final bool canDowngrade;
@override
Widget build(BuildContext context) {
final highlightPlan = !isCurrent && !canDowngrade && canUpgrade;
return Container(
width: 210,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: !highlightPlan
? null
: const LinearGradient(
colors: [
Color(0xFF251D37),
Color(0xFF7547C0),
],
),
),
padding: !highlightPlan
? const EdgeInsets.only(top: 4)
: const EdgeInsets.all(4),
child: Container(
clipBehavior: Clip.antiAlias,
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
color: Theme.of(context).cardColor,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Heading(
title: title,
description: description,
isPrimary: !highlightPlan,
horizontalInset: 12,
),
_Heading(
title: price,
description: priceInfo,
isPrimary: !highlightPlan,
height: 64,
horizontalInset: 12,
),
if (canUpgrade || canDowngrade) ...[
Padding(
padding: const EdgeInsets.only(left: 12),
child: _ActionButton(
label: canUpgrade && !canDowngrade
? LocaleKeys.settings_comparePlanDialog_actions_upgrade
.tr()
: LocaleKeys.settings_comparePlanDialog_actions_downgrade
.tr(),
onPressed: onSelected,
isUpgrade: canUpgrade && !canDowngrade,
useGradientBorder: !isCurrent && canUpgrade,
),
),
] else if (isCurrent) ...[
Padding(
padding: const EdgeInsets.only(left: 12),
child: _ActionButton(
label: LocaleKeys.settings_comparePlanDialog_actions_current
.tr(),
onPressed: () {},
isUpgrade: canUpgrade && !canDowngrade,
useGradientBorder: !isCurrent && canUpgrade,
),
),
] else ...[
const SizedBox(height: 56),
],
...cells.map((e) => _ComparisonCell(label: e)),
],
),
),
);
}
}
class _ComparisonCell extends StatelessWidget {
const _ComparisonCell({required this.label, this.tooltip});
final String label;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
height: 36,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(child: FlowyText.medium(label)),
if (tooltip != null)
FlowyTooltip(
message: tooltip,
child: const FlowySvg(FlowySvgs.information_s),
),
],
),
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.label,
required this.onPressed,
required this.isUpgrade,
this.useGradientBorder = false,
});
final String label;
final VoidCallback onPressed;
final bool isUpgrade;
final bool useGradientBorder;
@override
Widget build(BuildContext context) {
final isLM = Theme.of(context).brightness == Brightness.light;
final gradientBorder = useGradientBorder && isLM;
return SizedBox(
height: 56,
child: Row(
children: [
GestureDetector(
onTap: onPressed,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: _drawGradientBorder(
isLM: isLM,
child: Container(
height: gradientBorder ? 36 : 40,
width: gradientBorder ? 148 : 152,
decoration: BoxDecoration(
color: gradientBorder
? Theme.of(context).cardColor
: Colors.transparent,
border: Border.all(
color: gradientBorder
? Colors.transparent
: AFThemeExtension.of(context).textColor,
),
borderRadius:
BorderRadius.circular(gradientBorder ? 14 : 16),
),
child: Center(
child: _drawText(
label,
isLM,
),
),
),
),
),
),
],
),
);
}
Widget _drawText(String text, bool isLM) {
final child = FlowyText(
text,
fontSize: 14,
fontWeight: useGradientBorder ? FontWeight.w600 : FontWeight.w500,
);
if (!useGradientBorder || !isLM) {
return child;
}
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => const LinearGradient(
transform: GradientRotation(-1.55),
stops: [0.4, 1],
colors: [
Color(0xFF251D37),
Color(0xFF7547C0),
],
).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
child: child,
);
}
Widget _drawGradientBorder({required bool isLM, required Widget child}) {
if (!useGradientBorder || !isLM) {
return child;
}
return Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
gradient: const LinearGradient(
transform: GradientRotation(-1.2),
stops: [0.4, 1],
colors: [
Color(0xFF251D37),
Color(0xFF7547C0),
],
),
borderRadius: BorderRadius.circular(16),
),
child: child,
);
}
}
class _Heading extends StatelessWidget {
const _Heading({
required this.title,
this.description,
this.isPrimary = true,
this.height = 100,
this.horizontalInset = 0,
});
final String title;
final String? description;
final bool isPrimary;
final double height;
final double horizontalInset;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 165,
height: height,
child: Padding(
padding: EdgeInsets.only(left: horizontalInset),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.semibold(
title,
fontSize: 24,
color: isPrimary ? null : const Color(0xFF5C3699),
),
if (description != null && description!.isNotEmpty) ...[
const VSpace(4),
FlowyText.regular(
description!,
fontSize: 12,
maxLines: 3,
),
],
],
),
),
);
}
}
class _PlanItem {
const _PlanItem({required this.label, this.tooltip});
final String label;
final String? tooltip;
}
final _planLabels = [
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemOne.tr(),
),
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemTwo.tr(),
),
_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(),
),
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSeven.tr(),
),
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemEight.tr(),
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipEight.tr(),
),
];
final _freeLabels = [
LocaleKeys.settings_comparePlanDialog_freeLabels_itemOne.tr(),
LocaleKeys.settings_comparePlanDialog_freeLabels_itemTwo.tr(),
LocaleKeys.settings_comparePlanDialog_freeLabels_itemThree.tr(),
LocaleKeys.settings_comparePlanDialog_freeLabels_itemFour.tr(),
LocaleKeys.settings_comparePlanDialog_freeLabels_itemFive.tr(),
LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(),
LocaleKeys.settings_comparePlanDialog_freeLabels_itemSeven.tr(),
LocaleKeys.settings_comparePlanDialog_freeLabels_itemEight.tr(),
];
final _proLabels = [
LocaleKeys.settings_comparePlanDialog_proLabels_itemOne.tr(),
LocaleKeys.settings_comparePlanDialog_proLabels_itemTwo.tr(),
LocaleKeys.settings_comparePlanDialog_proLabels_itemThree.tr(),
LocaleKeys.settings_comparePlanDialog_proLabels_itemFour.tr(),
LocaleKeys.settings_comparePlanDialog_proLabels_itemFive.tr(),
LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(),
LocaleKeys.settings_comparePlanDialog_proLabels_itemSeven.tr(),
LocaleKeys.settings_comparePlanDialog_proLabels_itemEight.tr(),
];

View File

@ -0,0 +1,732 @@
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/int64_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.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/application/settings/plan/workspace_usage_ext.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart';
import 'package:appflowy/workspace/presentation/settings/shared/flowy_gradient_button.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.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/error_page.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SettingsPlanView extends StatelessWidget {
const SettingsPlanView({super.key, required this.workspaceId});
final String workspaceId;
@override
Widget build(BuildContext context) {
return BlocProvider<SettingsPlanBloc>(
create: (context) => SettingsPlanBloc(workspaceId: workspaceId)
..add(const SettingsPlanEvent.started()),
child: BlocBuilder<SettingsPlanBloc, SettingsPlanState>(
builder: (context, state) {
return state.map(
initial: (_) => const SizedBox.shrink(),
loading: (_) => const Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator.adaptive(strokeWidth: 3),
),
),
error: (state) {
if (state.error != null) {
return Padding(
padding: const EdgeInsets.all(16),
child: FlowyErrorPage.message(
state.error!.msg,
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
);
}
return ErrorWidget.withDetails(message: 'Something went wrong!');
},
ready: (state) {
return SettingsBody(
autoSeparate: false,
title: LocaleKeys.settings_planPage_title.tr(),
children: [
_PlanUsageSummary(
usage: state.workspaceUsage,
subscription: state.subscription,
),
_CurrentPlanBox(subscription: state.subscription),
],
);
},
);
},
),
);
}
}
class _CurrentPlanBox extends StatelessWidget {
const _CurrentPlanBox({required this.subscription});
final WorkspaceSubscriptionPB subscription;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Container(
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFBDBDBD)),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.semibold(
subscription.label,
fontSize: 24,
),
const VSpace(4),
FlowyText.regular(
LocaleKeys
.settings_planPage_planUsage_currentPlan_freeInfo
.tr(),
fontSize: 16,
maxLines: 3,
),
const VSpace(16),
FlowyGradientButton(
label: LocaleKeys
.settings_planPage_planUsage_currentPlan_upgrade
.tr(),
onPressed: () => _openPricingDialog(
context,
context.read<SettingsPlanBloc>().workspaceId,
subscription,
),
),
if (subscription.hasCanceled) ...[
const VSpace(12),
FlowyText(
LocaleKeys
.settings_planPage_planUsage_currentPlan_canceledInfo
.tr(
args: [_canceledDate(context)],
),
maxLines: 5,
fontSize: 12,
color: Theme.of(context).colorScheme.error,
),
],
],
),
),
const HSpace(16),
Expanded(
child: SeparatedColumn(
separatorBuilder: () => const VSpace(4),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
..._getPros(subscription.subscriptionPlan).map(
(s) => _ProConItem(label: s),
),
..._getCons(subscription.subscriptionPlan).map(
(s) => _ProConItem(label: s, isPro: false),
),
],
),
),
],
),
),
Positioned(
top: 0,
left: 0,
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(color: Color(0xFF4F3F5F)),
child: Center(
child: FlowyText.semibold(
LocaleKeys.settings_planPage_planUsage_currentPlan_bannerLabel
.tr(),
fontSize: 16,
color: Colors.white,
),
),
),
),
],
);
}
String _canceledDate(BuildContext context) {
final appearance = context.read<AppearanceSettingsCubit>().state;
return appearance.dateFormat.formatDate(
subscription.canceledAt.toDateTime(),
true,
appearance.timeFormat,
);
}
void _openPricingDialog(
BuildContext context,
String workspaceId,
WorkspaceSubscriptionPB subscription,
) =>
showDialog(
context: context,
builder: (_) => BlocProvider<SettingsPlanBloc>.value(
value: context.read<SettingsPlanBloc>(),
child: SettingsPlanComparisonDialog(
workspaceId: workspaceId,
subscription: subscription,
),
),
);
List<String> _getPros(SubscriptionPlanPB plan) => switch (plan) {
SubscriptionPlanPB.Pro => _proPros(),
_ => _freePros(),
};
List<String> _getCons(SubscriptionPlanPB plan) => switch (plan) {
SubscriptionPlanPB.Pro => _proCons(),
_ => _freeCons(),
};
List<String> _freePros() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProOne.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProTwo.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProThree.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProFour.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProFive.tr(),
];
List<String> _freeCons() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_freeConOne.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeConTwo.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeConThree.tr(),
];
List<String> _proPros() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProOne
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProTwo
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProThree
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProFour
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProFive
.tr(),
];
List<String> _proCons() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConOne
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConTwo
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConThree
.tr(),
];
}
class _ProConItem extends StatelessWidget {
const _ProConItem({
required this.label,
this.isPro = true,
});
final String label;
final bool isPro;
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
height: 24,
width: 24,
child: FlowySvg(
isPro ? FlowySvgs.check_s : FlowySvgs.close_s,
color: isPro ? null : const Color(0xFF900000),
),
),
const HSpace(4),
Flexible(
child: FlowyText.regular(
label,
fontSize: 12,
maxLines: 2,
),
),
],
);
}
}
class _PlanUsageSummary extends StatelessWidget {
const _PlanUsageSummary({required this.usage, required this.subscription});
final WorkspaceUsagePB usage;
final WorkspaceSubscriptionPB subscription;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.semibold(
LocaleKeys.settings_planPage_planUsage_title.tr(),
maxLines: 2,
fontSize: 16,
overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).secondaryTextColor,
),
const VSpace(16),
Row(
children: [
Expanded(
child: _UsageBox(
title: LocaleKeys.settings_planPage_planUsage_storageLabel.tr(),
label: LocaleKeys.settings_planPage_planUsage_storageUsage.tr(
args: [
usage.currentBlobInGb,
usage.totalBlobInGb,
],
),
value: usage.totalBlobBytes.toInt() /
usage.totalBlobBytesLimit.toInt(),
),
),
Expanded(
child: _UsageBox(
title: LocaleKeys.settings_planPage_planUsage_collaboratorsLabel
.tr(),
label: LocaleKeys.settings_planPage_planUsage_collaboratorsUsage
.tr(
args: [
usage.memberCount.toString(),
usage.memberCountLimit.toString(),
],
),
value:
usage.memberCount.toInt() / usage.memberCountLimit.toInt(),
),
),
],
),
const VSpace(16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ToggleMore(
value: subscription.subscriptionPlan == SubscriptionPlanPB.Pro,
label:
LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(),
subscription: subscription,
badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(),
),
const VSpace(8),
_ToggleMore(
value: subscription.subscriptionPlan == SubscriptionPlanPB.Pro,
label:
LocaleKeys.settings_planPage_planUsage_guestCollabToggle.tr(),
subscription: subscription,
badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(),
),
],
),
],
);
}
}
class _UsageBox extends StatelessWidget {
const _UsageBox({
required this.title,
required this.label,
required this.value,
});
final String title;
final String label;
final double value;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.regular(
title,
fontSize: 11,
color: AFThemeExtension.of(context).secondaryTextColor,
),
_PlanProgressIndicator(label: label, progress: value),
],
);
}
}
class _ToggleMore extends StatefulWidget {
const _ToggleMore({
required this.value,
required this.label,
required this.subscription,
this.badgeLabel,
});
final bool value;
final String label;
final WorkspaceSubscriptionPB subscription;
final String? badgeLabel;
@override
State<_ToggleMore> createState() => _ToggleMoreState();
}
class _ToggleMoreState extends State<_ToggleMore> {
late bool toggleValue = widget.value;
@override
Widget build(BuildContext context) {
final isLM = Brightness.light == Theme.of(context).brightness;
final primaryColor =
isLM ? const Color(0xFF653E8C) : const Color(0xFFE8E2EE);
final secondaryColor =
isLM ? const Color(0xFFE8E2EE) : const Color(0xFF653E8C);
return Row(
children: [
Toggle(
value: toggleValue,
padding: EdgeInsets.zero,
style: ToggleStyle.big,
onChanged: (_) {
setState(() => toggleValue = !toggleValue);
Future.delayed(const Duration(milliseconds: 150), () {
if (mounted) {
showDialog(
context: context,
builder: (_) => BlocProvider<SettingsPlanBloc>.value(
value: context.read<SettingsPlanBloc>(),
child: SettingsPlanComparisonDialog(
workspaceId: context.read<SettingsPlanBloc>().workspaceId,
subscription: widget.subscription,
),
),
).then((_) {
Future.delayed(const Duration(milliseconds: 150), () {
if (mounted) {
setState(() => toggleValue = !toggleValue);
}
});
});
}
});
},
),
const HSpace(10),
FlowyText.regular(widget.label, fontSize: 14),
if (widget.badgeLabel != null && widget.badgeLabel!.isNotEmpty) ...[
const HSpace(10),
SizedBox(
height: 26,
child: Badge(
padding: const EdgeInsets.symmetric(horizontal: 10),
backgroundColor: secondaryColor,
label: FlowyText.semibold(
widget.badgeLabel!,
fontSize: 12,
color: primaryColor,
),
),
),
],
],
);
}
}
class _PlanProgressIndicator extends StatelessWidget {
const _PlanProgressIndicator({required this.label, required this.progress});
final String label;
final double progress;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Container(
height: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: AFThemeExtension.of(context).progressBarBGColor,
border: Border.all(
color: const Color(0xFFDDF1F7).withOpacity(
theme.brightness == Brightness.light ? 1 : 0.1,
),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
FractionallySizedBox(
widthFactor: progress,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primary,
),
),
),
],
),
),
),
),
const HSpace(8),
FlowyText.medium(
label,
fontSize: 11,
color: AFThemeExtension.of(context).secondaryTextColor,
),
const HSpace(16),
],
);
}
}
/// Uncomment if we need it in the future
// class _DealBox extends StatelessWidget {
// const _DealBox();
// @override
// Widget build(BuildContext context) {
// final isLM = Theme.of(context).brightness == Brightness.light;
// return Container(
// clipBehavior: Clip.antiAlias,
// decoration: BoxDecoration(
// gradient: LinearGradient(
// stops: isLM ? null : [.2, .3, .6],
// transform: isLM ? null : const GradientRotation(-.9),
// begin: isLM ? Alignment.centerLeft : Alignment.topRight,
// end: isLM ? Alignment.centerRight : Alignment.bottomLeft,
// colors: [
// isLM
// ? const Color(0xFF7547C0).withAlpha(60)
// : const Color(0xFF7547C0),
// if (!isLM) const Color.fromARGB(255, 94, 57, 153),
// isLM
// ? const Color(0xFF251D37).withAlpha(60)
// : const Color(0xFF251D37),
// ],
// ),
// borderRadius: BorderRadius.circular(16),
// ),
// child: Stack(
// children: [
// Padding(
// padding: const EdgeInsets.all(16),
// child: Row(
// children: [
// Expanded(
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// const VSpace(18),
// FlowyText.semibold(
// LocaleKeys.settings_planPage_planUsage_deal_title.tr(),
// fontSize: 24,
// color: Theme.of(context).colorScheme.tertiary,
// ),
// const VSpace(8),
// FlowyText.medium(
// LocaleKeys.settings_planPage_planUsage_deal_info.tr(),
// maxLines: 6,
// color: Theme.of(context).colorScheme.tertiary,
// ),
// const VSpace(8),
// FlowyGradientButton(
// label: LocaleKeys
// .settings_planPage_planUsage_deal_viewPlans
// .tr(),
// fontWeight: FontWeight.w500,
// backgroundColor: isLM ? null : Colors.white,
// textColor: isLM
// ? Colors.white
// : Theme.of(context).colorScheme.onPrimary,
// ),
// ],
// ),
// ),
// ],
// ),
// ),
// Positioned(
// right: 0,
// top: 9,
// child: Container(
// height: 32,
// padding: const EdgeInsets.symmetric(horizontal: 16),
// decoration: BoxDecoration(
// gradient: LinearGradient(
// transform: const GradientRotation(.7),
// colors: [
// if (isLM) const Color(0xFF7156DF),
// isLM
// ? const Color(0xFF3B2E8A)
// : const Color(0xFFCE006F).withAlpha(150),
// isLM ? const Color(0xFF261A48) : const Color(0xFF431459),
// ],
// ),
// ),
// child: Center(
// child: FlowyText.semibold(
// LocaleKeys.settings_planPage_planUsage_deal_bannerLabel.tr(),
// fontSize: 16,
// color: Colors.white,
// ),
// ),
// ),
// ),
// ],
// ),
// );
// }
// }
/// Uncomment if we need it in the future
// class _AddAICreditBox extends StatelessWidget {
// const _AddAICreditBox();
// @override
// Widget build(BuildContext context) {
// return DecoratedBox(
// decoration: BoxDecoration(
// border: Border.all(color: const Color(0xFFBDBDBD)),
// borderRadius: BorderRadius.circular(16),
// ),
// child: Padding(
// padding: const EdgeInsets.all(16),
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// FlowyText.semibold(
// LocaleKeys.settings_planPage_planUsage_aiCredit_title.tr(),
// fontSize: 18,
// color: AFThemeExtension.of(context).secondaryTextColor,
// ),
// const VSpace(8),
// Row(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Flexible(
// flex: 5,
// child: ConstrainedBox(
// constraints: const BoxConstraints(maxWidth: 180),
// child: Column(
// mainAxisSize: MainAxisSize.min,
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// FlowyText.semibold(
// LocaleKeys.settings_planPage_planUsage_aiCredit_price
// .tr(),
// fontSize: 24,
// ),
// FlowyText.medium(
// LocaleKeys
// .settings_planPage_planUsage_aiCredit_priceDescription
// .tr(),
// fontSize: 14,
// color:
// AFThemeExtension.of(context).secondaryTextColor,
// ),
// const VSpace(8),
// FlowyGradientButton(
// label: LocaleKeys
// .settings_planPage_planUsage_aiCredit_purchase
// .tr(),
// ),
// ],
// ),
// ),
// ),
// const HSpace(16),
// Flexible(
// flex: 6,
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// mainAxisSize: MainAxisSize.min,
// children: [
// FlowyText.regular(
// LocaleKeys.settings_planPage_planUsage_aiCredit_info
// .tr(),
// overflow: TextOverflow.ellipsis,
// maxLines: 5,
// ),
// const VSpace(8),
// SeparatedColumn(
// separatorBuilder: () => const VSpace(4),
// children: [
// _AIStarItem(
// label: LocaleKeys
// .settings_planPage_planUsage_aiCredit_infoItemOne
// .tr(),
// ),
// _AIStarItem(
// label: LocaleKeys
// .settings_planPage_planUsage_aiCredit_infoItemTwo
// .tr(),
// ),
// ],
// ),
// ],
// ),
// ),
// ],
// ),
// ],
// ),
// ),
// );
// }
// }
/// Uncomment if we need it in the future
// class _AIStarItem extends StatelessWidget {
// const _AIStarItem({required this.label});
// final String label;
// @override
// Widget build(BuildContext context) {
// return Row(
// children: [
// const FlowySvg(FlowySvgs.ai_star_s, color: Color(0xFF750D7E)),
// const HSpace(4),
// Expanded(child: FlowyText(label, maxLines: 2)),
// ],
// );
// }
// }

View File

@ -637,7 +637,7 @@ class _ThemeDropdown extends StatelessWidget {
actions: [
SettingAction(
tooltip: 'Upload a custom theme',
icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(16)),
icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(20)),
onPressed: () => Dialogs.show(
context,
child: BlocProvider<DynamicPluginBloc>.value(
@ -658,7 +658,10 @@ class _ThemeDropdown extends StatelessWidget {
}),
),
SettingAction(
icon: const FlowySvg(FlowySvgs.restore_s),
icon: const FlowySvg(
FlowySvgs.restore_s,
size: Size.square(20),
),
label: LocaleKeys.settings_common_reset.tr(),
onPressed: () => context
.read<AppearanceSettingsCubit>()
@ -1018,7 +1021,10 @@ class _FontSelectorDropdownState extends State<_FontSelectorDropdown> {
const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
const FlowySvg(FlowySvgs.restore_s),
const FlowySvg(
FlowySvgs.restore_s,
size: Size.square(20),
),
const HSpace(4),
FlowyText.regular(
LocaleKeys.settings_common_reset.tr(),

View File

@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_billing_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_manage_data_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_page.dart';
@ -22,12 +24,14 @@ class SettingsDialog extends StatelessWidget {
required this.dismissDialog,
required this.didLogout,
required this.restartApp,
required this.workspaceId,
}) : super(key: ValueKey(user.id));
final VoidCallback dismissDialog;
final VoidCallback didLogout;
final VoidCallback restartApp;
final UserProfilePB user;
final String workspaceId;
@override
Widget build(BuildContext context) {
@ -37,6 +41,7 @@ class SettingsDialog extends StatelessWidget {
child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>(
builder: (context, state) => FlowyDialog(
width: MediaQuery.of(context).size.width * 0.7,
constraints: const BoxConstraints(maxWidth: 784, minWidth: 564),
child: ScaffoldMessenger(
child: Scaffold(
backgroundColor: Colors.transparent,
@ -89,6 +94,10 @@ class SettingsDialog extends StatelessWidget {
return const SettingsShortcutsView();
case SettingsPage.member:
return WorkspaceMembersPage(userProfile: user);
case SettingsPage.plan:
return SettingsPlanView(workspaceId: workspaceId);
case SettingsPage.billing:
return SettingsBillingView(workspaceId: workspaceId);
case SettingsPage.featureFlags:
return const FeatureFlagsPage();
default:

View File

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
class FlowyGradientButton extends StatefulWidget {
const FlowyGradientButton({
super.key,
required this.label,
this.onPressed,
this.fontWeight = FontWeight.w600,
this.textColor = Colors.white,
this.backgroundColor,
});
final String label;
final VoidCallback? onPressed;
final FontWeight fontWeight;
/// Used to provide a custom foreground color for the button, used in cases
/// where a custom [backgroundColor] is provided and the default text color
/// does not have enough contrast.
///
final Color textColor;
/// Used to provide a custom background color for the button, this will
/// override the gradient behavior, and is mostly used in rare cases
/// where the gradient doesn't have contrast with the background.
///
final Color? backgroundColor;
@override
State<FlowyGradientButton> createState() => _FlowyGradientButtonState();
}
class _FlowyGradientButtonState extends State<FlowyGradientButton> {
bool isHovering = false;
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: (_) => widget.onPressed?.call(),
child: MouseRegion(
onEnter: (_) => setState(() => isHovering = true),
onExit: (_) => setState(() => isHovering = false),
cursor: SystemMouseCursors.click,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
blurRadius: 4,
color: Colors.black.withOpacity(0.25),
offset: const Offset(0, 2),
),
],
borderRadius: BorderRadius.circular(16),
color: widget.backgroundColor,
gradient: widget.backgroundColor != null
? null
: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
isHovering
? const Color.fromARGB(255, 57, 40, 92)
: const Color(0xFF44326B),
isHovering
? const Color.fromARGB(255, 96, 53, 164)
: const Color(0xFF7547C0),
],
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: FlowyText(
widget.label,
fontSize: 16,
fontWeight: widget.fontWeight,
color: widget.textColor,
maxLines: 2,
textAlign: TextAlign.center,
),
),
),
),
);
}
}

View File

@ -57,6 +57,7 @@ class SettingListTile extends StatelessWidget {
icon: FlowySvg(
FlowySvgs.restore_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(20),
),
iconColorOnHover: Theme.of(context).colorScheme.onPrimary,
tooltipText: resetTooltipText ??

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
@ -10,6 +11,7 @@ import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
class SettingsAlertDialog extends StatefulWidget {
const SettingsAlertDialog({
super.key,
this.icon,
required this.title,
this.subtitle,
this.children,
@ -21,6 +23,7 @@ class SettingsAlertDialog extends StatefulWidget {
this.implyLeading = false,
});
final Widget? icon;
final String title;
final String? subtitle;
final List<Widget>? children;
@ -86,6 +89,10 @@ class _SettingsAlertDialogState extends State<SettingsAlertDialog> {
),
],
),
if (widget.icon != null) ...[
widget.icon!,
const VSpace(16),
],
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -168,6 +175,7 @@ class _Actions extends StatelessWidget {
fontColor: AFThemeExtension.of(context).textColor,
fillColor: Colors.transparent,
hoverColor: Colors.transparent,
radius: Corners.s12Border,
onPressed: () {
cancel?.call();
Navigator.of(context).pop();
@ -187,6 +195,7 @@ class _Actions extends StatelessWidget {
horizontal: 24,
vertical: 12,
),
radius: Corners.s12Border,
fontColor: isDangerous ? Colors.white : null,
fontHoverColor: Colors.white,
fillColor: isDangerous

View File

@ -9,11 +9,13 @@ class SettingsBody extends StatelessWidget {
super.key,
required this.title,
this.description,
this.autoSeparate = true,
required this.children,
});
final String title;
final String? description;
final bool autoSeparate;
final List<Widget> children;
@override
@ -28,7 +30,9 @@ class SettingsBody extends StatelessWidget {
SettingsHeader(title: title, description: description),
Flexible(
child: SeparatedColumn(
separatorBuilder: () => const SettingsCategorySpacer(),
separatorBuilder: () => autoSeparate
? const SettingsCategorySpacer()
: const VSpace(16),
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),

View File

@ -100,7 +100,8 @@ class _SettingsInputFieldState extends State<SettingsInputField> {
],
],
),
const VSpace(8),
if (widget.label?.isNotEmpty ?? false || widget.tooltip != null)
const VSpace(8),
SizedBox(
height: 48,
child: FlowyTextField(

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
@ -65,6 +66,7 @@ class SingleSettingAction extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
fillColor:
isDangerous ? null : Theme.of(context).colorScheme.primary,
radius: Corners.s12Border,
hoverColor: isDangerous ? null : const Color(0xFF005483),
fontColor: isDangerous ? Theme.of(context).colorScheme.error : null,
fontHoverColor: Colors.white,

View File

@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class FeatureFlagsPage extends StatelessWidget {
const FeatureFlagsPage({
@ -49,8 +50,10 @@ class _FeatureFlagItemState extends State<_FeatureFlagItem> {
subtitle: FlowyText.small(widget.featureFlag.description, maxLines: 3),
trailing: Switch.adaptive(
value: widget.featureFlag.isOn,
onChanged: (value) =>
setState(() async => widget.featureFlag.update(value)),
onChanged: (value) async {
await widget.featureFlag.update(value);
setState(() {});
},
),
);
}

View File

@ -99,6 +99,22 @@ class SettingsMenu extends StatelessWidget {
icon: const Icon(Icons.cut),
changeSelectedPage: changeSelectedPage,
),
if (FeatureFlag.planBilling.isOn) ...[
SettingsMenuElement(
page: SettingsPage.plan,
selectedPage: currentPage,
label: LocaleKeys.settings_planPage_menuLabel.tr(),
icon: const FlowySvg(FlowySvgs.settings_plan_m),
changeSelectedPage: changeSelectedPage,
),
SettingsMenuElement(
page: SettingsPage.billing,
selectedPage: currentPage,
label: LocaleKeys.settings_billingPage_menuLabel.tr(),
icon: const FlowySvg(FlowySvgs.settings_billing_m),
changeSelectedPage: changeSelectedPage,
),
],
if (kDebugMode)
SettingsMenuElement(
// no need to translate this page

View File

@ -52,7 +52,7 @@ class FlowyDialog extends StatelessWidget {
title: title,
shape: shape ??
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
clipBehavior: Clip.hardEdge,
clipBehavior: Clip.antiAliasWithSaveLayer,
children: [
Material(
type: MaterialType.transparency,

View File

@ -97,7 +97,13 @@ class _FlowyHoverState extends State<FlowyHover> {
child: child,
);
} else {
return Container(color: style.backgroundColor, child: child);
return Container(
decoration: BoxDecoration(
color: style.backgroundColor,
borderRadius: style.borderRadius,
),
child: child,
);
}
}
}

View File

@ -1,8 +1,14 @@
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:url_launcher/url_launcher.dart';
class FlowyErrorPage extends StatelessWidget {
@ -71,34 +77,56 @@ class FlowyErrorPage extends StatelessWidget {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const FlowyText.medium(
"AppFlowy Error",
fontSize: _titleFontSize,
),
const SizedBox(
height: _titleToMessagePadding,
const SizedBox(height: _titleToMessagePadding),
Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) async {
await Clipboard.setData(ClipboardData(text: message));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
content: FlowyText(
'Message copied to clipboard',
fontSize: kIsWeb || !Platform.isIOS && !Platform.isAndroid
? 14
: 12,
),
),
);
}
},
child: FlowyHover(
style: HoverStyle(
backgroundColor:
Theme.of(context).colorScheme.tertiaryContainer,
),
cursor: SystemMouseCursors.click,
child: FlowyTooltip(
message: 'Click to copy message',
child: Padding(
padding: const EdgeInsets.all(4),
child: FlowyText.semibold(message, maxLines: 10),
),
),
),
),
FlowyText.semibold(
message,
maxLines: 10,
),
const SizedBox(
height: _titleToMessagePadding,
),
FlowyText.regular(
howToFix,
maxLines: 10,
),
const SizedBox(
height: _titleToMessagePadding,
),
const GitHubRedirectButton(),
const SizedBox(
height: _titleToMessagePadding,
const SizedBox(height: _titleToMessagePadding),
FlowyText.regular(howToFix, maxLines: 10),
const SizedBox(height: _titleToMessagePadding),
GitHubRedirectButton(
title: 'Unexpected error',
message: message,
stackTrace: stackTrace,
),
const SizedBox(height: _titleToMessagePadding),
if (stackTrace != null) StackTracePreview(stackTrace!),
if (actions != null)
Row(
@ -175,7 +203,16 @@ class StackTracePreview extends StatelessWidget {
}
class GitHubRedirectButton extends StatelessWidget {
const GitHubRedirectButton({super.key});
const GitHubRedirectButton({
super.key,
this.title,
this.message,
this.stackTrace,
});
final String? title;
final String? message;
final String? stackTrace;
static const _height = 32.0;
@ -184,9 +221,34 @@ class GitHubRedirectButton extends StatelessWidget {
host: 'github.com',
path: '/AppFlowy-IO/AppFlowy/issues/new',
query:
'assignees=&labels=&projects=&template=bug_report.yaml&title=%5BBug%5D+',
'assignees=&labels=&projects=&template=bug_report.yaml&os=$_platform&title=%5BBug%5D+$title&context=$_contextString',
);
String get _contextString {
if (message == null && stackTrace == null) {
return '';
}
String msg = "";
if (message != null) {
msg += 'Error message:%0A```%0A$message%0A```%0A';
}
if (stackTrace != null) {
msg += 'StackTrace:%0A```%0A$stackTrace%0A```%0A';
}
return msg;
}
String get _platform {
if (kIsWeb) {
return 'Web';
}
return Platform.operatingSystem;
}
@override
Widget build(BuildContext context) {
return FlowyButton(

View File

@ -52,7 +52,7 @@ collab-user = { version = "0.2" }
# Run the script:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }
client-api = { version = "0.2" }
[dependencies]
serde_json.workspace = true
@ -113,3 +113,6 @@ collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFl
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }
shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }

View File

@ -55,9 +55,7 @@ yrs = "0.18.8"
# Run the script:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }
client-api = { version = "0.2" }
[profile.dev]
opt-level = 0
@ -77,3 +75,6 @@ collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFl
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }
shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }

View File

@ -52,7 +52,7 @@ collab-user = { version = "0.2" }
# Run the script:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }
client-api = { version = "0.2" }
[dependencies]
serde_json.workspace = true
@ -114,3 +114,6 @@ collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFl
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }
shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.76392 5.78125L13.8889 7.5L9.76392 9.21875L7.88892 13L6.01392 9.21875L1.88892 7.5L6.01392 5.78125L7.88892 2L9.76392 5.78125Z" fill="#750D7E"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.88 11.68L12.52 6.04L11.4 4.92L6.88 9.44L4.6 7.16L3.48 8.28L6.88 11.68ZM8 16C6.89333 16 5.85333 15.79 4.88 15.37C3.90667 14.95 3.06 14.38 2.34 13.66C1.62 12.94 1.05 12.0933 0.63 11.12C0.21 10.1467 0 9.10667 0 8C0 6.89333 0.21 5.85333 0.63 4.88C1.05 3.90667 1.62 3.06 2.34 2.34C3.06 1.62 3.90667 1.05 4.88 0.63C5.85333 0.21 6.89333 0 8 0C9.10667 0 10.1467 0.21 11.12 0.63C12.0933 1.05 12.94 1.62 13.66 2.34C14.38 3.06 14.95 3.90667 15.37 4.88C15.79 5.85333 16 6.89333 16 8C16 9.10667 15.79 10.1467 15.37 11.12C14.95 12.0933 14.38 12.94 13.66 13.66C12.94 14.38 12.0933 14.95 11.12 15.37C10.1467 15.79 9.10667 16 8 16Z" fill="#66CF80"/>
</svg>

After

Width:  |  Height:  |  Size: 748 B

View File

@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3738_1129" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_3738_1129)">
<path d="M22 6V18C22 18.55 21.8042 19.0208 21.4125 19.4125C21.0208 19.8042 20.55 20 20 20H4C3.45 20 2.97917 19.8042 2.5875 19.4125C2.19583 19.0208 2 18.55 2 18V6C2 5.45 2.19583 4.97917 2.5875 4.5875C2.97917 4.19583 3.45 4 4 4H20C20.55 4 21.0208 4.19583 21.4125 4.5875C21.8042 4.97917 22 5.45 22 6ZM4 8H20V6H4V8ZM4 12V18H20V12H4Z" fill="#1C1B1F"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 656 B

View File

@ -1,8 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_122_2580" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<mask id="mask0_3738_2461" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_122_2580)">
<path d="M22 6V18C22 18.55 21.8042 19.0208 21.4125 19.4125C21.0208 19.8042 20.55 20 20 20H4C3.45 20 2.97917 19.8042 2.5875 19.4125C2.19583 19.0208 2 18.55 2 18V6C2 5.45 2.19583 4.97917 2.5875 4.5875C2.97917 4.19583 3.45 4 4 4H20C20.55 4 21.0208 4.19583 21.4125 4.5875C21.8042 4.97917 22 5.45 22 6ZM4 8H20V6H4V8ZM4 12V18H20V12H4Z" fill="#1C1B1F"/>
<g mask="url(#mask0_3738_2461)">
<path d="M5 21C4.45 21 3.97917 20.8042 3.5875 20.4125C3.19583 20.0208 3 19.55 3 19V5C3 4.45 3.19583 3.97917 3.5875 3.5875C3.97917 3.19583 4.45 3 5 3H19C19.55 3 20.0208 3.19583 20.4125 3.5875C20.8042 3.97917 21 4.45 21 5V19C21 19.55 20.8042 20.0208 20.4125 20.4125C20.0208 20.8042 19.55 21 19 21H5ZM10 19V13H5V19H10ZM12 19H19V13H12V19ZM5 11H19V5H5V11Z" fill="#1C1B1F"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 654 B

After

Width:  |  Height:  |  Size: 678 B

View File

@ -330,7 +330,8 @@
"signInGoogle": "Sign in with Google",
"signInGithub": "Sign in with Github",
"signInDiscord": "Sign in with Discord",
"more": "More"
"more": "More",
"close": "Close"
},
"label": {
"welcome": "Welcome!",
@ -371,10 +372,10 @@
"keys": {
"title": "AI API Keys",
"openAILabel": "OpenAI API key",
"openAITooltip": "The OpenAI API key to use for the AI models",
"openAITooltip": "You can find your Secret API key on the API key page",
"openAIHint": "Input your OpenAI API Key",
"stabilityAILabel": "Stability API key",
"stabilityAITooltip": "The Stability API key to use for the AI models",
"stabilityAITooltip": "Your Stability API key, used to authenticate your requests",
"stabilityAIHint": "Input your Stability API Key"
},
"login": {
@ -501,6 +502,142 @@
}
}
},
"planPage": {
"menuLabel": "Plan",
"title": "Pricing plan",
"planUsage": {
"title": "Plan usage summary",
"storageLabel": "Storage",
"storageUsage": "{} of {} GB",
"collaboratorsLabel": "Collaborators",
"collaboratorsUsage": "{} of {}",
"aiResponseLabel": "AI Responses",
"aiResponseUsage": "{} of {}",
"proBadge": "Pro",
"memberProToggle": "Unlimited members",
"guestCollabToggle": "10 guest collaborators",
"aiCredit": {
"title": "Add AppFlowy AI Credit",
"price": "5$",
"priceDescription": "for 1,000 credits",
"purchase": "Purchase AI",
"info": "Add 1,000 Ai credits per workspace and seamlessly integrate customizable AI into your workflow for smarter, faster results with up to:",
"infoItemOne": "10,000 responses per database",
"infoItemTwo": "1,000 responses per workspace"
},
"currentPlan": {
"bannerLabel": "Current plan",
"freeTitle": "Free",
"proTitle": "Pro",
"teamTitle": "Team",
"freeInfo": "Perfect for individuals or small teams up to 3.",
"upgrade": "Compare &\n Upgrade",
"freeProOne": "Collaborative workspace",
"freeProTwo": "Up to 3 members (incl. owner)",
"freeProThree": "Unlimited guests (view-only)",
"freeProFour": "Storage 5gb",
"freeProFive": "30 day revision history",
"freeConOne": "Guest collaborators (edit access)",
"freeConTwo": "Unlimited storage",
"freeConThree": "6 month revision history",
"professionalProOne": "Collaborative workspace",
"professionalProTwo": "Unlimited members",
"professionalProThree": "Unlimited guests (view-only)",
"professionalProFour": "Unlimited storage",
"professionalProFive": "6 month revision history",
"professionalConOne": "Unlimited guest collaborators (edit access)",
"professionalConTwo": "Unlimited AI responses",
"professionalConThree": "1 year revision history",
"canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}."
},
"deal": {
"bannerLabel": "New year deal!",
"title": "Grow your team!",
"info": "Upgrade and save 10% off Pro and Team plans! Boost your workspace productivity with powerful new features including Appflowy Ai.",
"viewPlans": "View plans"
}
}
},
"billingPage": {
"menuLabel": "Billing",
"title": "Billing",
"plan": {
"title": "Plan",
"freeLabel": "Free",
"proLabel": "Pro",
"planButtonLabel": "Change plan",
"billingPeriod": "Billing period",
"periodButtonLabel": "Edit period"
},
"paymentDetails": {
"title": "Payment details",
"methodLabel": "Payment method",
"methodButtonLabel": "Edit method"
}
},
"comparePlanDialog": {
"title": "Compare & select plan",
"planFeatures": "Plan\nFeatures",
"actions": {
"upgrade": "Upgrade",
"downgrade": "Downgrade",
"current": "Current"
},
"freePlan": {
"title": "Free",
"description": "For organizing every corner of your work & life.",
"price": "$0",
"priceInfo": "free forever"
},
"proPlan": {
"title": "Professional",
"description": "A palce for small groups to plan & get organized.",
"price": "$10 /month",
"priceInfo": "billed annually"
},
"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"
},
"freeLabels": {
"itemOne": "charged per workspace",
"itemTwo": "3",
"itemThree": "",
"itemFour": "0",
"itemFive": "5 GB",
"itemSix": "yes",
"itemSeven": "yes",
"itemEight": "1,000 lifetime"
},
"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"
},
"paymentSuccess": {
"title": "You are now on the {} plan!",
"description": "Your payment has been successfully processed and your plan is upgraded to AppFlowy {}. You can view your plan details on the Plan page"
},
"downgradeDialog": {
"title": "Are you sure you want to downgrade your plan?",
"description": "Downgrading your plan will revert you back to the Free plan. Members may lose access to workspaces and you may need to free up space to meet the storage limits of the Free plan.",
"downgradeLabel": "Downgrade plan"
}
},
"common": {
"reset": "Reset"
},

View File

@ -194,6 +194,20 @@ dependencies = [
"thiserror",
]
[[package]]
name = "appflowy-cloud-billing-client"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud-Billing-Client?rev=9f9c2d1ad180987a31d18c6c067a56a5fa1f6da6#9f9c2d1ad180987a31d18c6c067a56a5fa1f6da6"
dependencies = [
"client-api",
"reqwest",
"serde",
"serde_json",
"shared-entity",
"tokio",
"yrs",
]
[[package]]
name = "arc-swap"
version = "1.7.1"
@ -2066,6 +2080,7 @@ name = "flowy-server"
version = "0.1.0"
dependencies = [
"anyhow",
"appflowy-cloud-billing-client",
"assert-json-diff",
"bytes",
"chrono",
@ -5590,9 +5605,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.36.0"
version = "1.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
dependencies = [
"backtrace",
"bytes",
@ -5620,9 +5635,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
dependencies = [
"proc-macro2",
"quote",

View File

@ -76,7 +76,7 @@ uuid = { version = "1.5.0", features = ["serde", "v4", "v5"] }
serde_repr = "0.1"
parking_lot = "0.12"
futures = "0.3.29"
tokio = "1.34.0"
tokio = "1.38.0"
tokio-stream = "0.1.14"
async-trait = "0.1.74"
chrono = { version = "0.4.31", default-features = false, features = ["clock"] }
@ -94,7 +94,8 @@ yrs = "0.18.8"
# Run the script.add_workspace_members:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }
client-api = { version = "0.2" }
appflowy-cloud-billing-client = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud-Billing-Client", rev = "9f9c2d1ad180987a31d18c6c067a56a5fa1f6da6" }
[profile.dev]
opt-level = 1
@ -121,6 +122,9 @@ lto = false
incremental = false
[patch.crates-io]
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }
shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" }
# TODO(Lucas.Xu) Upgrade to the latest version of RocksDB once PR(https://github.com/rust-rocksdb/rust-rocksdb/pull/869) is merged.
# Currently, using the following revision id. This commit is patched to fix the 32-bit build issue and it's checked out from 0.21.0, not 0.22.0.
rocksdb = { git = "https://github.com/LucasXu0/rust-rocksdb", rev = "21cf4a23ec131b9d82dc94e178fe8efc0c147b09" }

View File

@ -93,7 +93,7 @@ async fn delete_view_subscription_test() {
let update = test
.appflowy_core
.dispatcher()
.run_until(receive_with_timeout(rx, Duration::from_secs(30)))
.run_until(receive_with_timeout(rx, Duration::from_secs(60)))
.await
.unwrap();

View File

@ -21,11 +21,9 @@ impl From<AppResponseError> for FlowyError {
AppErrorCode::NotEnoughPermissions => ErrorCode::NotEnoughPermissions,
AppErrorCode::NetworkError => ErrorCode::HttpError,
AppErrorCode::PayloadTooLarge => ErrorCode::CloudRequestPayloadTooLarge,
AppErrorCode::UserUnAuthorized => match &*error.message {
"Workspace Limit Exceeded" => ErrorCode::WorkspaceLimitExceeded,
"Workspace Member Limit Exceeded" => ErrorCode::WorkspaceMemberLimitExceeded,
_ => ErrorCode::UserUnauthorized,
},
AppErrorCode::UserUnAuthorized => ErrorCode::UserUnauthorized,
AppErrorCode::WorkspaceLimitExceeded => ErrorCode::WorkspaceLimitExceeded,
AppErrorCode::WorkspaceMemberLimitExceeded => ErrorCode::WorkspaceMemberLimitExceeded,
_ => ErrorCode::Internal,
};

View File

@ -37,6 +37,7 @@ flowy-user-pub = { workspace = true }
flowy-folder-pub = { workspace = true }
flowy-database-pub = { workspace = true }
flowy-document-pub = { workspace = true }
appflowy-cloud-billing-client = { workspace = true }
flowy-error = { workspace = true, features = ["impl_from_serde", "impl_from_reqwest", "impl_from_url", "impl_from_appflowy_cloud"] }
flowy-server-pub = { workspace = true }
flowy-encrypt = { workspace = true }
@ -69,4 +70,4 @@ assert-json-diff = "2.0.2"
serde_json.workspace = true
[features]
enable_supabase = ["collab-plugins/postgres_plugin"]
enable_supabase = ["collab-plugins/postgres_plugin"]

View File

@ -1,7 +1,11 @@
use appflowy_cloud_billing_client::entities::{
RecurringInterval, SubscriptionPlan, WorkspaceSubscriptionStatus,
};
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::anyhow;
use appflowy_cloud_billing_client::BillingClient;
use client_api::entity::workspace_dto::{
CreateWorkspaceMember, CreateWorkspaceParam, PatchWorkspaceParam, WorkspaceMemberChangeset,
WorkspaceMemberInvitation,
@ -20,6 +24,7 @@ use flowy_user_pub::cloud::{UserCloudService, UserCollabParams, UserUpdate, User
use flowy_user_pub::entities::{
AFCloudOAuthParams, AuthResponse, Role, UpdateUserProfileParams, UserCredentials, UserProfile,
UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember,
WorkspaceSubscription, WorkspaceUsage,
};
use lib_infra::box_any::BoxAny;
use lib_infra::future::FutureResult;
@ -473,6 +478,82 @@ where
Ok(())
})
}
fn subscribe_workspace(
&self,
workspace_id: String,
recurring_interval: flowy_user_pub::entities::RecurringInterval,
workspace_subscription_plan: flowy_user_pub::entities::SubscriptionPlan,
success_url: String,
) -> FutureResult<String, FlowyError> {
let try_get_client = self.server.try_get_client();
let workspace_id = workspace_id.to_string();
FutureResult::new(async move {
let subscription_plan = to_workspace_subscription_plan(workspace_subscription_plan)?;
let client = try_get_client?;
let payment_link = BillingClient::from(client.as_ref())
.create_subscription(
&workspace_id,
to_recurring_interval(recurring_interval),
subscription_plan,
&success_url,
)
.await?;
Ok(payment_link)
})
}
fn get_workspace_subscriptions(&self) -> FutureResult<Vec<WorkspaceSubscription>, FlowyError> {
let try_get_client = self.server.try_get_client();
FutureResult::new(async move {
let client = try_get_client?;
let workspace_subscriptions = BillingClient::from(client.as_ref())
.list_subscription()
.await?
.into_iter()
.map(to_workspace_subscription)
.collect();
Ok(workspace_subscriptions)
})
}
fn cancel_workspace_subscription(&self, workspace_id: String) -> FutureResult<(), FlowyError> {
let try_get_client = self.server.try_get_client();
FutureResult::new(async move {
let client = try_get_client?;
BillingClient::from(client.as_ref())
.cancel_subscription(&workspace_id)
.await?;
Ok(())
})
}
fn get_workspace_usage(&self, workspace_id: String) -> FutureResult<WorkspaceUsage, FlowyError> {
let try_get_client = self.server.try_get_client();
FutureResult::new(async move {
let client = try_get_client?;
let usage = BillingClient::from(client.as_ref())
.get_workspace_usage(&workspace_id)
.await?;
Ok(WorkspaceUsage {
member_count: usage.member_count,
member_count_limit: usage.member_count_limit,
total_blob_bytes: usage.total_blob_bytes,
total_blob_bytes_limit: usage.total_blob_bytes_limit,
})
})
}
fn get_billing_portal_url(&self) -> FutureResult<String, FlowyError> {
let try_get_client = self.server.try_get_client();
FutureResult::new(async move {
let client = try_get_client?;
let url = BillingClient::from(client.as_ref())
.get_portal_session_link()
.await?;
Ok(url)
})
}
}
async fn get_admin_client(client: &Arc<AFCloudClient>) -> FlowyResult<Client> {
@ -569,3 +650,47 @@ fn oauth_params_from_box_any(any: BoxAny) -> Result<AFCloudOAuthParams, FlowyErr
sign_in_url: sign_in_url.to_string(),
})
}
fn to_recurring_interval(r: flowy_user_pub::entities::RecurringInterval) -> RecurringInterval {
match r {
flowy_user_pub::entities::RecurringInterval::Month => RecurringInterval::Month,
flowy_user_pub::entities::RecurringInterval::Year => RecurringInterval::Year,
}
}
fn to_workspace_subscription_plan(
s: flowy_user_pub::entities::SubscriptionPlan,
) -> Result<SubscriptionPlan, FlowyError> {
match s {
flowy_user_pub::entities::SubscriptionPlan::Pro => Ok(SubscriptionPlan::Pro),
flowy_user_pub::entities::SubscriptionPlan::Team => Ok(SubscriptionPlan::Team),
flowy_user_pub::entities::SubscriptionPlan::None => Err(FlowyError::new(
ErrorCode::InvalidParams,
"Invalid subscription plan",
)),
}
}
fn to_workspace_subscription(s: WorkspaceSubscriptionStatus) -> WorkspaceSubscription {
WorkspaceSubscription {
workspace_id: s.workspace_id,
subscription_plan: match s.workspace_plan {
appflowy_cloud_billing_client::entities::WorkspaceSubscriptionPlan::Pro => {
flowy_user_pub::entities::SubscriptionPlan::Pro
},
appflowy_cloud_billing_client::entities::WorkspaceSubscriptionPlan::Team => {
flowy_user_pub::entities::SubscriptionPlan::Team
},
_ => flowy_user_pub::entities::SubscriptionPlan::None,
},
recurring_interval: match s.recurring_interval {
RecurringInterval::Month => flowy_user_pub::entities::RecurringInterval::Month,
RecurringInterval::Year => flowy_user_pub::entities::RecurringInterval::Year,
},
is_active: matches!(
s.subscription_status,
appflowy_cloud_billing_client::entities::SubscriptionStatus::Active
),
canceled_at: s.canceled_at,
}
}

View File

@ -13,8 +13,9 @@ use tokio_stream::wrappers::WatchStream;
use uuid::Uuid;
use crate::entities::{
AuthResponse, Authenticator, Role, UpdateUserProfileParams, UserCredentials, UserProfile,
UserTokenState, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember,
AuthResponse, Authenticator, RecurringInterval, Role, SubscriptionPlan, UpdateUserProfileParams,
UserCredentials, UserProfile, UserTokenState, UserWorkspace, WorkspaceInvitation,
WorkspaceInvitationStatus, WorkspaceMember, WorkspaceSubscription, WorkspaceUsage,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -266,6 +267,32 @@ pub trait UserCloudService: Send + Sync + 'static {
fn leave_workspace(&self, workspace_id: &str) -> FutureResult<(), FlowyError> {
FutureResult::new(async { Ok(()) })
}
fn subscribe_workspace(
&self,
workspace_id: String,
recurring_interval: RecurringInterval,
workspace_subscription_plan: SubscriptionPlan,
success_url: String,
) -> FutureResult<String, FlowyError> {
FutureResult::new(async { Err(FlowyError::not_support()) })
}
fn get_workspace_subscriptions(&self) -> FutureResult<Vec<WorkspaceSubscription>, FlowyError> {
FutureResult::new(async { Err(FlowyError::not_support()) })
}
fn cancel_workspace_subscription(&self, workspace_id: String) -> FutureResult<(), FlowyError> {
FutureResult::new(async { Err(FlowyError::not_support()) })
}
fn get_workspace_usage(&self, workspace_id: String) -> FutureResult<WorkspaceUsage, FlowyError> {
FutureResult::new(async { Err(FlowyError::not_support()) })
}
fn get_billing_portal_url(&self) -> FutureResult<String, FlowyError> {
FutureResult::new(async { Err(FlowyError::not_support()) })
}
}
pub type UserUpdateReceiver = tokio::sync::mpsc::Receiver<UserUpdate>;

View File

@ -422,3 +422,29 @@ pub struct WorkspaceInvitation {
pub status: WorkspaceInvitationStatus,
pub updated_at: DateTime<Utc>,
}
pub enum RecurringInterval {
Month,
Year,
}
pub enum SubscriptionPlan {
None,
Pro,
Team,
}
pub struct WorkspaceSubscription {
pub workspace_id: String,
pub subscription_plan: SubscriptionPlan,
pub recurring_interval: RecurringInterval,
pub is_active: bool,
pub canceled_at: Option<i64>,
}
pub struct WorkspaceUsage {
pub member_count: usize,
pub member_count_limit: usize,
pub total_blob_bytes: usize,
pub total_blob_bytes_limit: usize,
}

View File

@ -1,7 +1,10 @@
use validator::Validate;
use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
use flowy_user_pub::entities::{Role, WorkspaceInvitation, WorkspaceMember};
use flowy_user_pub::entities::{
RecurringInterval, Role, SubscriptionPlan, WorkspaceInvitation, WorkspaceMember,
WorkspaceSubscription,
};
use lib_infra::validator_fn::required_not_empty_str;
#[derive(ProtoBuf, Default, Clone)]
@ -201,3 +204,136 @@ pub struct ChangeWorkspaceIconPB {
#[pb(index = 2)]
pub new_icon: String,
}
#[derive(ProtoBuf, Default, Clone, Validate, Debug)]
pub struct SubscribeWorkspacePB {
#[pb(index = 1)]
#[validate(custom = "required_not_empty_str")]
pub workspace_id: String,
#[pb(index = 2)]
pub recurring_interval: RecurringIntervalPB,
#[pb(index = 3)]
pub workspace_subscription_plan: SubscriptionPlanPB,
#[pb(index = 4)]
pub success_url: String,
}
#[derive(ProtoBuf_Enum, Clone, Default, Debug)]
pub enum RecurringIntervalPB {
#[default]
Month = 0,
Year = 1,
}
impl From<RecurringIntervalPB> for RecurringInterval {
fn from(r: RecurringIntervalPB) -> Self {
match r {
RecurringIntervalPB::Month => RecurringInterval::Month,
RecurringIntervalPB::Year => RecurringInterval::Year,
}
}
}
impl From<RecurringInterval> for RecurringIntervalPB {
fn from(r: RecurringInterval) -> Self {
match r {
RecurringInterval::Month => RecurringIntervalPB::Month,
RecurringInterval::Year => RecurringIntervalPB::Year,
}
}
}
#[derive(ProtoBuf_Enum, Clone, Default, Debug)]
pub enum SubscriptionPlanPB {
#[default]
None = 0,
Pro = 1,
Team = 2,
}
impl From<SubscriptionPlanPB> for SubscriptionPlan {
fn from(value: SubscriptionPlanPB) -> Self {
match value {
SubscriptionPlanPB::Pro => SubscriptionPlan::Pro,
SubscriptionPlanPB::Team => SubscriptionPlan::Team,
SubscriptionPlanPB::None => SubscriptionPlan::None,
}
}
}
impl From<SubscriptionPlan> for SubscriptionPlanPB {
fn from(value: SubscriptionPlan) -> Self {
match value {
SubscriptionPlan::Pro => SubscriptionPlanPB::Pro,
SubscriptionPlan::Team => SubscriptionPlanPB::Team,
SubscriptionPlan::None => SubscriptionPlanPB::None,
}
}
}
#[derive(Debug, ProtoBuf, Default, Clone)]
pub struct PaymentLinkPB {
#[pb(index = 1)]
pub payment_link: String,
}
#[derive(Debug, ProtoBuf, Default, Clone)]
pub struct RepeatedWorkspaceSubscriptionPB {
#[pb(index = 1)]
pub items: Vec<WorkspaceSubscriptionPB>,
}
#[derive(Debug, ProtoBuf, Default, Clone)]
pub struct WorkspaceSubscriptionPB {
#[pb(index = 1)]
pub workspace_id: String,
#[pb(index = 2)]
pub subscription_plan: SubscriptionPlanPB,
#[pb(index = 3)]
pub recurring_interval: RecurringIntervalPB,
#[pb(index = 4)]
pub is_active: bool,
#[pb(index = 5)]
pub has_canceled: bool,
#[pb(index = 6)]
pub canceled_at: i64, // value is valid only if has_canceled is true
}
impl From<WorkspaceSubscription> for WorkspaceSubscriptionPB {
fn from(s: WorkspaceSubscription) -> Self {
Self {
workspace_id: s.workspace_id,
subscription_plan: s.subscription_plan.into(),
recurring_interval: s.recurring_interval.into(),
is_active: s.is_active,
has_canceled: s.canceled_at.is_some(),
canceled_at: s.canceled_at.unwrap_or_default(),
}
}
}
#[derive(Debug, ProtoBuf, Default, Clone)]
pub struct WorkspaceUsagePB {
#[pb(index = 1)]
pub member_count: u64,
#[pb(index = 2)]
pub member_count_limit: u64,
#[pb(index = 3)]
pub total_blob_bytes: u64,
#[pb(index = 4)]
pub total_blob_bytes_limit: u64,
}
#[derive(Debug, ProtoBuf, Default, Clone)]
pub struct BillingPortalPB {
#[pb(index = 1)]
pub url: String,
}

View File

@ -774,3 +774,64 @@ pub async fn leave_workspace_handler(
manager.leave_workspace(&workspace_id).await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub async fn subscribe_workspace_handler(
params: AFPluginData<SubscribeWorkspacePB>,
manager: AFPluginState<Weak<UserManager>>,
) -> DataResult<PaymentLinkPB, FlowyError> {
let params = params.try_into_inner()?;
let manager = upgrade_manager(manager)?;
let payment_link = manager.subscribe_workspace(params).await?;
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 cancel_workspace_subscription_handler(
param: AFPluginData<UserWorkspaceIdPB>,
manager: AFPluginState<Weak<UserManager>>,
) -> Result<(), FlowyError> {
let workspace_id = param.into_inner().workspace_id;
let manager = upgrade_manager(manager)?;
manager.cancel_workspace_subscription(workspace_id).await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub async fn get_workspace_usage_handler(
param: AFPluginData<UserWorkspaceIdPB>,
manager: AFPluginState<Weak<UserManager>>,
) -> DataResult<WorkspaceUsagePB, FlowyError> {
let workspace_id = param.into_inner().workspace_id;
let manager = upgrade_manager(manager)?;
let workspace_usage = manager.get_workspace_usage(workspace_id).await?;
data_result_ok(WorkspaceUsagePB {
member_count: workspace_usage.member_count as u64,
member_count_limit: workspace_usage.member_count_limit as u64,
total_blob_bytes: workspace_usage.total_blob_bytes as u64,
total_blob_bytes_limit: workspace_usage.total_blob_bytes_limit as u64,
})
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub async fn get_billing_portal_handler(
manager: AFPluginState<Weak<UserManager>>,
) -> DataResult<BillingPortalPB, FlowyError> {
let manager = upgrade_manager(manager)?;
let url = manager.get_billing_portal_url().await?;
data_result_ok(BillingPortalPB { url })
}

View File

@ -71,6 +71,12 @@ pub fn init(user_manager: Weak<UserManager>) -> AFPlugin {
.event(UserEvent::InviteWorkspaceMember, invite_workspace_member_handler)
.event(UserEvent::ListWorkspaceInvitations, list_workspace_invitations_handler)
.event(UserEvent::AcceptWorkspaceInvitation, accept_workspace_invitations_handler)
// Billing
.event(UserEvent::SubscribeWorkspace, subscribe_workspace_handler)
.event(UserEvent::GetWorkspaceSubscriptions, get_workspace_subscriptions_handler)
.event(UserEvent::CancelWorkspaceSubscription, cancel_workspace_subscription_handler)
.event(UserEvent::GetWorkspaceUsage, get_workspace_usage_handler)
.event(UserEvent::GetBillingPortal, get_billing_portal_handler)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)]
@ -230,6 +236,21 @@ pub enum UserEvent {
#[event(input = "MagicLinkSignInPB", output = "UserProfilePB")]
MagicLinkSignIn = 50,
#[event(input = "SubscribeWorkspacePB", output = "PaymentLinkPB")]
SubscribeWorkspace = 51,
#[event(output = "RepeatedWorkspaceSubscriptionPB")]
GetWorkspaceSubscriptions = 52,
#[event(input = "UserWorkspaceIdPB")]
CancelWorkspaceSubscription = 53,
#[event(input = "UserWorkspaceIdPB", output = "WorkspaceUsagePB")]
GetWorkspaceUsage = 54,
#[event(output = "BillingPortalPB")]
GetBillingPortal = 55,
}
pub trait UserStatusCallback: Send + Sync + 'static {

View File

@ -11,10 +11,13 @@ use flowy_sqlite::schema::user_workspace_table;
use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods};
use flowy_user_pub::entities::{
Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember,
WorkspaceSubscription, WorkspaceUsage,
};
use lib_dispatch::prelude::af_spawn;
use crate::entities::{RepeatedUserWorkspacePB, ResetWorkspacePB, UserWorkspacePB};
use crate::entities::{
RepeatedUserWorkspacePB, ResetWorkspacePB, SubscribeWorkspacePB, UserWorkspacePB,
};
use crate::migrations::AnonUser;
use crate::notification::{send_notification, UserNotification};
use crate::services::data_import::{
@ -417,6 +420,65 @@ impl UserManager {
.await?;
Ok(())
}
#[instrument(level = "info", skip(self), err)]
pub async fn subscribe_workspace(
&self,
workspace_subscription: SubscribeWorkspacePB,
) -> FlowyResult<String> {
let payment_link = self
.cloud_services
.get_user_service()?
.subscribe_workspace(
workspace_subscription.workspace_id,
workspace_subscription.recurring_interval.into(),
workspace_subscription.workspace_subscription_plan.into(),
workspace_subscription.success_url,
)
.await?;
Ok(payment_link)
}
#[instrument(level = "info", skip(self), err)]
pub async fn get_workspace_subscriptions(&self) -> FlowyResult<Vec<WorkspaceSubscription>> {
let res = self
.cloud_services
.get_user_service()?
.get_workspace_subscriptions()
.await?;
Ok(res)
}
#[instrument(level = "info", skip(self), err)]
pub async fn cancel_workspace_subscription(&self, workspace_id: String) -> FlowyResult<()> {
self
.cloud_services
.get_user_service()?
.cancel_workspace_subscription(workspace_id)
.await?;
Ok(())
}
#[instrument(level = "info", skip(self), err)]
pub async fn get_workspace_usage(&self, workspace_id: String) -> FlowyResult<WorkspaceUsage> {
let workspace_usage = self
.cloud_services
.get_user_service()?
.get_workspace_usage(workspace_id)
.await?;
Ok(workspace_usage)
}
#[instrument(level = "info", skip(self), err)]
pub async fn get_billing_portal_url(&self) -> FlowyResult<String> {
let url = self
.cloud_services
.get_user_service()?
.get_billing_portal_url()
.await?;
Ok(url)
}
}
/// This method is used to save one user workspace to the SQLite database