feat: complete UIUX for AI and limits

This commit is contained in:
Mathias Mogensen 2024-07-12 01:21:41 +02:00
parent 61419e305c
commit e9715abe90
9 changed files with 257 additions and 133 deletions

View File

@ -106,6 +106,7 @@ class SettingsBillingBloc
addSubscription: (plan) async {
final result =
await _userService.createSubscription(workspaceId, plan);
result.fold(
(link) => afLaunchUrlString(link.paymentLink),
(f) => Log.error(f.msg, f),

View File

@ -28,8 +28,10 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
on<SettingsPlanEvent>((event, emit) async {
await event.when(
started: (withSuccessfulUpgrade) async {
emit(const SettingsPlanState.loading());
started: (withSuccessfulUpgrade, shouldLoad) async {
if (shouldLoad) {
emit(const SettingsPlanState.loading());
}
final snapshots = await Future.wait([
_service.getWorkspaceUsage(),
@ -109,7 +111,12 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
return;
}
add(SettingsPlanEvent.started(withSuccessfulUpgrade: plan));
add(
SettingsPlanEvent.started(
withSuccessfulUpgrade: plan,
shouldLoad: false,
),
);
},
);
});
@ -142,6 +149,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
class SettingsPlanEvent with _$SettingsPlanEvent {
const factory SettingsPlanEvent.started({
@Default(null) SubscriptionPlanPB? withSuccessfulUpgrade,
@Default(true) bool shouldLoad,
}) = _Started;
const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) =

View File

@ -367,8 +367,9 @@ class _PlanUsageSummary extends StatelessWidget {
],
),
const VSpace(16),
Column(
SeparatedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
separatorBuilder: () => const VSpace(4),
children: [
if (subscriptionInfo.plan == WorkspacePlanPB.FreePlan) ...[
_ToggleMore(
@ -383,10 +384,40 @@ class _PlanUsageSummary extends StatelessWidget {
SubscriptionPlanPB.Pro,
),
);
await Future.delayed(
const Duration(seconds: 2),
() {},
);
await Future.delayed(const Duration(seconds: 2), () {});
},
),
],
if (!subscriptionInfo.hasAIMax) ...[
_ToggleMore(
value: false,
label: LocaleKeys.settings_planPage_planUsage_aiMaxToggle.tr(),
badgeLabel:
LocaleKeys.settings_planPage_planUsage_aiMaxBadge.tr(),
onTap: () async {
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
SubscriptionPlanPB.AiMax,
),
);
await Future.delayed(const Duration(seconds: 2), () {});
},
),
],
if (!subscriptionInfo.hasAIOnDevice) ...[
_ToggleMore(
value: false,
label: LocaleKeys.settings_planPage_planUsage_aiOnDeviceToggle
.tr(),
badgeLabel:
LocaleKeys.settings_planPage_planUsage_aiOnDeviceBadge.tr(),
onTap: () async {
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
SubscriptionPlanPB.AiLocal,
),
);
await Future.delayed(const Duration(seconds: 2), () {});
},
),
],
@ -417,8 +448,6 @@ class _UsageBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isLM = Theme.of(context).isLightMode;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -429,33 +458,22 @@ class _UsageBox extends StatelessWidget {
),
if (unlimited) ...[
const VSpace(4),
DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: isLM ? const Color(0xFFE8E2EE) : const Color(0xFF9C00FB),
),
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,
),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
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 ...[
@ -569,7 +587,9 @@ class _PlanProgressIndicator extends StatelessWidget {
widthFactor: progress,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primary,
color: progress >= 1
? theme.colorScheme.error
: theme.colorScheme.primary,
),
),
),
@ -613,6 +633,8 @@ class _AddOnBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isLM = Theme.of(context).isLightMode;
return Container(
height: 220,
padding: const EdgeInsets.symmetric(
@ -621,11 +643,9 @@ class _AddOnBox extends StatelessWidget {
),
decoration: BoxDecoration(
border: Border.all(
color: isActive ? const Color(0xFF9C00FB) : const Color(0xFFBDBDBD),
color: isActive ? const Color(0xFFBDBDBD) : const Color(0xFF9C00FB),
),
color: isActive
? const Color(0xFFF7F8FC).withOpacity(0.05)
: Colors.transparent,
color: const Color(0xFFF7F8FC).withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
),
child: Column(
@ -673,22 +693,36 @@ class _AddOnBox extends StatelessWidget {
Expanded(
child: FlowyTextButton(
buttonText,
heading: isActive
? const FlowySvg(
FlowySvgs.check_circle_outlined_s,
color: Color(0xFF9C00FB),
)
: null,
mainAxisAlignment: MainAxisAlignment.center,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
fillColor:
isActive ? const Color(0xFFE8E2EE) : Colors.transparent,
fillColor: isActive
? const Color(0xFFE8E2EE)
: isLM
? Colors.transparent
: const Color(0xFF5C3699),
constraints: const BoxConstraints(minWidth: 115),
radius: Corners.s16Border,
hoverColor: isActive
? const Color(0xFFE8E2EE)
: const Color(0xFF5C3699),
fontColor: const Color(0xFF5C3699),
: isLM
? const Color(0xFF5C3699)
: const Color(0xFF4d3472),
fontColor:
isLM || isActive ? const Color(0xFF5C3699) : Colors.white,
fontHoverColor:
isActive ? const Color(0xFF5C3699) : Colors.white,
borderColor: isActive
? const Color(0xFFE8E2EE)
: const Color(0xFF5C3699),
: isLM
? const Color(0xFF5C3699)
: const Color(0xFF4d3472),
fontSize: 12,
onPressed: isActive
? null

View File

@ -120,7 +120,10 @@ class SettingsDialog extends StatelessWidget {
return const AIFeatureOnlySupportedWhenUsingAppFlowyCloud();
}
case SettingsPage.member:
return WorkspaceMembersPage(userProfile: user);
return WorkspaceMembersPage(
userProfile: user,
workspaceId: workspaceId,
);
case SettingsPage.plan:
return SettingsPlanView(workspaceId: workspaceId, user: user);
case SettingsPage.billing:

View File

@ -1,3 +1,6 @@
import 'dart:async';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
@ -24,13 +27,14 @@ class WorkspaceMemberBloc
extends Bloc<WorkspaceMemberEvent, WorkspaceMemberState> {
WorkspaceMemberBloc({
required this.userProfile,
String? workspaceId,
this.workspace,
}) : _userBackendService = UserBackendService(userId: userProfile.id),
super(WorkspaceMemberState.initial()) {
on<WorkspaceMemberEvent>((event, emit) async {
await event.when(
initial: () async {
await _setCurrentWorkspaceId();
await _setCurrentWorkspaceId(workspaceId);
final result = await _userBackendService.getWorkspaceMembers(
_workspaceId,
@ -135,9 +139,7 @@ class WorkspaceMemberBloc
(s) => state.members.map((e) {
if (e.email == email) {
e.freeze();
return e.rebuild((p0) {
p0.role = role;
});
return e.rebuild((p0) => p0.role = role);
}
return e;
}).toList(),
@ -153,7 +155,26 @@ class WorkspaceMemberBloc
),
);
},
upgradePlan: () {},
updateSubscriptionInfo: (info) async =>
emit(state.copyWith(subscriptionInfo: info)),
upgradePlan: () async {
final plan = state.subscriptionInfo?.plan;
if (plan == null) {
return Log.error('Failed to upgrade plan: plan is null');
}
if (plan == WorkspacePlanPB.FreePlan) {
final checkoutLink = await _userBackendService.createSubscription(
_workspaceId,
SubscriptionPlanPB.Pro,
);
checkoutLink.fold(
(pl) => afLaunchUrlString(pl.paymentLink),
(f) => Log.error('Failed to create subscription: ${f.msg}', f),
);
}
},
);
});
}
@ -179,9 +200,11 @@ class WorkspaceMemberBloc
return role;
}
Future<void> _setCurrentWorkspaceId() async {
Future<void> _setCurrentWorkspaceId(String? workspaceId) async {
if (workspace != null) {
_workspaceId = workspace!.workspaceId;
} else if (workspaceId != null && workspaceId.isNotEmpty) {
_workspaceId = workspaceId;
} else {
final currentWorkspace = await FolderEventReadCurrentWorkspace().send();
currentWorkspace.fold((s) {
@ -192,6 +215,20 @@ class WorkspaceMemberBloc
_workspaceId = '';
});
}
unawaited(_fetchWorkspaceSubscriptionInfo());
}
// We fetch workspace subscription info lazily as it's not needed in the first
// render of the page.
Future<void> _fetchWorkspaceSubscriptionInfo() async {
final result =
await UserBackendService.getWorkspaceSubscriptionInfo(_workspaceId);
result.fold(
(info) => add(WorkspaceMemberEvent.updateSubscriptionInfo(info)),
(f) => Log.error('Failed to fetch subscription info: ${f.msg}', f),
);
}
}
@ -210,6 +247,9 @@ class WorkspaceMemberEvent with _$WorkspaceMemberEvent {
String email,
AFRolePB role,
) = UpdateWorkspaceMember;
const factory WorkspaceMemberEvent.updateSubscriptionInfo(
WorkspaceSubscriptionInfoPB subscriptionInfo,
) = UpdateSubscriptionInfo;
const factory WorkspaceMemberEvent.upgradePlan() = UpgradePlan;
}
@ -244,6 +284,7 @@ class WorkspaceMemberState with _$WorkspaceMemberState {
@Default(AFRolePB.Guest) AFRolePB myRole,
@Default(null) WorkspaceMemberActionResult? actionResult,
@Default(true) bool isLoading,
@Default(null) WorkspaceSubscriptionInfoPB? subscriptionInfo,
}) = _WorkspaceMemberState;
factory WorkspaceMemberState.initial() => const WorkspaceMemberState();
@ -258,6 +299,7 @@ class WorkspaceMemberState with _$WorkspaceMemberState {
return other is WorkspaceMemberState &&
other.members == members &&
other.myRole == myRole &&
other.subscriptionInfo == subscriptionInfo &&
identical(other.actionResult, actionResult);
}
}

View File

@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.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/settings/shared/settings_body.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.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/pop_up_action.dart';
@ -13,7 +14,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.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/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
@ -21,9 +22,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
class WorkspaceMembersPage extends StatelessWidget {
const WorkspaceMembersPage({super.key, required this.userProfile});
const WorkspaceMembersPage({
super.key,
required this.userProfile,
required this.workspaceId,
});
final UserProfilePB userProfile;
final String workspaceId;
@override
Widget build(BuildContext context) {
@ -34,13 +40,16 @@ class WorkspaceMembersPage extends StatelessWidget {
builder: (context, state) {
return SettingsBody(
title: LocaleKeys.settings_appearance_members_title.tr(),
autoSeparate: false,
children: [
if (state.actionResult != null)
_showMemberLimitWarning(
context,
state.actionResult!,
),
if (state.myRole.canInvite) const _InviteMember(),
if (state.actionResult != null) ...[
_showMemberLimitWarning(context, state),
const VSpace(16),
],
if (state.myRole.canInvite) ...[
const _InviteMember(),
const SettingsCategorySpacer(),
],
if (state.members.isNotEmpty)
_MemberList(
members: state.members,
@ -56,78 +65,98 @@ class WorkspaceMembersPage extends StatelessWidget {
Widget _showMemberLimitWarning(
BuildContext context,
WorkspaceMemberActionResult result,
WorkspaceMemberState state,
) {
final isLM = Theme.of(context).isLightMode;
// We promise that state.actionResult != null before calling
// this method
final actionResult = state.actionResult!.result;
final actionType = state.actionResult!.actionType;
final actionType = result.actionType;
final actionResult = result.result;
debugPrint("Plan: ${state.subscriptionInfo?.plan}");
if (actionType == WorkspaceMemberActionType.invite &&
actionResult.isFailure) {
final error = actionResult.getFailure().code;
if (error == ErrorCode.WorkspaceMemberLimitExceeded) {
return DecoratedBox(
decoration: BoxDecoration(
color: isLM
? const Color(0xFFFFF4E5)
: const Color.fromARGB(255, 255, 200, 125),
borderRadius: Corners.s8Border,
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 16,
return Row(
children: [
const FlowySvg(
FlowySvgs.warning_s,
blendMode: BlendMode.dst,
size: Size.square(20),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowySvg(
FlowySvgs.warning_m,
color: isLM
? const Color(0xFFEF6C00)
: const Color.fromARGB(255, 160, 75, 0),
),
const HSpace(12),
Expanded(
child: FlowyText(
LocaleKeys.settings_appearance_members_memberLimitExceeded
.tr(),
color: const Color(0xFF663C00),
maxLines: 3,
fontSize: 14,
),
),
MouseRegion(
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),
const HSpace(12),
Expanded(
child: RichText(
text: TextSpan(
children: [
if (state.subscriptionInfo?.plan ==
WorkspacePlanPB.ProPlan) ...[
TextSpan(
text: LocaleKeys
.settings_appearance_members_memberLimitExceededPro
.tr(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AFThemeExtension.of(context).strongText,
),
),
),
),
WidgetSpan(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
// Hardcoded support email, in the future we might
// want to add this to an environment variable
onTap: () async => afLaunchUrlString(
'mailto:support@appflowy.io',
),
child: FlowyText(
LocaleKeys
.settings_appearance_members_memberLimitExceededProContact
.tr(),
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
] else ...[
TextSpan(
text: LocaleKeys
.settings_appearance_members_memberLimitExceeded
.tr(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AFThemeExtension.of(context).strongText,
),
),
WidgetSpan(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => context
.read<WorkspaceMemberBloc>()
.add(const WorkspaceMemberEvent.upgradePlan()),
child: FlowyText(
LocaleKeys
.settings_appearance_members_memberLimitExceededUpgrade
.tr(),
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
],
],
),
],
),
),
),
],
);
}
}

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8.00033" cy="7.99984" r="7.33333" fill="#FF811A"/>
<path d="M7.99967 4.6665C7.63148 4.6665 7.33301 4.96498 7.33301 5.33317V8.6665C7.33301 9.03469 7.63148 9.33317 7.99967 9.33317C8.36786 9.33317 8.66634 9.03469 8.66634 8.6665V5.33317C8.66634 4.96498 8.36786 4.6665 7.99967 4.6665Z" fill="white"/>
<path d="M7.99967 11.3332C8.36786 11.3332 8.66634 11.0347 8.66634 10.6665C8.66634 10.2983 8.36786 9.99984 7.99967 9.99984C7.63148 9.99984 7.33301 10.2983 7.33301 10.6665C7.33301 11.0347 7.63148 11.3332 7.99967 11.3332Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

View File

@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 390 B

View File

@ -646,7 +646,11 @@
"aiResponseUsage": "{} of {}",
"unlimitedAILabel": "Unlimited responses",
"proBadge": "Pro",
"aiMaxBadge": "AI Max",
"aiOnDeviceBadge": "AI On-device",
"memberProToggle": "More members & unlimited AI",
"aiMaxToggle": "Unlimited AI responses",
"aiOnDeviceToggle": "On-device AI for ultimate privacy",
"aiCredit": {
"title": "Add @:appName AI Credit",
"price": "5$",
@ -717,15 +721,15 @@
"renewLabel": "Renew",
"aiMax": {
"label": "AI Max",
"description": "US$8 /user per month billed annually or US$10 billed monthly",
"description": "Unlock unlimited AI and advanced models",
"activeDescription": "Next invoice due on {}",
"canceledDescription": "AI Max will be removed on {}"
"canceledDescription": "AI Max will be available until {}"
},
"aiOnDevice": {
"label": "AI On-device",
"description": "US$8 /user per month billed annually or US$10 billed monthly",
"description": "Unlock unlimited AI offline on your device",
"activeDescription": "Next invoice due on {}",
"canceledDescription": "AI On-device will be removed on {}"
"canceledDescription": "AI On-device will be available until {}"
}
}
},
@ -951,7 +955,10 @@
"one": "{} member",
"other": "{} members"
},
"memberLimitExceeded": "Member limit reached. Upgrade to invite additional members.",
"memberLimitExceeded": "Member limit reached, to invite more members, please ",
"memberLimitExceededUpgrade": "upgrade",
"memberLimitExceededPro": "Member limit reached, if you require more members contact ",
"memberLimitExceededProContact": "support@appflowy.io",
"failedToAddMember": "Failed to add member",
"addMemberSuccess": "Member added successfully",
"removeMember": "Remove Member",