feat: start on AI plan+billing UI

This commit is contained in:
Mathias Mogensen 2024-07-05 02:07:33 +02:00
parent 6d0c9f766b
commit 7c77f0e9a9
8 changed files with 391 additions and 231 deletions

View File

@ -16,6 +16,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../generated/locale_keys.g.dart';
const _buttonsMinWidth = 116.0;
class SettingsBillingView extends StatelessWidget {
const SettingsBillingView({
super.key,
@ -57,7 +59,9 @@ class SettingsBillingView extends StatelessWidget {
},
ready: (state) {
final billingPortalEnabled = state.billingPortal != null &&
state.billingPortal!.url.isNotEmpty;
state.billingPortal!.url.isNotEmpty &&
state.subscription.subscriptionPlan !=
SubscriptionPlanPB.None;
return SettingsBody(
title: LocaleKeys.settings_billingPage_title.tr(),
@ -77,6 +81,7 @@ class SettingsBillingView extends StatelessWidget {
buttonLabel: LocaleKeys
.settings_billingPage_plan_planButtonLabel
.tr(),
minWidth: _buttonsMinWidth,
),
if (billingPortalEnabled)
SingleSettingAction(
@ -89,6 +94,7 @@ class SettingsBillingView extends StatelessWidget {
buttonLabel: LocaleKeys
.settings_billingPage_plan_periodButtonLabel
.tr(),
minWidth: _buttonsMinWidth,
),
],
),
@ -108,9 +114,34 @@ class SettingsBillingView extends StatelessWidget {
buttonLabel: LocaleKeys
.settings_billingPage_paymentDetails_methodButtonLabel
.tr(),
minWidth: _buttonsMinWidth,
),
],
),
// TODO(Mathias): Implement the business logic for AI Add-ons
const SettingsCategory(
title: 'Add-ons',
children: [
SingleSettingAction(
buttonType: SingleSettingsButtonType.highlight,
label: 'AppFlowy AI Max',
description:
"\$8 /user per month billed annually or \$10 billed monthly",
buttonLabel: 'Add AI Max',
fontWeight: FontWeight.w500,
minWidth: _buttonsMinWidth,
),
SingleSettingAction(
buttonType: SingleSettingsButtonType.highlight,
label: 'AppFlowy AI Offline',
description:
"\$8 /user per month billed annually or \$10 billed monthly",
buttonLabel: 'Add AI Offline',
fontWeight: FontWeight.w500,
minWidth: _buttonsMinWidth,
),
],
),
],
);
},

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy/util/theme_extension.dart';
@ -16,6 +15,7 @@ import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
@ -69,9 +69,44 @@ class SettingsPlanView extends StatelessWidget {
_PlanUsageSummary(
usage: state.workspaceUsage,
subscription: state.subscription,
billingPortal: state.billingPortal,
),
const VSpace(16),
_CurrentPlanBox(subscription: state.subscription),
const VSpace(16),
// TODO(Mathias): Localize and add business logic
FlowyText(
'Add-ons',
fontSize: 18,
color: AFThemeExtension.of(context).secondaryTextColor,
fontWeight: FontWeight.w600,
),
const VSpace(8),
const Row(
children: [
Flexible(
child: _AddOnBox(
title: "AI Max",
description:
"Unlimited AI responses with access to the latest advanced AI models.",
price: "\$8",
priceInfo: "billed annually or \$10 billed monthly",
buttonText: "Add AI Max",
),
),
HSpace(8),
Flexible(
child: _AddOnBox(
title: "AI Offline",
description:
"Run AI locally on your device for maximum privacy.",
price: "\$8",
priceInfo: "billed annually or \$10 billed monthly",
buttonText: "Add AI Offline",
),
),
],
),
],
),
);
@ -116,68 +151,67 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
border: Border.all(color: const Color(0xFFBDBDBD)),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
child: Column(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(4),
FlowyText.semibold(
widget.subscription.label,
fontSize: 24,
color: AFThemeExtension.of(context).strongText,
),
const VSpace(8),
FlowyText.regular(
widget.subscription.info,
fontSize: 16,
color: AFThemeExtension.of(context).strongText,
maxLines: 3,
),
const VSpace(16),
FlowyGradientButton(
label: LocaleKeys
.settings_planPage_planUsage_currentPlan_upgrade
.tr(),
onPressed: () => _openPricingDialog(
context,
context.read<SettingsPlanBloc>().workspaceId,
widget.subscription,
),
),
if (widget.subscription.hasCanceled) ...[
const VSpace(12),
FlowyText(
LocaleKeys
.settings_planPage_planUsage_currentPlan_canceledInfo
.tr(
args: [_canceledDate(context)],
Row(
children: [
Expanded(
flex: 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(4),
FlowyText.semibold(
widget.subscription.label,
fontSize: 24,
color: AFThemeExtension.of(context).strongText,
),
maxLines: 5,
fontSize: 12,
color: Theme.of(context).colorScheme.error,
),
],
],
),
),
const HSpace(16),
Expanded(
child: SeparatedColumn(
separatorBuilder: () => const VSpace(4),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
..._getPros(widget.subscription.subscriptionPlan).map(
(s) => _ProConItem(label: s),
const VSpace(8),
FlowyText.regular(
widget.subscription.info,
fontSize: 16,
color: AFThemeExtension.of(context).strongText,
maxLines: 3,
),
],
),
..._getCons(widget.subscription.subscriptionPlan).map(
(s) => _ProConItem(label: s, isPro: false),
),
Flexible(
flex: 5,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: FlowyGradientButton(
label: LocaleKeys
.settings_planPage_planUsage_currentPlan_upgrade
.tr(),
onPressed: () => _openPricingDialog(
context,
context.read<SettingsPlanBloc>().workspaceId,
widget.subscription,
),
),
),
],
),
],
),
),
],
),
if (widget.subscription.hasCanceled) ...[
const VSpace(12),
FlowyText(
LocaleKeys
.settings_planPage_planUsage_currentPlan_canceledInfo
.tr(
args: [_canceledDate(context)],
),
maxLines: 5,
fontSize: 12,
color: Theme.of(context).colorScheme.error,
),
],
],
),
),
@ -185,14 +219,21 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
top: 0,
left: 0,
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(color: Color(0xFF4F3F5F)),
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: const BoxDecoration(
color: Color(0xFF4F3F5F),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
bottomRight: Radius.circular(4),
),
),
child: Center(
child: FlowyText.semibold(
LocaleKeys.settings_planPage_planUsage_currentPlan_bannerLabel
.tr(),
fontSize: 16,
fontSize: 14,
color: Colors.white,
),
),
@ -226,97 +267,18 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
),
),
);
List<String> _getPros(SubscriptionPlanPB plan) => switch (plan) {
SubscriptionPlanPB.Pro => _proPros(),
_ => _freePros(),
};
List<String> _getCons(SubscriptionPlanPB plan) => switch (plan) {
SubscriptionPlanPB.Pro => _proCons(),
_ => _freeCons(),
};
List<String> _freePros() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProOne.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProTwo.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProThree.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProFour.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProFive.tr(),
];
List<String> _freeCons() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_freeConOne.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeConTwo.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeConThree.tr(),
];
List<String> _proPros() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProOne
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProTwo
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProThree
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProFour
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProFive
.tr(),
];
List<String> _proCons() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConOne
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConTwo
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConThree
.tr(),
];
}
class _ProConItem extends StatelessWidget {
const _ProConItem({
required this.label,
this.isPro = true,
});
final String label;
final bool isPro;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 18,
child: FlowySvg(
isPro ? FlowySvgs.check_m : FlowySvgs.close_error_s,
size: const Size.square(18),
color: isPro
? AFThemeExtension.of(context).strongText
: const Color(0xFF900000),
),
),
const HSpace(4),
Flexible(
child: FlowyText.regular(
label,
fontSize: 12,
color: AFThemeExtension.of(context).strongText,
maxLines: 3,
),
),
],
);
}
}
class _PlanUsageSummary extends StatelessWidget {
const _PlanUsageSummary({required this.usage, required this.subscription});
const _PlanUsageSummary({
required this.usage,
required this.subscription,
this.billingPortal,
});
final WorkspaceUsagePB usage;
final WorkspaceSubscriptionPB subscription;
final BillingPortalPB? billingPortal;
@override
Widget build(BuildContext context) {
@ -372,21 +334,29 @@ class _PlanUsageSummary extends StatelessWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ToggleMore(
value: subscription.subscriptionPlan == SubscriptionPlanPB.Pro,
label:
LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(),
subscription: subscription,
badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(),
),
const VSpace(8),
_ToggleMore(
value: subscription.subscriptionPlan == SubscriptionPlanPB.Pro,
label:
LocaleKeys.settings_planPage_planUsage_guestCollabToggle.tr(),
subscription: subscription,
badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(),
),
if (subscription.subscriptionPlan == SubscriptionPlanPB.None) ...[
_ToggleMore(
value: false,
label:
LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(),
subscription: subscription,
badgeLabel:
LocaleKeys.settings_planPage_planUsage_proBadge.tr(),
onTap: billingPortal?.url == null
? null
: () async {
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
SubscriptionPlanPB.Pro,
),
);
await Future.delayed(
const Duration(seconds: 2),
() {},
);
},
),
],
],
),
],
@ -445,12 +415,14 @@ class _ToggleMore extends StatefulWidget {
required this.label,
required this.subscription,
this.badgeLabel,
this.onTap,
});
final bool value;
final String label;
final WorkspaceSubscriptionPB subscription;
final String? badgeLabel;
final Future<void> Function()? onTap;
@override
State<_ToggleMore> createState() => _ToggleMoreState();
@ -472,29 +444,17 @@ class _ToggleMoreState extends State<_ToggleMore> {
Toggle(
value: toggleValue,
padding: EdgeInsets.zero,
onChanged: (_) {
setState(() => toggleValue = !toggleValue);
onChanged: (_) async {
if (widget.onTap == null || toggleValue) {
return;
}
Future.delayed(const Duration(milliseconds: 150), () {
if (mounted) {
showDialog(
context: context,
builder: (_) => BlocProvider<SettingsPlanBloc>.value(
value: context.read<SettingsPlanBloc>(),
child: SettingsPlanComparisonDialog(
workspaceId: context.read<SettingsPlanBloc>().workspaceId,
subscription: widget.subscription,
),
),
).then((_) {
Future.delayed(const Duration(milliseconds: 150), () {
if (mounted) {
setState(() => toggleValue = !toggleValue);
}
});
});
}
});
setState(() => toggleValue = !toggleValue);
await widget.onTap!();
if (mounted) {
setState(() => toggleValue = !toggleValue);
}
},
),
const HSpace(10),
@ -575,6 +535,104 @@ class _PlanProgressIndicator extends StatelessWidget {
}
}
class _AddOnBox extends StatelessWidget {
const _AddOnBox({
required this.title,
required this.description,
required this.price,
required this.priceInfo,
required this.buttonText,
});
final String title;
final String description;
final String price;
final String priceInfo;
final String buttonText;
@override
Widget build(BuildContext context) {
return Container(
height: 200,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFBDBDBD)),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.semibold(
title,
fontSize: 14,
color: AFThemeExtension.of(context).secondaryTextColor,
),
const VSpace(4),
const VSpace(4),
FlowyText.regular(
description,
fontSize: 11,
color: AFThemeExtension.of(context).strongText,
maxLines: 4,
),
const VSpace(4),
Row(
children: [
FlowyText(
price,
fontSize: 24,
color: AFThemeExtension.of(context).strongText,
),
const HSpace(4),
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText(
'/user per month',
fontSize: 11,
color: AFThemeExtension.of(context).strongText,
),
),
],
),
Row(
children: [
Expanded(
child: FlowyText(
priceInfo,
color: AFThemeExtension.of(context).secondaryTextColor,
fontSize: 11,
maxLines: 2,
),
),
],
),
const Spacer(),
Row(
children: [
FlowyTextButton(
buttonText,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
fillColor: Colors.transparent,
radius: Corners.s16Border,
hoverColor: const Color(0xFF5C3699),
fontColor: const Color(0xFF5C3699),
fontHoverColor: Colors.white,
borderColor: const Color(0xFF5C3699),
fontSize: 12,
onPressed: () {},
),
],
),
],
),
);
}
}
/// Uncomment if we need it in the future
// class _DealBox extends StatelessWidget {
// const _DealBox();

View File

@ -207,7 +207,7 @@ class SettingsWorkspaceView extends StatelessWidget {
Navigator.of(context).pop();
},
).show(context),
isDangerous: true,
buttonType: SingleSettingsButtonType.danger,
buttonLabel: workspaceMember?.role.isOwner ?? false
? LocaleKeys
.settings_workspacePage_manageWorkspace_deleteWorkspace

View File

@ -71,7 +71,7 @@ class _FlowyGradientButtonState extends State<FlowyGradientButton> {
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: FlowyText(
widget.label,
fontSize: 16,

View File

@ -6,6 +6,16 @@ import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
enum SingleSettingsButtonType {
primary,
danger,
highlight;
bool get isPrimary => this == primary;
bool get isDangerous => this == danger;
bool get isHighlight => this == highlight;
}
/// This is used to describe a single setting action
///
/// This will render a simple action that takes the title,
@ -18,15 +28,18 @@ class SingleSettingAction extends StatelessWidget {
const SingleSettingAction({
super.key,
required this.label,
this.description,
this.labelMaxLines,
required this.buttonLabel,
this.onPressed,
this.isDangerous = false,
this.buttonType = SingleSettingsButtonType.primary,
this.fontSize = 14,
this.fontWeight = FontWeight.normal,
this.minWidth,
});
final String label;
final String? description;
final int? labelMaxLines;
final String buttonLabel;
@ -36,46 +49,115 @@ class SingleSettingAction extends StatelessWidget {
///
final VoidCallback? onPressed;
/// If isDangerous is true, the button will be rendered as a dangerous
/// action, with a red outline.
///
final bool isDangerous;
final SingleSettingsButtonType buttonType;
final double fontSize;
final FontWeight fontWeight;
final double? minWidth;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: FlowyText(
label,
fontSize: fontSize,
fontWeight: fontWeight,
maxLines: labelMaxLines,
overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).secondaryTextColor,
child: Column(
children: [
Row(
children: [
Expanded(
child: FlowyText(
label,
fontSize: fontSize,
fontWeight: fontWeight,
maxLines: labelMaxLines,
overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).secondaryTextColor,
),
),
],
),
if (description != null) ...[
const VSpace(4),
Row(
children: [
Expanded(
child: FlowyText.regular(
description!,
fontSize: 11,
color: AFThemeExtension.of(context).secondaryTextColor,
maxLines: 2,
),
),
],
),
],
],
),
),
const HSpace(24),
SizedBox(
height: 32,
ConstrainedBox(
constraints: BoxConstraints(
minWidth: minWidth ?? 0.0,
maxHeight: 32,
minHeight: 32,
),
child: FlowyTextButton(
buttonLabel,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
fillColor:
isDangerous ? null : Theme.of(context).colorScheme.primary,
radius: Corners.s12Border,
hoverColor: isDangerous ? null : const Color(0xFF005483),
fontColor: isDangerous ? Theme.of(context).colorScheme.error : null,
fontHoverColor: Colors.white,
fillColor: fillColor(context),
radius: Corners.s8Border,
hoverColor: hoverColor(context),
fontColor: fontColor(context),
fontHoverColor: fontHoverColor(context),
borderColor: borderColor(context),
fontSize: 12,
isDangerous: isDangerous,
isDangerous: buttonType.isDangerous,
onPressed: onPressed,
),
),
],
);
}
Color? fillColor(BuildContext context) {
if (buttonType.isPrimary) {
return Theme.of(context).colorScheme.primary;
}
return Colors.transparent;
}
Color? hoverColor(BuildContext context) {
if (buttonType.isPrimary) {
return const Color(0xFF005483);
}
if (buttonType.isHighlight) {
return const Color(0xFF5C3699);
}
return null;
}
Color? fontColor(BuildContext context) {
if (buttonType.isDangerous) {
return Theme.of(context).colorScheme.error;
}
if (buttonType.isHighlight) {
return const Color(0xFF5C3699);
}
return null;
}
Color? fontHoverColor(BuildContext context) {
return Colors.white;
}
Color? borderColor(BuildContext context) {
if (buttonType.isHighlight) {
return const Color(0xFF5C3699);
}
return null;
}
}

View File

@ -82,4 +82,7 @@ class Corners {
static const BorderRadius s12Border = BorderRadius.all(s12Radius);
static const Radius s12Radius = Radius.circular(12);
static const BorderRadius s16Border = BorderRadius.all(s16Radius);
static const Radius s16Radius = Radius.circular(16);
}

View File

@ -165,6 +165,7 @@ class FlowyTextButton extends StatelessWidget {
this.decoration,
this.fontFamily,
this.isDangerous = false,
this.borderColor,
});
final String text;
@ -188,6 +189,7 @@ class FlowyTextButton extends StatelessWidget {
final String? fontFamily;
final bool isDangerous;
final Color? borderColor;
@override
Widget build(BuildContext context) {
@ -222,9 +224,10 @@ class FlowyTextButton extends StatelessWidget {
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
side: BorderSide(
color: isDangerous
? Theme.of(context).colorScheme.error
: Colors.transparent,
color: borderColor ??
(isDangerous
? Theme.of(context).colorScheme.error
: Colors.transparent),
),
borderRadius: radius ?? Corners.s6Border,
),

View File

@ -636,8 +636,7 @@
"aiResponseLabel": "AI Responses",
"aiResponseUsage": "{} of {}",
"proBadge": "Pro",
"memberProToggle": "Unlimited members",
"guestCollabToggle": "10 guest collaborators",
"memberProToggle": "10 members and unlimited AI responses",
"storageUnlimited": "Unlimited storage with your Pro Plan",
"aiCredit": {
"title": "Add @:appName AI Credit",
@ -656,23 +655,7 @@
"freeInfo": "Perfect for individuals or small teams up to 3 members.",
"proInfo": "Perfect for small and medium teams up to 10 members.",
"teamInfo": "Perfect for all productive and well-organized teams..",
"upgrade": "Compare &\n Upgrade",
"freeProOne": "Collaborative workspace",
"freeProTwo": "Up to 3 members (incl. owner)",
"freeProThree": "Unlimited guests (view-only)",
"freeProFour": "Storage 5GB",
"freeProFive": "30 day revision history",
"freeConOne": "Guest collaborators (edit access)",
"freeConTwo": "Unlimited storage",
"freeConThree": "6 month revision history",
"professionalProOne": "Collaborative workspace",
"professionalProTwo": "Unlimited members",
"professionalProThree": "Unlimited guests (view-only)",
"professionalProFour": "Unlimited storage",
"professionalProFive": "6 month revision history",
"professionalConOne": "Unlimited guest collaborators (edit access)",
"professionalConTwo": "Unlimited AI responses",
"professionalConThree": "1 year revision history",
"upgrade": "Upgrade plan",
"canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}."
},
"deal": {
@ -2031,4 +2014,4 @@
"movePageToSpace": "Move page to space",
"switchSpace": "Switch space"
}
}
}