feat: refine UI and add business logic for AI

This commit is contained in:
Mathias Mogensen 2024-07-11 09:47:59 +02:00
parent 792e6f1370
commit 61419e305c
11 changed files with 281 additions and 128 deletions

View File

@ -86,10 +86,11 @@ class SettingsBillingBloc
}, },
billingPortalFetched: (billingPortal) async => state.maybeWhen( billingPortalFetched: (billingPortal) async => state.maybeWhen(
orElse: () {}, orElse: () {},
ready: (subscriptionInfo, _) => emit( ready: (subscriptionInfo, _, plan) => emit(
SettingsBillingState.ready( SettingsBillingState.ready(
subscriptionInfo: subscriptionInfo, subscriptionInfo: subscriptionInfo,
billingPortal: billingPortal, billingPortal: billingPortal,
successfulPlanUpgrade: plan,
), ),
), ),
), ),
@ -114,7 +115,7 @@ class SettingsBillingBloc
await _userService.cancelSubscription(workspaceId, plan); await _userService.cancelSubscription(workspaceId, plan);
await _onPaymentSuccessful(); await _onPaymentSuccessful();
}, },
paymentSuccessful: () async { paymentSuccessful: (plan) async {
final result = await UserBackendService.getWorkspaceSubscriptionInfo( final result = await UserBackendService.getWorkspaceSubscriptionInfo(
workspaceId, workspaceId,
); );
@ -152,7 +153,11 @@ class SettingsBillingBloc
// Invalidate cache for this workspace // Invalidate cache for this workspace
await UserBackendService.invalidateWorkspaceSubscriptionCache(workspaceId); await UserBackendService.invalidateWorkspaceSubscriptionCache(workspaceId);
add(const SettingsBillingEvent.paymentSuccessful()); add(
SettingsBillingEvent.paymentSuccessful(
plan: _successListenable.subscribedPlan,
),
);
} }
} }
@ -168,7 +173,9 @@ class SettingsBillingEvent with _$SettingsBillingEvent {
const factory SettingsBillingEvent.cancelSubscription( const factory SettingsBillingEvent.cancelSubscription(
SubscriptionPlanPB plan, SubscriptionPlanPB plan,
) = _CancelSubscription; ) = _CancelSubscription;
const factory SettingsBillingEvent.paymentSuccessful() = _PaymentSuccessful; const factory SettingsBillingEvent.paymentSuccessful({
SubscriptionPlanPB? plan,
}) = _PaymentSuccessful;
} }
@freezed @freezed
@ -186,12 +193,17 @@ class SettingsBillingState extends Equatable with _$SettingsBillingState {
const factory SettingsBillingState.ready({ const factory SettingsBillingState.ready({
required WorkspaceSubscriptionInfoPB subscriptionInfo, required WorkspaceSubscriptionInfoPB subscriptionInfo,
required BillingPortalPB? billingPortal, required BillingPortalPB? billingPortal,
@Default(null) SubscriptionPlanPB? successfulPlanUpgrade,
}) = _Ready; }) = _Ready;
@override @override
List<Object?> get props => maybeWhen( List<Object?> get props => maybeWhen(
orElse: () => const [], orElse: () => const [],
error: (error) => [error], error: (error) => [error],
ready: (subscription, billingPortal) => [subscription, billingPortal], ready: (subscription, billingPortal, plan) => [
subscription,
billingPortal,
plan,
],
); );
} }

View File

@ -28,7 +28,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
on<SettingsPlanEvent>((event, emit) async { on<SettingsPlanEvent>((event, emit) async {
await event.when( await event.when(
started: (withShowSuccessful) async { started: (withSuccessfulUpgrade) async {
emit(const SettingsPlanState.loading()); emit(const SettingsPlanState.loading());
final snapshots = await Future.wait([ final snapshots = await Future.wait([
@ -64,11 +64,11 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
SettingsPlanState.ready( SettingsPlanState.ready(
workspaceUsage: usageResult, workspaceUsage: usageResult,
subscriptionInfo: subscriptionInfo, subscriptionInfo: subscriptionInfo,
showSuccessDialog: withShowSuccessful, successfulPlanUpgrade: withSuccessfulUpgrade,
), ),
); );
if (withShowSuccessful) { if (withSuccessfulUpgrade != null) {
emit( emit(
SettingsPlanState.ready( SettingsPlanState.ready(
workspaceUsage: usageResult, workspaceUsage: usageResult,
@ -80,7 +80,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
addSubscription: (plan) async { addSubscription: (plan) async {
final result = await _userService.createSubscription( final result = await _userService.createSubscription(
workspaceId, workspaceId,
SubscriptionPlanPB.Pro, plan,
); );
result.fold( result.fold(
@ -103,13 +103,13 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
add(const SettingsPlanEvent.started()); add(const SettingsPlanEvent.started());
}, },
paymentSuccessful: () { paymentSuccessful: (plan) {
final readyState = state.mapOrNull(ready: (state) => state); final readyState = state.mapOrNull(ready: (state) => state);
if (readyState == null) { if (readyState == null) {
return; return;
} }
add(const SettingsPlanEvent.started(withShowSuccessful: true)); add(SettingsPlanEvent.started(withSuccessfulUpgrade: plan));
}, },
); );
}); });
@ -124,7 +124,11 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
// Invalidate cache for this workspace // Invalidate cache for this workspace
await UserBackendService.invalidateWorkspaceSubscriptionCache(workspaceId); await UserBackendService.invalidateWorkspaceSubscriptionCache(workspaceId);
add(const SettingsPlanEvent.paymentSuccessful()); add(
SettingsPlanEvent.paymentSuccessful(
plan: _successListenable.subscribedPlan,
),
);
} }
@override @override
@ -137,7 +141,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
@freezed @freezed
class SettingsPlanEvent with _$SettingsPlanEvent { class SettingsPlanEvent with _$SettingsPlanEvent {
const factory SettingsPlanEvent.started({ const factory SettingsPlanEvent.started({
@Default(false) bool withShowSuccessful, @Default(null) SubscriptionPlanPB? withSuccessfulUpgrade,
}) = _Started; }) = _Started;
const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) = const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) =
@ -145,7 +149,9 @@ class SettingsPlanEvent with _$SettingsPlanEvent {
const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription; const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription;
const factory SettingsPlanEvent.paymentSuccessful() = _PaymentSuccessful; const factory SettingsPlanEvent.paymentSuccessful({
@Default(null) SubscriptionPlanPB? plan,
}) = _PaymentSuccessful;
} }
@freezed @freezed
@ -161,7 +167,7 @@ class SettingsPlanState with _$SettingsPlanState {
const factory SettingsPlanState.ready({ const factory SettingsPlanState.ready({
required WorkspaceUsagePB workspaceUsage, required WorkspaceUsagePB workspaceUsage,
required WorkspaceSubscriptionInfoPB subscriptionInfo, required WorkspaceSubscriptionInfoPB subscriptionInfo,
@Default(false) bool showSuccessDialog, @Default(null) SubscriptionPlanPB? successfulPlanUpgrade,
@Default(false) bool downgradeProcessing, @Default(false) bool downgradeProcessing,
}) = _Ready; }) = _Ready;
} }

View File

@ -26,6 +26,22 @@ extension SubscriptionLabels on WorkspaceSubscriptionInfoPB {
}; };
} }
extension AllSubscriptionLabels on SubscriptionPlanPB {
String get label => switch (this) {
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(),
SubscriptionPlanPB.AiMax =>
LocaleKeys.settings_billingPage_addons_aiMax_label.tr(),
SubscriptionPlanPB.AiLocal =>
LocaleKeys.settings_billingPage_addons_aiOnDevice_label.tr(),
_ => 'N/A',
};
}
extension WorkspaceSubscriptionStatusExt on WorkspaceSubscriptionInfoPB { extension WorkspaceSubscriptionStatusExt on WorkspaceSubscriptionInfoPB {
bool get isCanceled => bool get isCanceled =>
planSubscription.status == WorkspaceSubscriptionStatusPB.Canceled; planSubscription.status == WorkspaceSubscriptionStatusPB.Canceled;

View File

@ -1,12 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/billing/settings_billing_bloc.dart'; import 'package:appflowy/workspace/application/settings/billing/settings_billing_bloc.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/settings_plan_bloc.dart';
import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.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/pages/settings_plan_comparison_dialog.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_body.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/settings_category.dart';
import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -35,7 +40,8 @@ class SettingsBillingView extends StatelessWidget {
workspaceId: workspaceId, workspaceId: workspaceId,
userId: user.id, userId: user.id,
)..add(const SettingsBillingEvent.started()), )..add(const SettingsBillingEvent.started()),
child: BlocBuilder<SettingsBillingBloc, SettingsBillingState>( child: BlocConsumer<SettingsBillingBloc, SettingsBillingState>(
listener: (context, state) {},
builder: (context, state) { builder: (context, state) {
return state.map( return state.map(
initial: (_) => const SizedBox.shrink(), initial: (_) => const SizedBox.shrink(),
@ -124,7 +130,6 @@ class SettingsBillingView extends StatelessWidget {
), ),
], ],
), ),
// TODO(Mathias): Implement the business logic for AI Add-ons
SettingsCategory( SettingsCategory(
title: LocaleKeys.settings_billingPage_addons_title.tr(), title: LocaleKeys.settings_billingPage_addons_title.tr(),
children: [ children: [
@ -134,8 +139,11 @@ class SettingsBillingView extends StatelessWidget {
.settings_billingPage_addons_aiMax_label .settings_billingPage_addons_aiMax_label
.tr(), .tr(),
description: LocaleKeys description: LocaleKeys
.settings_billingPage_addons_aiMax_description .settings_billingPage_addons_aiMax_description,
.tr(), activeDescription: LocaleKeys
.settings_billingPage_addons_aiMax_activeDescription,
canceledDescription: LocaleKeys
.settings_billingPage_addons_aiMax_canceledDescription,
subscriptionInfo: subscriptionInfo:
state.subscriptionInfo.addOns.firstWhereOrNull( state.subscriptionInfo.addOns.firstWhereOrNull(
(a) => a.type == WorkspaceAddOnPBType.AddOnAiMax, (a) => a.type == WorkspaceAddOnPBType.AddOnAiMax,
@ -147,8 +155,11 @@ class SettingsBillingView extends StatelessWidget {
.settings_billingPage_addons_aiOnDevice_label .settings_billingPage_addons_aiOnDevice_label
.tr(), .tr(),
description: LocaleKeys description: LocaleKeys
.settings_billingPage_addons_aiOnDevice_description .settings_billingPage_addons_aiOnDevice_description,
.tr(), activeDescription: LocaleKeys
.settings_billingPage_addons_aiOnDevice_activeDescription,
canceledDescription: LocaleKeys
.settings_billingPage_addons_aiOnDevice_canceledDescription,
subscriptionInfo: subscriptionInfo:
state.subscriptionInfo.addOns.firstWhereOrNull( state.subscriptionInfo.addOns.firstWhereOrNull(
(a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal, (a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal,
@ -195,12 +206,16 @@ class _AITile extends StatelessWidget {
const _AITile({ const _AITile({
required this.label, required this.label,
required this.description, required this.description,
required this.canceledDescription,
required this.activeDescription,
required this.plan, required this.plan,
this.subscriptionInfo, this.subscriptionInfo,
}); });
final String label; final String label;
final String description; final String description;
final String canceledDescription;
final String activeDescription;
final SubscriptionPlanPB plan; final SubscriptionPlanPB plan;
final WorkspaceAddOnPB? subscriptionInfo; final WorkspaceAddOnPB? subscriptionInfo;
@ -209,9 +224,29 @@ class _AITile extends StatelessWidget {
final isCanceled = subscriptionInfo?.addOnSubscription.status == final isCanceled = subscriptionInfo?.addOnSubscription.status ==
WorkspaceSubscriptionStatusPB.Canceled; WorkspaceSubscriptionStatusPB.Canceled;
final dateFormat = context.read<AppearanceSettingsCubit>().state.dateFormat;
return SingleSettingAction( return SingleSettingAction(
label: label, label: label,
description: description, description: subscriptionInfo != null && isCanceled
? canceledDescription.tr(
args: [
dateFormat.formatDate(
subscriptionInfo!.addOnSubscription.endDate.toDateTime(),
false,
),
],
)
: subscriptionInfo != null
? activeDescription.tr(
args: [
dateFormat.formatDate(
subscriptionInfo!.addOnSubscription.endDate.toDateTime(),
false,
),
],
)
: description.tr(),
buttonLabel: subscriptionInfo != null buttonLabel: subscriptionInfo != null
? isCanceled ? isCanceled
? LocaleKeys.settings_billingPage_addons_renewLabel.tr() ? LocaleKeys.settings_billingPage_addons_renewLabel.tr()
@ -220,13 +255,25 @@ class _AITile extends StatelessWidget {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
minWidth: _buttonsMinWidth, minWidth: _buttonsMinWidth,
onPressed: () { onPressed: () {
if (subscriptionInfo != null && !isCanceled) { if (subscriptionInfo != null && isCanceled) {
// Cancel the addon // Show customer portal to renew
context
.read<SettingsBillingBloc>()
.add(const SettingsBillingEvent.openCustomerPortal());
} else if (subscriptionInfo != null) {
SettingsAlertDialog(
title: 'Remove AI Max',
subtitle:
'Are you sure you want to remove AI Max? You will keep the benefits until the end of the billing period.',
confirm: () {
Navigator.of(context).pop();
context context
.read<SettingsBillingBloc>() .read<SettingsBillingBloc>()
.add(SettingsBillingEvent.cancelSubscription(plan)); .add(SettingsBillingEvent.cancelSubscription(plan));
},
).show(context);
} else { } else {
// Add/renew the addon // Add the addon
context context
.read<SettingsBillingBloc>() .read<SettingsBillingBloc>()
.add(SettingsBillingEvent.addSubscription(plan)); .add(SettingsBillingEvent.addSubscription(plan));

View File

@ -56,13 +56,13 @@ class _SettingsPlanComparisonDialogState
return; return;
} }
if (readyState.showSuccessDialog) { if (readyState.successfulPlanUpgrade != null) {
SettingsAlertDialog( SettingsAlertDialog(
title: LocaleKeys.settings_comparePlanDialog_paymentSuccess_title title: LocaleKeys.settings_comparePlanDialog_paymentSuccess_title
.tr(args: [readyState.subscriptionInfo.label]), .tr(args: [readyState.successfulPlanUpgrade!.label]),
subtitle: LocaleKeys subtitle: LocaleKeys
.settings_comparePlanDialog_paymentSuccess_description .settings_comparePlanDialog_paymentSuccess_description
.tr(args: [readyState.subscriptionInfo.label]), .tr(args: [readyState.successfulPlanUpgrade!.label]),
hideCancelButton: true, hideCancelButton: true,
confirm: Navigator.of(context).pop, confirm: Navigator.of(context).pop,
confirmLabel: LocaleKeys.button_close.tr(), confirmLabel: LocaleKeys.button_close.tr(),

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/util/theme_extension.dart';
@ -108,6 +109,7 @@ class SettingsPlanView extends StatelessWidget {
.settings_planPage_planUsage_addons_addLabel .settings_planPage_planUsage_addons_addLabel
.tr(), .tr(),
isActive: state.subscriptionInfo.hasAIMax, isActive: state.subscriptionInfo.hasAIMax,
plan: SubscriptionPlanPB.AiMax,
), ),
), ),
const HSpace(8), const HSpace(8),
@ -136,6 +138,7 @@ class SettingsPlanView extends StatelessWidget {
.settings_planPage_planUsage_addons_addLabel .settings_planPage_planUsage_addons_addLabel
.tr(), .tr(),
isActive: state.subscriptionInfo.hasAIOnDevice, isActive: state.subscriptionInfo.hasAIOnDevice,
plan: SubscriptionPlanPB.AiLocal,
), ),
), ),
], ],
@ -328,11 +331,10 @@ class _PlanUsageSummary extends StatelessWidget {
Expanded( Expanded(
child: _UsageBox( child: _UsageBox(
title: LocaleKeys.settings_planPage_planUsage_storageLabel.tr(), title: LocaleKeys.settings_planPage_planUsage_storageLabel.tr(),
replacementText: subscriptionInfo.plan == unlimitedLabel: LocaleKeys
WorkspacePlanPB.ProPlan .settings_planPage_planUsage_unlimitedStorageLabel
? LocaleKeys.settings_planPage_planUsage_storageUnlimited .tr(),
.tr() unlimited: usage.storageBytesUnlimited,
: null,
label: LocaleKeys.settings_planPage_planUsage_storageUsage.tr( label: LocaleKeys.settings_planPage_planUsage_storageUsage.tr(
args: [ args: [
usage.currentBlobInGb, usage.currentBlobInGb,
@ -345,17 +347,21 @@ class _PlanUsageSummary extends StatelessWidget {
), ),
Expanded( Expanded(
child: _UsageBox( child: _UsageBox(
title: LocaleKeys.settings_planPage_planUsage_collaboratorsLabel title:
.tr(), LocaleKeys.settings_planPage_planUsage_aiResponseLabel.tr(),
label: LocaleKeys.settings_planPage_planUsage_collaboratorsUsage label:
.tr( LocaleKeys.settings_planPage_planUsage_aiResponseUsage.tr(
args: [ args: [
usage.memberCount.toString(), usage.aiResponsesCount.toString(),
usage.memberCountLimit.toString(), usage.aiResponsesCountLimit.toString(),
], ],
), ),
value: unlimitedLabel: LocaleKeys
usage.memberCount.toInt() / usage.memberCountLimit.toInt(), .settings_planPage_planUsage_unlimitedAILabel
.tr(),
unlimited: usage.aiResponsesUnlimited,
value: usage.aiResponsesCount.toInt() /
usage.aiResponsesCountLimit.toInt(),
), ),
), ),
], ],
@ -396,18 +402,23 @@ class _UsageBox extends StatelessWidget {
required this.title, required this.title,
required this.label, required this.label,
required this.value, required this.value,
this.replacementText, required this.unlimitedLabel,
this.unlimited = false,
}); });
final String title; final String title;
final String label; final String label;
final double value; final double value;
/// Replaces the progress indicator if not null final String unlimitedLabel;
final String? replacementText;
// Replaces the progress bar with an unlimited badge
final bool unlimited;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isLM = Theme.of(context).isLightMode;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -416,18 +427,37 @@ class _UsageBox extends StatelessWidget {
fontSize: 11, fontSize: 11,
color: AFThemeExtension.of(context).secondaryTextColor, color: AFThemeExtension.of(context).secondaryTextColor,
), ),
if (replacementText != null) ...[ if (unlimited) ...[
Row( const VSpace(4),
children: [ DecoratedBox(
Flexible( decoration: BoxDecoration(
child: FlowyText.medium( border: Border.all(
replacementText!, color: isLM ? const Color(0xFFE8E2EE) : const Color(0xFF9C00FB),
fontSize: 11,
color: AFThemeExtension.of(context).secondaryTextColor,
), ),
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FlowySvg(
FlowySvgs.check_circle_outlined_s,
color: Color(0xFF9C00FB),
),
const HSpace(4),
FlowyText(
unlimitedLabel,
fontWeight: FontWeight.w500,
fontSize: 11,
), ),
], ],
), ),
),
),
] else ...[ ] else ...[
_PlanProgressIndicator(label: label, progress: value), _PlanProgressIndicator(label: label, progress: value),
], ],
@ -569,6 +599,7 @@ class _AddOnBox extends StatelessWidget {
required this.billingInfo, required this.billingInfo,
required this.buttonText, required this.buttonText,
required this.isActive, required this.isActive,
required this.plan,
}); });
final String title; final String title;
@ -578,6 +609,7 @@ class _AddOnBox extends StatelessWidget {
final String billingInfo; final String billingInfo;
final String buttonText; final String buttonText;
final bool isActive; final bool isActive;
final SubscriptionPlanPB plan;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -658,7 +690,11 @@ class _AddOnBox extends StatelessWidget {
? const Color(0xFFE8E2EE) ? const Color(0xFFE8E2EE)
: const Color(0xFF5C3699), : const Color(0xFF5C3699),
fontSize: 12, fontSize: 12,
onPressed: isActive ? null : () {}, onPressed: isActive
? null
: () => context
.read<SettingsPlanBloc>()
.add(SettingsPlanEvent.addSubscription(plan)),
), ),
), ),
], ],

View File

@ -153,6 +153,7 @@ class WorkspaceMemberBloc
), ),
); );
}, },
upgradePlan: () {},
); );
}); });
} }
@ -209,6 +210,8 @@ class WorkspaceMemberEvent with _$WorkspaceMemberEvent {
String email, String email,
AFRolePB role, AFRolePB role,
) = UpdateWorkspaceMember; ) = UpdateWorkspaceMember;
const factory WorkspaceMemberEvent.upgradePlan() = UpgradePlan;
} }
enum WorkspaceMemberActionType { enum WorkspaceMemberActionType {

View File

@ -3,16 +3,17 @@ import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart';
@ -29,12 +30,16 @@ class WorkspaceMembersPage extends StatelessWidget {
return BlocProvider<WorkspaceMemberBloc>( return BlocProvider<WorkspaceMemberBloc>(
create: (context) => WorkspaceMemberBloc(userProfile: userProfile) create: (context) => WorkspaceMemberBloc(userProfile: userProfile)
..add(const WorkspaceMemberEvent.initial()), ..add(const WorkspaceMemberEvent.initial()),
child: BlocConsumer<WorkspaceMemberBloc, WorkspaceMemberState>( child: BlocBuilder<WorkspaceMemberBloc, WorkspaceMemberState>(
listener: _showResultDialog,
builder: (context, state) { builder: (context, state) {
return SettingsBody( return SettingsBody(
title: LocaleKeys.settings_appearance_members_title.tr(), title: LocaleKeys.settings_appearance_members_title.tr(),
children: [ children: [
if (state.actionResult != null)
_showMemberLimitWarning(
context,
state.actionResult!,
),
if (state.myRole.canInvite) const _InviteMember(), if (state.myRole.canInvite) const _InviteMember(),
if (state.members.isNotEmpty) if (state.members.isNotEmpty)
_MemberList( _MemberList(
@ -49,62 +54,85 @@ class WorkspaceMembersPage extends StatelessWidget {
); );
} }
void _showResultDialog(BuildContext context, WorkspaceMemberState state) { Widget _showMemberLimitWarning(
final actionResult = state.actionResult; BuildContext context,
if (actionResult == null) { WorkspaceMemberActionResult result,
return; ) {
} final isLM = Theme.of(context).isLightMode;
final actionType = actionResult.actionType; final actionType = result.actionType;
final result = actionResult.result; final actionResult = result.result;
// only show the result dialog when the action is WorkspaceMemberActionType.add if (actionType == WorkspaceMemberActionType.invite &&
if (actionType == WorkspaceMemberActionType.add) { actionResult.isFailure) {
result.fold( final error = actionResult.getFailure().code;
(s) { if (error == ErrorCode.WorkspaceMemberLimitExceeded) {
showSnackBarMessage( return DecoratedBox(
context, decoration: BoxDecoration(
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), color: isLM
); ? const Color(0xFFFFF4E5)
}, : const Color.fromARGB(255, 255, 200, 125),
(f) { borderRadius: Corners.s8Border,
Log.error('add workspace member failed: $f'); ),
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded child: Padding(
? LocaleKeys.settings_appearance_members_memberLimitExceeded.tr() padding: const EdgeInsets.symmetric(
: LocaleKeys.settings_appearance_members_failedToAddMember.tr(); vertical: 6,
showDialog( horizontal: 16,
context: context, ),
builder: (context) => NavigatorOkCancelDialog(message: message), child: Row(
); crossAxisAlignment: CrossAxisAlignment.start,
}, children: [
); FlowySvg(
} else if (actionType == WorkspaceMemberActionType.invite) { FlowySvgs.warning_m,
result.fold( color: isLM
(s) { ? const Color(0xFFEF6C00)
showSnackBarMessage( : const Color.fromARGB(255, 160, 75, 0),
context, ),
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), const HSpace(12),
); Expanded(
}, child: FlowyText(
(f) { LocaleKeys.settings_appearance_members_memberLimitExceeded
Log.error('invite workspace member failed: $f'); .tr(),
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded color: const Color(0xFF663C00),
? LocaleKeys.settings_appearance_members_memberLimitExceeded.tr() maxLines: 3,
: LocaleKeys.settings_appearance_members_failedToInviteMember fontSize: 14,
.tr(); ),
showDialog( ),
context: context, MouseRegion(
builder: (context) => NavigatorOkCancelDialog(message: message), cursor: SystemMouseCursors.click,
); child: GestureDetector(
}, onTap: () {},
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: const Color(0xFF5F2120),
),
borderRadius: Corners.s4Border,
),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
child: FlowyText(
// TODO(Mathias): Localization
'UPGRADE',
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF663C00),
),
),
),
),
),
],
),
),
); );
} }
}
result.onFailure((f) { return const SizedBox.shrink();
Log.error(
'[Member] Failed to perform ${actionType.toString()} action: $f',
);
});
} }
} }

View File

@ -0,0 +1,5 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.0003 5.949L17.9028 17.8748H4.09783L11.0003 5.949ZM11.0003 2.2915L0.916992 19.7082H21.0837L11.0003 2.2915Z" fill="#EF6C00"/>
<path d="M11.917 15.1248H10.0837V16.9582H11.917V15.1248Z" fill="#EF6C00"/>
<path d="M11.917 9.62484H10.0837V14.2082H11.917V9.62484Z" fill="#EF6C00"/>
</svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@ -639,13 +639,14 @@
"title": "Plan usage summary", "title": "Plan usage summary",
"storageLabel": "Storage", "storageLabel": "Storage",
"storageUsage": "{} of {} GB", "storageUsage": "{} of {} GB",
"unlimitedStorageLabel": "Unlimited storage",
"collaboratorsLabel": "Members", "collaboratorsLabel": "Members",
"collaboratorsUsage": "{} of {}", "collaboratorsUsage": "{} of {}",
"aiResponseLabel": "AI Responses", "aiResponseLabel": "AI Responses",
"aiResponseUsage": "{} of {}", "aiResponseUsage": "{} of {}",
"unlimitedAILabel": "Unlimited responses",
"proBadge": "Pro", "proBadge": "Pro",
"memberProToggle": "More members & unlimited AI", "memberProToggle": "More members & unlimited AI",
"storageUnlimited": "Unlimited storage with Pro Plan",
"aiCredit": { "aiCredit": {
"title": "Add @:appName AI Credit", "title": "Add @:appName AI Credit",
"price": "5$", "price": "5$",
@ -716,11 +717,15 @@
"renewLabel": "Renew", "renewLabel": "Renew",
"aiMax": { "aiMax": {
"label": "AI Max", "label": "AI Max",
"description": "US$8 /user per month billed annually or US$10 billed monthly" "description": "US$8 /user per month billed annually or US$10 billed monthly",
"activeDescription": "Next invoice due on {}",
"canceledDescription": "AI Max will be removed on {}"
}, },
"aiOnDevice": { "aiOnDevice": {
"label": "AI On-device", "label": "AI On-device",
"description": "US$8 /user per month billed annually or US$10 billed monthly" "description": "US$8 /user per month billed annually or US$10 billed monthly",
"activeDescription": "Next invoice due on {}",
"canceledDescription": "AI On-device will be removed on {}"
} }
} }
}, },
@ -946,7 +951,7 @@
"one": "{} member", "one": "{} member",
"other": "{} members" "other": "{} members"
}, },
"memberLimitExceeded": "You've reached the maximum member limit allowed for your account. If you want to add more additional members to continue your work, please request on Github", "memberLimitExceeded": "Member limit reached. Upgrade to invite additional members.",
"failedToAddMember": "Failed to add member", "failedToAddMember": "Failed to add member",
"addMemberSuccess": "Member added successfully", "addMemberSuccess": "Member added successfully",
"removeMember": "Remove Member", "removeMember": "Remove Member",

View File

@ -1,6 +1,6 @@
use chrono::Utc; use chrono::Utc;
use client_api::entity::billing_dto::{ use client_api::entity::billing_dto::{
RecurringInterval, SubscriptionPlan, SubscriptionStatus, WorkspaceSubscriptionStatus, RecurringInterval, SubscriptionPlan, WorkspaceSubscriptionStatus,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::str::FromStr; use std::str::FromStr;
@ -631,7 +631,11 @@ impl From<WorkspaceSubscriptionStatus> for WorkspaceSubscriptionV2PB {
Self { Self {
workspace_id: sub.workspace_id, workspace_id: sub.workspace_id,
subscription_plan: sub.workspace_plan.clone().into(), subscription_plan: sub.workspace_plan.clone().into(),
status: sub.subscription_status.into(), status: if sub.cancel_at.is_some() {
WorkspaceSubscriptionStatusPB::Canceled
} else {
WorkspaceSubscriptionStatusPB::Active
},
end_date: sub.current_period_end, end_date: sub.current_period_end,
} }
} }
@ -658,12 +662,3 @@ impl From<i64> for WorkspaceSubscriptionStatusPB {
} }
} }
} }
impl From<SubscriptionStatus> for WorkspaceSubscriptionStatusPB {
fn from(status: SubscriptionStatus) -> Self {
match status {
SubscriptionStatus::Active => WorkspaceSubscriptionStatusPB::Active,
_ => WorkspaceSubscriptionStatusPB::Canceled,
}
}
}