mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
@ -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 {
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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',
|
||||
};
|
||||
}
|
@ -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();
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
];
|
@ -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)),
|
||||
// ],
|
||||
// );
|
||||
// }
|
||||
// }
|
@ -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:
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
3
frontend/resources/flowy_icons/16x/ai_star.svg
Normal file
3
frontend/resources/flowy_icons/16x/ai_star.svg
Normal 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 |
8
frontend/resources/flowy_icons/24x/settings_billing.svg
Normal file
8
frontend/resources/flowy_icons/24x/settings_billing.svg
Normal 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 |
@ -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 |
@ -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"
|
||||
},
|
||||
|
@ -209,6 +209,7 @@ pub struct SubscribeWorkspacePB {
|
||||
|
||||
#[pb(index = 2)]
|
||||
pub recurring_interval: RecurringIntervalPB,
|
||||
|
||||
#[pb(index = 3)]
|
||||
pub workspace_subscription_plan: SubscriptionPlanPB,
|
||||
|
||||
|
Reference in New Issue
Block a user