mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
3d7a500550
commit
4708c0f779
16
.github/workflows/rust_ci.yaml
vendored
16
.github/workflows/rust_ci.yaml
vendored
@ -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
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 '';
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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',
|
||||
};
|
||||
}
|
@ -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();
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SubscriptionSuccessListenable extends ChangeNotifier {
|
||||
SubscriptionSuccessListenable();
|
||||
|
||||
void onPaymentSuccess() => notifyListeners();
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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: [
|
||||
|
@ -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());
|
||||
}
|
||||
});
|
||||
}
|
@ -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'),
|
||||
),
|
||||
],
|
||||
|
@ -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(),
|
||||
];
|
@ -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)),
|
||||
// ],
|
||||
// );
|
||||
// }
|
||||
// }
|
@ -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(),
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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 ??
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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(() {});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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" }
|
||||
|
@ -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" }
|
||||
|
@ -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" }
|
||||
|
3
frontend/resources/flowy_icons/16x/ai_star.svg
Normal file
3
frontend/resources/flowy_icons/16x/ai_star.svg
Normal 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 |
3
frontend/resources/flowy_icons/16x/check_circle.svg
Normal file
3
frontend/resources/flowy_icons/16x/check_circle.svg
Normal 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 |
8
frontend/resources/flowy_icons/24x/settings_billing.svg
Normal file
8
frontend/resources/flowy_icons/24x/settings_billing.svg
Normal 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 |
@ -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 |
@ -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"
|
||||
},
|
||||
|
23
frontend/rust-lib/Cargo.lock
generated
23
frontend/rust-lib/Cargo.lock
generated
@ -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",
|
||||
|
@ -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" }
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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 }
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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 })
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user