Skip to content

Commit

Permalink
feat: billing UI (#5455)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Xazin authored Jun 3, 2024
1 parent cc5590f commit cafa97d
Show file tree
Hide file tree
Showing 23 changed files with 1,805 additions and 36 deletions.
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down
32 changes: 29 additions & 3 deletions frontend/appflowy_flutter/lib/user/application/user_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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',
};
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ enum SettingsPage {
account,
workspace,
manageData,
plan,
billing,
// OLD
notifications,
cloud,
Expand Down Expand Up @@ -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,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
),
],
),
],
);
}
}
Loading

0 comments on commit cafa97d

Please sign in to comment.