From cafa97d3e4faa4682f49271467634f572b3248eb Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Mon, 3 Jun 2024 13:45:49 +0200 Subject: [PATCH] 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 --- .../startup/tasks/appflowy_cloud_task.dart | 3 +- .../lib/user/application/user_service.dart | 32 +- .../settings/plan/settings_plan_bloc.dart | 135 ++++ .../plan/workspace_subscription_ext.dart | 16 + .../settings/plan/workspace_usage_ext.dart | 8 + .../settings/settings_dialog_bloc.dart | 4 +- .../workspace/workspace_service.dart | 10 + .../menu/sidebar/shared/sidebar_setting.dart | 5 + .../settings/pages/settings_billing_view.dart | 48 ++ .../settings_plan_comparison_dialog.dart | 513 +++++++++++++ .../settings/pages/settings_plan_view.dart | 698 ++++++++++++++++++ .../settings/settings_dialog.dart | 9 + .../shared/flowy_gradient_button.dart | 88 +++ .../settings/shared/settings_body.dart | 6 +- .../settings/widgets/settings_menu.dart | 14 + .../lib/src/flowy_overlay/flowy_dialog.dart | 2 +- .../lib/style_widget/hover.dart | 8 +- .../flowy_infra_ui/lib/widget/error_page.dart | 110 ++- .../resources/flowy_icons/16x/ai_star.svg | 3 + .../flowy_icons/24x/settings_billing.svg | 8 + .../flowy_icons/24x/settings_plan.svg | 6 +- frontend/resources/translations/en.json | 114 +++ .../flowy-user/src/entities/workspace.rs | 1 + 23 files changed, 1805 insertions(+), 36 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart create mode 100644 frontend/resources/flowy_icons/16x/ai_star.svg create mode 100644 frontend/resources/flowy_icons/24x/settings_billing.svg diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index b8da2cd1ad30..06445bd0d976 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -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'; @@ -17,7 +19,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 { diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 5eeaacdc7703..6f02908d9c72 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -7,10 +7,10 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; +const _deepLinkSubscriptionUrl = 'appflowy-flutter://subscription-callback'; + class UserBackendService { - UserBackendService({ - required this.userId, - }); + UserBackendService({required this.userId}); final Int64 userId; @@ -219,4 +219,30 @@ class UserBackendService { final data = UserWorkspaceIdPB.create()..workspaceId = workspaceId; return UserEventLeaveWorkspace(data).send(); } + + static Future> + getWorkspaceSubscriptions() { + return UserEventGetWorkspaceSubscriptions().send(); + } + + static Future> createSubscription( + String workspaceId, + SubscriptionPlanPB plan, + ) { + final request = SubscribeWorkspacePB() + ..workspaceId = workspaceId + ..recurringInterval = RecurringIntervalPB.Month + ..workspaceSubscriptionPlan = plan + ..successUrl = + 'http://$_deepLinkSubscriptionUrl'; // TODO(Mathias): Change once Zack has resolved + + return UserEventSubscribeWorkspace(request).send(); + } + + static Future> cancelSubscription( + String workspaceId, + ) { + final request = UserWorkspaceIdPB()..workspaceId = workspaceId; + return UserEventCancelWorkspaceSubscription(request).send(); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart new file mode 100644 index 000000000000..99f866bc6e06 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart @@ -0,0 +1,135 @@ +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; +import 'package: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 { + SettingsPlanBloc({ + required this.workspaceId, + }) : super(const _Initial()) { + _service = WorkspaceService(workspaceId: workspaceId); + + on((event, emit) async { + await event.when( + started: () 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, + ), + ); + }, + 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); + }, + ); + }); + } + + late final String workspaceId; + late final WorkspaceService _service; +} + +@freezed +class SettingsPlanEvent with _$SettingsPlanEvent { + const factory SettingsPlanEvent.started() = _Started; + const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) = + _AddSubscription; + const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription; +} + +@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, + }) = _Ready; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart new file mode 100644 index 000000000000..fe6cb896bde7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart @@ -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', + }; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart new file mode 100644 index 000000000000..bc309b60c59a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart @@ -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(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index 02aabc6c579b..7395dce731dc 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -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 successOrFailure, required SettingsPage page, }) = _SettingsDialogState; factory SettingsDialogState.initial(UserProfilePB userProfile) => SettingsDialogState( userProfile: userProfile, - successOrFailure: FlowyResult.success(null), page: SettingsPage.account, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart index 6e42b744f65c..55d6a479e110 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart @@ -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> getWorkspaceUsage() { + final payload = UserWorkspaceIdPB(workspaceId: workspaceId); + return UserEventGetWorkspaceUsage(payload).send(); + } + + Future> getBillingPortal() { + return UserEventGetBillingPortal().send(); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart index 8c6d9c5cf7ac..209e40de3abe 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart @@ -76,6 +76,11 @@ void showSettingsDialog(BuildContext context, UserProfilePB userProfile) => ], child: SettingsDialog( userProfile, + workspaceId: context + .read() + .state + .currentWorkspace! + .workspaceId, didLogout: () async { // Pop the dialog using the dialog context Navigator.of(dialogContext).pop(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart new file mode 100644 index 000000000000..873cda054115 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart @@ -0,0 +1,48 @@ +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:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../../../generated/locale_keys.g.dart'; + +class SettingsBillingView extends StatelessWidget { + const SettingsBillingView({super.key}); + + @override + Widget build(BuildContext context) { + return SettingsBody( + title: LocaleKeys.settings_billingPage_title.tr(), + description: LocaleKeys.settings_billingPage_description.tr(), + children: [ + SettingsCategory( + title: LocaleKeys.settings_billingPage_plan_title.tr(), + children: [ + SingleSettingAction( + label: LocaleKeys.settings_billingPage_plan_freeLabel.tr(), + buttonLabel: + LocaleKeys.settings_billingPage_plan_planButtonLabel.tr(), + ), + SingleSettingAction( + label: LocaleKeys.settings_billingPage_plan_billingPeriod.tr(), + buttonLabel: + LocaleKeys.settings_billingPage_plan_periodButtonLabel.tr(), + ), + ], + ), + SettingsCategory( + title: LocaleKeys.settings_billingPage_paymentDetails_title.tr(), + children: [ + SingleSettingAction( + label: LocaleKeys.settings_billingPage_paymentDetails_methodLabel + .tr(), + buttonLabel: LocaleKeys + .settings_billingPage_paymentDetails_methodButtonLabel + .tr(), + ), + ], + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart new file mode 100644 index 000000000000..fb90c83ab01b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart @@ -0,0 +1,513 @@ +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_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.currentPlan, + }); + + final String workspaceId; + final SubscriptionPlanPB currentPlan; + + @override + State createState() => + _SettingsPlanComparisonDialogState(); +} + +class _SettingsPlanComparisonDialogState + extends State { + final horizontalController = ScrollController(); + final verticalController = ScrollController(); + + @override + void dispose() { + horizontalController.dispose(); + verticalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return 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: [ + const FlowyText.semibold( + 'Compare & select plan', + fontSize: 24, + ), + const Spacer(), + GestureDetector( + onTap: Navigator.of(context).pop, + 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: [ + const VSpace(18), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 248, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(22), + const SizedBox( + height: 100, + child: FlowyText.semibold( + 'Plan\nFeatures', + fontSize: 24, + maxLines: 2, + color: 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: + widget.currentPlan == SubscriptionPlanPB.None, + canDowngrade: + widget.currentPlan != SubscriptionPlanPB.None, + onSelected: () async { + if (widget.currentPlan == SubscriptionPlanPB.None) { + return; + } + + context.read().add( + const SettingsPlanEvent.cancelSubscription(), + ); + }, + ), + _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: + widget.currentPlan == SubscriptionPlanPB.Pro, + canUpgrade: + widget.currentPlan == SubscriptionPlanPB.None, + onSelected: () => + context.read().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 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: 200, + 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( + onPressed: onSelected, + 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: [ + FlowyText.medium(label), + const Spacer(), + if (tooltip != null) + FlowyTooltip( + message: tooltip, + child: const FlowySvg(FlowySvgs.information_s), + ), + ], + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.onPressed, + required this.isUpgrade, + this.useGradientBorder = false, + }); + + 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( + isUpgrade + ? LocaleKeys + .settings_comparePlanDialog_actions_upgrade + .tr() + : LocaleKeys + .settings_comparePlanDialog_actions_downgrade + .tr(), + 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(), +]; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart new file mode 100644 index 000000000000..46402a3149bf --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart @@ -0,0 +1,698 @@ +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/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( + create: (context) => SettingsPlanBloc(workspaceId: workspaceId) + ..add(const SettingsPlanEvent.started()), + child: BlocBuilder( + 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, + currentPlan: state.subscription.subscriptionPlan, + ), + _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().workspaceId, + subscription.subscriptionPlan, + ), + ), + ], + ), + ), + const HSpace(16), + Expanded( + child: SeparatedColumn( + separatorBuilder: () => const VSpace(4), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ProConItem( + label: LocaleKeys + .settings_planPage_planUsage_currentPlan_freeProOne + .tr(), + ), + _ProConItem( + label: LocaleKeys + .settings_planPage_planUsage_currentPlan_freeProTwo + .tr(), + ), + _ProConItem( + label: LocaleKeys + .settings_planPage_planUsage_currentPlan_freeProThree + .tr(), + ), + _ProConItem( + label: LocaleKeys + .settings_planPage_planUsage_currentPlan_freeProFour + .tr(), + ), + _ProConItem( + label: LocaleKeys + .settings_planPage_planUsage_currentPlan_freeConOne + .tr(), + isPro: false, + ), + _ProConItem( + label: LocaleKeys + .settings_planPage_planUsage_currentPlan_freeConTwo + .tr(), + isPro: false, + ), + _ProConItem( + label: LocaleKeys + .settings_planPage_planUsage_currentPlan_freeConThree + .tr(), + isPro: false, + ), + _ProConItem( + label: LocaleKeys + .settings_planPage_planUsage_currentPlan_freeConFour + .tr(), + 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, + ), + ), + ), + ), + ], + ); + } + + void _openPricingDialog( + BuildContext context, + String workspaceId, + SubscriptionPlanPB plan, + ) => + showDialog( + context: context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: SettingsPlanComparisonDialog( + workspaceId: workspaceId, + currentPlan: plan, + ), + ), + ); +} + +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.currentPlan}); + + final WorkspaceUsagePB usage; + final SubscriptionPlanPB currentPlan; + + @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(), + ), + ), + // TODO(Mathias): Implement AI Usage once it's ready in backend + Expanded( + child: _UsageBox( + title: + LocaleKeys.settings_planPage_planUsage_aiResponseLabel.tr(), + label: + LocaleKeys.settings_planPage_planUsage_aiResponseUsage.tr( + args: ['750', '1,000'], + ), + value: .75, + ), + ), + ], + ), + const VSpace(16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ToggleMore( + value: false, + label: + LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(), + currentPlan: currentPlan, + badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(), + ), + const VSpace(8), + _ToggleMore( + value: false, + label: + LocaleKeys.settings_planPage_planUsage_guestCollabToggle.tr(), + currentPlan: currentPlan, + 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.currentPlan, + this.badgeLabel, + }); + + final bool value; + final String label; + final SubscriptionPlanPB currentPlan; + 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.value( + value: context.read(), + child: SettingsPlanComparisonDialog( + workspaceId: context.read().workspaceId, + currentPlan: widget.currentPlan, + ), + ), + ).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), + border: Border.all( + color: const Color(0xFFDDF1F7).withOpacity( + theme.brightness == Brightness.light ? 1 : 0.1, + ), + ), + color: AFThemeExtension.of(context).progressBarBGColor, + ), + 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)), +// ], +// ); +// } +// } \ No newline at end of file diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index 9153215c798b..e5c8559ff16d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -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( 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 const SettingsBillingView(); case SettingsPage.featureFlags: return const FeatureFlagsPage(); default: diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart new file mode 100644 index 000000000000..41e8733cc5ec --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart @@ -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 createState() => _FlowyGradientButtonState(); +} + +class _FlowyGradientButtonState extends State { + 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, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart index 54593e1bd09b..3c838bd6fa85 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart @@ -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 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, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index e44af72edd08..6d9623da555d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -99,6 +99,20 @@ class SettingsMenu extends StatelessWidget { icon: const Icon(Icons.cut), changeSelectedPage: changeSelectedPage, ), + 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 diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart index 8a2c504bc5db..78d89f02971f 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart @@ -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, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart index 299eb76015f3..27bff59045b9 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart @@ -97,7 +97,13 @@ class _FlowyHoverState extends State { child: child, ); } else { - return Container(color: style.backgroundColor, child: child); + return Container( + decoration: BoxDecoration( + color: style.backgroundColor, + borderRadius: style.borderRadius, + ), + child: child, + ); } } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart index c79f430942b4..fd6b7347156f 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart @@ -1,8 +1,14 @@ +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:flutter/material.dart'; -import 'package:flutter/services.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, - ), - FlowyText.semibold( - message, - maxLines: 10, - ), - const SizedBox( - height: _titleToMessagePadding, - ), - FlowyText.regular( - howToFix, - maxLines: 10, - ), - 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), + ), + ), + ), ), - 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( diff --git a/frontend/resources/flowy_icons/16x/ai_star.svg b/frontend/resources/flowy_icons/16x/ai_star.svg new file mode 100644 index 000000000000..b98634fda127 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_star.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/settings_billing.svg b/frontend/resources/flowy_icons/24x/settings_billing.svg new file mode 100644 index 000000000000..a9f45f71233f --- /dev/null +++ b/frontend/resources/flowy_icons/24x/settings_billing.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/settings_plan.svg b/frontend/resources/flowy_icons/24x/settings_plan.svg index 5c6f53f8368d..3c6f05400f39 100644 --- a/frontend/resources/flowy_icons/24x/settings_plan.svg +++ b/frontend/resources/flowy_icons/24x/settings_plan.svg @@ -1,8 +1,8 @@ - + - - + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 04ab72f795f9..377ba93c0c44 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -472,6 +472,120 @@ } } }, + "planPage": { + "menuLabel": "Plan", + "title": "Pricing plan", + "planUsage": { + "title": "Plan usage summary", + "storageLabel": "Storage", + "storageUsage": "{} of {} GB", + "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", + "freeConOne": "30 day revision history", + "freeConTwo": "Guest collaborators (edit access)", + "freeConThree": "unlimited storage", + "freeConFour": "6 month revision history" + }, + "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", + "description": "Customize your profile, manage account security, open AI keys, or login into your account.", + "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": { + "actions": { + "upgrade": "Upgrade", + "downgrade": "Downgrade" + }, + "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 chared 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": "100,000 monthly" + } + }, "common": { "reset": "Reset" }, diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index babcbbdb4965..d410c2bb3c6b 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -209,6 +209,7 @@ pub struct SubscribeWorkspacePB { #[pb(index = 2)] pub recurring_interval: RecurringIntervalPB, + #[pb(index = 3)] pub workspace_subscription_plan: SubscriptionPlanPB,