Compare commits

...
425 changed files with 10263 additions and 8557 deletions

No files matched your search

-72
View File
@@ -1,72 +0,0 @@
name: Deploy Web (Test)
on:
push:
branches:
- build/test
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
env:
SSH_PRIVATE_KEY: ${{ secrets.WEB_TEST_SSH_PRIVATE_KEY }}
REMOTE_HOST: ${{ secrets.WEB_TEST_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.WEB_TEST_REMOTE_USER }}
SSL_CERTIFICATE: ${{ secrets.WEB_TEST_SSL_CERTIFICATE }}
SSL_CERTIFICATE_KEY: ${{ secrets.WEB_TEST_SSL_CERTIFICATE_KEY }}
ENV_FILE: test.env
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
- name: setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Node_modules cache
uses: actions/cache@v2
with:
path: frontend/appflowy_web_app/node_modules
key: node-modules-${{ runner.os }}
- name: install frontend dependencies
working-directory: frontend/appflowy_web_app
run: |
pnpm install
- name: copy env file
working-directory: frontend/appflowy_web_app
run: |
cp ${{ env.ENV_FILE }} .env
- name: test and lint
working-directory: frontend/appflowy_web_app
run: |
pnpm run lint
- name: build
working-directory: frontend/appflowy_web_app
run: |
pnpm run build
- name: generate SSL certificate
run: |
echo "${{ env.SSL_CERTIFICATE }}" > nginx-signed.crt
echo "${{ env.SSL_CERTIFICATE_KEY }}" > nginx-signed.key
- name: Deploy to EC2
uses: easingthemes/ssh-deploy@main
with:
SSH_PRIVATE_KEY: ${{ env.SSH_PRIVATE_KEY }}
ARGS: "-rlgoDzvc -i"
SOURCE: "frontend/appflowy_web_app/dist frontend/appflowy_web_app/server.cjs frontend/appflowy_web_app/start.sh frontend/appflowy_web_app/Dockerfile frontend/appflowy_web_app/nginx.conf frontend/appflowy_web_app/.env nginx-signed.crt nginx-signed.key"
REMOTE_HOST: ${{ env.REMOTE_HOST }}
REMOTE_USER: ${{ env.REMOTE_USER }}
EXCLUDE: "frontend/appflowy_web_app/dist/, frontend/appflowy_web_app/node_modules/"
SCRIPT_AFTER: |
docker build -t appflowy-web-app .
docker rm -f appflowy-web-app || true
docker run -d -p 80:80 -p 443:443 --name appflowy-web-app appflowy-web-app
+2 -2
View File
@@ -135,7 +135,7 @@ jobs:
fail-fast: false
matrix:
job:
- { target: x86_64-apple-darwin, os: macos-11, extra-build-args: "" }
- { target: x86_64-apple-darwin, os: macos-latest, extra-build-args: "" }
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -233,7 +233,7 @@ jobs:
job:
- {
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-11,
os: macos-latest,
extra-build-args: "",
}
steps:
+4
View File
@@ -1,4 +1,8 @@
# Release Notes
## Version 0.6.3 - 08/07/2024
### New Features
- Publish a Document to the Web
## Version 0.6.2 - 01/07/2024
### New Features
- Added support for duplicating spaces.
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.6.2"
APPFLOWY_VERSION = "0.6.3"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -8,9 +8,10 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Empty', () {
testWidgets('toggle theme mode', (tester) async {
testWidgets('empty test', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.wait(1000);
});
});
}
@@ -82,11 +82,11 @@ void main() {
HeadingBlockKeys.type,
);
expect(
importedPageEditorState.getNodeAtPath([2])!.type,
importedPageEditorState.getNodeAtPath([1])!.type,
HeadingBlockKeys.type,
);
expect(
importedPageEditorState.getNodeAtPath([4])!.type,
importedPageEditorState.getNodeAtPath([2])!.type,
TableBlockKeys.type,
);
});
@@ -51,13 +51,14 @@ void main() {
},
);
final shareButton = find.byType(ShareActionList);
final shareButton = find.byType(DocumentShareButton);
final shareButtonState =
tester.state(shareButton) as ShareActionListState;
tester.widget(shareButton) as DocumentShareButton;
final path = await mockSaveFilePath(
p.join(
context.applicationDataDirectory,
'${shareButtonState.name}.md',
'${shareButtonState.view.name}.md',
),
);
+7
View File
@@ -29,4 +29,11 @@ abstract class Env {
defaultValue: '',
)
static const String afCloudUrl = _Env.afCloudUrl;
@EnviedField(
obfuscate: false,
varName: 'INTERNAL_BUILD',
defaultValue: '',
)
static const String internalBuild = _Env.internalBuild;
}
@@ -119,7 +119,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final uniqueMessages = {...allMessages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
uniqueMessages.insertAll(0, onetimeMessages);
emit(
state.copyWith(
messages: uniqueMessages,
@@ -380,7 +379,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
Message _createStreamMessage(AnswerStream stream, Int64 questionMessageId) {
final streamMessageId = nanoid();
final streamMessageId = (questionMessageId + 1).toString();
lastStreamMessageId = streamMessageId;
return TextMessage(
@@ -1,10 +1,16 @@
import 'dart:convert';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_popmenu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
@@ -176,9 +182,20 @@ class CopyButton extends StatelessWidget {
size: const Size.square(14),
color: Theme.of(context).colorScheme.primary,
),
onPressed: () {
Clipboard.setData(ClipboardData(text: textMessage.text));
showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
onPressed: () async {
final document = markdownToDocument(textMessage.text);
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: textMessage.text,
inAppJson: jsonEncode(document.toJson()),
),
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
}
},
),
);
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'chat_input.dart';
@@ -0,0 +1,377 @@
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:markdown_widget/markdown_widget.dart';
import 'selectable_highlight.dart';
enum AIMarkdownType {
appflowyEditor,
markdownWidget,
}
// Wrap the appflowy_editor or markdown_widget as a chat text message widget
class AIMarkdownText extends StatelessWidget {
const AIMarkdownText({
super.key,
required this.markdown,
this.type = AIMarkdownType.appflowyEditor,
});
final String markdown;
final AIMarkdownType type;
@override
Widget build(BuildContext context) {
switch (type) {
case AIMarkdownType.appflowyEditor:
return _AppFlowyEditorMarkdown(markdown: markdown);
case AIMarkdownType.markdownWidget:
return _ThirdPartyMarkdown(markdown: markdown);
}
}
}
class _AppFlowyEditorMarkdown extends StatefulWidget {
const _AppFlowyEditorMarkdown({
required this.markdown,
});
// the text should be the markdown format
final String markdown;
@override
State<_AppFlowyEditorMarkdown> createState() =>
_AppFlowyEditorMarkdownState();
}
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
late EditorState editorState;
late final styleCustomizer = EditorStyleCustomizer(
context: context,
padding: EdgeInsets.zero,
);
late final editorStyle = styleCustomizer.style().copyWith(
// hide the cursor
cursorColor: Colors.transparent,
cursorWidth: 0,
);
late EditorScrollController scrollController;
@override
void initState() {
super.initState();
editorState = _parseMarkdown(widget.markdown);
scrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: true,
);
}
@override
void didUpdateWidget(covariant _AppFlowyEditorMarkdown oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.markdown != widget.markdown) {
editorState.dispose();
editorState = _parseMarkdown(widget.markdown);
scrollController.dispose();
scrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: true,
);
}
}
@override
void dispose() {
scrollController.dispose();
editorState.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final blockBuilders = getEditorBuilderMap(
context: context,
editorState: editorState,
styleCustomizer: styleCustomizer,
// the editor is not editable in the chat
editable: false,
);
return IntrinsicHeight(
child: AppFlowyEditor(
shrinkWrap: true,
// the editor is not editable in the chat
editable: false,
editorStyle: editorStyle,
editorScrollController: scrollController,
blockComponentBuilders: blockBuilders,
commandShortcutEvents: [customCopyCommand],
editorState: editorState,
),
);
}
EditorState _parseMarkdown(String markdown) {
final document = markdownToDocument(
markdown,
markdownParsers: [
const MarkdownCodeBlockParser(),
],
);
final editorState = EditorState(document: document);
return editorState;
}
}
class _ThirdPartyMarkdown extends StatelessWidget {
const _ThirdPartyMarkdown({
required this.markdown,
});
final String markdown;
@override
Widget build(BuildContext context) {
return MarkdownWidget(
data: markdown,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
config: configFromContext(context),
);
}
MarkdownConfig configFromContext(BuildContext context) {
return MarkdownConfig(
configs: [
HrConfig(color: AFThemeExtension.of(context).textColor),
_ChatH1Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
_ChatH2Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
_ChatH3Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 18,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
H4Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H5Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H6Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 12,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
PreConfig(
builder: (code, language) {
return ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 800,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6.0)),
child: SelectableHighlightView(
code,
language: language,
theme: getHighlightTheme(context),
padding: const EdgeInsets.all(14),
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
),
);
},
),
PConfig(
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
CodeConfig(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
BlockquoteConfig(
sideColor: AFThemeExtension.of(context).lightGreyHover,
textColor: AFThemeExtension.of(context).textColor,
),
],
);
}
Map<String, TextStyle> getHighlightTheme(BuildContext context) {
return {
'root': TextStyle(
color: const Color(0xffabb2bf),
backgroundColor:
Theme.of(context).isLightMode ? Colors.white : Colors.black38,
),
'comment': const TextStyle(
color: Color(0xff5c6370),
fontStyle: FontStyle.italic,
),
'quote': const TextStyle(
color: Color(0xff5c6370),
fontStyle: FontStyle.italic,
),
'doctag': const TextStyle(color: Color(0xffc678dd)),
'keyword': const TextStyle(color: Color(0xffc678dd)),
'formula': const TextStyle(color: Color(0xffc678dd)),
'section': const TextStyle(color: Color(0xffe06c75)),
'name': const TextStyle(color: Color(0xffe06c75)),
'selector-tag': const TextStyle(color: Color(0xffe06c75)),
'deletion': const TextStyle(color: Color(0xffe06c75)),
'subst': const TextStyle(color: Color(0xffe06c75)),
'literal': const TextStyle(color: Color(0xff56b6c2)),
'string': const TextStyle(color: Color(0xff98c379)),
'regexp': const TextStyle(color: Color(0xff98c379)),
'addition': const TextStyle(color: Color(0xff98c379)),
'attribute': const TextStyle(color: Color(0xff98c379)),
'meta-string': const TextStyle(color: Color(0xff98c379)),
'built_in': const TextStyle(color: Color(0xffe6c07b)),
'attr': const TextStyle(color: Color(0xffd19a66)),
'variable': const TextStyle(color: Color(0xffd19a66)),
'template-variable': const TextStyle(color: Color(0xffd19a66)),
'type': const TextStyle(color: Color(0xffd19a66)),
'selector-class': const TextStyle(color: Color(0xffd19a66)),
'selector-attr': const TextStyle(color: Color(0xffd19a66)),
'selector-pseudo': const TextStyle(color: Color(0xffd19a66)),
'number': const TextStyle(color: Color(0xffd19a66)),
'symbol': const TextStyle(color: Color(0xff61aeee)),
'bullet': const TextStyle(color: Color(0xff61aeee)),
'link': const TextStyle(color: Color(0xff61aeee)),
'meta': const TextStyle(color: Color(0xff61aeee)),
'selector-id': const TextStyle(color: Color(0xff61aeee)),
'title': const TextStyle(color: Color(0xff61aeee)),
'emphasis': const TextStyle(fontStyle: FontStyle.italic),
'strong': const TextStyle(fontWeight: FontWeight.bold),
};
}
}
class _ChatH1Config extends HeadingConfig {
const _ChatH1Config({
this.style = const TextStyle(
fontSize: 32,
height: 40 / 32,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h1.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
///config class for h2
class _ChatH2Config extends HeadingConfig {
const _ChatH2Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h2.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
class _ChatH3Config extends HeadingConfig {
const _ChatH3Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h3.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
@@ -2,19 +2,15 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_loading.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:markdown_widget/markdown_widget.dart';
import 'selectable_highlight.dart';
class ChatAITextMessageWidget extends StatelessWidget {
const ChatAITextMessageWidget({
@@ -59,248 +55,12 @@ class ChatAITextMessageWidget extends StatelessWidget {
if (state.text.isEmpty) {
return const ChatAILoading();
} else {
return _textWidgetBuilder(user, context, state.text);
return AIMarkdownText(markdown: state.text);
}
},
),
);
}
Widget _textWidgetBuilder(
User user,
BuildContext context,
String text,
) {
return MarkdownWidget(
data: text,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
config: configFromContext(context),
);
}
MarkdownConfig configFromContext(BuildContext context) {
return MarkdownConfig(
configs: [
HrConfig(color: AFThemeExtension.of(context).textColor),
ChatH1Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
ChatH2Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
ChatH3Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 18,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
H4Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H5Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H6Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 12,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
PreConfig(
builder: (code, language) {
return ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 800,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6.0)),
child: SelectableHighlightView(
code,
language: language,
theme: getHightlineTheme(context),
padding: const EdgeInsets.all(14),
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
),
);
},
),
PConfig(
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
CodeConfig(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
BlockquoteConfig(
sideColor: AFThemeExtension.of(context).lightGreyHover,
textColor: AFThemeExtension.of(context).textColor,
),
],
);
}
}
Map<String, TextStyle> getHightlineTheme(BuildContext context) {
return {
'root': TextStyle(
color: const Color(0xffabb2bf),
backgroundColor:
Theme.of(context).isLightMode ? Colors.white : Colors.black38,
),
'comment':
const TextStyle(color: Color(0xff5c6370), fontStyle: FontStyle.italic),
'quote':
const TextStyle(color: Color(0xff5c6370), fontStyle: FontStyle.italic),
'doctag': const TextStyle(color: Color(0xffc678dd)),
'keyword': const TextStyle(color: Color(0xffc678dd)),
'formula': const TextStyle(color: Color(0xffc678dd)),
'section': const TextStyle(color: Color(0xffe06c75)),
'name': const TextStyle(color: Color(0xffe06c75)),
'selector-tag': const TextStyle(color: Color(0xffe06c75)),
'deletion': const TextStyle(color: Color(0xffe06c75)),
'subst': const TextStyle(color: Color(0xffe06c75)),
'literal': const TextStyle(color: Color(0xff56b6c2)),
'string': const TextStyle(color: Color(0xff98c379)),
'regexp': const TextStyle(color: Color(0xff98c379)),
'addition': const TextStyle(color: Color(0xff98c379)),
'attribute': const TextStyle(color: Color(0xff98c379)),
'meta-string': const TextStyle(color: Color(0xff98c379)),
'built_in': const TextStyle(color: Color(0xffe6c07b)),
'attr': const TextStyle(color: Color(0xffd19a66)),
'variable': const TextStyle(color: Color(0xffd19a66)),
'template-variable': const TextStyle(color: Color(0xffd19a66)),
'type': const TextStyle(color: Color(0xffd19a66)),
'selector-class': const TextStyle(color: Color(0xffd19a66)),
'selector-attr': const TextStyle(color: Color(0xffd19a66)),
'selector-pseudo': const TextStyle(color: Color(0xffd19a66)),
'number': const TextStyle(color: Color(0xffd19a66)),
'symbol': const TextStyle(color: Color(0xff61aeee)),
'bullet': const TextStyle(color: Color(0xff61aeee)),
'link': const TextStyle(color: Color(0xff61aeee)),
'meta': const TextStyle(color: Color(0xff61aeee)),
'selector-id': const TextStyle(color: Color(0xff61aeee)),
'title': const TextStyle(color: Color(0xff61aeee)),
'emphasis': const TextStyle(fontStyle: FontStyle.italic),
'strong': const TextStyle(fontWeight: FontWeight.bold),
};
}
class ChatH1Config extends HeadingConfig {
const ChatH1Config({
this.style = const TextStyle(
fontSize: 32,
height: 40 / 32,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h1.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
///config class for h2
class ChatH2Config extends HeadingConfig {
const ChatH2Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h2.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
class ChatH3Config extends HeadingConfig {
const ChatH3Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h3.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
class StreamingError extends StatelessWidget {
@@ -52,7 +52,6 @@ class TextMessageText extends StatelessWidget {
text,
fontSize: 16,
fontWeight: FontWeight.w500,
lineHeight: 1.5,
maxLines: null,
selectable: true,
color: AFThemeExtension.of(context).textColor,
@@ -18,6 +18,7 @@ import 'package:appflowy/util/color_to_hex_string.dart';
import 'package:appflowy/util/debounce.dart';
import 'package:appflowy/util/throttle.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@@ -37,6 +38,8 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'document_bloc.freezed.dart';
bool enableDocumentInternalLog = false;
class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
DocumentBloc({
required this.documentId,
@@ -212,6 +215,10 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
Future<EditorState?> _initAppFlowyEditorState(DocumentDataPB data) async {
if (enableDocumentInternalLog) {
Log.info('document data: ${data.toProto3Json()}');
}
final document = data.toDocument();
if (document == null) {
assert(false, 'document is null');
@@ -180,6 +180,8 @@ extension NodeToBlock on Node {
String? parentId,
String? childrenId,
Attributes? attributes,
String? externalId,
String? externalType,
}) {
assert(id.isNotEmpty);
final block = BlockPB.create()
@@ -192,6 +194,12 @@ extension NodeToBlock on Node {
if (parentId != null && parentId.isNotEmpty) {
block.parentId = parentId;
}
if (externalId != null && externalId.isNotEmpty) {
block.externalId = externalId;
}
if (externalType != null && externalType.isNotEmpty) {
block.externalType = externalType;
}
return block;
}
@@ -1,58 +1,190 @@
import 'dart:io';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'document_share_bloc.freezed.dart';
const _url = 'https://appflowy.com';
class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
DocumentShareBloc({
required this.view,
}) : super(const DocumentShareState.initial()) {
}) : super(DocumentShareState.initial()) {
on<DocumentShareEvent>((event, emit) async {
await event.when(
initial: () async {
viewListener = ViewListener(viewId: view.id)
..start(
onViewUpdated: (value) {
add(DocumentShareEvent.updateViewName(value.name));
},
onViewMoveToTrash: (p0) {
add(const DocumentShareEvent.setPublishStatus(false));
},
);
add(const DocumentShareEvent.updatePublishStatus());
},
share: (type, path) async {
if (DocumentShareType.unimplemented.contains(type)) {
Log.error('DocumentShareType $type is not implemented');
return;
}
emit(const DocumentShareState.loading());
emit(state.copyWith(isLoading: true));
final exporter = DocumentExporter(view);
final FlowyResult<ExportDataPB, FlowyError> result =
await exporter.export(type.exportType).then((value) {
return value.fold(
(s) {
if (path != null) {
switch (type) {
case DocumentShareType.markdown:
return FlowyResult.success(_saveMarkdownToPath(s, path));
case DocumentShareType.html:
return FlowyResult.success(_saveHTMLToPath(s, path));
default:
break;
}
}
return FlowyResult.failure(FlowyError());
},
(f) => FlowyResult.failure(f),
final result = await _export(type, path);
emit(
state.copyWith(
isLoading: false,
exportResult: result,
),
);
},
publish: (nameSpace, publishName) async {
// set space name
try {
final result =
await ViewBackendService.getPublishNameSpace().getOrThrow();
await ViewBackendService.publish(
view,
name: publishName,
).getOrThrow();
emit(
state.copyWith(
isPublished: true,
publishResult: FlowySuccess(null),
unpublishResult: null,
url: '$_url/${result.namespace}/$publishName',
),
);
} catch (e) {
Log.error('publish error: $e');
emit(
state.copyWith(
isPublished: false,
publishResult: FlowyResult.failure(
FlowyError(msg: 'publish error: $e'),
),
unpublishResult: null,
url: '',
),
);
}
},
unPublish: () async {
emit(
state.copyWith(
publishResult: null,
unpublishResult: null,
),
);
final result = await ViewBackendService.unpublish(view);
final isPublished = !result.isSuccess;
result.onFailure((f) {
Log.error('unpublish error: $f');
});
emit(DocumentShareState.finish(result));
emit(
state.copyWith(
isPublished: isPublished,
publishResult: null,
unpublishResult: result,
url: result.fold((_) => '', (_) => state.url),
),
);
},
updateViewName: (viewName) async {
emit(state.copyWith(viewName: viewName));
},
setPublishStatus: (isPublished) {
emit(
state.copyWith(
isPublished: isPublished,
url: isPublished ? state.url : '',
),
);
},
updatePublishStatus: () async {
final publishInfo = await ViewBackendService.getPublishInfo(view);
final enablePublish =
await UserBackendService.getCurrentUserProfile().fold(
(v) => v.authenticator == AuthenticatorPB.AppFlowyCloud,
(p) => false,
);
publishInfo.fold((s) {
emit(
state.copyWith(
isPublished: true,
url: '$_url/${s.namespace}/${s.publishName}',
viewName: view.name,
enablePublish: enablePublish,
),
);
}, (f) {
emit(
state.copyWith(
isPublished: false,
url: '',
viewName: view.name,
enablePublish: enablePublish,
),
);
});
},
);
});
}
final ViewPB view;
late final ViewListener viewListener;
late final exporter = DocumentExporter(view);
@override
Future<void> close() async {
await viewListener.stop();
return super.close();
}
Future<FlowyResult<ExportDataPB, FlowyError>> _export(
DocumentShareType type,
String? path,
) async {
final result = await exporter.export(type.exportType);
return result.fold(
(s) {
if (path != null) {
switch (type) {
case DocumentShareType.markdown:
return FlowySuccess(_saveMarkdownToPath(s, path));
case DocumentShareType.html:
return FlowySuccess(_saveHTMLToPath(s, path));
default:
break;
}
}
return FlowyResult.failure(FlowyError());
},
(f) => FlowyResult.failure(f),
);
}
ExportDataPB _saveMarkdownToPath(String markdown, String path) {
File(path).writeAsStringSync(markdown);
@@ -93,15 +225,41 @@ enum DocumentShareType {
@freezed
class DocumentShareEvent with _$DocumentShareEvent {
const factory DocumentShareEvent.share(DocumentShareType type, String? path) =
Share;
const factory DocumentShareEvent.initial() = _Initial;
const factory DocumentShareEvent.share(
DocumentShareType type,
String? path,
) = _Share;
const factory DocumentShareEvent.publish(
String nameSpace,
String pageId,
) = _Publish;
const factory DocumentShareEvent.unPublish() = _UnPublish;
const factory DocumentShareEvent.updateViewName(String name) =
_UpdateViewName;
const factory DocumentShareEvent.updatePublishStatus() = _UpdatePublishStatus;
const factory DocumentShareEvent.setPublishStatus(bool isPublished) =
_SetPublishStatus;
}
@freezed
class DocumentShareState with _$DocumentShareState {
const factory DocumentShareState.initial() = _Initial;
const factory DocumentShareState.loading() = _Loading;
const factory DocumentShareState.finish(
FlowyResult<ExportDataPB, FlowyError> successOrFail,
) = _Finish;
const factory DocumentShareState({
required bool isPublished,
required bool isLoading,
required String url,
required String viewName,
required bool enablePublish,
FlowyResult<ExportDataPB, FlowyError>? exportResult,
FlowyResult<void, FlowyError>? publishResult,
FlowyResult<void, FlowyError>? unpublishResult,
}) = _DocumentShareState;
factory DocumentShareState.initial() => const DocumentShareState(
isLoading: false,
isPublished: false,
enablePublish: true,
url: '',
viewName: '',
);
}
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/document_service.dart';
import 'package:appflowy_backend/log.dart';
@@ -21,6 +22,8 @@ import 'package:appflowy_editor/appflowy_editor.dart'
import 'package:collection/collection.dart';
import 'package:nanoid/nanoid.dart';
const _kExternalTextType = 'text';
/// Uses to adjust the data structure between the editor and the backend.
///
/// The editor uses a tree structure to represent the document, while the backend uses a flat structure.
@@ -34,11 +37,9 @@ class TransactionAdapter {
final DocumentService documentService;
final String documentId;
final bool _enableDebug = false;
Future<void> apply(Transaction transaction, EditorState editorState) async {
final stopwatch = Stopwatch()..start();
if (_enableDebug) {
if (enableDocumentInternalLog) {
Log.debug('transaction => ${transaction.toJson()}');
}
final actions = transaction.operations
@@ -60,8 +61,10 @@ class TransactionAdapter {
textId: payload.textId,
delta: payload.delta,
);
if (_enableDebug) {
Log.debug('create external text: ${payload.delta}');
if (enableDocumentInternalLog) {
Log.debug(
'[editor_transaction_adapter] create external text: ${payload.delta}',
);
}
} else if (type == TextDeltaType.update) {
await documentService.updateExternalText(
@@ -69,8 +72,10 @@ class TransactionAdapter {
textId: payload.textId,
delta: payload.delta,
);
if (_enableDebug) {
Log.debug('update external text: ${payload.delta}');
if (enableDocumentInternalLog) {
Log.debug(
'[editor_transaction_adapter] update external text: ${payload.delta}',
);
}
}
}
@@ -82,9 +87,9 @@ class TransactionAdapter {
);
final elapsed = stopwatch.elapsedMilliseconds;
stopwatch.stop();
if (_enableDebug) {
if (enableDocumentInternalLog) {
Log.debug(
'apply transaction cost: total $elapsed ms, converter action $actionCostTime ms, apply action ${elapsed - actionCostTime} ms',
'[editor_transaction_adapter] apply transaction cost: total $elapsed ms, converter action $actionCostTime ms, apply action ${elapsed - actionCostTime} ms',
);
}
}
@@ -136,8 +141,9 @@ extension on InsertOperation {
// create the external text if the node contains the delta in its data.
final delta = node.delta;
TextDeltaPayloadPB? textDeltaPayloadPB;
String? textId;
if (delta != null) {
final textId = nanoid(6);
textId = nanoid(6);
textDeltaPayloadPB = TextDeltaPayloadPB(
documentId: documentId,
@@ -148,13 +154,17 @@ extension on InsertOperation {
// sync the text id to the node
node.externalValues = ExternalValues(
externalId: textId,
externalType: 'text',
externalType: _kExternalTextType,
);
}
// remove the delta from the data when the incremental update is stable.
final payload = BlockActionPayloadPB()
..block = node.toBlock(childrenId: nanoid(6))
..block = node.toBlock(
childrenId: nanoid(6),
externalId: textId,
externalType: textId != null ? _kExternalTextType : null,
)
..parentId = parentId
..prevId = prevId;
@@ -248,7 +258,7 @@ extension on UpdateOperation {
node.externalValues = ExternalValues(
externalId: textId,
externalType: 'text',
externalType: _kExternalTextType,
);
actions.add(
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart';
@@ -9,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_too
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -284,6 +286,14 @@ class _MobileToolbarState extends State<_MobileToolbar>
if (canUpdateCachedKeyboardHeight) {
cachedKeyboardHeight.value = height;
if (defaultTargetPlatform == TargetPlatform.android) {
// cache the keyboard height with the view padding in Android
if (cachedKeyboardHeight.value != 0) {
cachedKeyboardHeight.value +=
MediaQuery.of(context).viewPadding.bottom;
}
}
}
if (height == 0) {
@@ -385,13 +395,19 @@ class _MobileToolbarState extends State<_MobileToolbar>
return ValueListenableBuilder(
valueListenable: showMenuNotifier,
builder: (_, showingMenu, __) {
var paddingHeight = height;
if (Platform.isAndroid) {
paddingHeight =
height + MediaQuery.of(context).viewPadding.bottom;
var keyboardHeight = height;
if (defaultTargetPlatform == TargetPlatform.android) {
if (!showingMenu) {
// take the max value of the keyboard height and the view padding
// to make sure the toolbar is above the keyboard
keyboardHeight = max(
keyboardHeight,
MediaQuery.of(context).viewInsets.bottom,
);
}
}
return SizedBox(
height: paddingHeight,
height: keyboardHeight,
child: (showingMenu && selectedMenuIndex != null)
? widget.toolbarItems[selectedMenuIndex!].menuBuilder?.call(
context,
@@ -0,0 +1,52 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownCodeBlockParser extends CustomMarkdownParser {
const MarkdownCodeBlockParser();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers, {
MarkdownListType listType = MarkdownListType.unknown,
int? startNumber,
}) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'pre') {
return [];
}
final ec = element.children;
if (ec == null || ec.isEmpty) {
return [];
}
final code = ec.first;
if (code is! md.Element || code.tag != 'code') {
return [];
}
String? language;
if (code.attributes.containsKey('class')) {
final classes = code.attributes['class']!.split(' ');
final languageClass = classes.firstWhere(
(c) => c.startsWith('language-'),
orElse: () => '',
);
language = languageClass.substring('language-'.length);
}
final deltaDecoder = DeltaMarkdownDecoder();
return [
codeBlockNode(
language: language,
delta: deltaDecoder.convertNodes(code.children),
),
];
}
}
@@ -1,3 +1,4 @@
export 'callout_node_parser.dart';
export 'markdown_code_parser.dart';
export 'math_equation_node_parser.dart';
export 'toggle_list_node_parser.dart';
@@ -0,0 +1,130 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ExportTab extends StatelessWidget {
const ExportTab({
super.key,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
const VSpace(10),
_ExportButton(
title: LocaleKeys.shareAction_html.tr(),
svg: FlowySvgs.export_html_s,
onTap: () => _exportHTML(context),
),
const VSpace(10),
_ExportButton(
title: LocaleKeys.shareAction_markdown.tr(),
svg: FlowySvgs.export_markdown_s,
onTap: () => _exportMarkdown(context),
),
const VSpace(10),
_ExportButton(
title: LocaleKeys.shareAction_clipboard.tr(),
svg: FlowySvgs.duplicate_s,
onTap: () => _exportToClipboard(context),
),
],
);
}
Future<void> _exportHTML(BuildContext context) async {
final viewName = context.read<DocumentShareBloc>().state.viewName;
final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
fileName: '${viewName.toFileName()}.html',
);
if (context.mounted && exportPath != null) {
context.read<DocumentShareBloc>().add(
DocumentShareEvent.share(
DocumentShareType.html,
exportPath,
),
);
}
}
Future<void> _exportMarkdown(BuildContext context) async {
final viewName = context.read<DocumentShareBloc>().state.viewName;
final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
fileName: '${viewName.toFileName()}.md',
);
if (context.mounted && exportPath != null) {
context.read<DocumentShareBloc>().add(
DocumentShareEvent.share(
DocumentShareType.markdown,
exportPath,
),
);
}
}
Future<void> _exportToClipboard(BuildContext context) async {
final documentExporter =
DocumentExporter(context.read<DocumentShareBloc>().view);
final result = await documentExporter.export(DocumentExportType.markdown);
result.fold(
(markdown) {
getIt<ClipboardService>().setData(
ClipboardServiceData(plainText: markdown),
);
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
},
(error) => showToastNotification(context, message: error.msg),
);
}
}
class _ExportButton extends StatelessWidget {
const _ExportButton({
required this.title,
required this.svg,
required this.onTap,
});
final String title;
final FlowySvgData svg;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).isLightMode
? const Color(0x1E14171B)
: Colors.white.withOpacity(0.1);
final radius = BorderRadius.circular(10.0);
return FlowyButton(
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
iconPadding: 12,
decoration: BoxDecoration(
border: Border.all(
color: color,
),
borderRadius: radius,
),
radius: radius,
text: FlowyText(title),
leftIcon: FlowySvg(svg),
onTap: onTap,
);
}
}
@@ -0,0 +1,11 @@
import 'package:appflowy/util/theme_extension.dart';
import 'package:flutter/material.dart';
class ShareMenuColors {
static Color borderColor(BuildContext context) {
final borderColor = Theme.of(context).isLightMode
? const Color(0x1E14171B)
: Colors.white.withOpacity(0.1);
return borderColor;
}
}
@@ -0,0 +1,19 @@
String replaceInvalidChars(String input) {
final RegExp invalidCharsRegex = RegExp('[^a-zA-Z0-9-]');
return input.replaceAll(invalidCharsRegex, '-');
}
Future<String> generateNameSpace() async {
return '';
}
// The backend limits the publish name to a maximum of 120 characters.
// If the combined length of the ID and the name exceeds 120 characters,
// we will truncate the name to ensure the final result is within the limit.
// The name should only contain alphanumeric characters and hyphens.
Future<String> generatePublishName(String id, String name) async {
if (name.length >= 120 - id.length) {
name = name.substring(0, 120 - id.length);
}
return replaceInvalidChars('$name-$id');
}
@@ -0,0 +1,284 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/share/pubish_color_extension.dart';
import 'package:appflowy/plugins/document/presentation/share/publish_name_generator.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.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/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PublishTab extends StatelessWidget {
const PublishTab({super.key});
@override
Widget build(BuildContext context) {
return BlocConsumer<DocumentShareBloc, DocumentShareState>(
listener: (context, state) {
_showToast(context, state);
},
builder: (context, state) {
return state.isPublished
? _PublishedWidget(
url: state.url,
onVisitSite: () {},
onUnPublish: () {
context
.read<DocumentShareBloc>()
.add(const DocumentShareEvent.unPublish());
},
)
: _UnPublishWidget(
onPublish: () async {
final id = context.read<DocumentShareBloc>().view.id;
final publishName = await generatePublishName(
id,
state.viewName,
);
if (context.mounted) {
context.read<DocumentShareBloc>().add(
DocumentShareEvent.publish('', publishName),
);
}
},
);
},
);
}
void _showToast(BuildContext context, DocumentShareState state) {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
),
);
} else if (state.unpublishResult != null) {
state.unpublishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg,
),
);
}
}
}
class _PublishedWidget extends StatefulWidget {
const _PublishedWidget({
required this.url,
required this.onVisitSite,
required this.onUnPublish,
});
final String url;
final VoidCallback onVisitSite;
final VoidCallback onUnPublish;
@override
State<_PublishedWidget> createState() => _PublishedWidgetState();
}
class _PublishedWidgetState extends State<_PublishedWidget> {
final controller = TextEditingController();
@override
void initState() {
super.initState();
controller.text = widget.url;
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(16),
const _PublishTabHeader(),
const VSpace(16),
_PublishUrl(
controller: controller,
onCopy: (url) {
getIt<ClipboardService>().setData(
ClipboardServiceData(plainText: url),
);
showToastNotification(
context,
message: LocaleKeys.grid_url_copy.tr(),
);
},
onSubmitted: (url) {},
),
const VSpace(16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildUnpublishButton(),
const Spacer(),
_buildVisitSiteButton(),
],
),
],
);
}
Widget _buildUnpublishButton() {
return SizedBox(
height: 36,
width: 184,
child: FlowyButton(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: ShareMenuColors.borderColor(context)),
),
radius: BorderRadius.circular(10),
text: FlowyText.regular(
LocaleKeys.shareAction_unPublish.tr(),
textAlign: TextAlign.center,
),
onTap: widget.onUnPublish,
),
);
}
Widget _buildVisitSiteButton() {
return RoundedTextButton(
onPressed: () {
safeLaunchUrl(controller.text);
},
title: LocaleKeys.shareAction_visitSite.tr(),
width: 184,
height: 36,
borderRadius: const BorderRadius.all(Radius.circular(10)),
fillColor: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
);
}
}
class _UnPublishWidget extends StatelessWidget {
const _UnPublishWidget({
required this.onPublish,
});
final VoidCallback onPublish;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(16),
const _PublishTabHeader(),
const VSpace(16),
RoundedTextButton(
height: 36,
title: LocaleKeys.shareAction_publish.tr(),
padding: const EdgeInsets.symmetric(vertical: 9.0),
fontSize: 14.0,
textColor: Theme.of(context).colorScheme.onPrimary,
onPressed: onPublish,
),
],
);
}
}
class _PublishTabHeader extends StatelessWidget {
const _PublishTabHeader();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const FlowySvg(FlowySvgs.share_publish_s),
const HSpace(6),
FlowyText(LocaleKeys.shareAction_publishToTheWeb.tr()),
],
),
const VSpace(4),
FlowyText.regular(
LocaleKeys.shareAction_publishToTheWebHint.tr(),
fontSize: 12,
maxLines: 3,
color: Theme.of(context).hintColor,
),
],
);
}
}
class _PublishUrl extends StatelessWidget {
const _PublishUrl({
required this.controller,
required this.onCopy,
required this.onSubmitted,
});
final TextEditingController controller;
final void Function(String url) onCopy;
final void Function(String url) onSubmitted;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: FlowyTextField(
readOnly: true,
autoFocus: false,
controller: controller,
enableBorderColor: ShareMenuColors.borderColor(context),
suffixIcon: _buildCopyLinkIcon(context),
),
);
}
Widget _buildCopyLinkIcon(BuildContext context) {
return FlowyHover(
child: GestureDetector(
onTap: () => onCopy(controller.text),
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
padding: const EdgeInsets.all(10),
decoration: const BoxDecoration(
border: Border(left: BorderSide(color: Color(0x141F2329))),
),
child: const FlowySvg(
FlowySvgs.m_toolbar_link_m,
),
),
),
);
}
}
@@ -1,6 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/share/share_menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
@@ -13,6 +14,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -28,23 +30,44 @@ class DocumentShareButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<DocumentShareBloc>(param1: view),
create: (context) => getIt<DocumentShareBloc>(param1: view)
..add(const DocumentShareEvent.initial()),
child: BlocListener<DocumentShareBloc, DocumentShareState>(
listener: (context, state) {
state.mapOrNull(
finish: (state) {
state.successOrFail.fold(
(data) => _handleExportData(context, data),
_handleExportError,
);
},
);
if (state.isLoading == false && state.exportResult != null) {
state.exportResult!.fold(
(data) => _handleExportData(context, data),
_handleExportError,
);
}
},
child: BlocBuilder<DocumentShareBloc, DocumentShareState>(
builder: (context, state) => SizedBox(
height: 32.0,
child: IntrinsicWidth(child: ShareActionList(view: view)),
),
builder: (context, state) {
final tabs = [
if (state.enablePublish) ShareMenuTab.publish,
ShareMenuTab.exportAs,
];
final shareBloc = context.read<DocumentShareBloc>();
return SizedBox(
height: 32.0,
child: IntrinsicWidth(
child: AppFlowyPopover(
direction: PopoverDirection.bottomWithRightAligned,
constraints: const BoxConstraints(
maxWidth: 422,
),
offset: const Offset(0, 8),
popupBuilder: (context) => BlocProvider.value(
value: shareBloc,
child: ShareMenu(
tabs: tabs,
),
),
child: const InnerDocumentShareButton(),
),
),
);
},
),
),
);
@@ -75,6 +98,24 @@ class DocumentShareButton extends StatelessWidget {
}
}
class InnerDocumentShareButton extends StatelessWidget {
const InnerDocumentShareButton({super.key});
@override
Widget build(BuildContext context) {
return RoundedTextButton(
title: LocaleKeys.shareAction_buttonText.tr(),
padding: const EdgeInsets.symmetric(horizontal: 14.0),
fontSize: 14.0,
fontWeight: FontWeight.w500,
borderRadius: const BorderRadius.all(
Radius.circular(10.0),
),
textColor: Theme.of(context).colorScheme.onPrimary,
);
}
}
class ShareActionList extends StatefulWidget {
const ShareActionList({
super.key,
@@ -0,0 +1,186 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart';
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
import 'package:appflowy/plugins/document/presentation/share/export_tab.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'publish_tab.dart';
enum ShareMenuTab {
share,
publish,
exportAs;
String get i18n {
switch (this) {
case ShareMenuTab.share:
return LocaleKeys.shareAction_shareTab.tr();
case ShareMenuTab.publish:
return LocaleKeys.shareAction_publishTab.tr();
case ShareMenuTab.exportAs:
return LocaleKeys.shareAction_exportAsTab.tr();
}
}
}
class ShareMenu extends StatefulWidget {
const ShareMenu({
super.key,
required this.tabs,
});
final List<ShareMenuTab> tabs;
@override
State<ShareMenu> createState() => _ShareMenuState();
}
class _ShareMenuState extends State<ShareMenu>
with SingleTickerProviderStateMixin {
late ShareMenuTab selectedTab = widget.tabs.first;
late final tabController = TabController(
length: widget.tabs.length,
vsync: this,
initialIndex: widget.tabs.indexOf(selectedTab),
);
@override
Widget build(BuildContext context) {
if (widget.tabs.isEmpty) {
return const SizedBox.shrink();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const VSpace(10),
Container(
alignment: Alignment.centerLeft,
height: 30,
child: _buildTabBar(context),
),
Divider(
color: Theme.of(context).dividerColor,
height: 1,
thickness: 1,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14.0),
child: _buildTab(context),
),
const VSpace(20),
],
);
}
@override
void dispose() {
tabController.dispose();
super.dispose();
}
Widget _buildTabBar(BuildContext context) {
final children = [
for (final tab in widget.tabs)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _Segment(
tab: tab,
isSelected: selectedTab == tab,
),
),
];
return TabBar(
indicatorSize: TabBarIndicatorSize.label,
indicator: RoundUnderlineTabIndicator(
width: 68.0,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 3,
),
insets: const EdgeInsets.only(bottom: -2),
),
isScrollable: true,
controller: tabController,
tabs: children,
onTap: (index) {
setState(() {
selectedTab = widget.tabs[index];
});
},
);
}
Widget _buildTab(BuildContext context) {
switch (selectedTab) {
case ShareMenuTab.publish:
return const PublishTab();
case ShareMenuTab.exportAs:
return const ExportTab();
default:
return const Center(
child: FlowyText('🏡 under construction'),
);
}
}
}
class _Segment extends StatefulWidget {
const _Segment({
required this.tab,
required this.isSelected,
});
final bool isSelected;
final ShareMenuTab tab;
@override
State<_Segment> createState() => _SegmentState();
}
class _SegmentState extends State<_Segment> {
bool isHovered = false;
@override
Widget build(BuildContext context) {
Color? textColor = Theme.of(context).hintColor;
if (isHovered) {
textColor = const Color(0xFF00BCF0);
} else if (widget.isSelected) {
textColor = null;
}
Widget child = MouseRegion(
onEnter: (_) => setState(() => isHovered = true),
onExit: (_) => setState(() => isHovered = false),
child: FlowyText(
widget.tab.i18n,
textAlign: TextAlign.center,
color: textColor,
),
);
if (widget.tab == ShareMenuTab.publish) {
final isPublished = context.watch<DocumentShareBloc>().state.isPublished;
// show checkmark icon if published
if (isPublished) {
child = Row(
children: [
const FlowySvg(
FlowySvgs.published_checkmark_s,
blendMode: null,
),
const HSpace(6),
child,
],
);
}
}
return child;
}
}
@@ -88,6 +88,7 @@ enum FeatureFlag {
bool get isOn {
if ([
FeatureFlag.planBilling,
// release this feature in version 0.5.9
FeatureFlag.search,
// release this feature in version 0.5.6
@@ -107,9 +108,9 @@ enum FeatureFlag {
switch (this) {
case FeatureFlag.collaborativeWorkspace:
case FeatureFlag.membersSettings:
case FeatureFlag.planBilling:
case FeatureFlag.unknown:
return false;
case FeatureFlag.planBilling:
case FeatureFlag.search:
case FeatureFlag.syncDocument:
case FeatureFlag.syncDatabase:
@@ -95,7 +95,8 @@ class AppFlowyCloudDeepLink {
}
if (_isPaymentSuccessUri(uri)) {
return getIt<SubscriptionSuccessListenable>().onPaymentSuccess();
final plan = uri.queryParameters['plan'];
return getIt<SubscriptionSuccessListenable>().onPaymentSuccess(plan);
}
return _isAuthCallbackDeepLink(uri).fold(
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
@@ -10,7 +11,10 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:fixnum/fixnum.dart';
abstract class IUserBackendService {
Future<FlowyResult<void, FlowyError>> cancelSubscription(String workspaceId);
Future<FlowyResult<void, FlowyError>> cancelSubscription(
String workspaceId,
SubscriptionPlanPB plan,
);
Future<FlowyResult<PaymentLinkPB, FlowyError>> createSubscription(
String workspaceId,
SubscriptionPlanPB plan,
@@ -228,9 +232,16 @@ class UserBackendService implements IUserBackendService {
return UserEventLeaveWorkspace(data).send();
}
static Future<FlowyResult<RepeatedWorkspaceSubscriptionPB, FlowyError>>
getWorkspaceSubscriptions() {
return UserEventGetWorkspaceSubscriptions().send();
static Future<FlowyResult<WorkspaceSubscriptionInfoPB, FlowyError>>
getWorkspaceSubscriptionInfo(String workspaceId) {
final params = UserWorkspaceIdPB.create()..workspaceId = workspaceId;
return UserEventGetWorkspaceSubscriptionInfo(params).send();
}
static Future<FlowyResult<void, FlowyError>>
invalidateWorkspaceSubscriptionCache(String workspaceId) {
final params = UserWorkspaceIdPB.create()..workspaceId = workspaceId;
return UserEventInvalidateWorkspaceSubscriptionInfoCache(params).send();
}
Future<FlowyResult<WorkspaceMemberPB, FlowyError>>
@@ -250,15 +261,19 @@ class UserBackendService implements IUserBackendService {
..recurringInterval = RecurringIntervalPB.Year
..workspaceSubscriptionPlan = plan
..successUrl =
'${getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.base_url}/web/payment-success';
'${getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.base_url}/web/payment-success?plan=${plan.toRecognizable()}';
return UserEventSubscribeWorkspace(request).send();
}
@override
Future<FlowyResult<void, FlowyError>> cancelSubscription(
String workspaceId,
SubscriptionPlanPB plan,
) {
final request = UserWorkspaceIdPB()..workspaceId = workspaceId;
final request = CancelWorkspaceSubscriptionPB()
..workspaceId = workspaceId
..plan = plan;
return UserEventCancelWorkspaceSubscription(request).send();
}
}
@@ -5,6 +5,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'
show WorkspaceSettingPB;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'home_bloc.freezed.dart';
class HomeBloc extends Bloc<HomeEvent, HomeState> {
@@ -54,9 +54,11 @@ class SidebarSectionsBloc
_initial(userProfile, workspaceId);
final sectionViews = await _getSectionViews();
if (sectionViews != null) {
final containsSpace = _containsSpace(sectionViews);
emit(
state.copyWith(
section: sectionViews,
containsSpace: containsSpace,
),
);
}
@@ -65,9 +67,11 @@ class SidebarSectionsBloc
_reset(userProfile, workspaceId);
final sectionViews = await _getSectionViews();
if (sectionViews != null) {
final containsSpace = _containsSpace(sectionViews);
emit(
state.copyWith(
section: sectionViews,
containsSpace: containsSpace,
),
);
}
@@ -160,9 +164,11 @@ class SidebarSectionsBloc
_initial(userProfile, workspaceId);
final sectionViews = await _getSectionViews();
if (sectionViews != null) {
final containsSpace = _containsSpace(sectionViews);
emit(
state.copyWith(
section: sectionViews,
containsSpace: containsSpace,
),
);
// try to open the fist view in public section or private section
@@ -229,6 +235,11 @@ class SidebarSectionsBloc
}
}
bool _containsSpace(SidebarSection section) {
return section.publicViews.any((view) => view.isSpace) ||
section.privateViews.any((view) => view.isSpace);
}
void _initial(UserProfilePB userProfile, String workspaceId) {
_workspaceService = WorkspaceService(workspaceId: workspaceId);
@@ -292,6 +303,7 @@ class SidebarSectionsState with _$SidebarSectionsState {
required SidebarSection section,
@Default(null) ViewPB? lastCreatedRootView,
FlowyResult<void, FlowyError>? createRootViewResult,
@Default(true) bool containsSpace,
}) = _SidebarSectionsState;
factory SidebarSectionsState.initial() => const SidebarSectionsState(
@@ -1,15 +1,24 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart';
import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart';
import 'package:appflowy/workspace/application/workspace/workspace_service.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/log.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:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:fixnum/fixnum.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:protobuf/protobuf.dart';
part 'settings_billing_bloc.freezed.dart';
@@ -17,86 +26,218 @@ class SettingsBillingBloc
extends Bloc<SettingsBillingEvent, SettingsBillingState> {
SettingsBillingBloc({
required this.workspaceId,
required Int64 userId,
}) : super(const _Initial()) {
_userService = UserBackendService(userId: userId);
_service = WorkspaceService(workspaceId: workspaceId);
_successListenable = getIt<SubscriptionSuccessListenable>();
_successListenable.addListener(_onPaymentSuccessful);
on<SettingsBillingEvent>((event, emit) async {
await event.when(
started: () async {
emit(const SettingsBillingState.loading());
final snapshots = await Future.wait([
UserBackendService.getWorkspaceSubscriptions(),
_service.getBillingPortal(),
]);
FlowyError? error;
final subscription = snapshots.first.fold(
(s) =>
(s as RepeatedWorkspaceSubscriptionPB)
.items
.firstWhereOrNull((i) => i.workspaceId == workspaceId) ??
WorkspaceSubscriptionPB(
workspaceId: workspaceId,
subscriptionPlan: SubscriptionPlanPB.None,
isActive: true,
),
final subscriptionInfo =
(await UserBackendService.getWorkspaceSubscriptionInfo(
workspaceId,
))
.fold(
(s) => s,
(e) {
// Not a Cjstomer yet
if (e.code == ErrorCode.InvalidParams) {
return WorkspaceSubscriptionPB(
workspaceId: workspaceId,
subscriptionPlan: SubscriptionPlanPB.None,
isActive: true,
);
}
error = e;
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 (subscription == null || billingPortal == null || error != null) {
if (subscriptionInfo == null || error != null) {
return emit(SettingsBillingState.error(error: error));
}
if (!_billingPortalCompleter.isCompleted) {
unawaited(_fetchBillingPortal());
unawaited(
_billingPortalCompleter.future.then(
(result) {
if (isClosed) return;
result.fold(
(portal) {
_billingPortal = portal;
add(
SettingsBillingEvent.billingPortalFetched(
billingPortal: portal,
),
);
},
(e) => Log.error('Error fetching billing portal: $e'),
);
},
),
);
}
emit(
SettingsBillingState.ready(
subscription: subscription,
billingPortal: billingPortal,
subscriptionInfo: subscriptionInfo,
billingPortal: _billingPortal,
),
);
},
billingPortalFetched: (billingPortal) async => state.maybeWhen(
orElse: () {},
ready: (subscriptionInfo, _, plan) => emit(
SettingsBillingState.ready(
subscriptionInfo: subscriptionInfo,
billingPortal: billingPortal,
successfulPlanUpgrade: plan,
),
),
),
openCustomerPortal: () async {
if (_billingPortalCompleter.isCompleted && _billingPortal != null) {
await afLaunchUrlString(_billingPortal!.url);
}
await _billingPortalCompleter.future;
if (_billingPortal != null) {
await afLaunchUrlString(_billingPortal!.url);
}
},
addSubscription: (plan) async {
final result =
await _userService.createSubscription(workspaceId, plan);
result.fold(
(link) => afLaunchUrlString(link.paymentLink),
(f) => Log.error(f.msg, f),
);
},
cancelSubscription: (plan) async {
final result =
await _userService.cancelSubscription(workspaceId, plan);
final successOrNull = result.fold(
(_) => true,
(f) {
Log.error(
'Failed to cancel subscription of ${plan.label}: ${f.msg}',
f,
);
return null;
},
);
if (successOrNull != true) {
return;
}
// Invalidate cache for this workspace
await UserBackendService.invalidateWorkspaceSubscriptionCache(
workspaceId,
);
final subscriptionInfo = state.mapOrNull(
ready: (s) => s.subscriptionInfo,
);
// This is impossible, but for good measure
if (subscriptionInfo == null) {
return;
}
subscriptionInfo.freeze();
final newInfo = subscriptionInfo.rebuild((value) {
if (plan.isAddOn) {
value.addOns.removeWhere(
(addon) => addon.addOnSubscription.subscriptionPlan == plan,
);
}
if (value.plan == WorkspacePlanPB.ProPlan) {
value.plan = WorkspacePlanPB.FreePlan;
value.planSubscription.freeze();
value.planSubscription = value.planSubscription.rebuild((sub) {
sub.status = WorkspaceSubscriptionStatusPB.Active;
sub.subscriptionPlan = SubscriptionPlanPB.None;
});
}
});
emit(
SettingsBillingState.ready(
subscriptionInfo: newInfo,
billingPortal: _billingPortal,
),
);
},
paymentSuccessful: (plan) async {
final result = await UserBackendService.getWorkspaceSubscriptionInfo(
workspaceId,
);
final subscriptionInfo = result.toNullable();
if (subscriptionInfo != null) {
emit(
SettingsBillingState.ready(
subscriptionInfo: subscriptionInfo,
billingPortal: _billingPortal,
),
);
}
},
);
});
}
late final String workspaceId;
late final WorkspaceService _service;
late final UserBackendService _userService;
final _billingPortalCompleter =
Completer<FlowyResult<BillingPortalPB, FlowyError>>();
BillingPortalPB? _billingPortal;
late final SubscriptionSuccessListenable _successListenable;
Future<void> _fetchBillingPortal() async {
final billingPortalResult = await _service.getBillingPortal();
_billingPortalCompleter.complete(billingPortalResult);
}
Future<void> _onPaymentSuccessful() async {
// Invalidate cache for this workspace
await UserBackendService.invalidateWorkspaceSubscriptionCache(workspaceId);
add(
SettingsBillingEvent.paymentSuccessful(
plan: _successListenable.subscribedPlan,
),
);
}
}
@freezed
class SettingsBillingEvent with _$SettingsBillingEvent {
const factory SettingsBillingEvent.started() = _Started;
const factory SettingsBillingEvent.billingPortalFetched({
required BillingPortalPB billingPortal,
}) = _BillingPortalFetched;
const factory SettingsBillingEvent.openCustomerPortal() = _OpenCustomerPortal;
const factory SettingsBillingEvent.addSubscription(SubscriptionPlanPB plan) =
_AddSubscription;
const factory SettingsBillingEvent.cancelSubscription(
SubscriptionPlanPB plan,
) = _CancelSubscription;
const factory SettingsBillingEvent.paymentSuccessful({
SubscriptionPlanPB? plan,
}) = _PaymentSuccessful;
}
@freezed
class SettingsBillingState with _$SettingsBillingState {
class SettingsBillingState extends Equatable with _$SettingsBillingState {
const SettingsBillingState._();
const factory SettingsBillingState.initial() = _Initial;
const factory SettingsBillingState.loading() = _Loading;
@@ -106,7 +247,20 @@ class SettingsBillingState with _$SettingsBillingState {
}) = _Error;
const factory SettingsBillingState.ready({
required WorkspaceSubscriptionPB subscription,
required WorkspaceSubscriptionInfoPB subscriptionInfo,
required BillingPortalPB? billingPortal,
@Default(null) SubscriptionPlanPB? successfulPlanUpgrade,
}) = _Ready;
@override
List<Object?> get props => maybeWhen(
orElse: () => const [],
error: (error) => [error],
ready: (subscription, billingPortal, plan) => [
subscription,
billingPortal,
plan,
...subscription.addOns,
],
);
}
@@ -3,18 +3,18 @@ import 'package:flutter/foundation.dart';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart';
import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.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:fixnum/fixnum.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:protobuf/protobuf.dart';
part 'settings_plan_bloc.freezed.dart';
@@ -30,13 +30,14 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
on<SettingsPlanEvent>((event, emit) async {
await event.when(
started: (withShowSuccessful) async {
emit(const SettingsPlanState.loading());
started: (withSuccessfulUpgrade, shouldLoad) async {
if (shouldLoad) {
emit(const SettingsPlanState.loading());
}
final snapshots = await Future.wait([
_service.getWorkspaceUsage(),
UserBackendService.getWorkspaceSubscriptions(),
_service.getBillingPortal(),
UserBackendService.getWorkspaceSubscriptionInfo(workspaceId),
]);
FlowyError? error;
@@ -49,39 +50,16 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
},
);
final subscription = snapshots[1].fold(
(s) =>
(s as RepeatedWorkspaceSubscriptionPB)
.items
.firstWhereOrNull((i) => i.workspaceId == workspaceId) ??
WorkspaceSubscriptionPB(
workspaceId: workspaceId,
subscriptionPlan: SubscriptionPlanPB.None,
isActive: true,
),
final subscriptionInfo = snapshots[1].fold(
(s) => s as WorkspaceSubscriptionInfoPB,
(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 ||
subscriptionInfo == null ||
error != null) {
return emit(SettingsPlanState.error(error: error));
}
@@ -89,18 +67,16 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
emit(
SettingsPlanState.ready(
workspaceUsage: usageResult,
subscription: subscription,
billingPortal: billingPortal,
showSuccessDialog: withShowSuccessful,
subscriptionInfo: subscriptionInfo,
successfulPlanUpgrade: withSuccessfulUpgrade,
),
);
if (withShowSuccessful) {
if (withSuccessfulUpgrade != null) {
emit(
SettingsPlanState.ready(
workspaceUsage: usageResult,
subscription: subscription,
billingPortal: billingPortal,
subscriptionInfo: subscriptionInfo,
),
);
}
@@ -108,12 +84,15 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
addSubscription: (plan) async {
final result = await _userService.createSubscription(
workspaceId,
SubscriptionPlanPB.Pro,
plan,
);
result.fold(
(pl) => afLaunchUrlString(pl.paymentLink),
(f) => Log.error(f.msg, f),
(f) => Log.error(
'Failed to fetch paymentlink for $plan: ${f.msg}',
f,
),
);
},
cancelSubscription: () async {
@@ -121,16 +100,84 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
.mapOrNull(ready: (state) => state)
?.copyWith(downgradeProcessing: true);
emit(newState ?? state);
await _userService.cancelSubscription(workspaceId);
add(const SettingsPlanEvent.started());
// We can hardcode the subscription plan here because we cannot cancel addons
// on the Plan page
final result = await _userService.cancelSubscription(
workspaceId,
SubscriptionPlanPB.Pro,
);
final successOrNull = result.fold(
(_) => true,
(f) {
Log.error('Failed to cancel subscription of Pro: ${f.msg}', f);
return null;
},
);
if (successOrNull != true) {
return;
}
// Invalidate the cache
await UserBackendService.invalidateWorkspaceSubscriptionCache(
workspaceId,
);
final subscriptionInfo = state.mapOrNull(
ready: (s) => s.subscriptionInfo,
);
// This is impossible, but for good measure
if (subscriptionInfo == null) {
return;
}
// We assume their new plan is Free, since we only have Pro plan
// at the moment.
subscriptionInfo.freeze();
final newInfo = subscriptionInfo.rebuild((value) {
value.plan = WorkspacePlanPB.FreePlan;
value.planSubscription.freeze();
value.planSubscription = value.planSubscription.rebuild((sub) {
sub.status = WorkspaceSubscriptionStatusPB.Active;
sub.subscriptionPlan = SubscriptionPlanPB.None;
});
});
// We need to remove unlimited indicator for storage and
// AI usage, if they don't have an addon that changes this behavior.
final usage = state.mapOrNull(ready: (s) => s.workspaceUsage)!;
usage.freeze();
final newUsage = usage.rebuild((value) {
if (!newInfo.hasAIMax && !newInfo.hasAIOnDevice) {
value.aiResponsesUnlimited = false;
}
value.storageBytesUnlimited = false;
});
emit(
SettingsPlanState.ready(
subscriptionInfo: newInfo,
workspaceUsage: newUsage,
),
);
},
paymentSuccessful: () {
paymentSuccessful: (plan) {
final readyState = state.mapOrNull(ready: (state) => state);
if (readyState == null) {
return;
}
add(const SettingsPlanEvent.started(withShowSuccessful: true));
add(
SettingsPlanEvent.started(
withSuccessfulUpgrade: plan,
shouldLoad: false,
),
);
},
);
});
@@ -141,8 +188,15 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
late final IUserBackendService _userService;
late final SubscriptionSuccessListenable _successListenable;
void _onPaymentSuccessful() {
add(const SettingsPlanEvent.paymentSuccessful());
Future<void> _onPaymentSuccessful() async {
// Invalidate cache for this workspace
await UserBackendService.invalidateWorkspaceSubscriptionCache(workspaceId);
add(
SettingsPlanEvent.paymentSuccessful(
plan: _successListenable.subscribedPlan,
),
);
}
@override
@@ -155,12 +209,18 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
@freezed
class SettingsPlanEvent with _$SettingsPlanEvent {
const factory SettingsPlanEvent.started({
@Default(false) bool withShowSuccessful,
@Default(null) SubscriptionPlanPB? withSuccessfulUpgrade,
@Default(true) bool shouldLoad,
}) = _Started;
const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) =
_AddSubscription;
const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription;
const factory SettingsPlanEvent.paymentSuccessful() = _PaymentSuccessful;
const factory SettingsPlanEvent.paymentSuccessful({
@Default(null) SubscriptionPlanPB? plan,
}) = _PaymentSuccessful;
}
@freezed
@@ -175,9 +235,8 @@ class SettingsPlanState with _$SettingsPlanState {
const factory SettingsPlanState.ready({
required WorkspaceUsagePB workspaceUsage,
required WorkspaceSubscriptionPB subscription,
required BillingPortalPB? billingPortal,
@Default(false) bool showSuccessDialog,
required WorkspaceSubscriptionInfoPB subscriptionInfo,
@Default(null) SubscriptionPlanPB? successfulPlanUpgrade,
@Default(false) bool downgradeProcessing,
}) = _Ready;
}
@@ -1,26 +1,79 @@
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:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart';
import 'package:easy_localization/easy_localization.dart';
extension SubscriptionLabels on WorkspaceSubscriptionPB {
String get label => switch (subscriptionPlan) {
extension SubscriptionLabels on WorkspaceSubscriptionInfoPB {
String get label => switch (plan) {
WorkspacePlanPB.FreePlan =>
LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(),
WorkspacePlanPB.ProPlan =>
LocaleKeys.settings_planPage_planUsage_currentPlan_proTitle.tr(),
WorkspacePlanPB.TeamPlan =>
LocaleKeys.settings_planPage_planUsage_currentPlan_teamTitle.tr(),
_ => 'N/A',
};
String get info => switch (plan) {
WorkspacePlanPB.FreePlan =>
LocaleKeys.settings_planPage_planUsage_currentPlan_freeInfo.tr(),
WorkspacePlanPB.ProPlan =>
LocaleKeys.settings_planPage_planUsage_currentPlan_proInfo.tr(),
WorkspacePlanPB.TeamPlan =>
LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(),
_ => 'N/A',
};
}
extension AllSubscriptionLabels on SubscriptionPlanPB {
String get label => switch (this) {
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',
};
String get info => switch (subscriptionPlan) {
SubscriptionPlanPB.None =>
LocaleKeys.settings_planPage_planUsage_currentPlan_freeInfo.tr(),
SubscriptionPlanPB.Pro =>
LocaleKeys.settings_planPage_planUsage_currentPlan_proInfo.tr(),
SubscriptionPlanPB.Team =>
LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(),
SubscriptionPlanPB.AiMax =>
LocaleKeys.settings_billingPage_addons_aiMax_label.tr(),
SubscriptionPlanPB.AiLocal =>
LocaleKeys.settings_billingPage_addons_aiOnDevice_label.tr(),
_ => 'N/A',
};
}
extension WorkspaceSubscriptionStatusExt on WorkspaceSubscriptionInfoPB {
bool get isCanceled =>
planSubscription.status == WorkspaceSubscriptionStatusPB.Canceled;
}
extension WorkspaceAddonsExt on WorkspaceSubscriptionInfoPB {
bool get hasAIMax =>
addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiMax);
bool get hasAIOnDevice =>
addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiLocal);
}
/// These have to match [SubscriptionSuccessListenable.subscribedPlan] labels
extension ToRecognizable on SubscriptionPlanPB {
String? toRecognizable() => switch (this) {
SubscriptionPlanPB.None => 'free',
SubscriptionPlanPB.Pro => 'pro',
SubscriptionPlanPB.Team => 'team',
SubscriptionPlanPB.AiMax => 'ai_max',
SubscriptionPlanPB.AiLocal => 'ai_local',
_ => null,
};
}
extension IsAddOn on SubscriptionPlanPB {
/// Returns true if the plan is an add-on and not
/// a workspace plan.
///
bool get isAddOn => switch (this) {
SubscriptionPlanPB.AiMax => true,
SubscriptionPlanPB.AiLocal => true,
_ => false,
};
}
@@ -7,7 +7,7 @@ final _storageNumberFormat = NumberFormat()
extension PresentableUsage on WorkspaceUsagePB {
String get totalBlobInGb =>
(totalBlobBytesLimit.toInt() / 1024 / 1024 / 1024).round().toString();
(storageBytesLimit.toInt() / 1024 / 1024 / 1024).round().toString();
/// We use [NumberFormat] to format the current blob in GB.
///
@@ -16,5 +16,5 @@ extension PresentableUsage on WorkspaceUsagePB {
/// And [NumberFormat.minimumFractionDigits] is set to 0.
///
String get currentBlobInGb =>
_storageNumberFormat.format(totalBlobBytes.toInt() / 1024 / 1024 / 1024);
_storageNumberFormat.format(storageBytes.toInt() / 1024 / 1024 / 1024);
}
@@ -4,12 +4,14 @@ import 'dart:convert';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/list_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/application/workspace/prelude.dart';
import 'package:appflowy/workspace/application/workspace/workspace_sections_listener.dart';
import 'package:appflowy/workspace/application/workspace/workspace_service.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@@ -20,6 +22,7 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:protobuf/protobuf.dart';
@@ -68,6 +71,7 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
_initial(userProfile, workspaceId);
final (spaces, publicViews, privateViews) = await _getSpaces();
final shouldShowUpgradeDialog = await this.shouldShowUpgradeDialog(
spaces: spaces,
publicViews: publicViews,
@@ -82,13 +86,12 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
currentSpace: currentSpace,
isExpanded: isExpanded,
shouldShowUpgradeDialog: shouldShowUpgradeDialog,
isInitialized: true,
),
);
if (shouldShowUpgradeDialog) {
if (!integrationMode().isTest) {
add(const SpaceEvent.migrate());
}
if (shouldShowUpgradeDialog && !integrationMode().isTest) {
add(const SpaceEvent.migrate());
}
if (openFirstPage) {
@@ -192,13 +195,31 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
open: (space) async {
await _openSpace(space);
final isExpanded = await _getSpaceExpandStatus(space);
emit(state.copyWith(currentSpace: space, isExpanded: isExpanded));
final views = await ViewBackendService.getChildViews(
viewId: space.id,
);
final currentSpace = views.fold(
(views) {
space.freeze();
return space.rebuild((b) {
b.childViews.clear();
b.childViews.addAll(views);
});
},
(_) => space,
);
emit(
state.copyWith(
currentSpace: currentSpace,
isExpanded: isExpanded,
),
);
// don't open the page automatically on mobile
if (PlatformExtension.isDesktop) {
// open the first page by default
if (space.childViews.isNotEmpty) {
final firstPage = space.childViews.first;
if (currentSpace.childViews.isNotEmpty) {
final firstPage = currentSpace.childViews.first;
emit(
state.copyWith(
lastCreatedPage: firstPage,
@@ -330,8 +351,9 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
if (sectionViews == null || sectionViews.views.isEmpty) {
return (<ViewPB>[], <ViewPB>[], <ViewPB>[]);
}
final publicViews = sectionViews.publicViews;
final privateViews = sectionViews.privateViews;
final publicViews = sectionViews.publicViews.unique((e) => e.id);
final privateViews = sectionViews.privateViews.unique((e) => e.id);
final publicSpaces = publicViews.where((e) => e.isSpace);
final privateSpaces = privateViews.where((e) => e.isSpace);
@@ -690,6 +712,7 @@ class SpaceState with _$SpaceState {
FlowyResult<void, FlowyError>? createPageResult,
@Default(false) bool shouldShowUpgradeDialog,
@Default(false) bool isDuplicatingSpace,
@Default(false) bool isInitialized,
}) = _SpaceState;
factory SpaceState.initial() => const SpaceState();
@@ -1,7 +1,23 @@
import 'package:flutter/foundation.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
class SubscriptionSuccessListenable extends ChangeNotifier {
SubscriptionSuccessListenable();
void onPaymentSuccess() => notifyListeners();
String? _plan;
SubscriptionPlanPB? get subscribedPlan => switch (_plan) {
'free' => SubscriptionPlanPB.None,
'pro' => SubscriptionPlanPB.Pro,
'team' => SubscriptionPlanPB.Team,
'ai_max' => SubscriptionPlanPB.AiMax,
'ai_local' => SubscriptionPlanPB.AiLocal,
_ => null,
};
void onPaymentSuccess(String? plan) {
_plan = plan;
notifyListeners();
}
}
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/core/config/kv.dart';
@@ -7,6 +8,7 @@ import 'package:appflowy/workspace/application/favorite/favorite_listener.dart';
import 'package:appflowy/workspace/application/recent/cached_recent_service.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.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-folder/view.pb.dart';
@@ -138,11 +140,18 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
);
},
delete: (e) async {
// unpublish the page and all its child pages if they are published
await _unpublishPage(view);
final result = await ViewBackendService.delete(viewId: view.id);
emit(
result.fold(
(l) =>
state.copyWith(successOrFailure: FlowyResult.success(null)),
(l) {
return state.copyWith(
successOrFailure: FlowyResult.success(null),
);
},
(error) => state.copyWith(
successOrFailure: FlowyResult.failure(error),
),
@@ -180,8 +189,11 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
);
emit(
result.fold(
(l) =>
state.copyWith(successOrFailure: FlowyResult.success(null)),
(l) {
return state.copyWith(
successOrFailure: FlowyResult.success(null),
);
},
(error) => state.copyWith(
successOrFailure: FlowyResult.failure(error),
),
@@ -236,6 +248,13 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
}
add(const ViewEvent.setIsExpanded(false));
},
unpublish: (value) async {
if (value.sync) {
await _unpublishPage(view);
} else {
unawaited(_unpublishPage(view));
}
},
);
},
);
@@ -383,6 +402,20 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
return null;
}
// unpublish the page and all its child pages
Future<void> _unpublishPage(ViewPB views) async {
final (_, publishedPages) = await ViewBackendService.containPublishedPage(
view,
);
await Future.wait(
publishedPages.map((view) async {
Log.info('unpublishing page: ${view.id}, ${view.name}');
await ViewBackendService.unpublish(view);
}),
);
}
bool _isSameViewIgnoreChildren(ViewPB from, ViewPB to) {
return _hash(from) == _hash(to);
}
@@ -430,6 +463,8 @@ class ViewEvent with _$ViewEvent {
) = UpdateViewVisibility;
const factory ViewEvent.updateIcon(String? icon) = UpdateIcon;
const factory ViewEvent.collapseAllPages() = CollapseAllPages;
// this event will unpublish the page and all its child pages if they are published
const factory ViewEvent.unpublish({required bool sync}) = Unpublish;
}
@freezed
@@ -291,4 +291,78 @@ class ViewBackendService {
);
return FolderEventUpdateViewVisibilityStatus(payload).send();
}
static Future<FlowyResult<PublishInfoResponsePB, FlowyError>> getPublishInfo(
ViewPB view,
) async {
final payload = ViewIdPB()..value = view.id;
return FolderEventGetPublishInfo(payload).send();
}
static Future<FlowyResult<void, FlowyError>> publish(
ViewPB view, {
String? name,
}) async {
final payload = PublishViewParamsPB()..viewId = view.id;
if (name != null) {
payload.publishName = name;
}
return FolderEventPublishView(payload).send();
}
static Future<FlowyResult<void, FlowyError>> unpublish(
ViewPB view,
) async {
final payload = UnpublishViewsPayloadPB(viewIds: [view.id]);
return FolderEventUnpublishViews(payload).send();
}
static Future<FlowyResult<void, FlowyError>> setPublishNameSpace(
String name,
) async {
final payload = SetPublishNamespacePayloadPB()..newNamespace = name;
return FolderEventSetPublishNamespace(payload).send();
}
static Future<FlowyResult<PublishNamespacePB, FlowyError>>
getPublishNameSpace() async {
return FolderEventGetPublishNamespace().send();
}
static Future<List<ViewPB>> getAllChildViews(ViewPB view) async {
final views = <ViewPB>[];
final childViews =
await ViewBackendService.getChildViews(viewId: view.id).fold(
(s) => s,
(f) => [],
);
for (final child in childViews) {
// filter the view itself
if (child.id == view.id) {
continue;
}
views.add(child);
views.addAll(await getAllChildViews(child));
}
return views;
}
static Future<(bool, List<ViewPB>)> containPublishedPage(ViewPB view) async {
final childViews = await ViewBackendService.getAllChildViews(view);
final views = [view, ...childViews];
final List<ViewPB> publishedPages = [];
for (final view in views) {
final publishInfo = await ViewBackendService.getPublishInfo(view);
if (publishInfo.isSuccess) {
publishedPages.add(view);
}
}
return (publishedPages.isNotEmpty, publishedPages);
}
}
@@ -15,6 +15,14 @@ class ViewTitleBarBloc extends Bloc<ViewTitleBarEvent, ViewTitleBarState> {
await event.when(
initial: () async {
add(const ViewTitleBarEvent.reload());
viewListener = ViewListener(
viewId: view.id,
)..start(
onViewUpdated: (p0) {
add(const ViewTitleBarEvent.reload());
},
);
},
reload: () async {
final List<ViewPB> ancestors =
@@ -30,6 +38,13 @@ class ViewTitleBarBloc extends Bloc<ViewTitleBarEvent, ViewTitleBarState> {
}
final ViewPB view;
late final ViewListener viewListener;
@override
Future<void> close() {
viewListener.stop();
return super.close();
}
}
@freezed
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/markdown_code_parser.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/share/import_service.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/import/import_type.dart';
@@ -220,7 +221,12 @@ class _ImportPanelState extends State<ImportPanel> {
Uint8List? _documentDataFrom(ImportType importType, String data) {
switch (importType) {
case ImportType.markdownOrText:
final document = markdownToDocument(data);
final document = markdownToDocument(
data,
markdownParsers: [
const MarkdownCodeBlockParser(),
],
);
return DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer();
case ImportType.historyDocument:
final document = EditorMigration.migrateDocument(data);
@@ -11,6 +11,8 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
typedef MovePageMenuOnSelected = void Function(ViewPB space, ViewPB view);
class MovePageMenu extends StatefulWidget {
const MovePageMenu({
super.key,
@@ -23,7 +25,7 @@ class MovePageMenu extends StatefulWidget {
final ViewPB sourceView;
final UserProfilePB userProfile;
final String workspaceId;
final void Function(ViewPB view) onSelected;
final MovePageMenuOnSelected onSelected;
@override
State<MovePageMenu> createState() => _MovePageMenuState();
@@ -88,7 +90,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
);
}
return Expanded(
child: _buildGroupedViews(state.queryResults!),
child: _buildGroupedViews(space, state.queryResults!),
);
},
),
@@ -99,7 +101,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
);
}
Widget _buildGroupedViews(List<ViewPB> views) {
Widget _buildGroupedViews(ViewPB space, List<ViewPB> views) {
final groupedViews = views
.where(
(view) =>
@@ -108,7 +110,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
.toList();
return _MovePageGroupedViews(
views: groupedViews,
onSelected: widget.onSelected,
onSelected: (view) => widget.onSelected(space, view),
);
}
@@ -124,7 +126,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
child: CurrentSpace(
onTapBlankArea: () {
// move the page to current space
widget.onSelected(space);
widget.onSelected(space, space);
},
space: space,
),
@@ -145,7 +147,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
disableSelectedStatus: true,
// hide the ... and + buttons
rightIconsBuilder: (context, view) => [],
onSelected: (_, view) => widget.onSelected(view),
onSelected: (_, view) => widget.onSelected(space, view),
),
),
),
@@ -1,7 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/blank/blank.dart';
@@ -32,11 +30,12 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/side
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
show UserProfilePB;
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
Loading? _duplicateSpaceLoading;
@@ -360,10 +359,24 @@ class _SidebarState extends State<_Sidebar> {
Widget _renderFolderOrSpace(EdgeInsets menuHorizontalInset) {
final spaceState = context.read<SpaceBloc>().state;
final workspaceState = context.read<UserWorkspaceBloc>().state;
if (!spaceState.isInitialized) {
return const SizedBox.shrink();
}
// there's no space or the workspace is not collaborative,
// show the folder section (Workspace, Private, Personal)
// otherwise, show the space
return spaceState.spaces.isEmpty || !workspaceState.isCollabWorkspaceOn
final sidebarSectionBloc = context.watch<SidebarSectionsBloc>();
final containsSpace = sidebarSectionBloc.state.containsSpace;
if (containsSpace && spaceState.spaces.isEmpty) {
context.read<SpaceBloc>().add(const SpaceEvent.didReceiveSpaceUpdate());
}
return !containsSpace ||
spaceState.spaces.isEmpty ||
!workspaceState.isCollabWorkspaceOn
? Expanded(
child: Padding(
padding: menuHorizontalInset - const EdgeInsets.only(right: 6),
@@ -19,6 +19,7 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SpacePermissionSwitch extends StatefulWidget {
@@ -222,55 +223,82 @@ class SpaceCancelOrConfirmButton extends StatelessWidget {
}
}
class DeleteSpacePopup extends StatelessWidget {
const DeleteSpacePopup({super.key});
class ConfirmDeletionPopup extends StatefulWidget {
const ConfirmDeletionPopup({
super.key,
required this.title,
required this.description,
required this.onConfirm,
});
final String title;
final String description;
final VoidCallback onConfirm;
@override
State<ConfirmDeletionPopup> createState() => _ConfirmDeletionPopupState();
}
class _ConfirmDeletionPopupState extends State<ConfirmDeletionPopup> {
final focusNode = FocusNode();
@override
Widget build(BuildContext context) {
final space = context.read<SpaceBloc>().state.currentSpace;
final name = space != null ? space.name : '';
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 20.0,
horizontal: 20.0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
FlowyText(
LocaleKeys.space_deleteConfirmation.tr() + name,
fontSize: 14.0,
),
const Spacer(),
FlowyButton(
useIntrinsicWidth: true,
text: const FlowySvg(FlowySvgs.upgrade_close_s),
onTap: () => Navigator.of(context).pop(),
),
],
),
const VSpace(8.0),
FlowyText.regular(
LocaleKeys.space_deleteConfirmationDescription.tr(),
fontSize: 12.0,
color: Theme.of(context).hintColor,
maxLines: 3,
lineHeight: 1.4,
),
const VSpace(20.0),
SpaceCancelOrConfirmButton(
onCancel: () => Navigator.of(context).pop(),
onConfirm: () {
context.read<SpaceBloc>().add(const SpaceEvent.delete(null));
Navigator.of(context).pop();
},
confirmButtonName: LocaleKeys.space_delete.tr(),
confirmButtonColor: Theme.of(context).colorScheme.error,
),
],
return KeyboardListener(
focusNode: focusNode,
autofocus: true,
onKeyEvent: (event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 20.0,
horizontal: 20.0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: FlowyText(
widget.title,
fontSize: 14.0,
overflow: TextOverflow.ellipsis,
),
),
const HSpace(6.0),
FlowyButton(
useIntrinsicWidth: true,
text: const FlowySvg(FlowySvgs.upgrade_close_s),
onTap: () => Navigator.of(context).pop(),
),
],
),
const VSpace(8.0),
FlowyText.regular(
widget.description,
fontSize: 12.0,
color: Theme.of(context).hintColor,
maxLines: 3,
lineHeight: 1.4,
),
const VSpace(20.0),
SpaceCancelOrConfirmButton(
onCancel: () => Navigator.of(context).pop(),
onConfirm: () {
widget.onConfirm();
Navigator.of(context).pop();
},
confirmButtonName: LocaleKeys.space_delete.tr(),
confirmButtonColor: Theme.of(context).colorScheme.error,
),
],
),
),
);
}
@@ -227,18 +227,14 @@ class _SidebarSpaceHeaderState extends State<SidebarSpaceHeader> {
void _showDeleteSpaceDialog(BuildContext context) {
final spaceBloc = context.read<SpaceBloc>();
showDialog(
final space = spaceBloc.state.currentSpace;
final name = space != null ? space.name : '';
showConfirmDeletionDialog(
context: context,
builder: (_) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: BlocProvider.value(
value: spaceBloc,
child: const SizedBox(width: 440, child: DeleteSpacePopup()),
),
);
name: name,
description: LocaleKeys.space_deleteConfirmationDescription.tr(),
onConfirm: () {
context.read<SpaceBloc>().add(const SpaceEvent.delete(null));
},
);
}
@@ -105,14 +105,16 @@ class _WorkspaceMoreActionWrapper extends CustomActionCell {
case WorkspaceMoreAction.divider:
break;
case WorkspaceMoreAction.delete:
await NavigatorAlertDialog(
title: LocaleKeys.workspace_deleteWorkspaceHintText.tr(),
confirm: () {
await showConfirmDeletionDialog(
context: context,
name: workspace.name,
description: LocaleKeys.workspace_deleteWorkspaceHintText.tr(),
onConfirm: () {
workspaceBloc.add(
UserWorkspaceEvent.deleteWorkspace(workspace.workspaceId),
);
},
).show(context);
);
case WorkspaceMoreAction.rename:
await NavigatorTextFieldDialog(
title: LocaleKeys.workspace_renameWorkspace.tr(),
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
@@ -5,6 +7,7 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@@ -17,9 +20,10 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.d
import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -334,6 +338,7 @@ class _InnerViewItemState extends State<InnerViewItem> {
onMove: widget.isPlaceholder
? (from, to) => _moveViewCrossSection(
context,
null,
widget.view,
widget.parentView,
widget.spaceType,
@@ -690,7 +695,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
spaceType: widget.spaceType,
onEditing: (value) =>
context.read<ViewBloc>().add(ViewEvent.setIsEditing(value)),
onAction: (action, data) {
onAction: (action, data) async {
switch (action) {
case ViewMoreActionType.favorite:
case ViewMoreActionType.unFavorite:
@@ -699,18 +704,36 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
.add(FavoriteEvent.toggle(widget.view));
break;
case ViewMoreActionType.rename:
NavigatorTextFieldDialog(
title: LocaleKeys.disclosureAction_rename.tr(),
autoSelectAllText: true,
value: widget.view.name,
maxLength: 256,
onConfirm: (newValue, _) {
context.read<ViewBloc>().add(ViewEvent.rename(newValue));
},
).show(context);
unawaited(
NavigatorTextFieldDialog(
title: LocaleKeys.disclosureAction_rename.tr(),
autoSelectAllText: true,
value: widget.view.name,
maxLength: 256,
onConfirm: (newValue, _) {
context.read<ViewBloc>().add(ViewEvent.rename(newValue));
},
).show(context),
);
break;
case ViewMoreActionType.delete:
context.read<ViewBloc>().add(const ViewEvent.delete());
// get if current page contains published child views
final (containPublishedPage, _) =
await ViewBackendService.containPublishedPage(
widget.view,
);
if (containPublishedPage && context.mounted) {
await showConfirmDeletionDialog(
context: context,
name: widget.view.name,
description: LocaleKeys.publish_containsPublishedPage.tr(),
onConfirm: () {
context.read<ViewBloc>().add(const ViewEvent.delete());
},
);
} else if (context.mounted) {
context.read<ViewBloc>().add(const ViewEvent.delete());
}
break;
case ViewMoreActionType.duplicate:
context.read<ViewBloc>().add(const ViewEvent.duplicate());
@@ -726,22 +749,22 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
return;
}
final result = data;
ViewBackendService.updateViewIcon(
await ViewBackendService.updateViewIcon(
viewId: widget.view.id,
viewIcon: result.emoji,
iconType: result.type.toProto(),
);
break;
case ViewMoreActionType.moveTo:
final target = data;
if (target is! ViewPB) {
final value = data;
if (value is! (ViewPB, ViewPB)) {
return;
}
debugPrint(
'Move view ${widget.view.id}, ${widget.view.name} to ${target.id}, ${target.name}',
);
final space = value.$1;
final target = value.$2;
_moveViewCrossSection(
context,
space,
widget.view,
widget.parentView,
widget.spaceType,
@@ -802,6 +825,7 @@ bool isReferencedDatabaseView(ViewPB view, ViewPB? parentView) {
void _moveViewCrossSection(
BuildContext context,
ViewPB? toSpace,
ViewPB view,
ViewPB? parentView,
FolderSpaceType spaceType,
@@ -822,6 +846,17 @@ void _moveViewCrossSection(
final toSection = spaceType == FolderSpaceType.public
? ViewSectionPB.Public
: ViewSectionPB.Private;
final currentSpace = context.read<SpaceBloc>().state.currentSpace;
if (currentSpace != null &&
toSpace != null &&
currentSpace.id != toSpace.id) {
Log.info(
'Move view(${from.name}) to another space(${toSpace.name}), unpublish the view',
);
context.read<ViewBloc>().add(const ViewEvent.unpublish(sync: false));
}
context.read<ViewBloc>().add(
ViewEvent.move(
from,
@@ -192,8 +192,8 @@ class ViewMoreActionTypeWrapper extends CustomActionCell {
sourceView: sourceView,
userProfile: userProfile,
workspaceId: workspaceId,
onSelected: (view) {
onTap(controller, view);
onSelected: (space, view) {
onTap(controller, (space, view));
},
);
},
@@ -53,7 +53,7 @@ class SettingsAIView extends StatelessWidget {
AIModelSelection(),
_AISearchToggle(value: false),
// Disable local AI configuration for now. It's not ready for production.
// LocalAIConfiguration(),
LocalAIConfiguration(),
],
);
},
@@ -1,14 +1,19 @@
import 'package:flutter/material.dart';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/billing/settings_billing_bloc.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.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/presentation/settings/pages/settings_plan_comparison_dialog.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart';
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:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
@@ -16,6 +21,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../generated/locale_keys.g.dart';
const _buttonsMinWidth = 100.0;
class SettingsBillingView extends StatelessWidget {
const SettingsBillingView({
super.key,
@@ -29,9 +36,12 @@ class SettingsBillingView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider<SettingsBillingBloc>(
create: (context) => SettingsBillingBloc(workspaceId: workspaceId)
..add(const SettingsBillingEvent.started()),
child: BlocBuilder<SettingsBillingBloc, SettingsBillingState>(
create: (_) => SettingsBillingBloc(
workspaceId: workspaceId,
userId: user.id,
)..add(const SettingsBillingEvent.started()),
child: BlocConsumer<SettingsBillingBloc, SettingsBillingState>(
listener: (context, state) {},
builder: (context, state) {
return state.map(
initial: (_) => const SizedBox.shrink(),
@@ -56,8 +66,8 @@ class SettingsBillingView extends StatelessWidget {
return ErrorWidget.withDetails(message: 'Something went wrong!');
},
ready: (state) {
final billingPortalEnabled = state.billingPortal != null &&
state.billingPortal!.url.isNotEmpty;
final billingPortalEnabled =
state.subscriptionInfo.plan != WorkspacePlanPB.FreePlan;
return SettingsBody(
title: LocaleKeys.settings_billingPage_title.tr(),
@@ -70,18 +80,22 @@ class SettingsBillingView extends StatelessWidget {
context,
workspaceId,
user.id,
state.subscription,
state.subscriptionInfo,
),
fontWeight: FontWeight.w500,
label: state.subscription.label,
label: state.subscriptionInfo.label,
buttonLabel: LocaleKeys
.settings_billingPage_plan_planButtonLabel
.tr(),
minWidth: _buttonsMinWidth,
),
if (billingPortalEnabled)
SingleSettingAction(
onPressed: () =>
afLaunchUrlString(state.billingPortal!.url),
onPressed: () => context
.read<SettingsBillingBloc>()
.add(
const SettingsBillingEvent.openCustomerPortal(),
),
label: LocaleKeys
.settings_billingPage_plan_billingPeriod
.tr(),
@@ -89,6 +103,7 @@ class SettingsBillingView extends StatelessWidget {
buttonLabel: LocaleKeys
.settings_billingPage_plan_periodButtonLabel
.tr(),
minWidth: _buttonsMinWidth,
),
],
),
@@ -99,8 +114,11 @@ class SettingsBillingView extends StatelessWidget {
.tr(),
children: [
SingleSettingAction(
onPressed: () =>
afLaunchUrlString(state.billingPortal!.url),
onPressed: () => context
.read<SettingsBillingBloc>()
.add(
const SettingsBillingEvent.openCustomerPortal(),
),
label: LocaleKeys
.settings_billingPage_paymentDetails_methodLabel
.tr(),
@@ -108,9 +126,47 @@ class SettingsBillingView extends StatelessWidget {
buttonLabel: LocaleKeys
.settings_billingPage_paymentDetails_methodButtonLabel
.tr(),
minWidth: _buttonsMinWidth,
),
],
),
SettingsCategory(
title: LocaleKeys.settings_billingPage_addons_title.tr(),
children: [
_AITile(
plan: SubscriptionPlanPB.AiMax,
label: LocaleKeys
.settings_billingPage_addons_aiMax_label
.tr(),
description: LocaleKeys
.settings_billingPage_addons_aiMax_description,
activeDescription: LocaleKeys
.settings_billingPage_addons_aiMax_activeDescription,
canceledDescription: LocaleKeys
.settings_billingPage_addons_aiMax_canceledDescription,
subscriptionInfo:
state.subscriptionInfo.addOns.firstWhereOrNull(
(a) => a.type == WorkspaceAddOnPBType.AddOnAiMax,
),
),
_AITile(
plan: SubscriptionPlanPB.AiLocal,
label: LocaleKeys
.settings_billingPage_addons_aiOnDevice_label
.tr(),
description: LocaleKeys
.settings_billingPage_addons_aiOnDevice_description,
activeDescription: LocaleKeys
.settings_billingPage_addons_aiOnDevice_activeDescription,
canceledDescription: LocaleKeys
.settings_billingPage_addons_aiOnDevice_canceledDescription,
subscriptionInfo:
state.subscriptionInfo.addOns.firstWhereOrNull(
(a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal,
),
),
],
),
],
);
},
@@ -124,7 +180,7 @@ class SettingsBillingView extends StatelessWidget {
BuildContext context,
String workspaceId,
Int64 userId,
WorkspaceSubscriptionPB subscription,
WorkspaceSubscriptionInfoPB subscriptionInfo,
) =>
showDialog<bool?>(
context: context,
@@ -134,7 +190,7 @@ class SettingsBillingView extends StatelessWidget {
..add(const SettingsPlanEvent.started()),
child: SettingsPlanComparisonDialog(
workspaceId: workspaceId,
subscription: subscription,
subscriptionInfo: subscriptionInfo,
),
),
).then((didChangePlan) {
@@ -145,3 +201,86 @@ class SettingsBillingView extends StatelessWidget {
}
});
}
class _AITile extends StatelessWidget {
const _AITile({
required this.label,
required this.description,
required this.canceledDescription,
required this.activeDescription,
required this.plan,
this.subscriptionInfo,
});
final String label;
final String description;
final String canceledDescription;
final String activeDescription;
final SubscriptionPlanPB plan;
final WorkspaceAddOnPB? subscriptionInfo;
@override
Widget build(BuildContext context) {
final isCanceled = subscriptionInfo?.addOnSubscription.status ==
WorkspaceSubscriptionStatusPB.Canceled;
final dateFormat = context.read<AppearanceSettingsCubit>().state.dateFormat;
return SingleSettingAction(
label: label,
description: subscriptionInfo != null && isCanceled
? canceledDescription.tr(
args: [
dateFormat.formatDate(
subscriptionInfo!.addOnSubscription.endDate.toDateTime(),
false,
),
],
)
: subscriptionInfo != null
? activeDescription.tr(
args: [
dateFormat.formatDate(
subscriptionInfo!.addOnSubscription.endDate.toDateTime(),
false,
),
],
)
: description.tr(),
buttonLabel: subscriptionInfo != null
? isCanceled
? LocaleKeys.settings_billingPage_addons_renewLabel.tr()
: LocaleKeys.settings_billingPage_addons_removeLabel.tr()
: LocaleKeys.settings_billingPage_addons_addLabel.tr(),
fontWeight: FontWeight.w500,
minWidth: _buttonsMinWidth,
onPressed: () {
if (subscriptionInfo != null && isCanceled) {
// Show customer portal to renew
context
.read<SettingsBillingBloc>()
.add(const SettingsBillingEvent.openCustomerPortal());
} else if (subscriptionInfo != null) {
SettingsAlertDialog(
title: LocaleKeys.settings_billingPage_addons_removeDialog_title
.tr(args: [plan.label]).tr(),
subtitle: LocaleKeys
.settings_billingPage_addons_removeDialog_description
.tr(args: [plan.label]).tr(),
confirm: () {
Navigator.of(context).pop();
context
.read<SettingsBillingBloc>()
.add(SettingsBillingEvent.cancelSubscription(plan));
},
).show(context);
} else {
// Add the addon
context
.read<SettingsBillingBloc>()
.add(SettingsBillingEvent.addSubscription(plan));
}
},
);
}
}
@@ -1,28 +1,29 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/theme_extension.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/presentation/settings/shared/settings_alert_dialog.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.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';
import '../../../../generated/locale_keys.g.dart';
class SettingsPlanComparisonDialog extends StatefulWidget {
const SettingsPlanComparisonDialog({
super.key,
required this.workspaceId,
required this.subscription,
required this.subscriptionInfo,
});
final String workspaceId;
final WorkspaceSubscriptionPB subscription;
final WorkspaceSubscriptionInfoPB subscriptionInfo;
@override
State<SettingsPlanComparisonDialog> createState() =>
@@ -34,7 +35,7 @@ class _SettingsPlanComparisonDialogState
final horizontalController = ScrollController();
final verticalController = ScrollController();
late WorkspaceSubscriptionPB currentSubscription = widget.subscription;
late WorkspaceSubscriptionInfoPB currentInfo = widget.subscriptionInfo;
@override
void dispose() {
@@ -55,32 +56,20 @@ class _SettingsPlanComparisonDialogState
return;
}
if (readyState.showSuccessDialog) {
if (readyState.successfulPlanUpgrade != null) {
SettingsAlertDialog(
icon: Center(
child: SizedBox(
height: 90,
width: 90,
child: FlowySvg(
FlowySvgs.check_circle_s,
color: AFThemeExtension.of(context).success,
),
),
),
title: LocaleKeys.settings_comparePlanDialog_paymentSuccess_title
.tr(args: [readyState.subscription.label]),
.tr(args: [readyState.successfulPlanUpgrade!.label]),
subtitle: LocaleKeys
.settings_comparePlanDialog_paymentSuccess_description
.tr(args: [readyState.subscription.label]),
.tr(args: [readyState.successfulPlanUpgrade!.label]),
hideCancelButton: true,
confirm: Navigator.of(context).pop,
confirmLabel: LocaleKeys.button_close.tr(),
).show(context);
}
setState(() {
currentSubscription = readyState.subscription;
});
setState(() => currentInfo = readyState.subscriptionInfo);
},
builder: (context, state) => FlowyDialog(
constraints: const BoxConstraints(maxWidth: 784, minWidth: 674),
@@ -100,8 +89,7 @@ class _SettingsPlanComparisonDialogState
const Spacer(),
GestureDetector(
onTap: () => Navigator.of(context).pop(
currentSubscription.subscriptionPlan !=
widget.subscription.subscriptionPlan,
currentInfo.plan != widget.subscriptionInfo.plan,
),
child: MouseRegion(
cursor: SystemMouseCursors.click,
@@ -180,32 +168,30 @@ class _SettingsPlanComparisonDialogState
.settings_comparePlanDialog_freePlan_priceInfo
.tr(),
cells: _freeLabels,
isCurrent: currentSubscription.subscriptionPlan ==
SubscriptionPlanPB.None,
isCurrent:
currentInfo.plan == WorkspacePlanPB.FreePlan,
canDowngrade:
currentSubscription.subscriptionPlan !=
SubscriptionPlanPB.None,
currentCanceled: currentSubscription.hasCanceled ||
currentInfo.plan != WorkspacePlanPB.FreePlan,
currentCanceled: currentInfo.isCanceled ||
(context
.watch<SettingsPlanBloc>()
.state
.mapOrNull(
loading: (_) => true,
ready: (state) =>
state.downgradeProcessing,
ready: (s) => s.downgradeProcessing,
) ??
false),
onSelected: () async {
if (currentSubscription.subscriptionPlan ==
SubscriptionPlanPB.None ||
currentSubscription.hasCanceled) {
if (currentInfo.plan ==
WorkspacePlanPB.FreePlan ||
currentInfo.isCanceled) {
return;
}
await SettingsAlertDialog(
title: LocaleKeys
.settings_comparePlanDialog_downgradeDialog_title
.tr(args: [currentSubscription.label]),
.tr(args: [currentInfo.label]),
subtitle: LocaleKeys
.settings_comparePlanDialog_downgradeDialog_description
.tr(),
@@ -238,11 +224,11 @@ class _SettingsPlanComparisonDialogState
.settings_comparePlanDialog_proPlan_priceInfo
.tr(),
cells: _proLabels,
isCurrent: currentSubscription.subscriptionPlan ==
SubscriptionPlanPB.Pro,
canUpgrade: currentSubscription.subscriptionPlan ==
SubscriptionPlanPB.None,
currentCanceled: currentSubscription.hasCanceled,
isCurrent:
currentInfo.plan == WorkspacePlanPB.ProPlan,
canUpgrade:
currentInfo.plan == WorkspacePlanPB.FreePlan,
currentCanceled: currentInfo.isCanceled,
onSelected: () =>
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
@@ -588,21 +574,28 @@ class _Heading extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
width: 175,
width: 185,
height: height,
child: Padding(
padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.semibold(
title,
fontSize: 24,
color: isPrimary
? AFThemeExtension.of(context).strongText
: Theme.of(context).isLightMode
? const Color(0xFF5C3699)
: const Color(0xFFC49BEC),
Row(
children: [
Expanded(
child: FlowyText.semibold(
title,
fontSize: 24,
overflow: TextOverflow.ellipsis,
color: isPrimary
? AFThemeExtension.of(context).strongText
: Theme.of(context).isLightMode
? const Color(0xFF5C3699)
: const Color(0xFFC49BEC),
),
),
],
),
if (description != null && description!.isNotEmpty) ...[
const VSpace(4),
@@ -636,24 +629,20 @@ final _planLabels = [
),
_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(),
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSix.tr(),
),
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSeven.tr(),
),
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemEight.tr(),
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipEight.tr(),
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSeven.tr(),
),
];
@@ -676,20 +665,17 @@ final List<_CellItem> _freeLabels = [
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_freeLabels_itemFour.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_freeLabels_itemFive.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_freeLabels_itemSeven.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_freeLabels_itemEight.tr(),
),
];
@@ -705,19 +691,17 @@ final List<_CellItem> _proLabels = [
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_proLabels_itemFour.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_proLabels_itemFive.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_proLabels_itemSeven.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_proLabels_itemEight.tr(),
),
];
@@ -16,6 +16,7 @@ import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
@@ -68,10 +69,79 @@ class SettingsPlanView extends StatelessWidget {
children: [
_PlanUsageSummary(
usage: state.workspaceUsage,
subscription: state.subscription,
subscriptionInfo: state.subscriptionInfo,
),
const VSpace(16),
_CurrentPlanBox(subscription: state.subscription),
_CurrentPlanBox(subscriptionInfo: state.subscriptionInfo),
const VSpace(16),
FlowyText(
LocaleKeys.settings_planPage_planUsage_addons_title.tr(),
fontSize: 18,
color: AFThemeExtension.of(context).strongText,
fontWeight: FontWeight.w600,
),
const VSpace(8),
Row(
children: [
Flexible(
child: _AddOnBox(
title: LocaleKeys
.settings_planPage_planUsage_addons_aiMax_title
.tr(),
description: LocaleKeys
.settings_planPage_planUsage_addons_aiMax_description
.tr(),
price: LocaleKeys
.settings_planPage_planUsage_addons_aiMax_price
.tr(args: ['\$8']),
priceInfo: LocaleKeys
.settings_planPage_planUsage_addons_aiMax_priceInfo
.tr(),
billingInfo: LocaleKeys
.settings_planPage_planUsage_addons_aiMax_billingInfo
.tr(args: ['\$10']),
buttonText: state.subscriptionInfo.hasAIMax
? LocaleKeys
.settings_planPage_planUsage_addons_activeLabel
.tr()
: LocaleKeys
.settings_planPage_planUsage_addons_addLabel
.tr(),
isActive: state.subscriptionInfo.hasAIMax,
plan: SubscriptionPlanPB.AiMax,
),
),
const HSpace(8),
Flexible(
child: _AddOnBox(
title: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_title
.tr(),
description: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_description
.tr(),
price: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_price
.tr(args: ['\$8']),
priceInfo: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_priceInfo
.tr(),
billingInfo: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_billingInfo
.tr(args: ['\$10']),
buttonText: state.subscriptionInfo.hasAIOnDevice
? LocaleKeys
.settings_planPage_planUsage_addons_activeLabel
.tr()
: LocaleKeys
.settings_planPage_planUsage_addons_addLabel
.tr(),
isActive: state.subscriptionInfo.hasAIOnDevice,
plan: SubscriptionPlanPB.AiLocal,
),
),
],
),
],
),
);
@@ -82,9 +152,9 @@ class SettingsPlanView extends StatelessWidget {
}
class _CurrentPlanBox extends StatefulWidget {
const _CurrentPlanBox({required this.subscription});
const _CurrentPlanBox({required this.subscriptionInfo});
final WorkspaceSubscriptionPB subscription;
final WorkspaceSubscriptionInfoPB subscriptionInfo;
@override
State<_CurrentPlanBox> createState() => _CurrentPlanBoxState();
@@ -116,68 +186,67 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
border: Border.all(color: const Color(0xFFBDBDBD)),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
child: Column(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(4),
FlowyText.semibold(
widget.subscription.label,
fontSize: 24,
color: AFThemeExtension.of(context).strongText,
),
const VSpace(8),
FlowyText.regular(
widget.subscription.info,
fontSize: 16,
color: AFThemeExtension.of(context).strongText,
maxLines: 3,
),
const VSpace(16),
FlowyGradientButton(
label: LocaleKeys
.settings_planPage_planUsage_currentPlan_upgrade
.tr(),
onPressed: () => _openPricingDialog(
context,
context.read<SettingsPlanBloc>().workspaceId,
widget.subscription,
),
),
if (widget.subscription.hasCanceled) ...[
const VSpace(12),
FlowyText(
LocaleKeys
.settings_planPage_planUsage_currentPlan_canceledInfo
.tr(
args: [_canceledDate(context)],
Row(
children: [
Expanded(
flex: 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(4),
FlowyText.semibold(
widget.subscriptionInfo.label,
fontSize: 24,
color: AFThemeExtension.of(context).strongText,
),
maxLines: 5,
fontSize: 12,
color: Theme.of(context).colorScheme.error,
),
],
],
),
),
const HSpace(16),
Expanded(
child: SeparatedColumn(
separatorBuilder: () => const VSpace(4),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
..._getPros(widget.subscription.subscriptionPlan).map(
(s) => _ProConItem(label: s),
const VSpace(8),
FlowyText.regular(
widget.subscriptionInfo.info,
fontSize: 16,
color: AFThemeExtension.of(context).strongText,
maxLines: 3,
),
],
),
..._getCons(widget.subscription.subscriptionPlan).map(
(s) => _ProConItem(label: s, isPro: false),
),
Flexible(
flex: 5,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: FlowyGradientButton(
label: LocaleKeys
.settings_planPage_planUsage_currentPlan_upgrade
.tr(),
onPressed: () => _openPricingDialog(
context,
context.read<SettingsPlanBloc>().workspaceId,
widget.subscriptionInfo,
),
),
),
],
),
],
),
),
],
),
if (widget.subscriptionInfo.isCanceled) ...[
const VSpace(12),
FlowyText(
LocaleKeys
.settings_planPage_planUsage_currentPlan_canceledInfo
.tr(
args: [_canceledDate(context)],
),
maxLines: 5,
fontSize: 12,
color: Theme.of(context).colorScheme.error,
),
],
],
),
),
@@ -185,14 +254,21 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
top: 0,
left: 0,
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(color: Color(0xFF4F3F5F)),
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: const BoxDecoration(
color: Color(0xFF4F3F5F),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
bottomRight: Radius.circular(4),
),
),
child: Center(
child: FlowyText.semibold(
LocaleKeys.settings_planPage_planUsage_currentPlan_bannerLabel
.tr(),
fontSize: 16,
fontSize: 14,
color: Colors.white,
),
),
@@ -205,16 +281,15 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
String _canceledDate(BuildContext context) {
final appearance = context.read<AppearanceSettingsCubit>().state;
return appearance.dateFormat.formatDate(
widget.subscription.canceledAt.toDateTime(),
true,
appearance.timeFormat,
widget.subscriptionInfo.planSubscription.endDate.toDateTime(),
false,
);
}
void _openPricingDialog(
BuildContext context,
String workspaceId,
WorkspaceSubscriptionPB subscription,
WorkspaceSubscriptionInfoPB subscriptionInfo,
) =>
showDialog(
context: context,
@@ -222,101 +297,20 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
value: planBloc,
child: SettingsPlanComparisonDialog(
workspaceId: workspaceId,
subscription: subscription,
subscriptionInfo: subscriptionInfo,
),
),
);
List<String> _getPros(SubscriptionPlanPB plan) => switch (plan) {
SubscriptionPlanPB.Pro => _proPros(),
_ => _freePros(),
};
List<String> _getCons(SubscriptionPlanPB plan) => switch (plan) {
SubscriptionPlanPB.Pro => _proCons(),
_ => _freeCons(),
};
List<String> _freePros() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProOne.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProTwo.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProThree.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProFour.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeProFive.tr(),
];
List<String> _freeCons() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_freeConOne.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeConTwo.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_freeConThree.tr(),
];
List<String> _proPros() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProOne
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProTwo
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProThree
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProFour
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProFive
.tr(),
];
List<String> _proCons() => [
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConOne
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConTwo
.tr(),
LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConThree
.tr(),
];
}
class _ProConItem extends StatelessWidget {
const _ProConItem({
required this.label,
this.isPro = true,
});
final String label;
final bool isPro;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 18,
child: FlowySvg(
isPro ? FlowySvgs.check_m : FlowySvgs.close_error_s,
size: const Size.square(18),
color: isPro
? AFThemeExtension.of(context).strongText
: const Color(0xFF900000),
),
),
const HSpace(4),
Flexible(
child: FlowyText.regular(
label,
fontSize: 12,
color: AFThemeExtension.of(context).strongText,
maxLines: 3,
),
),
],
);
}
}
class _PlanUsageSummary extends StatelessWidget {
const _PlanUsageSummary({required this.usage, required this.subscription});
const _PlanUsageSummary({
required this.usage,
required this.subscriptionInfo,
});
final WorkspaceUsagePB usage;
final WorkspaceSubscriptionPB subscription;
final WorkspaceSubscriptionInfoPB subscriptionInfo;
@override
Widget build(BuildContext context) {
@@ -332,61 +326,101 @@ class _PlanUsageSummary extends StatelessWidget {
),
const VSpace(16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _UsageBox(
title: LocaleKeys.settings_planPage_planUsage_storageLabel.tr(),
replacementText: subscription.subscriptionPlan ==
SubscriptionPlanPB.Pro
? LocaleKeys.settings_planPage_planUsage_storageUnlimited
.tr()
: null,
unlimitedLabel: LocaleKeys
.settings_planPage_planUsage_unlimitedStorageLabel
.tr(),
unlimited: usage.storageBytesUnlimited,
label: LocaleKeys.settings_planPage_planUsage_storageUsage.tr(
args: [
usage.currentBlobInGb,
usage.totalBlobInGb,
],
),
value: usage.totalBlobBytes.toInt() /
usage.totalBlobBytesLimit.toInt(),
value: usage.storageBytes.toInt() /
usage.storageBytesLimit.toInt(),
),
),
Expanded(
child: _UsageBox(
title: LocaleKeys.settings_planPage_planUsage_collaboratorsLabel
.tr(),
label: LocaleKeys.settings_planPage_planUsage_collaboratorsUsage
.tr(
title:
LocaleKeys.settings_planPage_planUsage_aiResponseLabel.tr(),
label:
LocaleKeys.settings_planPage_planUsage_aiResponseUsage.tr(
args: [
usage.memberCount.toString(),
usage.memberCountLimit.toString(),
usage.aiResponsesCount.toString(),
usage.aiResponsesCountLimit.toString(),
],
),
value:
usage.memberCount.toInt() / usage.memberCountLimit.toInt(),
unlimitedLabel: LocaleKeys
.settings_planPage_planUsage_unlimitedAILabel
.tr(),
unlimited: usage.aiResponsesUnlimited,
value: usage.aiResponsesCount.toInt() /
usage.aiResponsesCountLimit.toInt(),
),
),
],
),
const VSpace(16),
Column(
SeparatedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
separatorBuilder: () => const VSpace(4),
children: [
_ToggleMore(
value: subscription.subscriptionPlan == SubscriptionPlanPB.Pro,
label:
LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(),
subscription: subscription,
badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(),
),
const VSpace(8),
_ToggleMore(
value: subscription.subscriptionPlan == SubscriptionPlanPB.Pro,
label:
LocaleKeys.settings_planPage_planUsage_guestCollabToggle.tr(),
subscription: subscription,
badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(),
),
if (subscriptionInfo.plan == WorkspacePlanPB.FreePlan) ...[
_ToggleMore(
value: false,
label:
LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(),
badgeLabel:
LocaleKeys.settings_planPage_planUsage_proBadge.tr(),
onTap: () async {
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
SubscriptionPlanPB.Pro,
),
);
await Future.delayed(const Duration(seconds: 2), () {});
},
),
],
if (!subscriptionInfo.hasAIMax) ...[
_ToggleMore(
value: false,
label: LocaleKeys.settings_planPage_planUsage_aiMaxToggle.tr(),
badgeLabel:
LocaleKeys.settings_planPage_planUsage_aiMaxBadge.tr(),
onTap: () async {
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
SubscriptionPlanPB.AiMax,
),
);
await Future.delayed(const Duration(seconds: 2), () {});
},
),
],
if (!subscriptionInfo.hasAIOnDevice) ...[
_ToggleMore(
value: false,
label: LocaleKeys.settings_planPage_planUsage_aiOnDeviceToggle
.tr(),
badgeLabel:
LocaleKeys.settings_planPage_planUsage_aiOnDeviceBadge.tr(),
onTap: () async {
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
SubscriptionPlanPB.AiLocal,
),
);
await Future.delayed(const Duration(seconds: 2), () {});
},
),
],
],
),
],
@@ -399,15 +433,18 @@ class _UsageBox extends StatelessWidget {
required this.title,
required this.label,
required this.value,
this.replacementText,
required this.unlimitedLabel,
this.unlimited = false,
});
final String title;
final String label;
final double value;
/// Replaces the progress indicator if not null
final String? replacementText;
final String unlimitedLabel;
// Replaces the progress bar with an unlimited badge
final bool unlimited;
@override
Widget build(BuildContext context) {
@@ -419,19 +456,27 @@ class _UsageBox extends StatelessWidget {
fontSize: 11,
color: AFThemeExtension.of(context).secondaryTextColor,
),
if (replacementText != null) ...[
Row(
children: [
Flexible(
child: FlowyText.medium(
replacementText!,
fontSize: 11,
color: AFThemeExtension.of(context).secondaryTextColor,
if (unlimited) ...[
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FlowySvg(
FlowySvgs.check_circle_outlined_s,
color: Color(0xFF9C00FB),
),
),
],
const HSpace(4),
FlowyText(
unlimitedLabel,
fontWeight: FontWeight.w500,
fontSize: 11,
),
],
),
),
] else ...[
const VSpace(4),
_PlanProgressIndicator(label: label, progress: value),
],
],
@@ -443,14 +488,14 @@ class _ToggleMore extends StatefulWidget {
const _ToggleMore({
required this.value,
required this.label,
required this.subscription,
this.badgeLabel,
this.onTap,
});
final bool value;
final String label;
final WorkspaceSubscriptionPB subscription;
final String? badgeLabel;
final Future<void> Function()? onTap;
@override
State<_ToggleMore> createState() => _ToggleMoreState();
@@ -472,29 +517,17 @@ class _ToggleMoreState extends State<_ToggleMore> {
Toggle(
value: toggleValue,
padding: EdgeInsets.zero,
onChanged: (_) {
setState(() => toggleValue = !toggleValue);
onChanged: (_) async {
if (widget.onTap == null || toggleValue) {
return;
}
Future.delayed(const Duration(milliseconds: 150), () {
if (mounted) {
showDialog(
context: context,
builder: (_) => BlocProvider<SettingsPlanBloc>.value(
value: context.read<SettingsPlanBloc>(),
child: SettingsPlanComparisonDialog(
workspaceId: context.read<SettingsPlanBloc>().workspaceId,
subscription: widget.subscription,
),
),
).then((_) {
Future.delayed(const Duration(milliseconds: 150), () {
if (mounted) {
setState(() => toggleValue = !toggleValue);
}
});
});
}
});
setState(() => toggleValue = !toggleValue);
await widget.onTap!();
if (mounted) {
setState(() => toggleValue = !toggleValue);
}
},
),
const HSpace(10),
@@ -554,7 +587,9 @@ class _PlanProgressIndicator extends StatelessWidget {
widthFactor: progress,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primary,
color: progress >= 1
? theme.colorScheme.error
: theme.colorScheme.primary,
),
),
),
@@ -575,6 +610,135 @@ class _PlanProgressIndicator extends StatelessWidget {
}
}
class _AddOnBox extends StatelessWidget {
const _AddOnBox({
required this.title,
required this.description,
required this.price,
required this.priceInfo,
required this.billingInfo,
required this.buttonText,
required this.isActive,
required this.plan,
});
final String title;
final String description;
final String price;
final String priceInfo;
final String billingInfo;
final String buttonText;
final bool isActive;
final SubscriptionPlanPB plan;
@override
Widget build(BuildContext context) {
final isLM = Theme.of(context).isLightMode;
return Container(
height: 220,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
border: Border.all(
color: isActive ? const Color(0xFFBDBDBD) : const Color(0xFF9C00FB),
),
color: const Color(0xFFF7F8FC).withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.semibold(
title,
fontSize: 14,
color: AFThemeExtension.of(context).secondaryTextColor,
),
const VSpace(4),
FlowyText.regular(
description,
fontSize: 11,
color: AFThemeExtension.of(context).strongText,
maxLines: 4,
),
const VSpace(4),
FlowyText(
price,
fontSize: 24,
color: AFThemeExtension.of(context).strongText,
),
FlowyText(
priceInfo,
fontSize: 11,
color: AFThemeExtension.of(context).strongText,
),
const VSpace(6),
Row(
children: [
Expanded(
child: FlowyText(
billingInfo,
color: AFThemeExtension.of(context).secondaryTextColor,
fontSize: 11,
maxLines: 2,
),
),
],
),
const Spacer(),
Row(
children: [
Expanded(
child: FlowyTextButton(
buttonText,
heading: isActive
? const FlowySvg(
FlowySvgs.check_circle_outlined_s,
color: Color(0xFF9C00FB),
)
: null,
mainAxisAlignment: MainAxisAlignment.center,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
fillColor: isActive
? const Color(0xFFE8E2EE)
: isLM
? Colors.transparent
: const Color(0xFF5C3699),
constraints: const BoxConstraints(minWidth: 115),
radius: Corners.s16Border,
hoverColor: isActive
? const Color(0xFFE8E2EE)
: isLM
? const Color(0xFF5C3699)
: const Color(0xFF4d3472),
fontColor:
isLM || isActive ? const Color(0xFF5C3699) : Colors.white,
fontHoverColor:
isActive ? const Color(0xFF5C3699) : Colors.white,
borderColor: isActive
? const Color(0xFFE8E2EE)
: isLM
? const Color(0xFF5C3699)
: const Color(0xFF4d3472),
fontSize: 12,
onPressed: isActive
? null
: () => context
.read<SettingsPlanBloc>()
.add(SettingsPlanEvent.addSubscription(plan)),
),
),
],
),
],
),
);
}
}
/// Uncomment if we need it in the future
// class _DealBox extends StatelessWidget {
// const _DealBox();
@@ -207,7 +207,7 @@ class SettingsWorkspaceView extends StatelessWidget {
Navigator.of(context).pop();
},
).show(context),
isDangerous: true,
buttonType: SingleSettingsButtonType.danger,
buttonLabel: workspaceMember?.role.isOwner ?? false
? LocaleKeys
.settings_workspacePage_manageWorkspace_deleteWorkspace
@@ -120,7 +120,10 @@ class SettingsDialog extends StatelessWidget {
return const AIFeatureOnlySupportedWhenUsingAppFlowyCloud();
}
case SettingsPage.member:
return WorkspaceMembersPage(userProfile: user);
return WorkspaceMembersPage(
userProfile: user,
workspaceId: workspaceId,
);
case SettingsPage.plan:
return SettingsPlanView(workspaceId: workspaceId, user: user);
case SettingsPage.billing:
@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/color_to_hex_string.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flex_color_picker/flex_color_picker.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
import 'package:flutter/material.dart';
class DocumentColorSettingButton extends StatefulWidget {
const DocumentColorSettingButton({
@@ -90,24 +92,11 @@ class DocumentColorSettingDialogState
late TextEditingController hexController;
late TextEditingController opacityController;
void updateSelectedColor() {
if (widget.formKey.currentState!.validate()) {
setState(() {
final colorValue = int.tryParse(
hexController.text.combineHexWithOpacity(opacityController.text),
);
// colorValue has been validated in the _ColorSettingTextField for hex value and it won't be null as this point
selectedColorOnDialog = Color(colorValue!);
widget.onChanged(selectedColorOnDialog!);
});
}
}
@override
void initState() {
super.initState();
selectedColorOnDialog = widget.currentColor;
currentColorHexString = widget.currentColor.toHexString();
currentColorHexString = ColorExtension(widget.currentColor).toHexString();
hexController = TextEditingController(
text: currentColorHexString.extractHex(),
);
@@ -145,17 +134,25 @@ class DocumentColorSettingDialogState
controller: hexController,
labelText: LocaleKeys.editor_hexValue.tr(),
hintText: '6fc9e7',
onChanged: (_) => updateSelectedColor(),
onFieldSubmitted: (_) => updateSelectedColor(),
onChanged: (_) => _updateSelectedColor(),
onFieldSubmitted: (_) => _updateSelectedColor(),
validator: (v) => validateHexValue(v, opacityController.text),
suffixIcon: GestureDetector(
onTap: () => _showColorPickerDialog(
context: context,
currentColor: widget.currentColor,
updateColor: _updateColor,
),
child: const Icon(Icons.color_lens_rounded),
),
),
const VSpace(8),
_ColorSettingTextField(
controller: opacityController,
labelText: LocaleKeys.editor_opacity.tr(),
hintText: '50',
onChanged: (_) => updateSelectedColor(),
onFieldSubmitted: (_) => updateSelectedColor(),
onChanged: (_) => _updateSelectedColor(),
onFieldSubmitted: (_) => _updateSelectedColor(),
validator: (value) => validateOpacityValue(value),
),
],
@@ -164,6 +161,28 @@ class DocumentColorSettingDialogState
],
);
}
void _updateSelectedColor() {
if (widget.formKey.currentState!.validate()) {
setState(() {
final colorValue = int.tryParse(
hexController.text.combineHexWithOpacity(opacityController.text),
);
// colorValue has been validated in the _ColorSettingTextField for hex value and it won't be null as this point
selectedColorOnDialog = Color(colorValue!);
widget.onChanged(selectedColorOnDialog!);
});
}
}
void _updateColor(Color color) {
setState(() {
hexController.text = ColorExtension(color).toHexString().extractHex();
opacityController.text =
ColorExtension(color).toHexString().extractOpacity();
});
_updateSelectedColor();
}
}
class _ColorSettingTextField extends StatelessWidget {
@@ -172,6 +191,7 @@ class _ColorSettingTextField extends StatelessWidget {
required this.labelText,
required this.hintText,
required this.onFieldSubmitted,
this.suffixIcon,
this.onChanged,
this.validator,
});
@@ -180,6 +200,7 @@ class _ColorSettingTextField extends StatelessWidget {
final String labelText;
final String hintText;
final void Function(String) onFieldSubmitted;
final Widget? suffixIcon;
final void Function(String)? onChanged;
final String? Function(String?)? validator;
@@ -191,6 +212,7 @@ class _ColorSettingTextField extends StatelessWidget {
decoration: InputDecoration(
labelText: labelText,
hintText: hintText,
suffixIcon: suffixIcon,
border: OutlineInputBorder(
borderSide: BorderSide(color: style.colorScheme.outline),
),
@@ -241,3 +263,145 @@ String? validateOpacityValue(String? value) {
}
return null;
}
const _kColorCircleWidth = 46.0;
const _kColorCircleHeight = 46.0;
const _kColorCircleRadius = 23.0;
const _kColorOpacityThumbRadius = 23.0;
const _kDialogButtonPaddingHorizontal = 24.0;
const _kDialogButtonPaddingVertical = 12.0;
const _kColorsColumnSpacing = 3.0;
class _ColorPicker extends StatelessWidget {
const _ColorPicker({
required this.selectedColor,
required this.onColorChanged,
});
final Color selectedColor;
final void Function(Color) onColorChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
child: ColorPicker(
width: _kColorCircleWidth,
height: _kColorCircleHeight,
borderRadius: _kColorCircleRadius,
enableOpacity: true,
opacityThumbRadius: _kColorOpacityThumbRadius,
columnSpacing: _kColorsColumnSpacing,
enableTooltips: false,
pickersEnabled: const {
ColorPickerType.both: false,
ColorPickerType.primary: true,
ColorPickerType.accent: true,
ColorPickerType.wheel: true,
},
subheading: Text(
LocaleKeys.settings_appearance_documentSettings_colorShade.tr(),
style: theme.textTheme.labelLarge,
),
opacitySubheading: Text(
LocaleKeys.settings_appearance_documentSettings_opacity.tr(),
style: theme.textTheme.labelLarge,
),
onColorChanged: onColorChanged,
),
);
}
}
class _ColorPickerActions extends StatelessWidget {
const _ColorPickerActions({
required this.onReset,
required this.onUpdate,
});
final VoidCallback onReset;
final VoidCallback onUpdate;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 24,
child: FlowyTextButton(
LocaleKeys.button_cancel.tr(),
padding: const EdgeInsets.symmetric(
horizontal: _kDialogButtonPaddingHorizontal,
vertical: _kDialogButtonPaddingVertical,
),
fontColor: AFThemeExtension.of(context).textColor,
fillColor: Colors.transparent,
hoverColor: Colors.transparent,
radius: Corners.s12Border,
onPressed: onReset,
),
),
const HSpace(8),
SizedBox(
height: 48,
child: FlowyTextButton(
LocaleKeys.button_done.tr(),
padding: const EdgeInsets.symmetric(
horizontal: _kDialogButtonPaddingHorizontal,
vertical: _kDialogButtonPaddingVertical,
),
radius: Corners.s12Border,
fontHoverColor: Colors.white,
fillColor: Theme.of(context).colorScheme.primary,
hoverColor: const Color(0xFF005483),
onPressed: onUpdate,
),
),
],
);
}
}
void _showColorPickerDialog({
required BuildContext context,
String? title,
required Color currentColor,
required void Function(Color) updateColor,
}) {
final style = Theme.of(context);
Color selectedColor = currentColor;
showDialog(
context: context,
barrierColor: const Color.fromARGB(128, 0, 0, 0),
builder: (context) {
return AlertDialog(
icon: const Icon(Icons.palette),
title: Text(
title ??
LocaleKeys.settings_appearance_documentSettings_pickColor.tr(),
style: style.textTheme.titleLarge,
),
content: _ColorPicker(
selectedColor: selectedColor,
onColorChanged: (color) => selectedColor = color,
),
actionsPadding: const EdgeInsets.all(8),
actions: [
_ColorPickerActions(
onReset: () {
updateColor(currentColor);
Navigator.of(context).pop();
},
onUpdate: () {
updateColor(selectedColor);
Navigator.of(context).pop();
},
),
],
);
},
);
}
@@ -71,7 +71,7 @@ class _FlowyGradientButtonState extends State<FlowyGradientButton> {
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: FlowyText(
widget.label,
fontSize: 16,
@@ -6,6 +6,16 @@ import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
enum SingleSettingsButtonType {
primary,
danger,
highlight;
bool get isPrimary => this == primary;
bool get isDangerous => this == danger;
bool get isHighlight => this == highlight;
}
/// This is used to describe a single setting action
///
/// This will render a simple action that takes the title,
@@ -18,15 +28,18 @@ class SingleSettingAction extends StatelessWidget {
const SingleSettingAction({
super.key,
required this.label,
this.description,
this.labelMaxLines,
required this.buttonLabel,
this.onPressed,
this.isDangerous = false,
this.buttonType = SingleSettingsButtonType.primary,
this.fontSize = 14,
this.fontWeight = FontWeight.normal,
this.minWidth,
});
final String label;
final String? description;
final int? labelMaxLines;
final String buttonLabel;
@@ -36,46 +49,115 @@ class SingleSettingAction extends StatelessWidget {
///
final VoidCallback? onPressed;
/// If isDangerous is true, the button will be rendered as a dangerous
/// action, with a red outline.
///
final bool isDangerous;
final SingleSettingsButtonType buttonType;
final double fontSize;
final FontWeight fontWeight;
final double? minWidth;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: FlowyText(
label,
fontSize: fontSize,
fontWeight: fontWeight,
maxLines: labelMaxLines,
overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).secondaryTextColor,
child: Column(
children: [
Row(
children: [
Expanded(
child: FlowyText(
label,
fontSize: fontSize,
fontWeight: fontWeight,
maxLines: labelMaxLines,
overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).secondaryTextColor,
),
),
],
),
if (description != null) ...[
const VSpace(4),
Row(
children: [
Expanded(
child: FlowyText.regular(
description!,
fontSize: 11,
color: AFThemeExtension.of(context).secondaryTextColor,
maxLines: 2,
),
),
],
),
],
],
),
),
const HSpace(24),
SizedBox(
height: 32,
ConstrainedBox(
constraints: BoxConstraints(
minWidth: minWidth ?? 0.0,
maxHeight: 32,
minHeight: 32,
),
child: FlowyTextButton(
buttonLabel,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
fillColor:
isDangerous ? null : Theme.of(context).colorScheme.primary,
radius: Corners.s12Border,
hoverColor: isDangerous ? null : const Color(0xFF005483),
fontColor: isDangerous ? Theme.of(context).colorScheme.error : null,
fontHoverColor: Colors.white,
fillColor: fillColor(context),
radius: Corners.s8Border,
hoverColor: hoverColor(context),
fontColor: fontColor(context),
fontHoverColor: fontHoverColor(context),
borderColor: borderColor(context),
fontSize: 12,
isDangerous: isDangerous,
isDangerous: buttonType.isDangerous,
onPressed: onPressed,
),
),
],
);
}
Color? fillColor(BuildContext context) {
if (buttonType.isPrimary) {
return Theme.of(context).colorScheme.primary;
}
return Colors.transparent;
}
Color? hoverColor(BuildContext context) {
if (buttonType.isPrimary) {
return const Color(0xFF005483);
}
if (buttonType.isHighlight) {
return const Color(0xFF5C3699);
}
return null;
}
Color? fontColor(BuildContext context) {
if (buttonType.isDangerous) {
return Theme.of(context).colorScheme.error;
}
if (buttonType.isHighlight) {
return const Color(0xFF5C3699);
}
return null;
}
Color? fontHoverColor(BuildContext context) {
return Colors.white;
}
Color? borderColor(BuildContext context) {
if (buttonType.isHighlight) {
return const Color(0xFF5C3699);
}
return null;
}
}
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
@@ -24,13 +27,14 @@ class WorkspaceMemberBloc
extends Bloc<WorkspaceMemberEvent, WorkspaceMemberState> {
WorkspaceMemberBloc({
required this.userProfile,
String? workspaceId,
this.workspace,
}) : _userBackendService = UserBackendService(userId: userProfile.id),
super(WorkspaceMemberState.initial()) {
on<WorkspaceMemberEvent>((event, emit) async {
await event.when(
initial: () async {
await _setCurrentWorkspaceId();
await _setCurrentWorkspaceId(workspaceId);
final result = await _userBackendService.getWorkspaceMembers(
_workspaceId,
@@ -135,9 +139,7 @@ class WorkspaceMemberBloc
(s) => state.members.map((e) {
if (e.email == email) {
e.freeze();
return e.rebuild((p0) {
p0.role = role;
});
return e.rebuild((p0) => p0.role = role);
}
return e;
}).toList(),
@@ -153,6 +155,26 @@ class WorkspaceMemberBloc
),
);
},
updateSubscriptionInfo: (info) async =>
emit(state.copyWith(subscriptionInfo: info)),
upgradePlan: () async {
final plan = state.subscriptionInfo?.plan;
if (plan == null) {
return Log.error('Failed to upgrade plan: plan is null');
}
if (plan == WorkspacePlanPB.FreePlan) {
final checkoutLink = await _userBackendService.createSubscription(
_workspaceId,
SubscriptionPlanPB.Pro,
);
checkoutLink.fold(
(pl) => afLaunchUrlString(pl.paymentLink),
(f) => Log.error('Failed to create subscription: ${f.msg}', f),
);
}
},
);
});
}
@@ -178,9 +200,11 @@ class WorkspaceMemberBloc
return role;
}
Future<void> _setCurrentWorkspaceId() async {
Future<void> _setCurrentWorkspaceId(String? workspaceId) async {
if (workspace != null) {
_workspaceId = workspace!.workspaceId;
} else if (workspaceId != null && workspaceId.isNotEmpty) {
_workspaceId = workspaceId;
} else {
final currentWorkspace = await FolderEventReadCurrentWorkspace().send();
currentWorkspace.fold((s) {
@@ -191,6 +215,20 @@ class WorkspaceMemberBloc
_workspaceId = '';
});
}
unawaited(_fetchWorkspaceSubscriptionInfo());
}
// We fetch workspace subscription info lazily as it's not needed in the first
// render of the page.
Future<void> _fetchWorkspaceSubscriptionInfo() async {
final result =
await UserBackendService.getWorkspaceSubscriptionInfo(_workspaceId);
result.fold(
(info) => add(WorkspaceMemberEvent.updateSubscriptionInfo(info)),
(f) => Log.error('Failed to fetch subscription info: ${f.msg}', f),
);
}
}
@@ -209,6 +247,11 @@ class WorkspaceMemberEvent with _$WorkspaceMemberEvent {
String email,
AFRolePB role,
) = UpdateWorkspaceMember;
const factory WorkspaceMemberEvent.updateSubscriptionInfo(
WorkspaceSubscriptionInfoPB subscriptionInfo,
) = UpdateSubscriptionInfo;
const factory WorkspaceMemberEvent.upgradePlan() = UpgradePlan;
}
enum WorkspaceMemberActionType {
@@ -241,6 +284,7 @@ class WorkspaceMemberState with _$WorkspaceMemberState {
@Default(AFRolePB.Guest) AFRolePB myRole,
@Default(null) WorkspaceMemberActionResult? actionResult,
@Default(true) bool isLoading,
@Default(null) WorkspaceSubscriptionInfoPB? subscriptionInfo,
}) = _WorkspaceMemberState;
factory WorkspaceMemberState.initial() => const WorkspaceMemberState();
@@ -255,6 +299,7 @@ class WorkspaceMemberState with _$WorkspaceMemberState {
return other is WorkspaceMemberState &&
other.members == members &&
other.myRole == myRole &&
other.subscriptionInfo == subscriptionInfo &&
identical(other.actionResult, actionResult);
}
}
@@ -1,18 +1,20 @@
import 'package:flutter/material.dart';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
@@ -20,22 +22,34 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
class WorkspaceMembersPage extends StatelessWidget {
const WorkspaceMembersPage({super.key, required this.userProfile});
const WorkspaceMembersPage({
super.key,
required this.userProfile,
required this.workspaceId,
});
final UserProfilePB userProfile;
final String workspaceId;
@override
Widget build(BuildContext context) {
return BlocProvider<WorkspaceMemberBloc>(
create: (context) => WorkspaceMemberBloc(userProfile: userProfile)
..add(const WorkspaceMemberEvent.initial()),
child: BlocConsumer<WorkspaceMemberBloc, WorkspaceMemberState>(
listener: _showResultDialog,
child: BlocBuilder<WorkspaceMemberBloc, WorkspaceMemberState>(
builder: (context, state) {
return SettingsBody(
title: LocaleKeys.settings_appearance_members_title.tr(),
autoSeparate: false,
children: [
if (state.myRole.canInvite) const _InviteMember(),
if (state.actionResult != null) ...[
_showMemberLimitWarning(context, state),
const VSpace(16),
],
if (state.myRole.canInvite) ...[
const _InviteMember(),
const SettingsCategorySpacer(),
],
if (state.members.isNotEmpty)
_MemberList(
members: state.members,
@@ -49,62 +63,105 @@ class WorkspaceMembersPage extends StatelessWidget {
);
}
void _showResultDialog(BuildContext context, WorkspaceMemberState state) {
final actionResult = state.actionResult;
if (actionResult == null) {
return;
Widget _showMemberLimitWarning(
BuildContext context,
WorkspaceMemberState state,
) {
// We promise that state.actionResult != null before calling
// this method
final actionResult = state.actionResult!.result;
final actionType = state.actionResult!.actionType;
debugPrint("Plan: ${state.subscriptionInfo?.plan}");
if (actionType == WorkspaceMemberActionType.invite &&
actionResult.isFailure) {
final error = actionResult.getFailure().code;
if (error == ErrorCode.WorkspaceMemberLimitExceeded) {
return Row(
children: [
const FlowySvg(
FlowySvgs.warning_s,
blendMode: BlendMode.dst,
size: Size.square(20),
),
const HSpace(12),
Expanded(
child: RichText(
text: TextSpan(
children: [
if (state.subscriptionInfo?.plan ==
WorkspacePlanPB.ProPlan) ...[
TextSpan(
text: LocaleKeys
.settings_appearance_members_memberLimitExceededPro
.tr(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AFThemeExtension.of(context).strongText,
),
),
WidgetSpan(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
// Hardcoded support email, in the future we might
// want to add this to an environment variable
onTap: () async => afLaunchUrlString(
'mailto:support@appflowy.io',
),
child: FlowyText(
LocaleKeys
.settings_appearance_members_memberLimitExceededProContact
.tr(),
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
] else ...[
TextSpan(
text: LocaleKeys
.settings_appearance_members_memberLimitExceeded
.tr(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AFThemeExtension.of(context).strongText,
),
),
WidgetSpan(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => context
.read<WorkspaceMemberBloc>()
.add(const WorkspaceMemberEvent.upgradePlan()),
child: FlowyText(
LocaleKeys
.settings_appearance_members_memberLimitExceededUpgrade
.tr(),
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
],
],
),
),
),
],
);
}
}
final actionType = actionResult.actionType;
final result = actionResult.result;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.add) {
result.fold(
(s) {
showSnackBarMessage(
context,
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
);
},
(f) {
Log.error('add workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys.settings_appearance_members_memberLimitExceeded.tr()
: LocaleKeys.settings_appearance_members_failedToAddMember.tr();
showDialog(
context: context,
builder: (context) => NavigatorOkCancelDialog(message: message),
);
},
);
} else if (actionType == WorkspaceMemberActionType.invite) {
result.fold(
(s) {
showSnackBarMessage(
context,
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
);
},
(f) {
Log.error('invite workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys.settings_appearance_members_memberLimitExceeded.tr()
: LocaleKeys.settings_appearance_members_failedToInviteMember
.tr();
showDialog(
context: context,
builder: (context) => NavigatorOkCancelDialog(message: message),
);
},
);
}
result.onFailure((f) {
Log.error(
'[Member] Failed to perform ${actionType.toString()} action: $f',
);
});
return const SizedBox.shrink();
}
}
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
@@ -10,6 +9,8 @@ import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:toastification/toastification.dart';
export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
@@ -283,3 +284,58 @@ class OkCancelButton extends StatelessWidget {
);
}
}
void showToastNotification(
BuildContext context, {
required String message,
String? description,
}) {
toastification.show(
context: context,
type: ToastificationType.success,
style: ToastificationStyle.flat,
title: FlowyText(message),
description: description != null
? FlowyText.regular(
description,
fontSize: 12,
lineHeight: 1.2,
maxLines: 3,
)
: null,
alignment: Alignment.bottomCenter,
autoCloseDuration: const Duration(milliseconds: 3000),
showProgressBar: false,
backgroundColor: Theme.of(context).colorScheme.surface,
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.4),
),
);
}
Future<void> showConfirmDeletionDialog({
required BuildContext context,
required String name,
required String description,
required VoidCallback onConfirm,
}) {
return showDialog(
context: context,
builder: (_) {
final title = LocaleKeys.space_deleteConfirmation.tr() + name;
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: SizedBox(
width: 440,
child: ConfirmDeletionPopup(
title: title,
description: description,
onConfirm: onConfirm,
),
),
);
},
);
}
@@ -1,9 +1,12 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/startup/tasks/rust_sdk.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:device_info_plus/device_info_plus.dart';
@@ -203,9 +206,24 @@ class FlowyVersionDescription extends CustomActionCell {
thickness: 1.0,
),
const VSpace(6),
FlowyText(
"$appName $version",
color: Theme.of(context).hintColor,
GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTap: () {
if (Env.internalBuild != '1') {
return;
}
enableDocumentInternalLog = !enableDocumentInternalLog;
showToastNotification(
context,
message: enableDocumentInternalLog
? 'Enabled Internal Log'
: 'Disabled Internal Log',
);
},
child: FlowyText(
'$appName $version',
color: Theme.of(context).hintColor,
),
),
],
).padding(
@@ -1,14 +1,15 @@
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/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
enum ViewActionType {
@@ -46,8 +47,8 @@ class ViewAction extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FlowyButton(
onTap: () {
context.read<ViewBloc>().add(type.actionEvent);
onTap: () async {
await _onAction(context);
mutex?.close();
},
text: FlowyText.regular(
@@ -63,4 +64,25 @@ class ViewAction extends StatelessWidget {
hoverColor: AFThemeExtension.of(context).lightGreyHover,
);
}
Future<void> _onAction(BuildContext context) async {
if (type == ViewActionType.delete) {
final (containPublishedPage, _) =
await ViewBackendService.containPublishedPage(view);
if (containPublishedPage && context.mounted) {
await showConfirmDeletionDialog(
context: context,
name: view.name,
description: LocaleKeys.publish_containsPublishedPage.tr(),
onConfirm: () {
context.read<ViewBloc>().add(const ViewEvent.delete());
},
);
} else if (context.mounted) {
context.read<ViewBloc>().add(const ViewEvent.delete());
}
} else {
context.read<ViewBloc>().add(type.actionEvent);
}
}
}
@@ -37,7 +37,9 @@ class ViewTitleBar extends StatelessWidget {
scrollDirection: Axis.horizontal,
child: SizedBox(
height: 24,
child: Row(children: _buildViewTitles(context, ancestors)),
child: Row(
children: _buildViewTitles(context, ancestors),
),
),
);
},
@@ -76,6 +78,7 @@ class ViewTitleBar extends StatelessWidget {
}
final child = FlowyTooltip(
key: ValueKey(view.id),
message: view.name,
child: _ViewTitle(
view: view,
@@ -82,4 +82,7 @@ class Corners {
static const BorderRadius s12Border = BorderRadius.all(s12Radius);
static const Radius s12Radius = Radius.circular(12);
static const BorderRadius s16Border = BorderRadius.all(s16Radius);
static const Radius s16Radius = Radius.circular(16);
}
@@ -165,6 +165,7 @@ class FlowyTextButton extends StatelessWidget {
this.decoration,
this.fontFamily,
this.isDangerous = false,
this.borderColor,
});
final String text;
@@ -188,6 +189,7 @@ class FlowyTextButton extends StatelessWidget {
final String? fontFamily;
final bool isDangerous;
final Color? borderColor;
@override
Widget build(BuildContext context) {
@@ -211,7 +213,7 @@ class FlowyTextButton extends StatelessWidget {
child = ConstrainedBox(
constraints: constraints,
child: TextButton(
onPressed: onPressed ?? () {},
onPressed: onPressed,
focusNode: FocusNode(skipTraversal: onPressed == null),
style: ButtonStyle(
overlayColor: const WidgetStatePropertyAll(Colors.transparent),
@@ -222,9 +224,10 @@ class FlowyTextButton extends StatelessWidget {
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
side: BorderSide(
color: isDangerous
? Theme.of(context).colorScheme.error
: Colors.transparent,
color: borderColor ??
(isDangerous
? Theme.of(context).colorScheme.error
: Colors.transparent),
),
borderRadius: radius ?? Corners.s6Border,
),
@@ -1,10 +1,9 @@
import 'dart:async';
import 'package:flowy_infra/size.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flowy_infra/size.dart';
class FlowyTextField extends StatefulWidget {
final String? hintText;
final String? text;
@@ -37,6 +36,8 @@ class FlowyTextField extends StatefulWidget {
final List<TextInputFormatter>? inputFormatters;
final bool obscureText;
final bool isDense;
final bool readOnly;
final Color? enableBorderColor;
const FlowyTextField({
super.key,
@@ -71,6 +72,8 @@ class FlowyTextField extends StatefulWidget {
this.inputFormatters,
this.obscureText = false,
this.isDense = true,
this.readOnly = false,
this.enableBorderColor,
});
@override
@@ -144,6 +147,7 @@ class FlowyTextFieldState extends State<FlowyTextField> {
@override
Widget build(BuildContext context) {
return TextField(
readOnly: widget.readOnly,
controller: controller,
focusNode: focusNode,
onChanged: (text) {
@@ -178,7 +182,8 @@ class FlowyTextFieldState extends State<FlowyTextField> {
enabledBorder: OutlineInputBorder(
borderRadius: Corners.s8Border,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
color: widget.enableBorderColor ??
Theme.of(context).colorScheme.outline,
),
),
isDense: false,
@@ -199,7 +204,10 @@ class FlowyTextFieldState extends State<FlowyTextField> {
focusedBorder: OutlineInputBorder(
borderRadius: Corners.s8Border,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
color: widget.readOnly
? widget.enableBorderColor ??
Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.primary,
),
),
errorBorder: OutlineInputBorder(
@@ -13,6 +13,7 @@ class RoundedTextButton extends StatelessWidget {
final Color? hoverColor;
final Color? textColor;
final double? fontSize;
final FontWeight? fontWeight;
final EdgeInsets padding;
const RoundedTextButton({
@@ -27,6 +28,7 @@ class RoundedTextButton extends StatelessWidget {
this.hoverColor,
this.textColor,
this.fontSize,
this.fontWeight,
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
});
@@ -42,6 +44,7 @@ class RoundedTextButton extends StatelessWidget {
child: SizedBox.expand(
child: FlowyTextButton(
title ?? '',
fontWeight: fontWeight,
onPressed: onPressed,
fontSize: fontSize,
mainAxisAlignment: MainAxisAlignment.center,
+108 -4
View File
@@ -53,11 +53,11 @@ packages:
dependency: "direct main"
description:
path: "."
ref: e8ee051
resolved-ref: e8ee051719eded6621ccdc2722f696411c020209
ref: d2d9873
resolved-ref: d2d987312d3a667336c7e12c36da7dbbb62d66db
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "3.0.0"
version: "3.1.0"
appflowy_editor_plugins:
dependency: "direct main"
description:
@@ -121,6 +121,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
barcode:
dependency: transitive
description:
name: barcode
sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003
url: "https://pub.dev"
source: hosted
version: "2.2.8"
bidi:
dependency: transitive
description:
name: bidi
sha256: "1a7d0c696324b2089f72e7671fd1f1f64fef44c980f3cebc84e803967c597b63"
url: "https://pub.dev"
source: hosted
version: "2.0.10"
bitsdojo_window:
dependency: "direct main"
description:
@@ -394,6 +410,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
custom_sliding_segmented_control:
dependency: "direct main"
description:
name: custom_sliding_segmented_control
sha256: "53c3e931c3ae1f696085d1ec70ac8e934da836595a9b7d9b88fdd0fcbf2a5574"
url: "https://pub.dev"
source: hosted
version: "1.8.3"
dart_style:
dependency: transitive
description:
@@ -578,6 +602,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flex_color_picker:
dependency: "direct main"
description:
name: flex_color_picker
sha256: "809af4ec82ede3b140ed0219b97d548de99e47aa4b99b14a10f705a2dbbcba5e"
url: "https://pub.dev"
source: hosted
version: "3.5.1"
flex_seed_scheme:
dependency: transitive
description:
name: flex_seed_scheme
sha256: "6c595e545b0678e1fe17e8eec3d1fbca7237482da194fadc20ad8607dc7a7f3d"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flowy_infra:
dependency: "direct main"
description:
@@ -949,6 +989,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.2"
iconsax_flutter:
dependency: transitive
description:
name: iconsax_flutter
sha256: "95b65699da8ea98f87c5d232f06b0debaaf1ec1332b697e4d90969ec9a93037d"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
image:
dependency: transitive
description:
name: image
sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
image_gallery_saver:
dependency: "direct main"
description:
@@ -1203,7 +1259,7 @@ packages:
source: hosted
version: "1.2.0"
markdown:
dependency: transitive
dependency: "direct main"
description:
name: markdown
sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051
@@ -1410,6 +1466,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.1"
pausable_timer:
dependency: transitive
description:
name: pausable_timer
sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074"
url: "https://pub.dev"
source: hosted
version: "3.1.0+3"
pdf:
dependency: transitive
description:
name: pdf
sha256: "81d5522bddc1ef5c28e8f0ee40b71708761753c163e0c93a40df56fd515ea0f0"
url: "https://pub.dev"
source: hosted
version: "3.11.0"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
percent_indicator:
dependency: "direct main"
description:
@@ -1530,6 +1610,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
printing:
dependency: transitive
description:
name: printing
sha256: cc4b256a5a89d5345488e3318897b595867f5181b8c5ed6fc63bfa5f2044aec3
url: "https://pub.dev"
source: hosted
version: "5.13.1"
process:
dependency: transitive
description:
@@ -1570,6 +1658,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.3"
qr:
dependency: transitive
description:
name: qr
sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
realtime_client:
dependency: transitive
description:
@@ -2049,6 +2145,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
toastification:
dependency: "direct main"
description:
name: toastification
sha256: "5e751acc2fb5b8d008138dac255d62290fde4e5a24824f29809ac098c3dfe395"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
tuple:
dependency: transitive
description:
+6 -2
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.6.2
version: 0.6.3
environment:
flutter: ">=3.22.0"
@@ -66,6 +66,7 @@ dependencies:
styled_widget: ^0.4.1
expandable: ^5.0.1
flutter_colorpicker: ^1.0.3
flex_color_picker: ^3.5.1
highlight: ^0.7.0
package_info_plus: ^6.0.0
url_launcher: ^6.1.11
@@ -141,6 +142,7 @@ dependencies:
shimmer: ^3.0.0
isolates: ^3.0.3+8
markdown_widget: ^2.3.2+6
markdown:
# Window Manager for MacOS and Linux
window_manager: ^0.3.9
@@ -149,6 +151,8 @@ dependencies:
bitsdojo_window: ^0.1.6
flutter_highlight: ^0.7.0
custom_sliding_segmented_control: ^1.8.3
toastification: ^2.0.0
dev_dependencies:
flutter_lints: ^3.0.1
@@ -185,7 +189,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "e8ee051"
ref: "d2d9873"
appflowy_editor_plugins:
git:
+83 -52
View File
@@ -172,7 +172,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "app-error"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"anyhow",
"bincode",
@@ -192,7 +192,7 @@ dependencies = [
[[package]]
name = "appflowy-ai-client"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"anyhow",
"bytes",
@@ -203,6 +203,40 @@ dependencies = [
"thiserror",
]
[[package]]
name = "appflowy-local-ai"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=0820a0d23f7b813dee505e7e29e88a8561699fe8#0820a0d23f7b813dee505e7e29e88a8561699fe8"
dependencies = [
"anyhow",
"appflowy-plugin",
"bytes",
"serde",
"serde_json",
"tokio",
"tokio-stream",
"tracing",
]
[[package]]
name = "appflowy-plugin"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=0820a0d23f7b813dee505e7e29e88a8561699fe8#0820a0d23f7b813dee505e7e29e88a8561699fe8"
dependencies = [
"anyhow",
"cfg-if",
"crossbeam-utils",
"log",
"once_cell",
"parking_lot 0.12.1",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-stream",
"tracing",
]
[[package]]
name = "appflowy_tauri"
version = "0.0.0"
@@ -772,7 +806,7 @@ dependencies = [
[[package]]
name = "client-api"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"again",
"anyhow",
@@ -821,7 +855,7 @@ dependencies = [
[[package]]
name = "client-api-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"collab-entity",
"collab-rt-entity",
@@ -833,7 +867,7 @@ dependencies = [
[[package]]
name = "client-websocket"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"futures-channel",
"futures-util",
@@ -907,7 +941,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5048762#5048762dbb01abcbe75237e86c0d090e2f1d7c23"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63"
dependencies = [
"anyhow",
"async-trait",
@@ -931,7 +965,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5048762#5048762dbb01abcbe75237e86c0d090e2f1d7c23"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63"
dependencies = [
"anyhow",
"async-trait",
@@ -961,7 +995,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5048762#5048762dbb01abcbe75237e86c0d090e2f1d7c23"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63"
dependencies = [
"anyhow",
"collab",
@@ -981,7 +1015,7 @@ dependencies = [
[[package]]
name = "collab-entity"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5048762#5048762dbb01abcbe75237e86c0d090e2f1d7c23"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63"
dependencies = [
"anyhow",
"bytes",
@@ -996,7 +1030,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5048762#5048762dbb01abcbe75237e86c0d090e2f1d7c23"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63"
dependencies = [
"anyhow",
"chrono",
@@ -1034,7 +1068,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5048762#5048762dbb01abcbe75237e86c0d090e2f1d7c23"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63"
dependencies = [
"anyhow",
"async-stream",
@@ -1073,7 +1107,7 @@ dependencies = [
[[package]]
name = "collab-rt-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"anyhow",
"bincode",
@@ -1098,7 +1132,7 @@ dependencies = [
[[package]]
name = "collab-rt-protocol"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"anyhow",
"async-trait",
@@ -1115,7 +1149,7 @@ dependencies = [
[[package]]
name = "collab-user"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5048762#5048762dbb01abcbe75237e86c0d090e2f1d7c23"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63"
dependencies = [
"anyhow",
"collab",
@@ -1341,7 +1375,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa 1.0.6",
"phf 0.8.0",
"phf 0.11.2",
"smallvec",
]
@@ -1452,7 +1486,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308"
[[package]]
name = "database-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"anyhow",
"app-error",
@@ -1847,6 +1881,8 @@ version = "0.1.0"
dependencies = [
"allo-isolate",
"anyhow",
"appflowy-local-ai",
"appflowy-plugin",
"bytes",
"dashmap",
"flowy-chat-pub",
@@ -1854,7 +1890,6 @@ dependencies = [
"flowy-derive",
"flowy-error",
"flowy-notification",
"flowy-sidecar",
"flowy-sqlite",
"futures",
"lib-dispatch",
@@ -2136,7 +2171,6 @@ dependencies = [
"fancy-regex 0.11.0",
"flowy-codegen",
"flowy-derive",
"flowy-sidecar",
"flowy-sqlite",
"lib-dispatch",
"protobuf",
@@ -2172,12 +2206,14 @@ dependencies = [
"flowy-notification",
"flowy-search-pub",
"flowy-sqlite",
"futures",
"lazy_static",
"lib-dispatch",
"lib-infra",
"nanoid",
"parking_lot 0.12.1",
"protobuf",
"regex",
"serde",
"serde_json",
"strum_macros 0.21.1",
@@ -2198,6 +2234,7 @@ dependencies = [
"collab-entity",
"collab-folder",
"lib-infra",
"serde",
"uuid",
]
@@ -2323,24 +2360,6 @@ dependencies = [
"serde_repr",
]
[[package]]
name = "flowy-sidecar"
version = "0.1.0"
dependencies = [
"anyhow",
"crossbeam-utils",
"lib-infra",
"log",
"once_cell",
"parking_lot 0.12.1",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-stream",
"tracing",
]
[[package]]
name = "flowy-sqlite"
version = "0.1.0"
@@ -2921,7 +2940,7 @@ dependencies = [
[[package]]
name = "gotrue"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"anyhow",
"futures-util",
@@ -2938,7 +2957,7 @@ dependencies = [
[[package]]
name = "gotrue-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"anyhow",
"app-error",
@@ -3370,7 +3389,7 @@ dependencies = [
[[package]]
name = "infra"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"anyhow",
"bytes",
@@ -3865,7 +3884,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
dependencies = [
"regex-automata",
"regex-automata 0.1.10",
]
[[package]]
@@ -3892,9 +3911,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.5.0"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "memmap2"
@@ -4878,7 +4897,7 @@ checksum = "c55e02e35260070b6f716a2423c2ff1c3bb1642ddca6f99e1f26d06268a0e2d2"
dependencies = [
"bytes",
"heck 0.4.1",
"itertools 0.10.5",
"itertools 0.11.0",
"log",
"multimap",
"once_cell",
@@ -4899,7 +4918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e"
dependencies = [
"anyhow",
"itertools 0.10.5",
"itertools 0.11.0",
"proc-macro2",
"quote",
"syn 2.0.47",
@@ -5225,13 +5244,14 @@ dependencies = [
[[package]]
name = "regex"
version = "1.8.4"
version = "1.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0ab3ca65655bb1e41f2a8c8cd662eb4fb035e67c3f78da1d61dffe89d07300f"
checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
dependencies = [
"aho-corasick 1.0.2",
"memchr",
"regex-syntax 0.7.2",
"regex-automata 0.4.7",
"regex-syntax 0.8.4",
]
[[package]]
@@ -5243,6 +5263,17 @@ dependencies = [
"regex-syntax 0.6.29",
]
[[package]]
name = "regex-automata"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
dependencies = [
"aho-corasick 1.0.2",
"memchr",
"regex-syntax 0.8.4",
]
[[package]]
name = "regex-syntax"
version = "0.6.29"
@@ -5251,9 +5282,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]]
name = "regex-syntax"
version = "0.7.2"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78"
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
[[package]]
name = "rend"
@@ -5863,7 +5894,7 @@ dependencies = [
[[package]]
name = "shared-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9884d93aa2805013f36a79c1757174a0b5063065#9884d93aa2805013f36a79c1757174a0b5063065"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a2f92bb#a2f92bb13e7c3d33783359d02235ba4a5058a32f"
dependencies = [
"anyhow",
"app-error",
@@ -8122,9 +8153,9 @@ dependencies = [
[[package]]
name = "yrs"
version = "0.18.8"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da227d69095141c331d9b60c11496d0a3c6505cd9f8e200898b197219e8e394f"
checksum = "71df0198938b69f1eec0d5f19f591c6e4f2f770b0bf16f858428f6d91b8bb280"
dependencies = [
"arc-swap",
"atomic_refcell",
+25 -9
View File
@@ -29,7 +29,7 @@ tokio = "1.34.0"
tokio-stream = "0.1.14"
async-trait = "0.1.74"
chrono = { version = "0.4.31", default-features = false, features = ["clock"] }
yrs = "0.18.8"
yrs = "0.19.1"
# Please use the following script to update collab.
# Working directory: frontend
#
@@ -52,7 +52,7 @@ collab-user = { version = "0.2" }
# Run the script:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9884d93aa2805013f36a79c1757174a0b5063065" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "a2f92bb" }
[dependencies]
serde_json.workspace = true
@@ -106,10 +106,26 @@ default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[patch.crates-io]
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5048762" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5048762" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5048762" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5048762" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5048762" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5048762" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5048762" }
# Please use the following script to update collab.
# Working directory: frontend
#
# To update the commit ID, run:
# scripts/tool/update_collab_rev.sh new_rev_id
#
# To switch to the local path, run:
# scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" }
# Working directory: frontend
# To update the commit ID, run:
# scripts/tool/update_local_ai_rev.sh new_rev_id
# ⚠️⚠️⚠️️
appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "0820a0d23f7b813dee505e7e29e88a8561699fe8" }
appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "0820a0d23f7b813dee505e7e29e88a8561699fe8" }
-34
View File
@@ -1,34 +0,0 @@
FROM oven/bun:latest
WORKDIR /app
RUN apt-get update && \
apt-get install -y nginx
RUN bun install cheerio pino axios pino-pretty
COPY . .
RUN addgroup --system nginx && \
adduser --system --no-create-home --disabled-login --ingroup nginx nginx
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
COPY dist/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf
COPY nginx-signed.crt /etc/ssl/certs/nginx-signed.crt
COPY nginx-signed.key /etc/ssl/private/nginx-signed.key
RUN chown -R nginx:nginx /etc/ssl/certs/nginx-signed.crt /etc/ssl/private/nginx-signed.key
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
EXPOSE 80 443
CMD ["/app/start.sh"]
+114 -235
View File
@@ -1,284 +1,163 @@
<div align="center">
<h1><code>AppFlowy Web Project</code></h1>
<div>Welcome to the AppFlowy Web Project, a robust and versatile platform designed to bring the innovative features of
AppFlowy to the web. This project uniquely supports running as a desktop application via Tauri, and offers web
interfaces powered by WebAssembly (WASM). Dive into an exceptional development experience with high performance and
extensive capabilities.</div>
<div align="center">
<h1>AppFlowy Web</h1>
</div>
<img src="https://img.shields.io/badge/React-v18.2.0-blue"/>
<img src="https://img.shields.io/badge/TypeScript-v4.9.5-blue"/>
<img src="https://img.shields.io/badge/Nginx-v1.21.6-brightgreen"/>
<img src="https://img.shields.io/badge/Bun-latest-black"/>
<img src="https://img.shields.io/badge/Docker-v20.10.12-blue"/>
</div>
## 🐑 Features
## 🌟 Introduction
- **Cross-Platform Compatibility**: Seamlessly run on desktop environments with Tauri, and on any web browser through
WASM.
- **High Performance**: Leverage the speed and efficiency of WebAssembly for your web interfaces.
- **Tauri Integration**: Build lightweight, secure, and efficient desktop applications.
- **Flexible Development**: Utilize a wide range of AppFlowy's functionalities in your web or desktop projects.
Welcome to the AppFlowy Web project! This project aims to bring the powerful features of AppFlowy to the web. Whether
you're a developer looking to contribute or a user eager to try out the latest features, this guide will help you get
started.
## 🚀 Getting Started
AppFlowy Web is built with the following technologies:
### 🛠️ Prerequisites
- **React**: A JavaScript library for building user interfaces.
- **TypeScript**: A typed superset of JavaScript that compiles to plain JavaScript.
- **Bun**: A fast all-in-one JavaScript runtime.
- **Nginx**: A high-performance web server.
- **Docker**: A platform to develop, ship, and run applications in containers.
Before you begin, ensure you have the following installed:
### Resource Sharing
- Node.js (v14 or later)
- Rust (latest stable version)
- Tauri prerequisites for your operating system
- PNPM (8.5.0)
To maintain consistency across different platforms, the Web project shares i18n translation files and Icons with the
Flutter project. This ensures a unified user experience and reduces duplication of effort in maintaining these
resources.
### 🏗️ Installation
- **i18n Translation Files**: The translation files are shared to provide a consistent localization experience across
both Web and Flutter applications. The path to the translation files is `frontend/resources/translations/`.
#### Clone the Repository
> The translation files are stored in JSON format and contain translations for different languages. The files are
named according to the language code (e.g., `en.json` for English, `es.json` for Spanish, etc.).
```bash
git clone https://github.com/AppFlowy-IO/AppFlowy
```
- **Icons**: The icon set used in the Web project is the same as the one used in the Flutter project, ensuring visual
consistency. The icons are stored in the `frontend/resources/flowy_icons/` directory.
#### 🌐 Install the frontend dependencies:
Let's dive in and get the project up and running! 🚀
```bash
cd frontend/appflowy_web_app
pnpm install
```
## 🛠 Getting Started
#### 🖥️ Desktop Application (Tauri) (Optional)
### Prerequisites
> **Note**: if you want to run the web app in the browser, skip this step
Before you begin, make sure you have the following installed on your system:
- Follow the instructions [here](https://tauri.app/v1/guides/getting-started/prerequisites/) to install Tauri
- [Node.js](https://nodejs.org/) (v18.6.0) 🌳
- [pnpm](https://pnpm.io/) (package manager) 📦
- [Jest](https://jestjs.io/) (testing framework) 🃏
- [Cypress](https://www.cypress.io/) (end-to-end testing) 🧪
##### Windows and Linux Prerequisites
### Clone the Repository
###### Windows only
First, clone the repository to your local machine:
- Install the Duckscript CLI and vcpkg
```bash
git clone https://github.com/AppFlowy-IO/AppFlowy.git
cd frontend/appflowy_web_app
```
```bash
cargo install --force duckscript_cli
vcpkg integrate install
```
### Install Dependencies
###### Linux only
Install the required dependencies using pnpm:
- Install the required dependencies
```bash
## ensure you have pnpm installed, if not run the following command
# npm install -g pnpm@8.5.0
```bash
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
```
pnpm install
```
- **Get error**: failed to run custom build command for librocksdb-sys v6.11.4
### Configure Environment Variables
```bash
sudo apt install clang
```
Create a `.env` file in the root of the project and add the following environment variables:
##### Install Tauri Dependencies
```bash
AF_BASE_URL=http://localhost:8080
AF_GOTRUE_URL=http://localhost:9999
AF_WS_URL=ws://localhost:8080/ws/v1
```
- Install cargo-make
### Start the Development Server
```bash
cargo install --force cargo-make
```
To start the development server, run the following command:
```bash
pnpm run dev
```
- Install AppFlowy dev tools
### 🚀 Building for Production(Optional)
```bash
# install development tools
# make sure you are in the root directory of the project
cd frontend
cargo make appflowy-tauri-deps-tools
```
if you want to run the production build, use the following commands
- Build the service/dependency
```bash
pnpm run build
pnpm run start
```
```bash
# make sure you are in the root directory of the project
cd frontend/appflowy_web_app
mkdir dist
cd src-tauri
cargo build
```
This will start the application in development mode. Open http://localhost:3000 to view it in the browser.
### 🚀 Running the Application
## 🧪 Running Tests
#### 🌐 Web Application
### Unit Tests
- Run the web application
We use **Jest** for running unit tests. To run the tests, use the following command:
```bash
pnpm run dev
```
- Open your browser and navigate to `http://localhost:3000`, You can now interact with the AppFlowy web application
```bash
pnpm run test:unit
```
#### 🖥️ Desktop Application (Tauri)
This will execute all the unit tests in the project and provide a summary of the results. ✅
**Ensure close web application before running the desktop application**
### Components Tests
- Run the desktop application
We use **Cypress** for end-to-end testing. To run the Cypress tests, use the following command:
```bash
pnpm run tauri:dev
```
- The AppFlowy desktop application will open, and you can interact with it
```bash
pnpm run cypress:open
```
### 🛠️ Development
This will open the Cypress Test Runner where you can run your end-to-end tests. 🧪
#### How to add or modify i18n keys
- Modify the i18n files in `frontend/resources/translations/en.json` to add or modify i18n keys
- Run the following command to update the i18n keys in the application
```bash
pnpm run sync:i18n
```
#### How to modify the theme
Don't modify the theme file in `frontend/appflowy_web_app/src/styles/variables` directly)
- Modify the theme file in `frontend/appflowy_web_app/style-dictionary/tokens/base.json( or dark.json or light.json)` to
add or modify theme keys
- Run the following command to update the theme in the application
```bash
pnpm run css:variables
```
#### How to add or modify the environment variables
- Modify the environment file in `frontend/appflowy_web_app/.env` to add or modify environment variables
#### How to create symlink for the @appflowyinc/client-api-wasm in local development
- Run the following command to create a symlink for the @appflowyinc/client-api-wasm
```bash
# ensure you are in the frontend/appflowy_web_app directory
pnpm run link:client-api $source_path $target_path
# Example
# pnpm run link:client-api ../../../AppFlowy-Cloud/libs/client-api-wasm/pkg ./node_modules/@appflowyinc/client-api-wasm
```
### 📝 About the Project
#### 📁 Directory Structure
- `frontend/appflowy_web_app`: Contains the web application source code
- `frontend/appflowy_web_app/src`: Contains the app entry point and the source code
- `frontend/appflowy_web_app/src/components`: Contains the react components
- `frontend/appflowy_web_app/src/styles`: Contains the styles for the application
- `frontend/appflowy_web_app/src/utils`: Contains the utility functions
- `frontend/appflowy_web_app/src/i18n`: Contains the i18n files
- `frontend/appflowy_web_app/src/assets`: Contains the assets for the application
- `frontend/appflowy_web_app/src/store`: Contains the redux store
- `frontend/appflowy_web_app/src/@types`: Contains the typescript types
- `frontend/appflowy_web_app/src/applications/services`: Contains the services for the application. In vite.config.ts,
we have defined the alias for the services directory for different environments(Tauri/Web)
```typescript
resolve: {
alias: [
// ...
{
find: '$client-services',
replacement: !!process.env.TAURI_PLATFORM
? `${__dirname}/src/application/services/tauri-services`
: `${__dirname}/src/application/services/js-services`,
},
]
}
```
### 📦 Deployment
Use the AppFlowy CI/CD pipeline to deploy the application to the test and production environments.
- Push the changes to the main branch
- Deploy Test Environment
- Automatically, the test environment will be deployed if merged to the main branch or build/test branch
- Deploy Production Environment
- Navigate to the Actions tab
- Click on the workflow and select the Run workflow
- Enter the options
- Click on the Run workflow button
#### 📦 Deployment (Self-Hosted EC2)
##### Pre-requisites
Please ensure you have learned about:
- [Deploy Web application on AWS Cloud using EC2 Instance](https://www.youtube.com/watch?v=gWVIIU1ev0Y)
- [How to Install and Use Rsync Command](https://operavps.com/docs/install-rsync-command-in-linux/)
- [How to Use ssh-keygen to Generate a New SSH Key?](https://www.ssh.com/academy/ssh/keygen)
- [Linux post-installation steps for Docker Engine](https://docs.docker.com/engine/install/linux-postinstall/)
- [Configuring HTTPS servers](https://nginx.org/en/docs/http/configuring_https_servers.html)
And then follow the steps below:
1. Ensure you have the following installed on your server:
- Docker: [Install Docker](https://docs.docker.com/engine/install/)
- Rsync: [Install Rsync](https://operavps.com/docs/install-rsync-command-in-linux/)
2. Create a new user for deploy, and generate an SSH key for the user
```bash
sudo adduser appflowy(or any name)
sudo su - appflowy
mkdir ~/.ssh
chmod 700 ~/.ssh
ssh-keygen -t rsa
chmod 600 ~/.ssh/authorized_keys
# add the user to the docker group, to run docker commands without sudo
sudo usermod -aG docker ${USER}
```
- visit the `~/.ssh/id_rsa` and `~/.ssh/id_rsa.pub` to get the private and public key respectively
- add the public key to the `~/.ssh/authorized_keys` file
- ensure the private key is kept safe
- exit and login back to the server with the new
user: `ssh -i your-existing-key.pem ec2-user@your-instance-public-dns`
3. Clone the AppFlowy repository
4. Set the following secrets in your
repository, have to
know [Using secrets in GitHub Actions](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions)
> Note: Test Environment: prefix the secret with `WEB_TEST_` and Production Environment: prefix the secret with `WEB_`
> for example, `WEB_TEST_SSH_PRIVATE_KEY` and `WEB_SSH_PRIVATE_KEY`
- `SSH_PRIVATE_KEY`: The private key generated in step 2: cat ~/.ssh/id_rsa
- `REMOTE_HOST`: The host of the server: `your-instance-public-dns` or `your-instance-ip`
- `REMOTE_USER`: The user created in step 2: `appflowy`
- `SSL_CERTIFICATE`: The SSL certificate for the
server - [Configuring HTTPS servers](https://nginx.org/en/docs/http/configuring_https_servers.html)
- `SSL_CERTIFICATE_KEY`: The SSL certificate key for the
server - [Configuring HTTPS servers](https://nginx.org/en/docs/http/configuring_https_servers.html)
5. Run the deployment workflow to deploy the application(production or test environment)
> Note: the test server will **automatically** deploy if merged to the main branch or build/test branch
### 🧪 Testing
> We use Cypress for end-to-end testing and component testing - [Cypress](https://www.cypress.io/)
#### 🧪 End-to-End Testing
> to be continued
#### 🧪 Component Testing
Run the following command to run the component tests
Alternatively, to run Cypress tests in the headless mode, use:
```bash
pnpm run test:components
```
Both commands will provide detailed test results and generate a code coverage report.
## 🔄 Development Workflow
### Linting
To maintain code quality, we use **ESLint**. To run the linter and fix any linting errors, use the following command:
```bash
pnpm run lint
```
## 🚀 Production Deployment
Our production deployment process is automated using GitHub Actions. The process involves:
1. **Setting up an AWS EC2 instance**: We use an EC2 instance to host the application.
2. **Installing Docker and Docker Compose**: Docker is installed on the AWS instance.
3. **Configuring SSH Access**: SSH access is set up with a user and password.
4. **Preparing Project Configuration**: We configure `Dockerfile`, `nginx.conf`, and `server.cjs` in the web project.
5. **Using GitHub Actions**: We use the easingthemes/ssh-deploy@main action to deploy the project to the remote server.
The deployment steps include building the Docker image and running the Docker container with the necessary port
mappings:
```bash
docker build -t appflowy-web-app .
docker rm -f appflowy-web-app || true
docker run -d -p 80:80 -p 443:443 --name appflowy-web-app appflowy-web-app
```
The Web server runs on Bun. For more details about Bun, please refer to the [Bun documentation](https://bun.sh/).
-3
View File
@@ -1,3 +0,0 @@
AF_WS_URL=wss://beta.appflowy.cloud/ws/v1
AF_BASE_URL=https://beta.appflowy.cloud
AF_GOTRUE_URL=https://beta.appflowy.cloud/gotrue
@@ -1,11 +0,0 @@
{
"id": "9eebea03-3ed5-4298-86b2-a7f77856d48b",
"name": "workspace",
"icon": "",
"owner": {
"id": 0,
"name": "system"
},
"type": 0,
"workspaceDatabaseId": "375874be-7a4f-4b7c-8b89-1dc9a39838f4"
}
@@ -1,80 +0,0 @@
[
{
"database_id": "037a985f-f369-4c4a-8011-620012850a68",
"created_at": "1713429700",
"views": [
"48c52cf7-bf98-43fa-96ad-b31aade9b071"
]
},
{
"database_id": "daea6aee-9365-4703-a8e2-a2fa6a07b214",
"created_at": "1714449533",
"views": [
"b6347acb-3174-4f0e-98e9-dcce07e5dbf7"
]
},
{
"database_id": "4c658817-20db-4f56-b7f9-0637a22dfeb6",
"created_at": "0",
"views": [
"7d2148fc-cace-4452-9c5c-96e52e6bf8b5",
"e410747b-5f2f-45a0-b2f7-890ad3001355",
"2143e95d-5dcb-4e0f-bb2c-50944e6e019f",
"a5566e49-f156-4168-9b2d-17926c5da329",
"135615fa-66f7-4451-9b54-d7e99445fca4",
"b4e77203-5c8b-48df-bbc5-2e1143eb0e61",
"a6af311f-cbc8-42c2-b801-7115619c3776"
]
},
{
"database_id": "4c658817-20db-4f56-b7f9-0637a22dfeb6",
"created_at": "0",
"views": [
"7d2148fc-cace-4452-9c5c-96e52e6bf8b5",
"e97877f5-c365-4025-9e6a-e590c4b19dbb",
"f0c59921-04ee-4971-995c-79b7fd8c00e2",
"7eb697cd-6a55-40bb-96ac-0d4a3bc924b2"
]
},
{
"database_id": "ee63da2b-aa2a-4d0b-aab0-59008635363a",
"created_at": "0",
"views": [
"2c1ee95a-1b09-4a1f-8d5e-501bc4861a9d",
"91ea7c08-f6b3-4b81-aa1e-d3664686186f"
]
},
{
"database_id": "e788f014-d0d3-4dfe-81ef-aa1ebb4d6366",
"created_at": "0",
"views": [
"1b0e322d-4909-4c63-914a-d034fc363097",
"350f425b-b671-4e2d-8182-5998a6e62924"
]
},
{
"database_id": "ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d",
"created_at": "0",
"views": [
"0ce13415-6cce-4497-94c6-475ad96c249e",
"e4c89421-12b2-4d02-863d-20949eec9271"
]
},
{
"database_id": "ce267d12-3b61-4ebb-bb03-d65272f5f817",
"created_at": "0",
"views": [
"ee3ae8ce-959a-4df3-8734-40b535ff88e3",
"66a6f3bc-c78f-4f74-a09e-08d4717bf1fd",
"2bf50c03-f41f-4363-b5b1-101216a6c5cc"
]
},
{
"database_id": "87bc006e-c1eb-47fd-9ac6-e39b17956369",
"created_at": "0",
"views": [
"7f233be4-1b4d-46b2-bcfc-f341b8d75267",
"a734a068-e73d-4b4b-853c-4daffea389c0"
]
}
]
@@ -1 +0,0 @@
{"data":{"state_vector":[6,248,208,217,159,7,16,226,250,246,177,3,17,211,142,141,147,5,8,196,148,203,38,26,230,150,209,139,14,193,6,167,238,246,72,86],"doc_state":[6,177,5,230,150,209,139,14,0,0,2,0,1,0,5,39,0,196,148,203,38,4,6,122,45,72,76,78,68,2,4,0,230,150,209,139,14,8,4,49,49,49,49,39,0,196,148,203,38,1,6,54,70,57,105,75,107,1,40,0,230,150,209,139,14,13,2,105,100,1,119,6,54,70,57,105,75,107,40,0,230,150,209,139,14,13,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,230,150,209,139,14,13,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,13,8,99,104,105,108,100,114,101,110,1,119,6,97,118,105,69,115,45,33,0,230,150,209,139,14,13,4,100,97,116,97,1,40,0,230,150,209,139,14,13,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,13,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,97,118,105,69,115,45,0,72,196,148,203,38,24,1,119,6,54,70,57,105,75,107,39,0,196,148,203,38,4,6,69,115,57,50,84,74,2,33,0,196,148,203,38,1,6,98,71,84,69,74,116,1,0,7,33,0,196,148,203,38,3,6,87,56,66,102,66,110,1,129,196,148,203,38,24,1,168,230,150,209,139,14,18,1,119,39,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,49,49,49,34,125,93,44,34,108,101,118,101,108,34,58,50,125,4,0,230,150,209,139,14,23,1,35,0,1,132,230,150,209,139,14,35,1,35,0,1,132,230,150,209,139,14,37,1,35,0,1,132,230,150,209,139,14,39,1,35,0,1,39,0,196,148,203,38,4,6,56,76,76,113,48,56,2,39,0,196,148,203,38,1,6,56,70,105,117,112,98,1,40,0,230,150,209,139,14,44,2,105,100,1,119,6,56,70,105,117,112,98,40,0,230,150,209,139,14,44,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,230,150,209,139,14,44,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,44,8,99,104,105,108,100,114,101,110,1,119,6,49,66,67,68,107,53,33,0,230,150,209,139,14,44,4,100,97,116,97,1,40,0,230,150,209,139,14,44,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,44,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,49,66,67,68,107,53,0,200,196,148,203,38,24,230,150,209,139,14,33,1,119,6,56,70,105,117,112,98,4,0,230,150,209,139,14,43,1,50,161,230,150,209,139,14,49,1,132,230,150,209,139,14,54,1,50,161,230,150,209,139,14,55,1,132,230,150,209,139,14,56,1,50,161,230,150,209,139,14,57,1,132,230,150,209,139,14,58,1,50,161,230,150,209,139,14,59,1,39,0,196,148,203,38,4,6,103,76,57,104,70,45,2,39,0,196,148,203,38,1,6,72,99,71,82,109,56,1,40,0,230,150,209,139,14,63,2,105,100,1,119,6,72,99,71,82,109,56,40,0,230,150,209,139,14,63,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,230,150,209,139,14,63,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,63,8,99,104,105,108,100,114,101,110,1,119,6,50,114,117,49,72,109,33,0,230,150,209,139,14,63,4,100,97,116,97,1,40,0,230,150,209,139,14,63,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,63,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,50,114,117,49,72,109,0,136,230,150,209,139,14,33,1,119,6,72,99,71,82,109,56,168,230,150,209,139,14,61,1,119,39,123,34,108,101,118,101,108,34,58,52,44,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,50,50,50,50,34,125,93,125,1,0,230,150,209,139,14,62,1,161,230,150,209,139,14,68,1,129,230,150,209,139,14,74,1,161,230,150,209,139,14,75,1,129,230,150,209,139,14,76,2,161,230,150,209,139,14,77,1,129,230,150,209,139,14,79,1,161,230,150,209,139,14,80,1,129,230,150,209,139,14,81,1,161,230,150,209,139,14,82,1,68,230,150,209,139,14,74,6,228,189,160,229,165,189,168,230,150,209,139,14,84,1,119,31,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,228,189,160,229,165,189,34,125,93,125,39,0,196,148,203,38,4,6,113,111,68,56,109,111,2,33,0,196,148,203,38,1,6,113,118,66,98,82,83,1,0,7,33,0,196,148,203,38,3,6,118,76,97,51,57,90,1,129,230,150,209,139,14,72,1,1,0,230,150,209,139,14,88,1,0,1,129,230,150,209,139,14,99,1,0,1,129,230,150,209,139,14,101,1,0,4,132,230,150,209,139,14,103,1,91,0,1,132,230,150,209,139,14,108,1,93,0,1,39,0,196,148,203,38,4,6,69,114,99,104,55,72,2,39,0,196,148,203,38,1,6,51,111,95,70,117,67,1,40,0,230,150,209,139,14,113,2,105,100,1,119,6,51,111,95,70,117,67,40,0,230,150,209,139,14,113,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,230,150,209,139,14,113,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,113,8,99,104,105,108,100,114,101,110,1,119,6,68,56,109,75,57,97,33,0,230,150,209,139,14,113,4,100,97,116,97,1,40,0,230,150,209,139,14,113,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,113,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,68,56,109,75,57,97,0,200,230,150,209,139,14,72,230,150,209,139,14,98,1,119,6,51,111,95,70,117,67,4,0,230,150,209,139,14,112,1,49,161,230,150,209,139,14,118,1,132,230,150,209,139,14,123,1,50,161,230,150,209,139,14,Line truncated
@@ -1 +0,0 @@
{"data":{"state_vector":[160,1,128,252,161,128,4,33,130,180,254,251,6,18,135,232,133,203,9,15,135,240,136,178,10,45,137,226,192,199,6,20,138,202,240,189,2,134,1,139,240,196,145,14,138,2,139,152,215,249,10,34,141,216,158,150,1,3,142,130,192,134,11,3,143,184,153,180,6,6,140,228,230,243,1,2,145,190,137,224,10,2,145,144,146,185,5,12,140,152,206,145,6,165,1,135,166,246,235,6,3,147,206,229,235,1,3,137,164,190,210,1,39,152,158,185,230,10,7,153,130,203,161,6,97,152,252,186,192,1,2,154,244,246,165,8,198,1,156,148,170,169,10,16,157,240,144,231,2,3,158,156,181,152,10,49,158,182,250,251,9,15,160,192,253,131,5,3,161,178,132,150,11,101,159,156,204,250,6,27,163,236,177,169,4,12,164,188,201,172,1,3,158,184,218,165,3,38,162,238,198,212,7,3,170,140,240,234,9,31,171,204,155,217,8,3,171,142,166,254,1,6,173,252,148,184,13,64,176,154,159,227,1,20,178,162,190,217,10,3,179,252,154,44,3,180,230,210,212,13,7,182,172,247,194,5,3,183,226,184,158,8,8,187,220,199,239,8,70,195,242,227,194,8,3,196,154,250,183,6,62,197,254,154,201,10,3,200,142,208,241,4,157,1,202,160,246,212,1,6,203,184,221,173,11,37,206,220,129,131,4,21,207,228,238,162,8,50,209,250,203,254,15,3,210,228,153,221,12,3,211,202,217,232,12,7,211,166,203,229,4,195,1,214,168,149,214,3,15,219,220,239,171,8,10,225,248,138,176,2,49,226,212,179,248,2,15,229,154,128,197,12,24,234,182,182,157,9,8,235,178,165,206,5,72,234,156,130,211,12,12,241,130,161,205,7,12,244,226,228,149,2,30,245,220,194,52,39,247,200,243,247,14,100,248,136,168,181,1,3,248,210,237,129,13,2,250,198,166,187,7,2,248,196,187,185,10,31,252,218,241,167,14,3,255,140,248,220,6,3,128,211,179,216,2,10,133,159,138,205,12,131,1,135,167,156,250,14,16,135,193,208,135,7,16,141,245,194,142,11,7,141,205,220,149,4,3,143,131,148,152,6,15,141,171,170,217,4,2,145,159,164,217,14,3,149,129,169,191,12,16,149,249,242,175,4,31,149,189,189,215,8,3,149,161,132,184,14,221,2,154,243,157,196,14,12,154,193,208,134,10,9,155,165,205,152,11,3,154,235,215,240,4,13,155,159,180,195,15,6,157,207,243,216,6,2,161,239,241,154,13,106,162,159,252,196,11,3,164,155,139,169,7,35,165,139,157,171,15,103,164,203,250,235,13,7,167,131,133,162,9,11,166,201,221,141,13,72,169,197,188,221,3,3,170,255,211,105,38,166,203,155,46,6,173,187,245,170,14,46,174,151,139,93,179,2,175,225,172,150,8,11,175,147,217,214,1,33,177,161,136,243,11,10,178,203,205,182,4,1,175,205,156,228,6,10,180,205,189,133,13,20,181,175,219,209,12,10,182,143,233,195,4,111,177,219,160,167,7,4,184,201,188,172,10,3,184,231,170,67,6,186,197,166,179,15,7,187,173,214,176,15,3,188,171,136,250,8,21,188,237,223,145,6,26,190,139,191,155,1,2,191,157,147,233,9,32,193,249,142,142,4,17,198,189,216,175,6,23,200,205,214,172,10,30,201,129,238,197,4,84,201,191,159,147,14,17,200,203,236,184,2,35,200,159,185,206,9,8,205,149,231,236,11,68,213,161,242,209,13,35,214,139,213,136,8,66,213,255,156,145,1,2,219,227,140,137,6,34,221,147,167,147,15,177,2,222,205,223,235,7,31,223,209,193,147,11,81,227,209,197,253,2,14,229,153,197,202,7,241,6,231,139,244,188,8,13,232,207,157,148,2,2,233,165,139,246,14,43,233,247,183,159,1,4,235,225,184,133,10,3,234,153,236,158,4,73,234,187,164,181,1,24,231,189,134,196,8,77,239,199,189,146,3,24,240,149,229,225,6,15,241,155,213,233,1,52,240,253,240,229,1,79,243,239,182,181,13,30,236,229,225,232,8,116,246,185,174,192,6,90,248,153,216,10,3,251,189,220,155,14,3,252,163,130,200,6,30,253,205,145,137,11,10,252,171,209,175,15,4,255,255,147,249,10,3],"doc_state":[160,1,3,209,250,203,254,15,0,161,226,212,179,248,2,9,1,161,226,212,179,248,2,10,1,136,174,151,139,93,249,1,1,118,2,2,105,100,119,36,50,54,100,53,99,56,99,49,45,49,99,54,54,45,52,53,57,99,45,98,99,54,99,45,102,52,100,97,49,97,54,54,51,51,52,56,9,116,105,109,101,115,116,97,109,112,122,0,0,0,0,102,83,246,78,2,155,159,180,195,15,0,161,198,189,216,175,6,22,1,161,253,205,145,137,11,9,5,7,186,197,166,179,15,0,161,161,178,132,150,11,98,1,161,161,178,132,150,11,99,1,129,161,178,132,150,11,100,1,161,161,178,132,150,11,97,1,161,161,178,132,150,11,94,1,161,161,178,132,150,11,95,1,129,186,197,166,179,15,2,1,3,187,173,214,176,15,0,168,244,226,228,149,2,27,1,122,4,56,115,160,190,64,16,0,168,244,226,228,149,2,28,1,122,0,0,0,0,102,32,153,171,136,248,153,216,10,2,1,118,2,2,105,100,119,36,48,53,51,51,50,98,97,52,45,97,54,57,48,45,52,50,57,51,45,57,56,54,54,45,56,52,100,97,99,55,102,101,50,97,101,97,9,116,105,109,101,115,116,97,109,112,122,0,0,0,0,102,32,153,171,1,252,171,209,175,15,0,161,128,211,179,216,2,9,4,103,165,139,157,171,15,0,8,0,201,129,238,197,4,52,1,118,1,2,105,100,119,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,168,201,129,238,197,4,51,1,122,0,0,0,0,102,74,244,14,161,170,140,240,234,9,22,1,39,0,203,184,221,173,11,1,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,1,40,0,165,139,157,171,15,3,2,105,100,1,119,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,40,0,Line truncated
@@ -1,66 +0,0 @@
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTI4Mjk2MjAsImlhdCI6MTcxMjgyNjAyMCwic3ViIjoiY2JmZjA2MGEtMTk2ZC00MTVhLWFhODAtNzU5YzAxODg2NDY2IiwiZW1haWwiOiJsdUBhcHBmbG93eS5pbyIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZ29vZ2xlIiwicHJvdmlkZXJzIjpbImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTEhabVZBczRTb0ZlVFFuWG5CU2JiNTBBVXF0YktHNWx5MGllVHZCSklYZ1o3UmdRPXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImFwcGZsb3d5LmlvIn0sImVtYWlsIjoibHVAYXBwZmxvd3kuaW8iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTHUgSGUiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTHUgSGUiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NMSFptVkFzNFNvRmVUUW5YbkJTYmI1MEFVcXRiS0c1bHkwaWVUdkJKSVhnWjdSZ1E9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSIsInN1YiI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSJ9LCJyb2xlIjoiIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoicGFzc3dvcmQiLCJ0aW1lc3RhbXAiOjE3MTI4MjYwMjB9XSwic2Vzc2lvbl9pZCI6ImJmMzE5OTRlLTQwMTgtNDhjMS05Yzc0LWVmYzkyMGNjOWQ0NSJ9.QeTrRhsnBjBL1GUS3TIWOgU1SPM6RcaWwxZdMVfcFBU",
"token_type": "bearer",
"expires_in": 3600,
"expires_at": 4869016461,
"refresh_token": "71vp1jJnSAVluZKaXkhG1A",
"user": {
"id": "cbff060a-196d-415a-aa80-759c01886466",
"aud": "",
"role": "",
"email": "lu@appflowy.io",
"email_confirmed_at": "2024-03-13T10:49:53.165361Z",
"phone": "",
"confirmed_at": "2024-03-13T10:49:53.165361Z",
"last_sign_in_at": "2024-04-11T09:00:20.547468985Z",
"app_metadata": {
"provider": "google",
"providers": [
"google"
]
},
"user_metadata": {
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"custom_claims": {
"hd": "appflowy.io"
},
"email": "lu@appflowy.io",
"email_verified": true,
"full_name": "Lu He",
"iss": "https://accounts.google.com",
"name": "Lu He",
"phone_verified": false,
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"provider_id": "101169250829554028381",
"sub": "101169250829554028381"
},
"identities": [
{
"identity_id": "e4cf8b69-7f80-42e9-aed2-e25132ad0178",
"id": "101169250829554028381",
"user_id": "cbff060a-196d-415a-aa80-759c01886466",
"identity_data": {
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"custom_claims": {
"hd": "appflowy.io"
},
"email": "lu@appflowy.io",
"email_verified": true,
"full_name": "Lu He",
"iss": "https://accounts.google.com",
"name": "Lu He",
"phone_verified": false,
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"provider_id": "101169250829554028381",
"sub": "101169250829554028381"
},
"provider": "google",
"last_sign_in_at": "2024-03-13T07:22:43.110504Z",
"created_at": "2024-03-13T07:22:43.110543Z",
"updated_at": "2024-04-04T06:15:14.03093Z"
}
],
"created_at": "2024-03-13T07:22:43.102586Z",
"updated_at": "2024-04-11T09:00:20.551485Z"
}
}
@@ -1,17 +0,0 @@
{
"data": {
"uid": 304120109071339520,
"uuid": "cbff060a-196d-415a-aa80-759c01886466",
"email": "lu@appflowy.io",
"password": "",
"name": "Kilu",
"metadata": {
"icon_url": "🇽🇰"
},
"encryption_sign": null,
"latest_workspace_id": "fcb503f9-9287-4de4-8de0-ea191e680968",
"updated_at": 1710421586
},
"code": 0,
"message": "Operation completed successfully."
}
@@ -1 +0,0 @@
{"data":{"user_profile":{"uid":304120109071339520,"uuid":"cbff060a-196d-415a-aa80-759c01886466","email":"lu@appflowy.io","password":"","name":"Kilu","metadata":{"icon_url":"🇽🇰"},"encryption_sign":null,"latest_workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","updated_at":1715847453},"visiting_workspace":{"workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","database_storage_id":"375874be-7a4f-4b7c-8b89-1dc9a39838f4","owner_uid":304120109071339520,"owner_name":"Kilu","workspace_type":0,"workspace_name":"Kilu Works","created_at":"2024-03-13T07:23:10.275174Z","icon":"😆"},"workspaces":[{"workspace_id":"81570fa8-8be9-4b2d-9f1c-1ef4f34079a8","database_storage_id":"6c1f1a2c-e8d5-4bc2-917f-495bce862abb","owner_uid":311828434584080384,"owner_name":"Zack Zi Xiang Fu","workspace_type":0,"workspace_name":"My Workspace","created_at":"2024-04-03T13:53:18.295918Z","icon":""},{"workspace_id":"fcb503f9-9287-4de4-8de0-ea191e680968","database_storage_id":"ae1b82a5-2b93-45c7-901a-f9357c544534","owner_uid":276169796100296704,"owner_name":"Annie Anqi Wang","workspace_type":0,"workspace_name":"AppFlowy Test","created_at":"2023-12-27T04:18:36.372013Z","icon":""},{"workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","database_storage_id":"375874be-7a4f-4b7c-8b89-1dc9a39838f4","owner_uid":304120109071339520,"owner_name":"Kilu","workspace_type":0,"workspace_name":"Kilu Works","created_at":"2024-03-13T07:23:10.275174Z","icon":"😆"}]},"code":0,"message":"Operation completed successfully."}
@@ -1,6 +0,0 @@
{
"code": 0,
"data": {
"is_new": false
}
}
@@ -25,96 +25,9 @@
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
import { YDoc } from '@/application/collab.type';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { JSDatabaseService } from '@/application/services/js-services/database.service';
import { JSDocumentService } from '@/application/services/js-services/document.service';
import { applyYDoc } from '@/application/ydoc/apply';
import * as Y from 'yjs';
Cypress.Commands.add('mockAPI', () => {
cy.fixture('sign_in_success').then((json) => {
cy.intercept('GET', `/api/user/verify/${json.access_token}`, {
fixture: 'verify_token',
}).as('verifyToken');
cy.intercept('POST', '/gotrue/token?grant_type=password', json).as('loginSuccess');
cy.intercept('POST', '/gotrue/token?grant_type=refresh_token', json).as('refreshToken');
});
cy.intercept('GET', '/api/user/profile', { fixture: 'user' }).as('getUserProfile');
cy.intercept('GET', '/api/user/workspace', { fixture: 'user_workspace' }).as('getUserWorkspace');
// Mock the API
});
// Example use:
// beforeEach(() => {
// cy.mockAPI();
// });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockCurrentWorkspace', () => {
cy.fixture('current_workspace').then((workspace) => {
cy.stub(JSDatabaseService.prototype, 'currentWorkspace').resolves(workspace);
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockGetWorkspaceDatabases', () => {
cy.fixture('database/databases').then((databases) => {
cy.stub(JSDatabaseService.prototype, 'getWorkspaceDatabases').resolves(databases);
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockDatabase', () => {
cy.mockCurrentWorkspace();
cy.mockGetWorkspaceDatabases();
const ids = [
'4c658817-20db-4f56-b7f9-0637a22dfeb6',
'ce267d12-3b61-4ebb-bb03-d65272f5f817',
'ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d',
];
const mockOpenDatabase = cy.stub(JSDatabaseService.prototype, 'openDatabase');
ids.forEach((id) => {
cy.fixture(`database/${id}`).then((database) => {
cy.fixture(`database/rows/${id}`).then((rows) => {
const doc = new Y.Doc();
const rootRowsDoc = new Y.Doc();
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const databaseState = new Uint8Array(database.data.doc_state);
applyYDoc(doc, databaseState);
Object.keys(rows).forEach((key) => {
const data = rows[key];
const rowDoc = new Y.Doc();
applyYDoc(rowDoc, new Uint8Array(data));
rowsFolder.set(key, rowDoc);
});
mockOpenDatabase.withArgs(id).resolves({
databaseDoc: doc,
rows: rowsFolder,
});
});
});
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockDocument', (id: string) => {
cy.fixture(`document/${id}`).then((subDocument) => {
const doc = new Y.Doc();
const state = new Uint8Array(subDocument.data.doc_state);
applyYDoc(doc, state);
cy.stub(JSDocumentService.prototype, 'openDocument').withArgs(id).resolves(doc);
});
});
export {};
@@ -7,6 +7,9 @@
<title>Components App</title>
</head>
<body id="body">
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js"></script>
<div data-cy-root></div>
</body>
</html>
@@ -0,0 +1,32 @@
FROM oven/bun:latest
WORKDIR /app
RUN apt-get update && \
apt-get install -y nginx supervisor
RUN bun install cheerio pino pino-pretty
COPY . .
COPY supervisord.conf /app/supervisord.conf
RUN addgroup --system nginx && \
adduser --system --no-create-home --disabled-login --ingroup nginx nginx
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
COPY dist /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
RUN chmod +x /app/supervisord.conf
EXPOSE 80
CMD ["supervisord", "-c", "/app/supervisord.conf"]
@@ -0,0 +1,28 @@
if [ -z "$1" ]; then
echo "No port number provided"
exit 1
fi
PORT=$1
echo "Starting deployment on port $PORT"
rm -rf deploy
tar -xzf build-output.tar.gz
rm -rf build-output.tar.gz
mv dist deploy/dist
mv .env deploy/.env
cd deploy
docker system prune -f
docker build -t appflowy-web-app-"$PORT" .
docker rm -f appflowy-web-app-"$PORT" || true
docker run -d --env-file .env -p "$PORT":80 --restart always --name appflowy-web-app-"$PORT" appflowy-web-app-"$PORT"
@@ -40,29 +40,6 @@ http {
server {
listen 80;
server_name localhost;
#server_name appflowy.com *.appflowy.com;
location / {
return 301 https://$host$request_uri;
}
}
# Additional server block for HTTPS
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name localhost;
#server_name appflowy.com *.appflowy.com;
ssl_certificate /etc/ssl/certs/nginx-signed.crt;
ssl_certificate_key /etc/ssl/private/nginx-signed.key;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://localhost:3000;
@@ -89,6 +66,18 @@ http {
access_log off;
}
location /og-image.png {
root /usr/share/nginx/html;
expires 30d;
access_log off;
}
location /covers/ {
root /usr/share/nginx/html;
expires 30d;
access_log off;
}
error_page 404 /404.html;
location = /404.html {
root /usr/share/nginx/html;
@@ -98,5 +87,6 @@ http {
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
}
+193
View File
@@ -0,0 +1,193 @@
import path from 'path';
import * as fs from 'fs';
import pino from 'pino';
import { type CheerioAPI, load } from 'cheerio';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
import { fetch } from 'bun';
const distDir = path.join(__dirname, 'dist');
const indexPath = path.join(distDir, 'index.html');
const baseURL = process.env.AF_BASE_URL as string;
const defaultSite = 'https://appflowy.io';
const setOrUpdateMetaTag = ($: CheerioAPI, selector: string, attribute: string, content: string) => {
if ($(selector).length === 0) {
$('head').append(`<meta ${attribute}="${selector.match(/\[(.*?)\]/)?.[1]}" content="${content}">`);
} else {
$(selector).attr('content', content);
}
};
const logger = pino({
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
destination: `${__dirname}/pino-logger.log`,
},
},
level: 'info',
});
const logRequestTimer = (req: Request) => {
const start = Date.now();
const pathname = new URL(req.url).pathname;
logger.info(`Incoming request: ${pathname}`);
return () => {
const duration = Date.now() - start;
logger.info(`Request for ${pathname} took ${duration}ms`);
};
};
const fetchMetaData = async (url: string) => {
logger.info(`Fetching meta data from ${url}`);
try {
const response = await fetch(url, {
verbose: true,
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
} catch (error) {
logger.error(`Error fetching meta data ${error}`);
return null;
}
};
const createServer = async (req: Request) => {
const timer = logRequestTimer(req);
const reqUrl = new URL(req.url);
const hostname = req.headers.get('host');
logger.info(`Request URL: ${hostname}${reqUrl.pathname}`);
const [namespace, publishName] = reqUrl.pathname.slice(1).split('/');
logger.info(`Namespace: ${namespace}, Publish Name: ${publishName}`);
if (req.method === 'GET') {
if (namespace === '' || !publishName) {
timer();
return new Response(null, {
status: 302,
headers: {
Location: defaultSite,
},
});
}
let metaData;
try {
metaData = await fetchMetaData(`${baseURL}/api/workspace/published/${namespace}/${publishName}`);
} catch (error) {
logger.error(`Error fetching meta data: ${error}`);
}
const htmlData = fs.readFileSync(indexPath, 'utf8');
const $ = load(htmlData);
const description = 'Write, share, and publish docs quickly on AppFlowy.\nGet started for free.';
let title = 'AppFlowy';
const url = `https://${hostname}${reqUrl.pathname}`;
let image = '/og-image.png';
let favicon = '/appflowy.svg';
try {
if (metaData && metaData.view) {
const view = metaData.view;
const emoji = view.icon.value;
const titleList = [];
if (emoji) {
const emojiCode = emoji.codePointAt(0).toString(16); // Convert emoji to hex code
const baseUrl = 'https://raw.githubusercontent.com/googlefonts/noto-emoji/main/svg/emoji_u';
favicon = `${baseUrl}${emojiCode}.svg`;
}
if (view.name) {
titleList.push(view.name);
titleList.push('|');
}
titleList.push('AppFlowy');
title = titleList.join(' ');
try {
const cover = view.extra ? JSON.parse(view.extra)?.cover : null;
if (cover) {
if (['unsplash', 'custom'].includes(cover.type)) {
image = cover.value;
} else if (cover.type === 'built_in') {
image = `/covers/m_cover_image_${cover.value}.png`;
}
}
} catch (_) {
// Do nothing
}
}
} catch (error) {
logger.error(`Error injecting meta data: ${error}`);
}
$('title').text(title);
$('link[rel="icon"]').attr('href', favicon);
setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description);
setOrUpdateMetaTag($, 'meta[property="og:title"]', 'property', title);
setOrUpdateMetaTag($, 'meta[property="og:description"]', 'property', description);
setOrUpdateMetaTag($, 'meta[property="og:image"]', 'property', image);
setOrUpdateMetaTag($, 'meta[property="og:url"]', 'property', url);
setOrUpdateMetaTag($, 'meta[property="og:site_name"]', 'property', 'AppFlowy');
setOrUpdateMetaTag($, 'meta[property="og:type"]', 'property', 'website');
setOrUpdateMetaTag($, 'meta[name="twitter:card"]', 'name', 'summary_large_image');
setOrUpdateMetaTag($, 'meta[name="twitter:title"]', 'name', title);
setOrUpdateMetaTag($, 'meta[name="twitter:description"]', 'name', description);
setOrUpdateMetaTag($, 'meta[name="twitter:image"]', 'name', image);
setOrUpdateMetaTag($, 'meta[name="twitter:site"]', 'name', '@appflowy');
timer();
return new Response($.html(), {
headers: { 'Content-Type': 'text/html' },
});
} else {
timer();
logger.error({ message: 'Method not allowed', method: req.method });
return new Response('Method not allowed', { status: 405 });
}
};
declare const Bun: {
serve: (options: { port: number; fetch: typeof createServer; error: (err: Error) => Response }) => void;
};
const start = () => {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
Bun.serve({
port: 3000,
fetch: createServer,
error: (err) => {
logger.error(`Internal Server Error: ${err}`);
return new Response('Internal Server Error', { status: 500 });
},
});
logger.info('Server is running on port 3000');
logger.info(`Base URL: ${baseURL}`);
} catch (err) {
logger.error(err);
process.exit(1);
}
};
start();
export {};
@@ -1,10 +1,12 @@
#!/bin/bash
#!/usr/bin/env bash
# Start the frontend server
bun run server.cjs &
# Start the nginx server
service nginx start
# Start the frontend server
bun run server.ts
tail -f /dev/null
@@ -0,0 +1,9 @@
[supervisord]
nodaemon=true
[program:bun]
command=sh /app/start.sh
autostart=true
autorestart=true
stderr_logfile=/var/log/bun.err.log
stdout_logfile=/var/log/bun.out.log
+7 -2
View File
@@ -13,7 +13,7 @@
content="AppFlowy is an AI collaborative workspace where you achieve more without losing control of your data"
/>
<meta property="og:image"
content="https://d3uafhn8yrvdfn.cloudfront.net/website/production/_next/static/media/og-image.e347bfb5.png"
content="/_next/static/media/og-image.e347bfb5.png"
/>
<meta property="og:url" content="https://appflowy.com" />
<meta name="twitter:card" content="summary_large_image" />
@@ -22,15 +22,17 @@
content="AppFlowy is an AI collaborative workspace where you achieve more without losing control of your data"
/>
<meta name="twitter:image"
content="https://d3uafhn8yrvdfn.cloudfront.net/website/production/_next/static/media/og-image.e347bfb5.png"
content="/_next/static/media/og-image.e347bfb5.png"
/>
<meta name="twitter:site" content="@appflowy" />
<meta name="twitter:creator" content="@appflowy" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap" rel="stylesheet">
</head>
<body id="body">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const userAgent = window.navigator.userAgent.toLowerCase();
@@ -62,5 +64,8 @@
}
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js"></script>
</body>
</html>
+7 -1
View File
@@ -24,7 +24,7 @@
"coverage": "pnpm run test:unit && pnpm run test:components"
},
"dependencies": {
"@appflowyinc/client-api-wasm": "0.0.3",
"@appflowyinc/client-api-wasm": "0.1.1",
"@atlaskit/primitives": "^5.5.3",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
@@ -43,6 +43,8 @@
"colorthief": "^2.4.0",
"dayjs": "^1.11.9",
"decimal.js": "^10.4.3",
"dexie": "^4.0.7",
"dexie-react-hooks": "^1.1.7",
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.2.1",
"events": "^3.3.0",
@@ -56,6 +58,7 @@
"katex": "^0.16.7",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.0",
"notistack": "^3.0.1",
"numeral": "^2.0.6",
"prismjs": "^1.29.0",
"protoc-gen-ts": "0.8.7",
@@ -136,6 +139,7 @@
"autoprefixer": "^10.4.13",
"babel-jest": "^29.6.2",
"chalk": "^4.1.2",
"cheerio": "1.0.0-rc.12",
"cross-env": "^7.0.3",
"cypress": "^13.7.2",
"eslint": "^8.57.0",
@@ -145,6 +149,8 @@
"istanbul-lib-coverage": "^3.2.2",
"jest-environment-jsdom": "^29.6.2",
"nyc": "^15.1.0",
"pino": "^9.2.0",
"pino-pretty": "^11.2.1",
"postcss": "^8.4.21",
"prettier": "2.8.4",
"prettier-plugin-tailwindcss": "^0.2.2",
+256 -8
View File
@@ -1,9 +1,13 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@appflowyinc/client-api-wasm':
specifier: 0.0.3
version: 0.0.3
specifier: 0.1.1
version: 0.1.1
'@atlaskit/primitives':
specifier: ^5.5.3
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
@@ -58,6 +62,12 @@ dependencies:
decimal.js:
specifier: ^10.4.3
version: 10.4.3
dexie:
specifier: ^4.0.7
version: 4.0.7
dexie-react-hooks:
specifier: ^1.1.7
version: 1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0)
emoji-mart:
specifier: ^5.5.2
version: 5.6.0
@@ -97,6 +107,9 @@ dependencies:
nanoid:
specifier: ^4.0.0
version: 4.0.2
notistack:
specifier: ^3.0.1
version: 3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0)
numeral:
specifier: ^2.0.6
version: 2.0.6
@@ -333,6 +346,9 @@ devDependencies:
chalk:
specifier: ^4.1.2
version: 4.1.2
cheerio:
specifier: 1.0.0-rc.12
version: 1.0.0-rc.12
cross-env:
specifier: ^7.0.3
version: 7.0.3
@@ -360,6 +376,12 @@ devDependencies:
nyc:
specifier: ^15.1.0
version: 15.1.0
pino:
specifier: ^9.2.0
version: 9.2.0
pino-pretty:
specifier: ^11.2.1
version: 11.2.1
postcss:
specifier: ^8.4.21
version: 8.4.21
@@ -429,8 +451,8 @@ packages:
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25
/@appflowyinc/client-api-wasm@0.0.3:
resolution: {integrity: sha512-ARjLhiDZ8MiZ9egWDbAX9VAdXXS30av+InCPLrS/iqCMYrhuuU9rxS9jQeNEB7jucFrj158gBRusimFN7P/lyw==}
/@appflowyinc/client-api-wasm@0.1.1:
resolution: {integrity: sha512-7+/TCmzMi9KrxX3HFLJv9R6ON2AO5xQavV547ii7RZM8+5bZJakuf6+pnyCzOquQX07q3ZYwJCa3MIgDvficaA==}
dev: false
/@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0):
@@ -4580,6 +4602,13 @@ packages:
deprecated: Use your platform's native atob() and btoa() methods instead
dev: true
/abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
dependencies:
event-target-shim: 5.0.1
dev: true
/acorn-globals@7.0.1:
resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==}
dependencies:
@@ -4886,6 +4915,11 @@ packages:
engines: {node: '>= 4.0.0'}
dev: true
/atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
dev: true
/autoprefixer@10.4.13(postcss@8.4.21):
resolution: {integrity: sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==}
engines: {node: ^10 || ^12 || >=14}
@@ -5180,6 +5214,13 @@ packages:
ieee754: 1.2.1
dev: true
/buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
dev: true
/cachedir@2.4.0:
resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==}
engines: {node: '>=6'}
@@ -5289,6 +5330,30 @@ packages:
engines: {node: '>= 0.8.0'}
dev: true
/cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
dependencies:
boolbase: 1.0.0
css-select: 5.1.0
css-what: 6.1.0
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
dev: true
/cheerio@1.0.0-rc.12:
resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==}
engines: {node: '>= 6'}
dependencies:
cheerio-select: 2.1.0
dom-serializer: 2.0.0
domhandler: 5.0.3
domutils: 3.1.0
htmlparser2: 8.0.2
parse5: 7.1.2
parse5-htmlparser2-tree-adapter: 7.0.0
dev: true
/chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -5739,6 +5804,10 @@ packages:
dependencies:
'@babel/runtime': 7.24.1
/dateformat@4.6.3:
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
dev: true
/dayjs@1.11.10:
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
dev: true
@@ -5860,6 +5929,22 @@ packages:
minimist: 1.2.8
dev: true
/dexie-react-hooks@1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0):
resolution: {integrity: sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==}
peerDependencies:
'@types/react': '>=16'
dexie: ^3.2 || ^4.0.1-alpha
react: '>=16'
dependencies:
'@types/react': 18.2.66
dexie: 4.0.7
react: 18.2.0
dev: false
/dexie@4.0.7:
resolution: {integrity: sha512-M+Lo6rk4pekIfrc2T0o2tvVJwL6EAAM/B78DNfb8aaxFVoI1f8/rz5KTxuAnApkwqTSuxx7T5t0RKH7qprapGg==}
dev: false
/didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dev: true
@@ -6372,6 +6457,11 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
dev: true
/eventemitter2@6.4.7:
resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==}
dev: true
@@ -6455,6 +6545,10 @@ packages:
resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==}
engines: {'0': node >=0.6.0}
/fast-copy@3.0.2:
resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==}
dev: true
/fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -6492,6 +6586,15 @@ packages:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
dev: true
/fast-redact@3.5.0:
resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==}
engines: {node: '>=6'}
dev: true
/fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
dev: true
/fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
dependencies:
@@ -6961,6 +7064,10 @@ packages:
tslib: 2.6.2
dev: true
/help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
dev: true
/hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
dependencies:
@@ -6983,6 +7090,15 @@ packages:
void-elements: 3.1.0
dev: false
/htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
entities: 4.5.0
dev: true
/http-proxy-agent@5.0.0:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
@@ -7881,6 +7997,11 @@ packages:
- supports-color
- ts-node
/joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
dev: true
/jpeg-js@0.4.4:
resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
dev: false
@@ -8440,6 +8561,21 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/notistack@3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==}
engines: {node: '>=12.0.0', npm: '>=6.0.0'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
clsx: 1.2.1
goober: 2.1.14(csstype@3.1.3)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
- csstype
dev: false
/npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -8576,6 +8712,11 @@ packages:
resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==}
dev: false
/on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
dev: true
/once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies:
@@ -8721,6 +8862,13 @@ packages:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
/parse5-htmlparser2-tree-adapter@7.0.0:
resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
dependencies:
domhandler: 5.0.3
parse5: 7.1.2
dev: true
/parse5@7.1.2:
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
dependencies:
@@ -8810,6 +8958,54 @@ packages:
engines: {node: '>=6'}
dev: true
/pino-abstract-transport@1.2.0:
resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==}
dependencies:
readable-stream: 4.5.2
split2: 4.2.0
dev: true
/pino-pretty@11.2.1:
resolution: {integrity: sha512-O05NuD9tkRasFRWVaF/uHLOvoRDFD7tb5VMertr78rbsYFjYp48Vg3477EshVAF5eZaEw+OpDl/tu+B0R5o+7g==}
hasBin: true
dependencies:
colorette: 2.0.20
dateformat: 4.6.3
fast-copy: 3.0.2
fast-safe-stringify: 2.1.1
help-me: 5.0.0
joycon: 3.1.1
minimist: 1.2.8
on-exit-leak-free: 2.1.2
pino-abstract-transport: 1.2.0
pump: 3.0.0
readable-stream: 4.5.2
secure-json-parse: 2.7.0
sonic-boom: 4.0.1
strip-json-comments: 3.1.1
dev: true
/pino-std-serializers@7.0.0:
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
dev: true
/pino@9.2.0:
resolution: {integrity: sha512-g3/hpwfujK5a4oVbaefoJxezLzsDgLcNJeITvC6yrfwYeT9la+edCK42j5QpEQSQCZgTKapXvnQIdgZwvRaZug==}
hasBin: true
dependencies:
atomic-sleep: 1.0.0
fast-redact: 3.5.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 1.2.0
pino-std-serializers: 7.0.0
process-warning: 3.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.4.3
sonic-boom: 4.0.1
thread-stream: 3.1.0
dev: true
/pirates@4.0.6:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
@@ -9027,6 +9223,10 @@ packages:
fromentries: 1.3.2
dev: true
/process-warning@3.0.0:
resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
dev: true
/process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@@ -9115,6 +9315,10 @@ packages:
resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==}
dev: true
/quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
dev: true
/quick-lru@5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
@@ -9635,12 +9839,28 @@ packages:
util-deprecate: 1.0.2
dev: true
/readable-stream@4.5.2:
resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
dev: true
/readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
dependencies:
picomatch: 2.3.1
/real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
dev: true
/redux-thunk@3.1.0(redux@5.0.1):
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
@@ -9924,6 +10144,11 @@ packages:
is-regex: 1.1.4
dev: true
/safe-stable-stringify@2.4.3:
resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==}
engines: {node: '>=10'}
dev: true
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -9973,6 +10198,10 @@ packages:
compute-scroll-into-view: 3.1.0
dev: false
/secure-json-parse@2.7.0:
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
dev: true
/semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -10151,6 +10380,12 @@ packages:
tslib: 2.6.2
dev: true
/sonic-boom@4.0.1:
resolution: {integrity: sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==}
dependencies:
atomic-sleep: 1.0.0
dev: true
/source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'}
@@ -10194,6 +10429,11 @@ packages:
which: 2.0.2
dev: true
/split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
dev: true
/sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -10306,6 +10546,12 @@ packages:
safe-buffer: 5.1.2
dev: true
/string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
dependencies:
safe-buffer: 5.2.1
dev: true
/strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -10499,6 +10745,12 @@ packages:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
/thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
dependencies:
real-require: 0.2.0
dev: true
/throttleit@1.0.1:
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
dev: true
@@ -11414,7 +11666,3 @@ packages:
resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
engines: {node: '>=12.20'}
dev: true
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+12 -38
View File
@@ -1,38 +1,12 @@
<svg width='100%' height='100%' viewBox='0 0 41 40' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M39.9564 24.0195C38.8098 30.1683 33.7828 35.5321 28.0061 38.5411C27.3005 38.9336 26.4627 39.1516 25.6689 39.1952H37.9279C39.1185 39.1952 39.9564 38.323 39.9564 37.2328V24.0195Z'
fill='#F7931E'
/>
<path
d='M15.4381 12.1576C15.2617 12.2884 15.0853 12.4192 14.9089 12.55C11.9103 14.6432 2.82634 21.3589 0.753788 18.4371C-1.27467 15.6026 0.886079 7.57868 6.08952 3.69755C6.17771 3.61033 6.31 3.56672 6.3982 3.4795C12.0867 -0.48885 16.32 0.078058 18.3926 2.95621C20.3328 5.65992 18.1721 9.93353 15.4381 12.1576Z'
fill='#8427E0'
/>
<path
d='M33.8715 36.098C33.7833 36.1852 33.6951 36.2288 33.5628 36.316C27.8743 40.2844 23.641 39.7175 21.5684 36.8393C19.6282 34.1356 21.7889 29.862 24.5229 27.638C24.6993 27.5072 24.8757 27.3763 25.0521 27.2455C28.0507 25.1959 37.1347 18.4366 39.1631 21.3584C41.2357 24.1929 39.119 32.2169 33.8715 36.098Z'
fill='#FFBD00'
/>
<path
d='M17.9954 38.8459C15.085 40.8955 6.70658 38.6715 2.87014 33.264C2.78195 33.1768 2.69376 33.046 2.64966 32.9588C-1.09858 27.5078 -0.481224 23.4086 2.38508 21.4462C5.20728 19.4838 9.61698 21.7515 11.8218 24.586C11.91 24.7168 11.9982 24.804 12.0864 24.9349C14.159 27.8566 20.9499 36.8399 17.9954 38.8459Z'
fill='#E3006D'
/>
<path
d='M15.4385 12.1576C11.3816 13.9455 2.73857 17.6086 1.45976 14.6432C0.357338 12.1576 2.3858 7.09899 6.08994 3.69755C6.17814 3.61033 6.31043 3.56672 6.39862 3.4795C12.0871 -0.48885 16.3204 0.078058 18.393 2.95621C20.3333 5.65992 18.1725 9.93353 15.4385 12.1576Z'
fill='#9327FF'
/>
<path
d='M37.6624 18.3955C34.8402 20.3579 30.4305 18.0903 28.2257 15.2557C28.1375 15.1249 28.0493 15.0377 27.9611 14.9069C25.8444 11.9415 19.0535 2.95819 21.9639 0.952211C24.8743 -1.09738 33.2968 1.12664 37.1333 6.53407C37.2215 6.6649 37.3096 6.75211 37.3978 6.88294C41.102 12.334 40.5287 16.3895 37.6624 18.3955Z'
fill='#00B5FF'
/>
<path
d='M37.6628 18.3934C34.8406 20.3557 30.4309 18.0881 28.2261 15.2536C26.4181 11.1108 22.9344 2.95603 25.8448 1.73499C28.4906 0.601179 33.9587 2.86881 37.4423 6.88077C41.1024 12.3318 40.5291 16.3874 37.6628 18.3934Z'
fill='#00C8FF'
/>
<path
d='M33.8715 36.0986C33.7833 36.1858 33.6951 36.2294 33.5628 36.3166C27.8743 40.285 23.641 39.7181 21.5684 36.8399C19.6282 34.1362 21.7889 29.8626 24.5229 27.6386C28.5799 25.8506 37.2229 22.1875 38.5017 25.1529C39.6482 27.6386 37.6197 32.6971 33.8715 36.0986Z'
fill='#FFCE00'
/>
<path
d='M14.2031 38.061C11.5572 39.1948 6.08922 36.9708 2.64966 32.9588C-1.09858 27.5078 -0.481224 23.4086 2.38508 21.4462C5.20728 19.4838 9.61698 21.7515 11.8218 24.586C13.6298 28.6852 17.1135 36.8399 14.2031 38.061Z'
fill='#FB006D'
/>
</svg>
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M455.885 297.861C455.85 297.861 455.82 297.886 455.814 297.92C444.323 359.916 394.213 414.254 336.155 444.713C328.858 448.526 326.952 450.629 318.768 451.286H435.513C438.163 451.384 440.805 450.945 443.282 449.998C445.759 449.051 448.019 447.613 449.927 445.772C451.836 443.931 453.353 441.724 454.389 439.283C455.425 436.843 455.958 434.218 455.956 431.566V297.933C455.956 297.893 455.924 297.861 455.885 297.861V297.861Z" fill="#F7931E"/>
<path d="M327.511 447.668C325.125 447.832 322.731 447.832 320.345 447.668H327.511Z" fill="#FFCE00"/>
<path d="M210.239 178.025C208.465 179.504 206.69 180.852 204.882 182.134C175.071 203.103 84.0943 271.073 63.5522 241.821C43.3716 213.095 64.6368 131.617 117.027 92.3728C118.013 91.584 119 90.8609 120.018 90.1707C176.846 50.204 219.377 55.7257 240.05 85.0105C259.245 112.488 237.651 155.61 210.239 178.025Z" fill="#8427E0"/>
<path d="M432.62 241.066C404.486 260.787 360.082 237.78 338.061 209.481C337.173 208.33 336.319 207.18 335.497 205.997C314.528 176.088 246.624 85.1106 275.81 64.5356C304.996 43.9606 389.137 66.409 427.46 121.002C428.315 122.218 429.136 123.401 429.925 124.584C467.197 179.67 461.248 220.82 432.62 241.066Z" fill="#00B5FF"/>
<path d="M394.954 420.458C393.968 421.225 392.982 421.948 391.996 422.627C335.136 462.594 292.605 457.04 272.03 427.788C252.737 400.311 274.298 357.287 301.611 334.773C303.386 333.294 305.193 331.913 307.001 330.632C336.812 309.695 427.789 241.89 448.331 270.977C468.643 299.67 447.378 381.215 394.954 420.458Z" fill="#FFBD00"/>
<path d="M236.205 448.131C206.92 468.673 122.878 446.192 84.5216 391.632L82.2537 388.345C44.7849 333.095 50.701 291.978 79.3943 271.83C107.496 252.11 151.9 275.117 173.921 303.383C174.808 304.534 175.663 305.684 176.517 306.867C197.487 336.711 265.457 427.688 236.205 448.131Z" fill="#E3006D"/>
<path d="M240.905 137.634C230.873 159.981 212.651 177.627 189.993 186.935C146.049 205.439 81.5306 228.939 70.4214 203.369C59.5423 178.225 79.6572 127.018 116.995 92.3754L117.783 91.718C175.761 50.0421 219.114 55.3667 240.05 85.0131C250.403 99.902 248.859 119.458 240.905 137.634Z" fill="#9327FF"/>
<path d="M432.62 241.066C417.37 251.78 397.354 249.907 378.718 241.46C356.357 231.053 338.75 212.589 329.417 189.759C311.241 145.75 289.056 83.3019 314.101 72.4885C340.394 61.1164 395.382 83.7621 429.925 124.583C467.197 179.669 461.248 220.819 432.62 241.066Z" fill="#00C8FF"/>
<path d="M394.954 420.458L394.297 420.984C336.286 462.726 292.868 457.434 272.03 427.787C261.578 412.899 263.123 393.441 271.077 375.2C281.097 352.845 299.324 335.195 321.989 325.899C365.899 307.361 430.451 283.894 441.56 309.465C452.669 335.036 432.325 385.684 394.954 420.458Z" fill="#FFCE00"/>
<path d="M197.914 440.311C171.62 451.65 116.83 429.136 82.2537 388.446C44.7849 333.098 50.701 291.981 79.3943 271.833C94.6119 261.118 114.628 262.959 133.264 271.406C155.591 281.872 173.141 300.384 182.401 323.237C200.609 366.918 222.959 429.432 197.914 440.311Z" fill="#FB006D"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Loaded 100 of 425 files, more files were not shown because too many files have changed in this diff. Show more