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
This commit is contained in:
Mathias Mogensen
2024-06-03 13:45:49 +02:00
committed by GitHub
parent cc5590f08b
commit cafa97d3e4
23 changed files with 1806 additions and 37 deletions

View File

@ -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 {

View File

@ -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<FlowyResult<RepeatedWorkspaceSubscriptionPB, FlowyError>>
getWorkspaceSubscriptions() {
return UserEventGetWorkspaceSubscriptions().send();
}
static Future<FlowyResult<PaymentLinkPB, FlowyError>> createSubscription(
String workspaceId,
SubscriptionPlanPB plan,
) {
final request = SubscribeWorkspacePB()
..workspaceId = workspaceId
..recurringInterval = RecurringIntervalPB.Month
..workspaceSubscriptionPlan = plan
..successUrl =
'http://$_deepLinkSubscriptionUrl'; // TODO(Mathias): Change once Zack has resolved
return UserEventSubscribeWorkspace(request).send();
}
static Future<FlowyResult<void, FlowyError>> cancelSubscription(
String workspaceId,
) {
final request = UserWorkspaceIdPB()..workspaceId = workspaceId;
return UserEventCancelWorkspaceSubscription(request).send();
}
}

View File

@ -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<SettingsPlanEvent, SettingsPlanState> {
SettingsPlanBloc({
required this.workspaceId,
}) : super(const _Initial()) {
_service = WorkspaceService(workspaceId: workspaceId);
on<SettingsPlanEvent>((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;
}

View File

@ -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',
};
}

View File

@ -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();
}

View File

@ -13,6 +13,8 @@ enum SettingsPage {
account,
workspace,
manageData,
plan,
billing,
// OLD
notifications,
cloud,
@ -81,14 +83,12 @@ class SettingsDialogEvent with _$SettingsDialogEvent {
class SettingsDialogState with _$SettingsDialogState {
const factory SettingsDialogState({
required UserProfilePB userProfile,
required FlowyResult<void, String> successOrFailure,
required SettingsPage page,
}) = _SettingsDialogState;
factory SettingsDialogState.initial(UserProfilePB userProfile) =>
SettingsDialogState(
userProfile: userProfile,
successOrFailure: FlowyResult.success(null),
page: SettingsPage.account,
);
}

View File

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
class WorkspaceService {
@ -70,4 +71,13 @@ class WorkspaceService {
return FolderEventMoveView(payload).send();
}
Future<FlowyResult<WorkspaceUsagePB, FlowyError>> getWorkspaceUsage() {
final payload = UserWorkspaceIdPB(workspaceId: workspaceId);
return UserEventGetWorkspaceUsage(payload).send();
}
Future<FlowyResult<BillingPortalPB, FlowyError>> getBillingPortal() {
return UserEventGetBillingPortal().send();
}
}

View File

@ -76,6 +76,11 @@ void showSettingsDialog(BuildContext context, UserProfilePB userProfile) =>
],
child: SettingsDialog(
userProfile,
workspaceId: context
.read<UserWorkspaceBloc>()
.state
.currentWorkspace!
.workspaceId,
didLogout: () async {
// Pop the dialog using the dialog context
Navigator.of(dialogContext).pop();

View File

@ -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(),
),
],
),
],
);
}
}

View File

@ -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<SettingsPlanComparisonDialog> createState() =>
_SettingsPlanComparisonDialogState();
}
class _SettingsPlanComparisonDialogState
extends State<SettingsPlanComparisonDialog> {
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<SettingsPlanBloc>().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<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
SubscriptionPlanPB.Pro,
),
),
),
],
),
],
),
),
),
),
],
),
);
}
}
class _PlanTable extends StatelessWidget {
const _PlanTable({
required this.title,
required this.description,
required this.price,
required this.priceInfo,
required this.cells,
required this.isCurrent,
required this.onSelected,
this.canUpgrade = false,
this.canDowngrade = false,
});
final String title;
final String description;
final String price;
final String priceInfo;
final List<String> cells;
final bool isCurrent;
final VoidCallback onSelected;
final bool canUpgrade;
final bool canDowngrade;
@override
Widget build(BuildContext context) {
final highlightPlan = !isCurrent && !canDowngrade && canUpgrade;
return Container(
width: 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(),
];

View File

@ -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<SettingsPlanBloc>(
create: (context) => SettingsPlanBloc(workspaceId: workspaceId)
..add(const SettingsPlanEvent.started()),
child: BlocBuilder<SettingsPlanBloc, SettingsPlanState>(
builder: (context, state) {
return state.map(
initial: (_) => const SizedBox.shrink(),
loading: (_) => const Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator.adaptive(strokeWidth: 3),
),
),
error: (state) {
if (state.error != null) {
return Padding(
padding: const EdgeInsets.all(16),
child: FlowyErrorPage.message(
state.error!.msg,
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
);
}
return ErrorWidget.withDetails(message: 'Something went wrong!');
},
ready: (state) {
return SettingsBody(
autoSeparate: false,
title: LocaleKeys.settings_planPage_title.tr(),
children: [
_PlanUsageSummary(
usage: state.workspaceUsage,
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<SettingsPlanBloc>().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<SettingsPlanBloc>.value(
value: context.read<SettingsPlanBloc>(),
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<SettingsPlanBloc>.value(
value: context.read<SettingsPlanBloc>(),
child: SettingsPlanComparisonDialog(
workspaceId: context.read<SettingsPlanBloc>().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)),
// ],
// );
// }
// }

View File

@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_billing_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_manage_data_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_page.dart';
@ -22,12 +24,14 @@ class SettingsDialog extends StatelessWidget {
required this.dismissDialog,
required this.didLogout,
required this.restartApp,
required this.workspaceId,
}) : super(key: ValueKey(user.id));
final VoidCallback dismissDialog;
final VoidCallback didLogout;
final VoidCallback restartApp;
final UserProfilePB user;
final String workspaceId;
@override
Widget build(BuildContext context) {
@ -37,6 +41,7 @@ class SettingsDialog extends StatelessWidget {
child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>(
builder: (context, state) => FlowyDialog(
width: MediaQuery.of(context).size.width * 0.7,
constraints: const BoxConstraints(maxWidth: 784, minWidth: 564),
child: ScaffoldMessenger(
child: Scaffold(
backgroundColor: Colors.transparent,
@ -89,6 +94,10 @@ class SettingsDialog extends StatelessWidget {
return const SettingsShortcutsView();
case SettingsPage.member:
return WorkspaceMembersPage(userProfile: user);
case SettingsPage.plan:
return SettingsPlanView(workspaceId: workspaceId);
case SettingsPage.billing:
return const SettingsBillingView();
case SettingsPage.featureFlags:
return const FeatureFlagsPage();
default:

View File

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
class FlowyGradientButton extends StatefulWidget {
const FlowyGradientButton({
super.key,
required this.label,
this.onPressed,
this.fontWeight = FontWeight.w600,
this.textColor = Colors.white,
this.backgroundColor,
});
final String label;
final VoidCallback? onPressed;
final FontWeight fontWeight;
/// Used to provide a custom foreground color for the button, used in cases
/// where a custom [backgroundColor] is provided and the default text color
/// does not have enough contrast.
///
final Color textColor;
/// Used to provide a custom background color for the button, this will
/// override the gradient behavior, and is mostly used in rare cases
/// where the gradient doesn't have contrast with the background.
///
final Color? backgroundColor;
@override
State<FlowyGradientButton> createState() => _FlowyGradientButtonState();
}
class _FlowyGradientButtonState extends State<FlowyGradientButton> {
bool isHovering = false;
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: (_) => widget.onPressed?.call(),
child: MouseRegion(
onEnter: (_) => setState(() => isHovering = true),
onExit: (_) => setState(() => isHovering = false),
cursor: SystemMouseCursors.click,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
blurRadius: 4,
color: Colors.black.withOpacity(0.25),
offset: const Offset(0, 2),
),
],
borderRadius: BorderRadius.circular(16),
color: widget.backgroundColor,
gradient: widget.backgroundColor != null
? null
: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
isHovering
? const Color.fromARGB(255, 57, 40, 92)
: const Color(0xFF44326B),
isHovering
? const Color.fromARGB(255, 96, 53, 164)
: const Color(0xFF7547C0),
],
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: FlowyText(
widget.label,
fontSize: 16,
fontWeight: widget.fontWeight,
color: widget.textColor,
maxLines: 2,
textAlign: TextAlign.center,
),
),
),
),
);
}
}

View File

@ -9,11 +9,13 @@ class SettingsBody extends StatelessWidget {
super.key,
required this.title,
this.description,
this.autoSeparate = true,
required this.children,
});
final String title;
final String? description;
final bool autoSeparate;
final List<Widget> children;
@override
@ -28,7 +30,9 @@ class SettingsBody extends StatelessWidget {
SettingsHeader(title: title, description: description),
Flexible(
child: SeparatedColumn(
separatorBuilder: () => const SettingsCategorySpacer(),
separatorBuilder: () => autoSeparate
? const SettingsCategorySpacer()
: const VSpace(16),
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),

View File

@ -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

View File

@ -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,

View File

@ -97,7 +97,13 @@ class _FlowyHoverState extends State<FlowyHover> {
child: child,
);
} else {
return Container(color: style.backgroundColor, child: child);
return Container(
decoration: BoxDecoration(
color: style.backgroundColor,
borderRadius: style.borderRadius,
),
child: child,
);
}
}
}

View File

@ -1,8 +1,14 @@
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:url_launcher/url_launcher.dart';
class FlowyErrorPage extends StatelessWidget {
@ -71,34 +77,56 @@ class FlowyErrorPage extends StatelessWidget {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const FlowyText.medium(
"AppFlowy Error",
fontSize: _titleFontSize,
),
const SizedBox(
height: _titleToMessagePadding,
const SizedBox(height: _titleToMessagePadding),
Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) async {
await Clipboard.setData(ClipboardData(text: message));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
content: FlowyText(
'Message copied to clipboard',
fontSize: kIsWeb || !Platform.isIOS && !Platform.isAndroid
? 14
: 12,
),
),
);
}
},
child: FlowyHover(
style: HoverStyle(
backgroundColor:
Theme.of(context).colorScheme.tertiaryContainer,
),
cursor: SystemMouseCursors.click,
child: FlowyTooltip(
message: 'Click to copy message',
child: Padding(
padding: const EdgeInsets.all(4),
child: FlowyText.semibold(message, maxLines: 10),
),
),
),
),
FlowyText.semibold(
message,
maxLines: 10,
),
const SizedBox(
height: _titleToMessagePadding,
),
FlowyText.regular(
howToFix,
maxLines: 10,
),
const SizedBox(
height: _titleToMessagePadding,
),
const GitHubRedirectButton(),
const SizedBox(
height: _titleToMessagePadding,
const SizedBox(height: _titleToMessagePadding),
FlowyText.regular(howToFix, maxLines: 10),
const SizedBox(height: _titleToMessagePadding),
GitHubRedirectButton(
title: 'Unexpected error',
message: message,
stackTrace: stackTrace,
),
const SizedBox(height: _titleToMessagePadding),
if (stackTrace != null) StackTracePreview(stackTrace!),
if (actions != null)
Row(
@ -175,7 +203,16 @@ class StackTracePreview extends StatelessWidget {
}
class GitHubRedirectButton extends StatelessWidget {
const GitHubRedirectButton({super.key});
const GitHubRedirectButton({
super.key,
this.title,
this.message,
this.stackTrace,
});
final String? title;
final String? message;
final String? stackTrace;
static const _height = 32.0;
@ -184,9 +221,34 @@ class GitHubRedirectButton extends StatelessWidget {
host: 'github.com',
path: '/AppFlowy-IO/AppFlowy/issues/new',
query:
'assignees=&labels=&projects=&template=bug_report.yaml&title=%5BBug%5D+',
'assignees=&labels=&projects=&template=bug_report.yaml&os=$_platform&title=%5BBug%5D+$title&context=$_contextString',
);
String get _contextString {
if (message == null && stackTrace == null) {
return '';
}
String msg = "";
if (message != null) {
msg += 'Error message:%0A```%0A$message%0A```%0A';
}
if (stackTrace != null) {
msg += 'StackTrace:%0A```%0A$stackTrace%0A```%0A';
}
return msg;
}
String get _platform {
if (kIsWeb) {
return 'Web';
}
return Platform.operatingSystem;
}
@override
Widget build(BuildContext context) {
return FlowyButton(

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.76392 5.78125L13.8889 7.5L9.76392 9.21875L7.88892 13L6.01392 9.21875L1.88892 7.5L6.01392 5.78125L7.88892 2L9.76392 5.78125Z" fill="#750D7E"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3738_1129" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_3738_1129)">
<path d="M22 6V18C22 18.55 21.8042 19.0208 21.4125 19.4125C21.0208 19.8042 20.55 20 20 20H4C3.45 20 2.97917 19.8042 2.5875 19.4125C2.19583 19.0208 2 18.55 2 18V6C2 5.45 2.19583 4.97917 2.5875 4.5875C2.97917 4.19583 3.45 4 4 4H20C20.55 4 21.0208 4.19583 21.4125 4.5875C21.8042 4.97917 22 5.45 22 6ZM4 8H20V6H4V8ZM4 12V18H20V12H4Z" fill="#1C1B1F"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 656 B

View File

@ -1,8 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_122_2580" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<mask id="mask0_3738_2461" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_122_2580)">
<path d="M22 6V18C22 18.55 21.8042 19.0208 21.4125 19.4125C21.0208 19.8042 20.55 20 20 20H4C3.45 20 2.97917 19.8042 2.5875 19.4125C2.19583 19.0208 2 18.55 2 18V6C2 5.45 2.19583 4.97917 2.5875 4.5875C2.97917 4.19583 3.45 4 4 4H20C20.55 4 21.0208 4.19583 21.4125 4.5875C21.8042 4.97917 22 5.45 22 6ZM4 8H20V6H4V8ZM4 12V18H20V12H4Z" fill="#1C1B1F"/>
<g mask="url(#mask0_3738_2461)">
<path d="M5 21C4.45 21 3.97917 20.8042 3.5875 20.4125C3.19583 20.0208 3 19.55 3 19V5C3 4.45 3.19583 3.97917 3.5875 3.5875C3.97917 3.19583 4.45 3 5 3H19C19.55 3 20.0208 3.19583 20.4125 3.5875C20.8042 3.97917 21 4.45 21 5V19C21 19.55 20.8042 20.0208 20.4125 20.4125C20.0208 20.8042 19.55 21 19 21H5ZM10 19V13H5V19H10ZM12 19H19V13H12V19ZM5 11H19V5H5V11Z" fill="#1C1B1F"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 654 B

After

Width:  |  Height:  |  Size: 678 B

View File

@ -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"
},

View File

@ -209,6 +209,7 @@ pub struct SubscribeWorkspacePB {
#[pb(index = 2)]
pub recurring_interval: RecurringIntervalPB,
#[pb(index = 3)]
pub workspace_subscription_plan: SubscriptionPlanPB,