Merge branch 'fix/publish-view-bugs' into feat/support-global-comment-on-web

This commit is contained in:
Kilu 2024-07-29 22:29:30 +08:00
commit e323db2797
57 changed files with 1425 additions and 586 deletions

View File

@ -13,7 +13,7 @@ on:
- release/*
paths:
- frontend/**
types: [opened, synchronize, reopened, unlocked, ready_for_review]
types: [ opened, synchronize, reopened, unlocked, ready_for_review ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}

View File

@ -248,10 +248,13 @@ jobs:
env:
BACKEND_VERSION: 0.3.24-amd64
run: |
docker compose down -v --remove-orphans
docker compose pull
docker compose up -d
sleep 10
if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then
docker compose pull
docker compose up -d
sleep 10
else
echo "Docker container 'appflowy-cloud' is already running."
fi
- name: Checkout source code
uses: actions/checkout@v4

View File

@ -28,20 +28,35 @@ concurrency:
cancel-in-progress: true
jobs:
build:
if: github.event.pull_request.draft != true
strategy:
fail-fast: true
matrix:
os: [ macos-14 ]
runs-on: ${{ matrix.os }}
build-self-hosted:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
steps:
- name: Checkout source code
uses: actions/checkout@v2
- name: Build AppFlowy
working-directory: frontend
run: |
cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios
cargo make --profile development-ios-arm64-sim code_generation
- uses: futureware-tech/simulator-action@v3
id: simulator-action
with:
model: 'iPhone 15'
shutdown_after_job: false
build-macos:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: macos-latest
steps:
- name: Checkout source code
uses: actions/checkout@v2
- name: Install Rust toolchain
id: rust_toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
@ -49,8 +64,7 @@ jobs:
override: true
profile: minimal
- name: Install flutter
id: flutter
- name: Install Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
@ -59,7 +73,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
prefix-key: ${{ matrix.os }}
prefix-key: macos-latest
workspaces: |
frontend/rust-lib

View File

@ -8,6 +8,7 @@ on:
- "release/*"
paths:
- "frontend/rust-lib/**"
- ".github/workflows/rust_ci.yaml"
pull_request:
branches:
@ -22,70 +23,25 @@ env:
RUST_TOOLCHAIN: "1.77.2"
jobs:
test-on-ubuntu:
runs-on: ubuntu-latest
self-hosted-job:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
steps:
# - name: Maximize build space
# uses: easimon/maximize-build-space@master
# with:
# root-reserve-mb: 2048
# swap-size-mb: 1024
# remove-dotnet: 'true'
# the following step is required to avoid running out of space
- name: Maximize build space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
sudo docker image prune --all --force
- name: Checkout source code
uses: actions/checkout@v4
- name: Install Rust toolchain
id: rust_toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
override: true
components: rustfmt, clippy
profile: minimal
- name: Install prerequisites
working-directory: frontend
run: |
cargo install --force cargo-make
cargo install --force duckscript_cli
- uses: Swatinem/rust-cache@v2
with:
prefix-key: "ubuntu-latest"
workspaces: |
frontend/rust-lib
- name: Checkout appflowy cloud code
- name: Checkout Appflowy Cloud
uses: actions/checkout@v4
with:
repository: AppFlowy-IO/AppFlowy-Cloud
path: AppFlowy-Cloud
- name: Prepare appflowy cloud env
- name: Prepare Appflowy Cloud env
working-directory: AppFlowy-Cloud
run: |
# log level
cp deploy.env .env
sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env
sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
- name: Run Docker-Compose
working-directory: AppFlowy-Cloud
env:
BACKEND_VERSION: 0.3.24-amd64
run: |
docker pull appflowyinc/appflowy_cloud:latest
docker compose up -d
sed -i '' 's|RUST_LOG=.*|RUST_LOG=trace|' .env
sed -i '' 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
- name: Run rust-lib tests
working-directory: frontend/rust-lib
@ -106,6 +62,84 @@ jobs:
run: cargo clippy --all-targets -- -D warnings
working-directory: frontend/rust-lib
ubuntu-job:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
steps:
- name: Maximize build space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
sudo docker image prune --all --force
- name: Checkout source code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
override: true
components: rustfmt, clippy
profile: minimal
- uses: Swatinem/rust-cache@v2
with:
prefix-key: ${{ runner.os }}
cache-on-failure: true
workspaces: |
frontend/rust-lib
- name: Checkout appflowy cloud code
uses: actions/checkout@v4
with:
repository: AppFlowy-IO/AppFlowy-Cloud
path: AppFlowy-Cloud
- name: Prepare appflowy cloud env
working-directory: AppFlowy-Cloud
run: |
cp deploy.env .env
sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env
sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
- name: Run Docker-Compose
working-directory: AppFlowy-Cloud
run: |
if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then
docker compose pull
docker compose up -d
sleep 10
else
echo "Docker container 'appflowy-cloud' is already running."
fi
- name: Run rust-lib tests
working-directory: frontend/rust-lib
env:
RUST_LOG: info
RUST_BACKTRACE: 1
af_cloud_test_base_url: http://localhost
af_cloud_test_ws_url: ws://localhost/ws/v1
af_cloud_test_gotrue_url: http://localhost/gotrue
run: |
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart"
- name: rustfmt rust-lib
run: cargo fmt --all -- --check
working-directory: frontend/rust-lib/
- name: clippy rust-lib
run: cargo clippy --all-targets -- -D warnings
working-directory: frontend/rust-lib
- name: "Debug: show Appflowy-Cloud container logs"
if: failure()
working-directory: AppFlowy-Cloud
run: |
docker compose logs appflowy_cloud
- name: Clean up Docker images
run: |
docker image prune -af

View File

@ -1,4 +1,5 @@
name: Tauri-CI
on:
pull_request:
paths:
@ -17,22 +18,45 @@ concurrency:
cancel-in-progress: true
jobs:
tauri-build:
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
platform: [ ubuntu-20.04 ]
runs-on: ${{ matrix.platform }}
tauri-build-self-hosted:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
env:
CI: true
steps:
- uses: actions/checkout@v4
- name: install frontend dependencies
working-directory: frontend/appflowy_web_app
run: |
mkdir dist
pnpm install
cd src-tauri && cargo build
- name: test and lint
working-directory: frontend/appflowy_web_app
run: |
pnpm run lint:tauri
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tauriScript: pnpm tauri
projectPath: frontend/appflowy_web_app
args: "--debug"
tauri-build-ubuntu:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
env:
CI: true
steps:
- name: Maximize build space (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
if: matrix.os == 'ubuntu-latest'
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
@ -73,14 +97,14 @@ jobs:
key: node-modules-${{ runner.os }}
- name: install dependencies (windows only)
if: matrix.platform == 'windows-latest'
if: matrix.os == 'windows-latest'
working-directory: frontend
run: |
cargo install --force duckscript_cli
vcpkg integrate install
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
if: matrix.os == 'ubuntu-latest'
working-directory: frontend
run: |
sudo apt-get update

View File

@ -1,4 +1,8 @@
# Release Notes
## Version 0.6.5 - 24/07/2024
### New Features
- Publish a Database to the Web
## Version 0.6.4 - 16/07/2024
### New Features
- Enhanced the message style on the AI chat page.

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.4"
APPFLOWY_VERSION = "0.6.5"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"

View File

@ -47,11 +47,11 @@ void main() {
);
await tester.tapButton(find.byType(SignInOutButton));
tester.expectToSeeText(LocaleKeys.button_confirm.tr());
await tester.tapButtonWithName(LocaleKeys.button_confirm.tr());
tester.expectToSeeText(LocaleKeys.button_ok.tr());
await tester.tapButtonWithName(LocaleKeys.button_ok.tr());
// Go to the sign in page again
await tester.pumpAndSettle(const Duration(seconds: 1));
await tester.pumpAndSettle(const Duration(seconds: 5));
tester.expectToSeeGoogleLoginButton();
});

View File

@ -50,26 +50,28 @@ void main() {
await tester.tapEscButton();
// wait 2 seconds for the sync to finish
await tester.pumpAndSettle(const Duration(seconds: 2));
await tester.pumpAndSettle(const Duration(seconds: 6));
});
testWidgets('get user icon and name from server', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
email: email,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
await tester.pumpAndSettle();
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account);
// Verify name
final profileSetting =
tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting;
expect(profileSetting.name, name);
});
});
testWidgets('get user icon and name from server', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
email: email,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
await tester.pumpAndSettle();
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account);
// Verify name
final profileSetting =
tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting;
expect(profileSetting.name, name);
});
}

View File

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
@ -7,6 +5,7 @@ import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflow
import 'package:appflowy/workspace/presentation/settings/widgets/setting_supabase_cloud.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'util.dart';
@ -29,8 +28,8 @@ extension AppFlowyAuthTest on WidgetTester {
await tapButton(find.byType(SignInOutButton));
expectToSeeText(LocaleKeys.button_confirm.tr());
await tapButtonWithName(LocaleKeys.button_confirm.tr());
expectToSeeText(LocaleKeys.button_ok.tr());
await tapButtonWithName(LocaleKeys.button_ok.tr());
}
Future<void> tapSignInAsGuest() async {

View File

@ -125,7 +125,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
return child;
},
)
: child;
: SafeArea(child: child);
return Scaffold(
extendBodyBehindAppBar: isDocument,
appBar: appBar,

View File

@ -17,17 +17,23 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
required this.questionId,
}) : super(ChatAIMessageState.initial(message)) {
if (state.stream != null) {
_subscription = state.stream!.listen((text) {
if (isClosed) {
return;
}
if (text.startsWith("data:")) {
add(ChatAIMessageEvent.newText(text.substring(5)));
} else if (text.startsWith("error:")) {
add(ChatAIMessageEvent.receiveError(text.substring(5)));
}
});
_subscription = state.stream!.listen(
onData: (text) {
if (!isClosed) {
add(ChatAIMessageEvent.updateText(text));
}
},
onError: (error) {
if (!isClosed) {
add(ChatAIMessageEvent.receiveError(error.toString()));
}
},
onAIResponseLimit: () {
if (!isClosed) {
add(const ChatAIMessageEvent.onAIResponseLimit());
}
},
);
if (state.stream!.error != null) {
Future.delayed(const Duration(milliseconds: 300), () {
@ -42,11 +48,16 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
(event, emit) async {
await event.when(
initial: () async {},
newText: (newText) {
emit(state.copyWith(text: state.text + newText, error: null));
updateText: (newText) {
emit(
state.copyWith(
text: newText,
messageState: const MessageState.ready(),
),
);
},
receiveError: (error) {
emit(state.copyWith(error: error));
emit(state.copyWith(messageState: MessageState.onError(error)));
},
retry: () {
if (questionId is! Int64) {
@ -55,8 +66,7 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
}
emit(
state.copyWith(
retryState: const LoadingState.loading(),
error: null,
messageState: const MessageState.loading(),
),
);
@ -82,8 +92,14 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
emit(
state.copyWith(
text: text,
error: null,
retryState: const LoadingState.finish(),
messageState: const MessageState.ready(),
),
);
},
onAIResponseLimit: () {
emit(
state.copyWith(
messageState: const MessageState.onAIResponseLimit(),
),
);
},
@ -98,7 +114,7 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
return super.close();
}
StreamSubscription<AnswerStreamElement>? _subscription;
StreamSubscription<String>? _subscription;
final String chatId;
final Int64? questionId;
}
@ -106,26 +122,34 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
@freezed
class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.initial() = Initial;
const factory ChatAIMessageEvent.newText(String text) = _NewText;
const factory ChatAIMessageEvent.updateText(String text) = _UpdateText;
const factory ChatAIMessageEvent.receiveError(String error) = _ReceiveError;
const factory ChatAIMessageEvent.retry() = _Retry;
const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult;
const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit;
}
@freezed
class ChatAIMessageState with _$ChatAIMessageState {
const factory ChatAIMessageState({
AnswerStream? stream,
String? error,
required String text,
required LoadingState retryState,
required MessageState messageState,
}) = _ChatAIMessageState;
factory ChatAIMessageState.initial(dynamic text) {
return ChatAIMessageState(
text: text is String ? text : "",
stream: text is AnswerStream ? text : null,
retryState: const LoadingState.finish(),
messageState: const MessageState.ready(),
);
}
}
@freezed
class MessageState with _$MessageState {
const factory MessageState.onError(String error) = _Error;
const factory MessageState.onAIResponseLimit() = _AIResponseLimit;
const factory MessageState.ready() = _Ready;
const factory MessageState.loading() = _Loading;
}

View File

@ -525,8 +525,6 @@ OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
return null;
}
typedef AnswerStreamElement = String;
class AnswerStream {
AnswerStream() {
_port.handler = _controller.add;
@ -534,23 +532,53 @@ class AnswerStream {
(event) {
if (event.startsWith("data:")) {
_hasStarted = true;
final newText = event.substring(5);
_text += newText;
if (_onData != null) {
_onData!(_text);
}
} else if (event.startsWith("error:")) {
_error = event.substring(5);
if (_onError != null) {
_onError!(_error!);
}
} else if (event == "AI_RESPONSE_LIMIT") {
if (_onAIResponseLimit != null) {
_onAIResponseLimit!();
}
}
},
onDone: () {
if (_onEnd != null) {
_onEnd!();
}
},
onError: (error) {
if (_onError != null) {
_onError!(error.toString());
}
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<AnswerStreamElement> _controller =
StreamController.broadcast();
late StreamSubscription<AnswerStreamElement> _subscription;
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
bool _hasStarted = false;
String? _error;
String _text = "";
// Callbacks
void Function(String text)? _onData;
void Function()? _onStart;
void Function()? _onEnd;
void Function(String error)? _onError;
void Function()? _onAIResponseLimit;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
String? get error => _error;
String get text => _text;
Future<void> dispose() async {
await _controller.close();
@ -558,9 +586,23 @@ class AnswerStream {
_port.close();
}
StreamSubscription<AnswerStreamElement> listen(
void Function(AnswerStreamElement event)? onData,
) {
return _controller.stream.listen(onData);
StreamSubscription<String> listen({
void Function(String text)? onData,
void Function()? onStart,
void Function()? onEnd,
void Function(String error)? onError,
void Function()? onAIResponseLimit,
}) {
_onData = onData;
_onStart = onStart;
_onEnd = onEnd;
_onError = onError;
_onAIResponseLimit = onAIResponseLimit;
if (_onStart != null) {
_onStart!();
}
return _subscription;
}
}

View File

@ -1,6 +1,5 @@
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/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
import 'package:easy_localization/easy_localization.dart';
@ -38,25 +37,34 @@ class ChatAITextMessageWidget extends StatelessWidget {
)..add(const ChatAIMessageEvent.initial()),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
if (state.error != null) {
return StreamingError(
onRetryPressed: () {
context.read<ChatAIMessageBloc>().add(
const ChatAIMessageEvent.retry(),
);
},
);
}
if (state.retryState == const LoadingState.loading()) {
return const ChatAILoading();
}
if (state.text.isEmpty) {
return const ChatAILoading();
} else {
return AIMarkdownText(markdown: state.text);
}
return state.messageState.when(
onError: (err) {
return StreamingError(
onRetryPressed: () {
context.read<ChatAIMessageBloc>().add(
const ChatAIMessageEvent.retry(),
);
},
);
},
onAIResponseLimit: () {
return FlowyText(
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
maxLines: 10,
lineHeight: 1.5,
);
},
ready: () {
if (state.text.isEmpty) {
return const ChatAILoading();
} else {
return AIMarkdownText(markdown: state.text);
}
},
loading: () {
return const ChatAILoading();
},
);
},
),
);

View File

@ -1,3 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart';
@ -7,19 +10,18 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller_build
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/dispatch/error.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/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
abstract class IEditableSummaryCellSkin {
@ -149,7 +151,22 @@ class SummaryCellAccessory extends StatelessWidget {
rowId: rowId,
fieldId: fieldId,
),
child: BlocBuilder<SummaryRowBloc, SummaryRowState>(
child: BlocConsumer<SummaryRowBloc, SummaryRowState>(
listenWhen: (previous, current) {
return previous.error != current.error;
},
listener: (context, state) {
if (state.error != null) {
if (state.error!.isAIResponseLimitExceeded) {
showSnackBarMessage(
context,
LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(),
);
} else {
showSnackBarMessage(context, state.error!.msg);
}
}
},
builder: (context, state) {
return const Row(
children: [SummaryButton(), HSpace(6), CopyButton()],
@ -169,13 +186,13 @@ class SummaryButton extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<SummaryRowBloc, SummaryRowState>(
builder: (context, state) {
return state.loadingState.map(
loading: (_) {
return state.loadingState.when(
loading: () {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
},
finish: (_) {
finish: () {
return FlowyTooltip(
message: LocaleKeys.tooltip_aiGenerate.tr(),
child: Container(

View File

@ -1,3 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart';
@ -7,19 +10,18 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller_build
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/dispatch/error.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/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
abstract class IEditableTranslateCellSkin {
@ -150,7 +152,22 @@ class TranslateCellAccessory extends StatelessWidget {
rowId: rowId,
fieldId: fieldId,
),
child: BlocBuilder<TranslateRowBloc, TranslateRowState>(
child: BlocConsumer<TranslateRowBloc, TranslateRowState>(
listenWhen: (previous, current) {
return previous.error != current.error;
},
listener: (context, state) {
if (state.error != null) {
if (state.error!.isAIResponseLimitExceeded) {
showSnackBarMessage(
context,
LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(),
);
} else {
showSnackBarMessage(context, state.error!.msg);
}
}
},
builder: (context, state) {
return const Row(
children: [TranslateButton(), HSpace(6), CopyButton()],

View File

@ -6,8 +6,8 @@ import 'package:appflowy/shared/custom_image_cache_manager.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/file_extension.dart';
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
import 'package:appflowy_backend/dispatch/error.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:path/path.dart' as p;
@ -65,7 +65,7 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage(
return (s.url, null);
},
(err) {
if (err.code == ErrorCode.FileStorageLimitExceeded) {
if (err.isStorageLimitExceeded) {
return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr());
} else {
return (null, err.msg);

View File

@ -1,5 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
@ -7,7 +9,6 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class OpenAIImageWidget extends StatefulWidget {
const OpenAIImageWidget({

View File

@ -1,35 +1,13 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class _AILimitDialog extends StatelessWidget {
const _AILimitDialog({
required this.message,
required this.onOkPressed,
});
final VoidCallback onOkPressed;
final String message;
@override
Widget build(BuildContext context) {
return NavigatorOkCancelDialog(
message: message,
okTitle: LocaleKeys.button_ok.tr(),
onOkPressed: onOkPressed,
titleUpperCase: false,
);
}
}
void showAILimitDialog(BuildContext context, String message) {
showDialog(
showConfirmDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (dialogContext) => _AILimitDialog(
message: message,
onOkPressed: () {},
),
title: LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(),
description: message,
);
}

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart';
@ -10,12 +12,7 @@ import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
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/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:provider/provider.dart';
import 'ai_limit_dialog.dart';
@ -46,7 +43,7 @@ Node autoCompletionNode({
SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node(
getName: LocaleKeys.document_plugins_autoGeneratorMenuItemName.tr,
iconData: Icons.generating_tokens,
keywords: ['ai', 'openai' 'writer', 'autogenerator'],
keywords: ['ai', 'openai', 'writer', 'ai writer', 'autogenerator'],
nodeBuilder: (editorState, _) {
final node = autoCompletionNode(start: editorState.selection!);
return node;
@ -130,7 +127,6 @@ class _AutoCompletionBlockComponentState
_unsubscribeSelectionGesture();
controller.dispose();
textFieldFocusNode.dispose();
super.dispose();
}
@ -181,9 +177,7 @@ class _AutoCompletionBlockComponentState
final transaction = editorState.transaction..deleteNode(widget.node);
await editorState.apply(
transaction,
options: const ApplyOptions(
recordUndo: false,
),
options: const ApplyOptions(recordUndo: false),
);
}
@ -230,6 +224,7 @@ class _AutoCompletionBlockComponentState
if (mounted) {
if (error.isLimitExceeded) {
showAILimitDialog(context, error.message);
await _onDiscard();
} else {
showSnackBarMessage(
context,
@ -417,12 +412,10 @@ class _AutoCompletionBlockComponentState
// show dialog
showDialog(
context: context,
builder: (context) {
return DiscardDialog(
onConfirm: () => _onDiscard(),
onCancel: () {},
);
},
builder: (_) => DiscardDialog(
onConfirm: _onDiscard,
onCancel: () {},
),
);
} else if (controller.text.isEmpty) {
_onExit();
@ -445,9 +438,7 @@ class _AutoCompletionBlockComponentState
}
class AutoCompletionHeader extends StatelessWidget {
const AutoCompletionHeader({
super.key,
});
const AutoCompletionHeader({super.key});
@override
Widget build(BuildContext context) {
@ -471,23 +462,27 @@ class AutoCompletionInputFooter extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
PrimaryTextButton(
LocaleKeys.button_generate.tr(),
FlowyTextButton.primary(
text: LocaleKeys.button_generate.tr(),
context: context,
onPressed: onGenerate,
),
const Space(10, 0),
SecondaryTextButton(
LocaleKeys.button_cancel.tr(),
FlowyTextButton.secondary(
text: LocaleKeys.button_cancel.tr(),
context: context,
onPressed: onExit,
),
Expanded(
Flexible(
child: Container(
alignment: Alignment.centerRight,
child: FlowyText.regular(
LocaleKeys.document_plugins_warning.tr(),
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
fontSize: 12,
),
),
),
@ -512,18 +507,21 @@ class AutoCompletionFooter extends StatelessWidget {
Widget build(BuildContext context) {
return Row(
children: [
PrimaryTextButton(
LocaleKeys.button_keep.tr(),
FlowyTextButton.primary(
context: context,
text: LocaleKeys.button_keep.tr(),
onPressed: onKeep,
),
const Space(10, 0),
SecondaryTextButton(
LocaleKeys.document_plugins_autoGeneratorRewrite.tr(),
FlowyTextButton.secondary(
context: context,
text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(),
onPressed: onRewrite,
),
const Space(10, 0),
SecondaryTextButton(
LocaleKeys.button_discard.tr(),
FlowyTextButton.secondary(
context: context,
text: LocaleKeys.button_discard.tr(),
onPressed: onDiscard,
),
],

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart';
@ -23,7 +25,6 @@ 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/style_widget/snap_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
@ -239,19 +240,15 @@ class PageStyleCoverImage extends StatelessWidget {
return;
}
if (result == null) {
showSnapBar(
return showSnapBar(
context,
LocaleKeys.document_plugins_image_imageUploadFailed,
LocaleKeys.document_plugins_image_imageUploadFailed.tr(),
);
return;
}
context.read<DocumentPageStyleBloc>().add(
DocumentPageStyleEvent.updateCoverImage(
PageStyleCover(
type: type,
value: result,
),
PageStyleCover(type: type, value: result),
),
);
}
@ -282,10 +279,7 @@ class PageStyleCoverImage extends StatelessWidget {
},
builder: (_) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: maxHeight,
minHeight: 80,
),
constraints: BoxConstraints(maxHeight: maxHeight, minHeight: 80),
child: BlocProvider.value(
value: pageStyleBloc,
child: Padding(

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
@ -6,7 +8,6 @@ import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.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';
class ShareMenuButton extends StatelessWidget {

View File

@ -91,7 +91,7 @@ enum FeatureFlag {
bool get isOn {
if ([
FeatureFlag.planBilling,
// FeatureFlag.planBilling,
// release this feature in version 0.6.1
FeatureFlag.spaceDesign,
// release this feature in version 0.5.9

View File

@ -35,7 +35,7 @@ class WindowSizeManager {
Future<Size> getSize() async {
final defaultWindowSize = jsonEncode(
{WindowSizeManager.height: 600.0, WindowSizeManager.width: 800.0},
{WindowSizeManager.height: minWindowHeight, WindowSizeManager.width: minWindowWidth},
);
final windowSize = await getIt<KeyValueStorage>().get(KVKeys.windowSize);
final size = json.decode(

View File

@ -90,7 +90,7 @@ class CompletionStream {
if (event == "AI_RESPONSE_LIMIT") {
onError(
AIError(
message: LocaleKeys.sideBar_aiResponseLitmit.tr(),
message: LocaleKeys.sideBar_aiResponseLimit.tr(),
code: AIErrorCode.aiResponseLimitExceeded,
),
);

View File

@ -10,6 +10,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
/// Only used for testing.
class AppFlowyCloudMockAuthService implements AuthService {
@ -64,12 +65,18 @@ class AppFlowyCloudMockAuthService implements AuthService {
);
Log.info("UserEventOauthSignIn with payload: $payload");
return UserEventOauthSignIn(payload).send().then((value) {
value.fold((l) => null, (err) => Log.error(err));
value.fold(
(l) => null,
(err) {
debugPrint("Error: $err");
Log.error(err);
},
);
return value;
});
},
(r) {
Log.error(r);
debugPrint("Error: $r");
return FlowyResult.failure(r);
},
);

View File

@ -1,7 +1,7 @@
import 'dart:async';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:flutter/foundation.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';
@ -14,6 +14,7 @@ abstract class IUserBackendService {
Future<FlowyResult<void, FlowyError>> cancelSubscription(
String workspaceId,
SubscriptionPlanPB plan,
String? reason,
);
Future<FlowyResult<PaymentLinkPB, FlowyError>> createSubscription(
String workspaceId,
@ -21,6 +22,9 @@ abstract class IUserBackendService {
);
}
const _baseBetaUrl = 'https://beta.appflowy.com';
const _baseProdUrl = 'https://appflowy.com';
class UserBackendService implements IUserBackendService {
UserBackendService({required this.userId});
@ -255,19 +259,24 @@ class UserBackendService implements IUserBackendService {
..recurringInterval = RecurringIntervalPB.Year
..workspaceSubscriptionPlan = plan
..successUrl =
'${getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.base_url}/web/payment-success?plan=${plan.toRecognizable()}';
'${kDebugMode ? _baseBetaUrl : _baseProdUrl}/after-payment?plan=${plan.toRecognizable()}';
return UserEventSubscribeWorkspace(request).send();
}
@override
Future<FlowyResult<void, FlowyError>> cancelSubscription(
String workspaceId,
SubscriptionPlanPB plan,
) {
SubscriptionPlanPB plan, [
String? reason,
]) {
final request = CancelWorkspaceSubscriptionPB()
..workspaceId = workspaceId
..plan = plan;
if (reason != null) {
request.reason = reason;
}
return UserEventCancelWorkspaceSubscription(request).send();
}

View File

@ -115,7 +115,7 @@ class SettingsBillingBloc
(f) => Log.error(f.msg, f),
);
},
cancelSubscription: (plan) async {
cancelSubscription: (plan, reason) async {
final s = state.mapOrNull(ready: (s) => s);
if (s == null) {
return;
@ -124,7 +124,7 @@ class SettingsBillingBloc
emit(s.copyWith(isLoading: true));
final result =
await _userService.cancelSubscription(workspaceId, plan);
await _userService.cancelSubscription(workspaceId, plan, reason);
final successOrNull = result.fold(
(_) => true,
(f) {
@ -276,8 +276,9 @@ class SettingsBillingEvent with _$SettingsBillingEvent {
_AddSubscription;
const factory SettingsBillingEvent.cancelSubscription(
SubscriptionPlanPB plan,
) = _CancelSubscription;
SubscriptionPlanPB plan, {
@Default(null) String? reason,
}) = _CancelSubscription;
const factory SettingsBillingEvent.paymentSuccessful({
SubscriptionPlanPB? plan,

View File

@ -95,7 +95,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
),
);
},
cancelSubscription: () async {
cancelSubscription: (reason) async {
final newState = state
.mapOrNull(ready: (state) => state)
?.copyWith(downgradeProcessing: true);
@ -106,6 +106,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
final result = await _userService.cancelSubscription(
workspaceId,
SubscriptionPlanPB.Pro,
reason,
);
final successOrNull = result.fold(
@ -206,7 +207,9 @@ class SettingsPlanEvent with _$SettingsPlanEvent {
const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) =
_AddSubscription;
const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription;
const factory SettingsPlanEvent.cancelSubscription({
@Default(null) String? reason,
}) = _CancelSubscription;
const factory SettingsPlanEvent.paymentSuccessful({
@Default(null) SubscriptionPlanPB? plan,

View File

@ -4,7 +4,7 @@ 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 WorkspaceSubscriptionInfoPB {
extension SubscriptionInfoHelpers on WorkspaceSubscriptionInfoPB {
String get label => switch (plan) {
WorkspacePlanPB.FreePlan =>
LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(),
@ -24,6 +24,14 @@ extension SubscriptionLabels on WorkspaceSubscriptionInfoPB {
LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(),
_ => 'N/A',
};
bool get isBillingPortalEnabled {
if (plan != WorkspacePlanPB.FreePlan || addOns.isNotEmpty) {
return true;
}
return false;
}
}
extension AllSubscriptionLabels on SubscriptionPlanPB {

View File

@ -16,7 +16,7 @@ part 'sidebar_plan_bloc.freezed.dart';
class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
SidebarPlanBloc() : super(const SidebarPlanState()) {
// After user pays for the subscription, the subscription success listenable will be triggered
// 1. Listen to user subscription payment callback. After user client 'Open AppFlowy', this listenable will be triggered.
final subscriptionListener = getIt<SubscriptionSuccessListenable>();
subscriptionListener.addListener(() {
final plan = subscriptionListener.subscribedPlan;
@ -49,6 +49,7 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
}
});
// 2. Listen to the storage notification
_storageListener = StoreageNotificationListener(
onError: (error) {
if (!isClosed) {
@ -57,6 +58,7 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
},
);
// 3. Listen to specific error codes
_globalErrorListener = GlobalErrorCodeNotifier.add(
onError: (error) {
if (!isClosed) {
@ -92,11 +94,21 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
) async {
await event.when(
receiveError: (FlowyError error) async {
emit(
state.copyWith(
tierIndicator: const SidebarToastTierIndicator.storageLimitHit(),
),
);
if (error.code == ErrorCode.AIResponseLimitExceeded) {
emit(
state.copyWith(
tierIndicator: const SidebarToastTierIndicator.aiMaxiLimitHit(),
),
);
} else if (error.code == ErrorCode.FileStorageLimitExceeded) {
emit(
state.copyWith(
tierIndicator: const SidebarToastTierIndicator.storageLimitHit(),
),
);
} else {
Log.error("Unhandle Unexpected error: $error");
}
},
init: (String workspaceId, UserProfilePB userProfile) {
emit(

View File

@ -1,3 +1,7 @@
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
@ -7,31 +11,33 @@ import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class SidebarFooter extends StatelessWidget {
const SidebarFooter({super.key});
@override
Widget build(BuildContext context) {
return const Row(
return Column(
children: [
Expanded(child: SidebarTrashButton()),
// Enable it when the widget button is ready
// SizedBox(
// height: 16,
// child: VerticalDivider(width: 1, color: Color(0x141F2329)),
// ),
// Expanded(child: SidebarWidgetButton()),
if (FeatureFlag.planBilling.isOn) const SidebarToast(),
const Row(
children: [
Expanded(child: SidebarTrashButton()),
// Enable it when the widget button is ready
// SizedBox(
// height: 16,
// child: VerticalDivider(width: 1, color: Color(0x141F2329)),
// ),
// Expanded(child: SidebarWidgetButton()),
],
),
],
);
}
}
class SidebarTrashButton extends StatelessWidget {
const SidebarTrashButton({
super.key,
});
const SidebarTrashButton({super.key});
@override
Widget build(BuildContext context) {

View File

@ -1,4 +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:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/billing/sidebar_plan_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
@ -6,125 +11,110 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
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:flutter/material.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SidebarToast extends StatefulWidget {
class SidebarToast extends StatelessWidget {
const SidebarToast({super.key});
@override
State<SidebarToast> createState() => _SidebarToastState();
}
class _SidebarToastState extends State<SidebarToast> {
@override
Widget build(BuildContext context) {
return BlocConsumer<SidebarPlanBloc, SidebarPlanState>(
listener: (context, state) {
listener: (_, state) {
// Show a dialog when the user hits the storage limit, After user click ok, it will navigate to the plan page.
// Even though the dislog is dissmissed, if the user triggers the storage limit again, the dialog will show again.
state.tierIndicator.maybeWhen(
storageLimitHit: () {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _showStorageLimitDialog(context),
debugLabel: 'Sidebar.showStorageLimit',
);
},
orElse: () {
// Do nothing
},
storageLimitHit: () => WidgetsBinding.instance.addPostFrameCallback(
(_) => _showStorageLimitDialog(context),
),
orElse: () {},
);
},
builder: (context, state) {
return BlocBuilder<SidebarPlanBloc, SidebarPlanState>(
builder: (context, state) {
return state.tierIndicator.when(
storageLimitHit: () => Column(
children: [
const Divider(height: 0.6),
PlanIndicator(
planName: "Pro",
text: LocaleKeys.sideBar_upgradeToPro.tr(),
onTap: () {
_hanldeOnTap(context, SubscriptionPlanPB.Pro);
},
reason: LocaleKeys.sideBar_storageLimitDialogTitle.tr(),
),
],
),
aiMaxiLimitHit: () => Column(
children: [
const Divider(height: 0.6),
PlanIndicator(
planName: "AI Max",
text: LocaleKeys.sideBar_upgradeToAIMax.tr(),
onTap: () {
_hanldeOnTap(context, SubscriptionPlanPB.AiMax);
},
reason: LocaleKeys.sideBar_aiResponseLitmitDialogTitle.tr(),
),
],
),
loading: () {
return const SizedBox.shrink();
},
);
},
builder: (_, state) {
return state.tierIndicator.when(
loading: () => const SizedBox.shrink(),
storageLimitHit: () => PlanIndicator(
planName: SubscriptionPlanPB.Free.label,
text: LocaleKeys.sideBar_upgradeToPro.tr(),
onTap: () => _hanldeOnTap(context, SubscriptionPlanPB.Pro),
reason: LocaleKeys.sideBar_storageLimitDialogTitle.tr(),
),
aiMaxiLimitHit: () => PlanIndicator(
planName: SubscriptionPlanPB.AiMax.label,
text: LocaleKeys.sideBar_upgradeToAIMax.tr(),
onTap: () => _hanldeOnTap(context, SubscriptionPlanPB.AiMax),
reason: LocaleKeys.sideBar_aiResponseLimitTitle.tr(),
),
);
},
);
}
void _showStorageLimitDialog(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (dialogContext) => _StorageLimitDialog(
onOkPressed: () {
final userProfile = context.read<SidebarPlanBloc>().state.userProfile;
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
if (userProfile != null) {
showSettingsDialog(
context,
userProfile,
userWorkspaceBloc,
SettingsPage.plan,
);
} else {
Log.error(
"UserProfile is null. It should not happen. If you see this error, it's a bug.",
);
}
void _showStorageLimitDialog(BuildContext context) => showConfirmDialog(
context: context,
title: LocaleKeys.sideBar_purchaseStorageSpace.tr(),
description: LocaleKeys.sideBar_storageLimitDialogTitle.tr(),
confirmLabel:
LocaleKeys.settings_comparePlanDialog_actions_upgrade.tr(),
onConfirm: () {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _hanldeOnTap(context, SubscriptionPlanPB.Pro),
);
},
),
);
}
);
void _hanldeOnTap(BuildContext context, SubscriptionPlanPB plan) {
final userProfile = context.read<SidebarPlanBloc>().state.userProfile;
if (userProfile == null) {
return Log.error(
'UserProfile is null, this should NOT happen! Please file a bug report',
);
}
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
if (userProfile != null) {
final member = userWorkspaceBloc.state.currentWorkspaceMember;
if (member == null) {
return Log.error(
"Member is null. It should not happen. If you see this error, it's a bug",
);
}
// Only if the user is the workspace owner will we navigate to the plan page.
if (member.role.isOwner) {
showSettingsDialog(
context,
userProfile,
userWorkspaceBloc,
SettingsPage.plan,
);
} else {
final message = plan == SubscriptionPlanPB.AiMax
? LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr()
: LocaleKeys.sideBar_askOwnerToUpgradeToPro.tr();
showDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (dialogContext) => _AskOwnerToChangePlan(
message: message,
onOkPressed: () {},
),
);
}
}
}
class PlanIndicator extends StatelessWidget {
class PlanIndicator extends StatefulWidget {
const PlanIndicator({
super.key,
required this.planName,
required this.text,
required this.onTap,
required this.reason,
super.key,
});
final String planName;
@ -132,62 +122,150 @@ class PlanIndicator extends StatelessWidget {
final String text;
final Function() onTap;
final textColor = const Color(0xFFE8E2EE);
final secondaryColor = const Color(0xFF653E8C);
@override
State<PlanIndicator> createState() => _PlanIndicatorState();
}
class _PlanIndicatorState extends State<PlanIndicator> {
final popoverController = PopoverController();
@override
void dispose() {
popoverController.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
FlowyButton(
margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
text: FlowyText(
text,
color: textColor,
fontSize: 12,
),
radius: BorderRadius.zero,
leftIconSize: const Size(40, 20),
leftIcon: Badge(
padding: const EdgeInsets.symmetric(horizontal: 6),
backgroundColor: secondaryColor,
label: FlowyText.semibold(
planName,
fontSize: 12,
color: textColor,
),
),
onTap: onTap,
),
Padding(
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 6),
child: Opacity(
opacity: 0.4,
child: FlowyText(
reason,
textAlign: TextAlign.start,
color: textColor,
fontSize: 8,
maxLines: 10,
),
),
),
const textGradient = LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF8032FF), Color(0xFFEF35FF)],
stops: [0.1545, 0.8225],
);
final backgroundGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
const Color(0xFF8032FF).withOpacity(.1),
const Color(0xFFEF35FF).withOpacity(.1),
],
);
return AppFlowyPopover(
controller: popoverController,
direction: PopoverDirection.rightWithBottomAligned,
offset: const Offset(10, -12),
popupBuilder: (context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
widget.text,
color: AFThemeExtension.of(context).strongText,
),
const VSpace(12),
Opacity(
opacity: 0.7,
child: FlowyText.regular(
widget.reason,
maxLines: null,
lineHeight: 1.3,
textAlign: TextAlign.center,
),
),
const VSpace(12),
Row(
children: [
Expanded(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
popoverController.close();
widget.onTap();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(9),
),
child: Center(
child: FlowyText(
LocaleKeys
.settings_comparePlanDialog_actions_upgrade
.tr(),
color: Colors.white,
fontSize: 12,
strutStyle: const StrutStyle(
forceStrutHeight: true,
),
),
),
),
),
),
),
],
),
],
),
);
},
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
gradient: backgroundGradient,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const FlowySvg(
FlowySvgs.upgrade_storage_s,
blendMode: null,
),
const HSpace(6),
ShaderMask(
shaderCallback: (bounds) => textGradient.createShader(bounds),
blendMode: BlendMode.srcIn,
child: FlowyText(
widget.text,
color: AFThemeExtension.of(context).strongText,
),
),
],
),
),
),
);
}
}
class _StorageLimitDialog extends StatelessWidget {
const _StorageLimitDialog({
class _AskOwnerToChangePlan extends StatelessWidget {
const _AskOwnerToChangePlan({
required this.message,
required this.onOkPressed,
});
final String message;
final VoidCallback onOkPressed;
@override
Widget build(BuildContext context) {
return NavigatorOkCancelDialog(
message: LocaleKeys.sideBar_storageLimitDialogTitle.tr(),
okTitle: LocaleKeys.sideBar_purchaseStorageSpace.tr(),
message: message,
okTitle: LocaleKeys.button_ok.tr(),
onOkPressed: onOkPressed,
titleUpperCase: false,
);

View File

@ -1,10 +1,10 @@
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
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/document/application/document_appearance_cubit.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart';
import 'package:appflowy/workspace/presentation/home/hotkeys.dart';

View File

@ -1,5 +1,7 @@
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';
@ -36,7 +38,6 @@ 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;
@ -358,9 +359,6 @@ class _SidebarState extends State<_Sidebar> {
child: const SidebarFooter(),
),
const VSpace(14),
// toast
// const SidebarToast(),
],
),
),

View File

@ -1,10 +1,11 @@
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/sidebar/space/space_bloc.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';
class SpaceMigration extends StatefulWidget {
@ -70,14 +71,8 @@ class _SpaceMigrationState extends State<SpaceMigration> {
const linearGradient = LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF8032FF),
Color(0xFFEF35FF),
],
stops: [
0.1545,
0.8225,
],
colors: [Color(0xFF8032FF), Color(0xFFEF35FF)],
stops: [0.1545, 0.8225],
);
return GestureDetector(
behavior: HitTestBehavior.translucent,

View File

@ -411,30 +411,32 @@ class _UserProfileSettingState extends State<UserProfileSetting> {
),
const HSpace(16),
if (!isEditing) ...[
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: FlowyText.medium(
widget.name,
overflow: TextOverflow.ellipsis,
),
),
const HSpace(4),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => isEditing = true),
child: const FlowyHover(
resetHoverOnRebuild: false,
child: Padding(
padding: EdgeInsets.all(4),
child: FlowySvg(FlowySvgs.edit_s),
Flexible(
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: FlowyText.medium(
widget.name,
overflow: TextOverflow.ellipsis,
),
),
),
],
const HSpace(4),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => isEditing = true),
child: const FlowyHover(
resetHoverOnRebuild: false,
child: Padding(
padding: EdgeInsets.all(4),
child: FlowySvg(FlowySvgs.edit_s),
),
),
),
],
),
),
),
] else ...[

View File

@ -91,7 +91,7 @@ class _SettingsBillingViewState extends State<SettingsBillingView> {
},
ready: (state) {
final billingPortalEnabled =
state.subscriptionInfo.plan != WorkspacePlanPB.FreePlan;
state.subscriptionInfo.isBillingPortalEnabled;
return SettingsBody(
title: LocaleKeys.settings_billingPage_title.tr(),
@ -327,14 +327,9 @@ class _AITileState extends State<_AITile> {
: LocaleKeys.settings_billingPage_addons_addLabel.tr(),
fontWeight: FontWeight.w500,
minWidth: _buttonsMinWidth,
onPressed: () {
if (widget.subscriptionInfo != null && isCanceled) {
// Show customer portal to renew
context
.read<SettingsBillingBloc>()
.add(const SettingsBillingEvent.openCustomerPortal());
} else if (widget.subscriptionInfo != null) {
showConfirmDialog(
onPressed: () async {
if (widget.subscriptionInfo != null) {
await showConfirmDialog(
context: context,
style: ConfirmPopupStyle.cancelAndOk,
title: LocaleKeys.settings_billingPage_addons_removeDialog_title
@ -343,11 +338,9 @@ class _AITileState extends State<_AITile> {
.settings_billingPage_addons_removeDialog_description
.tr(namedArgs: {"plan": widget.plan.label.tr()}),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () {
context.read<SettingsBillingBloc>().add(
SettingsBillingEvent.cancelSubscription(widget.plan),
);
},
onConfirm: () => context
.read<SettingsBillingBloc>()
.add(SettingsBillingEvent.cancelSubscription(widget.plan)),
);
} else {
// Add the addon

View File

@ -1,8 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
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';
@ -29,6 +26,8 @@ import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fluttertoast/fluttertoast.dart';
@ -71,6 +70,7 @@ class SettingsManageDataView extends StatelessWidget {
description: LocaleKeys
.settings_manageDataPage_dataStorage_resetDialog_description
.tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () async {
final directory =
await appFlowyApplicationDataDirectory();

View File

@ -5,6 +5,7 @@ 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/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
@ -141,7 +142,7 @@ class _SettingsPlanComparisonDialogState
children: [
const VSpace(30),
SizedBox(
height: 100,
height: 116,
child: FlowyText.semibold(
LocaleKeys
.settings_comparePlanDialog_planFeatures
@ -153,7 +154,7 @@ class _SettingsPlanComparisonDialogState
: const Color(0xFFE8E0FF),
),
),
const SizedBox(height: 96),
const SizedBox(height: 116),
const SizedBox(height: 56),
..._planLabels.map(
(e) => _ComparisonCell(
@ -184,17 +185,9 @@ class _SettingsPlanComparisonDialogState
cells: _freeLabels,
isCurrent:
currentInfo.plan == WorkspacePlanPB.FreePlan,
canDowngrade:
currentInfo.plan != WorkspacePlanPB.FreePlan,
currentCanceled: currentInfo.isCanceled ||
(context
.watch<SettingsPlanBloc>()
.state
.mapOrNull(
loading: (_) => true,
ready: (s) => s.downgradeProcessing,
) ??
false),
buttonType: WorkspacePlanPB.FreePlan.buttonTypeFor(
currentInfo.plan,
),
onSelected: () async {
if (currentInfo.plan ==
WorkspacePlanPB.FreePlan ||
@ -202,6 +195,12 @@ class _SettingsPlanComparisonDialogState
return;
}
final reason =
await showCancelSurveyDialog(context);
if (reason == null || !context.mounted) {
return;
}
await showConfirmDialog(
context: context,
title: LocaleKeys
@ -216,8 +215,9 @@ class _SettingsPlanComparisonDialogState
style: ConfirmPopupStyle.cancelAndOk,
onConfirm: () =>
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent
.cancelSubscription(),
SettingsPlanEvent.cancelSubscription(
reason: reason,
),
),
);
},
@ -242,9 +242,9 @@ class _SettingsPlanComparisonDialogState
cells: _proLabels,
isCurrent:
currentInfo.plan == WorkspacePlanPB.ProPlan,
canUpgrade:
currentInfo.plan == WorkspacePlanPB.FreePlan,
currentCanceled: currentInfo.isCanceled,
buttonType: WorkspacePlanPB.ProPlan.buttonTypeFor(
currentInfo.plan,
),
onSelected: () =>
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
@ -266,6 +266,35 @@ class _SettingsPlanComparisonDialogState
}
}
enum _PlanButtonType {
none,
upgrade,
downgrade;
bool get isDowngrade => this == downgrade;
bool get isUpgrade => this == upgrade;
}
extension _ButtonTypeFrom on WorkspacePlanPB {
/// Returns the button type for the given plan, taking the
/// current plan as [other].
///
_PlanButtonType buttonTypeFor(WorkspacePlanPB other) {
/// Current plan, no action
if (this == other) {
return _PlanButtonType.none;
}
// Free plan, can downgrade if not on the free plan
if (this == WorkspacePlanPB.FreePlan && other != WorkspacePlanPB.FreePlan) {
return _PlanButtonType.downgrade;
}
// Else we can assume it's an upgrade
return _PlanButtonType.upgrade;
}
}
class _PlanTable extends StatelessWidget {
const _PlanTable({
required this.title,
@ -275,9 +304,7 @@ class _PlanTable extends StatelessWidget {
required this.cells,
required this.isCurrent,
required this.onSelected,
this.canUpgrade = false,
this.canDowngrade = false,
this.currentCanceled = false,
this.buttonType = _PlanButtonType.none,
});
final String title;
@ -288,13 +315,11 @@ class _PlanTable extends StatelessWidget {
final List<_CellItem> cells;
final bool isCurrent;
final VoidCallback onSelected;
final bool canUpgrade;
final bool canDowngrade;
final bool currentCanceled;
final _PlanButtonType buttonType;
@override
Widget build(BuildContext context) {
final highlightPlan = !isCurrent && !canDowngrade && canUpgrade;
final highlightPlan = !isCurrent && buttonType == _PlanButtonType.upgrade;
final isLM = Theme.of(context).isLightMode;
return Container(
@ -336,37 +361,29 @@ class _PlanTable extends StatelessWidget {
title: price,
description: priceInfo,
isPrimary: !highlightPlan,
height: 96,
),
if (canUpgrade || canDowngrade) ...[
if (buttonType == _PlanButtonType.none) ...[
const SizedBox(height: 56),
] else ...[
Opacity(
opacity: canDowngrade && currentCanceled ? 0.5 : 1,
opacity: 1,
child: Padding(
padding: EdgeInsets.only(
left: 12 + (canUpgrade && !canDowngrade ? 12 : 0),
left: 12 + (buttonType.isUpgrade ? 12 : 0),
),
child: _ActionButton(
label: canUpgrade && !canDowngrade
label: buttonType.isUpgrade
? LocaleKeys.settings_comparePlanDialog_actions_upgrade
.tr()
: LocaleKeys
.settings_comparePlanDialog_actions_downgrade
.tr(),
onPressed: !canUpgrade && canDowngrade && currentCanceled
? null
: onSelected,
tooltip: !canUpgrade && canDowngrade && currentCanceled
? LocaleKeys
.settings_comparePlanDialog_actions_downgradeDisabledTooltip
.tr()
: null,
isUpgrade: canUpgrade && !canDowngrade,
useGradientBorder: !isCurrent && canUpgrade,
onPressed: onSelected,
isUpgrade: buttonType.isUpgrade,
useGradientBorder: buttonType.isUpgrade,
),
),
),
] else ...[
const SizedBox(height: 56),
],
...cells.map(
(cell) => _ComparisonCell(
@ -467,14 +484,12 @@ class _ComparisonCell extends StatelessWidget {
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.label,
this.tooltip,
required this.onPressed,
required this.isUpgrade,
this.useGradientBorder = false,
});
final String label;
final String? tooltip;
final VoidCallback? onPressed;
final bool isUpgrade;
final bool useGradientBorder;
@ -487,30 +502,27 @@ class _ActionButton extends StatelessWidget {
height: 56,
child: Row(
children: [
FlowyTooltip(
message: tooltip,
child: GestureDetector(
onTap: onPressed,
child: MouseRegion(
cursor: onPressed != null
? SystemMouseCursors.click
: MouseCursor.defer,
child: _drawBorder(
context,
isLM: isLM,
isUpgrade: isUpgrade,
child: Container(
height: 36,
width: 148,
decoration: BoxDecoration(
color: useGradientBorder
? Theme.of(context).cardColor
: Colors.transparent,
border: Border.all(color: Colors.transparent),
borderRadius: BorderRadius.circular(14),
),
child: Center(child: _drawText(label, isLM, isUpgrade)),
GestureDetector(
onTap: onPressed,
child: MouseRegion(
cursor: onPressed != null
? SystemMouseCursors.click
: MouseCursor.defer,
child: _drawBorder(
context,
isLM: isLM,
isUpgrade: isUpgrade,
child: Container(
height: 36,
width: 148,
decoration: BoxDecoration(
color: useGradientBorder
? Theme.of(context).cardColor
: Colors.transparent,
border: Border.all(color: Colors.transparent),
borderRadius: BorderRadius.circular(14),
),
child: Center(child: _drawText(label, isLM, isUpgrade)),
),
),
),
@ -538,10 +550,7 @@ class _ActionButton extends StatelessWidget {
shaderCallback: (bounds) => const LinearGradient(
transform: GradientRotation(-1.55),
stops: [0.4, 1],
colors: [
Color(0xFF251D37),
Color(0xFF7547C0),
],
colors: [Color(0xFF251D37), Color(0xFF7547C0)],
).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
child: child,
);
@ -579,19 +588,17 @@ class _Heading extends StatelessWidget {
required this.title,
this.description,
this.isPrimary = true,
this.height = 100,
});
final String title;
final String? description;
final bool isPrimary;
final double height;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 185,
height: height,
height: 116,
child: Padding(
padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)),
child: Column(
@ -615,11 +622,13 @@ class _Heading extends StatelessWidget {
),
if (description != null && description!.isNotEmpty) ...[
const VSpace(4),
FlowyText.regular(
description!,
fontSize: 12,
maxLines: 3,
lineHeight: 1.5,
Flexible(
child: FlowyText.regular(
description!,
fontSize: 12,
maxLines: 5,
lineHeight: 1.5,
),
),
],
],

View File

@ -232,7 +232,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
const VSpace(8),
FlowyText.regular(
widget.subscriptionInfo.info,
fontSize: 16,
fontSize: 14,
color: AFThemeExtension.of(context).strongText,
maxLines: 3,
),

View File

@ -0,0 +1,431 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:collection/collection.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';
Future<String?> showCancelSurveyDialog(BuildContext context) {
return showDialog<String?>(
context: context,
builder: (_) => const _Survey(),
);
}
class _Survey extends StatefulWidget {
const _Survey();
@override
State<_Survey> createState() => _SurveyState();
}
class _SurveyState extends State<_Survey> {
final PageController pageController = PageController();
final Map<String, String> answers = {};
@override
void dispose() {
pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: SizedBox(
width: 674,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Survey title
Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: FlowyText(
LocaleKeys.settings_cancelSurveyDialog_title.tr(),
fontSize: 22.0,
overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).strongText,
),
),
FlowyButton(
useIntrinsicWidth: true,
text: const FlowySvg(FlowySvgs.upgrade_close_s),
onTap: () => Navigator.of(context).pop(),
),
],
),
const VSpace(12),
// Survey explanation
FlowyText(
LocaleKeys.settings_cancelSurveyDialog_description.tr(),
maxLines: 3,
),
const VSpace(8),
const Divider(),
const VSpace(8),
// Question "sheet"
SizedBox(
height: 400,
width: 650,
child: PageView.builder(
controller: pageController,
itemCount: _questionsAndAnswers.length,
itemBuilder: (context, index) => _QAPage(
qa: _questionsAndAnswers[index],
isFirstQuestion: index == 0,
isFinalQuestion:
index == _questionsAndAnswers.length - 1,
selectedAnswer:
answers[_questionsAndAnswers[index].question],
onPrevious: () {
if (index > 0) {
pageController.animateToPage(
index - 1,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
onAnswerChanged: (answer) {
answers[_questionsAndAnswers[index].question] =
answer;
},
onAnswerSelected: (answer) {
answers[_questionsAndAnswers[index].question] =
answer;
if (index == _questionsAndAnswers.length - 1) {
Navigator.of(context).pop(jsonEncode(answers));
} else {
pageController.animateToPage(
index + 1,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
),
),
),
],
),
),
],
),
),
),
);
}
}
class _QAPage extends StatefulWidget {
const _QAPage({
required this.qa,
required this.onAnswerSelected,
required this.onAnswerChanged,
required this.onPrevious,
this.selectedAnswer,
this.isFirstQuestion = false,
this.isFinalQuestion = false,
});
final _QA qa;
final String? selectedAnswer;
/// Called when "Next" is pressed
///
final Function(String) onAnswerSelected;
/// Called whenever an answer is selected or changed
///
final Function(String) onAnswerChanged;
final VoidCallback onPrevious;
final bool isFirstQuestion;
final bool isFinalQuestion;
@override
State<_QAPage> createState() => _QAPageState();
}
class _QAPageState extends State<_QAPage> {
final otherController = TextEditingController();
int _selectedIndex = -1;
String? answer;
@override
void initState() {
super.initState();
if (widget.selectedAnswer != null) {
answer = widget.selectedAnswer;
_selectedIndex = widget.qa.answers.indexOf(widget.selectedAnswer!);
if (_selectedIndex == -1) {
// We assume the last question is "Other"
_selectedIndex = widget.qa.answers.length - 1;
otherController.text = widget.selectedAnswer!;
}
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText(
widget.qa.question,
fontSize: 16.0,
color: AFThemeExtension.of(context).strongText,
),
const VSpace(18),
SeparatedColumn(
separatorBuilder: () => const VSpace(6),
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.qa.answers
.mapIndexed(
(index, option) => _AnswerOption(
prefix: _indexToLetter(index),
option: option,
isSelected: _selectedIndex == index,
onTap: () => setState(() {
_selectedIndex = index;
if (_selectedIndex == widget.qa.answers.length - 1 &&
widget.qa.lastIsOther) {
answer = otherController.text;
} else {
answer = option;
}
widget.onAnswerChanged(option);
}),
),
)
.toList(),
),
if (widget.qa.lastIsOther &&
_selectedIndex == widget.qa.answers.length - 1) ...[
const VSpace(8),
FlowyTextField(
controller: otherController,
hintText: LocaleKeys.settings_cancelSurveyDialog_otherHint.tr(),
onChanged: (value) => setState(() {
answer = value;
widget.onAnswerChanged(value);
}),
),
],
const VSpace(20),
Row(
children: [
if (!widget.isFirstQuestion) ...[
DecoratedBox(
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: const BorderSide(color: Color(0x1E14171B)),
borderRadius: BorderRadius.circular(8),
),
),
child: FlowyButton(
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 9.0,
),
text: FlowyText.regular(LocaleKeys.button_previous.tr()),
onTap: widget.onPrevious,
),
),
const HSpace(12.0),
],
DecoratedBox(
decoration: ShapeDecoration(
color: Theme.of(context).colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: FlowyButton(
useIntrinsicWidth: true,
margin:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0),
radius: BorderRadius.circular(8),
text: FlowyText.regular(
widget.isFinalQuestion
? LocaleKeys.button_submit.tr()
: LocaleKeys.button_next.tr(),
color: Colors.white,
),
disable: !canProceed(),
onTap: canProceed()
? () => widget.onAnswerSelected(
answer ?? widget.qa.answers[_selectedIndex],
)
: null,
),
),
],
),
],
);
}
bool canProceed() {
if (_selectedIndex == widget.qa.answers.length - 1 &&
widget.qa.lastIsOther) {
return answer != null &&
answer!.isNotEmpty &&
answer != LocaleKeys.settings_cancelSurveyDialog_commonOther.tr();
}
return _selectedIndex != -1;
}
}
class _AnswerOption extends StatelessWidget {
const _AnswerOption({
required this.prefix,
required this.option,
required this.onTap,
this.isSelected = false,
});
final String prefix;
final String option;
final VoidCallback onTap;
final bool isSelected;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: Corners.s8Border,
border: Border.all(
width: 2,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const HSpace(2),
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor,
borderRadius: Corners.s6Border,
),
child: Center(
child: FlowyText(
prefix,
color: isSelected ? Colors.white : null,
),
),
),
const HSpace(8),
FlowyText(
option,
fontWeight: FontWeight.w400,
fontSize: 16.0,
color: AFThemeExtension.of(context).strongText,
),
const HSpace(6),
],
),
),
),
);
}
}
final _questionsAndAnswers = [
_QA(
question: LocaleKeys.settings_cancelSurveyDialog_questionOne_question.tr(),
answers: [
LocaleKeys.settings_cancelSurveyDialog_questionOne_answerOne.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionOne_answerTwo.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionOne_answerThree.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionOne_answerFour.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionOne_answerFive.tr(),
LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(),
],
lastIsOther: true,
),
_QA(
question: LocaleKeys.settings_cancelSurveyDialog_questionTwo_question.tr(),
answers: [
LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerOne.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerTwo.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerThree.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerFour.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerFive.tr(),
],
),
_QA(
question:
LocaleKeys.settings_cancelSurveyDialog_questionThree_question.tr(),
answers: [
LocaleKeys.settings_cancelSurveyDialog_questionThree_answerOne.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionThree_answerTwo.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionThree_answerThree.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionThree_answerFour.tr(),
LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(),
],
lastIsOther: true,
),
_QA(
question: LocaleKeys.settings_cancelSurveyDialog_questionFour_question.tr(),
answers: [
LocaleKeys.settings_cancelSurveyDialog_questionFour_answerOne.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionFour_answerTwo.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionFour_answerThree.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionFour_answerFour.tr(),
LocaleKeys.settings_cancelSurveyDialog_questionFour_answerFive.tr(),
],
),
];
class _QA {
const _QA({
required this.question,
required this.answers,
this.lastIsOther = false,
});
final String question;
final List<String> answers;
final bool lastIsOther;
}
/// Returns the letter corresponding to the index.
///
/// Eg. 0 -> A, 1 -> B, 2 -> C, ..., and so forth.
///
String _indexToLetter(int index) {
return String.fromCharCode(65 + index);
}

View File

@ -72,8 +72,6 @@ class WorkspaceMembersPage extends StatelessWidget {
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;

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.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/shared/feature_flags.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart';
@ -111,26 +112,26 @@ class SettingsMenu extends StatelessWidget {
),
changeSelectedPage: changeSelectedPage,
),
// if (FeatureFlag.planBilling.isOn &&
// userProfile.authenticator ==
// AuthenticatorPB.AppFlowyCloud &&
// member != null &&
// member!.role.isOwner) ...[
// SettingsMenuElement(
// page: SettingsPage.plan,
// selectedPage: currentPage,
// label: LocaleKeys.settings_planPage_menuLabel.tr(),
// icon: const FlowySvg(FlowySvgs.settings_plan_m),
// changeSelectedPage: changeSelectedPage,
// ),
// SettingsMenuElement(
// page: SettingsPage.billing,
// selectedPage: currentPage,
// label: LocaleKeys.settings_billingPage_menuLabel.tr(),
// icon: const FlowySvg(FlowySvgs.settings_billing_m),
// changeSelectedPage: changeSelectedPage,
// ),
// ],
if (FeatureFlag.planBilling.isOn &&
userProfile.authenticator ==
AuthenticatorPB.AppFlowyCloud &&
member != null &&
member!.role.isOwner) ...[
SettingsMenuElement(
page: SettingsPage.plan,
selectedPage: currentPage,
label: LocaleKeys.settings_planPage_menuLabel.tr(),
icon: const FlowySvg(FlowySvgs.settings_plan_m),
changeSelectedPage: changeSelectedPage,
),
SettingsMenuElement(
page: SettingsPage.billing,
selectedPage: currentPage,
label: LocaleKeys.settings_billingPage_menuLabel.tr(),
icon: const FlowySvg(FlowySvgs.settings_billing_m),
changeSelectedPage: changeSelectedPage,
),
],
if (kDebugMode)
SettingsMenuElement(
// no need to translate this page

View File

@ -1,5 +1,6 @@
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/dart-ffi/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
import 'package:flutter/foundation.dart';
@ -52,6 +53,8 @@ class StackTraceError {
typedef void ErrorListener();
/// Receive error when Rust backend send error message back to the flutter frontend
///
class GlobalErrorCodeNotifier extends ChangeNotifier {
// Static instance with lazy initialization
static final GlobalErrorCodeNotifier _instance =
@ -107,3 +110,10 @@ class GlobalErrorCodeNotifier extends ChangeNotifier {
_instance.removeListener(listener);
}
}
extension FlowyErrorExtension on FlowyError {
bool get isAIResponseLimitExceeded =>
code == ErrorCode.AIResponseLimitExceeded;
bool get isStorageLimitExceeded => code == ErrorCode.FileStorageLimitExceeded;
}

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart';
@ -168,6 +169,37 @@ class FlowyTextButton extends StatelessWidget {
this.borderColor,
});
factory FlowyTextButton.primary({
required BuildContext context,
required String text,
VoidCallback? onPressed,
}) =>
FlowyTextButton(
text,
constraints: const BoxConstraints(minHeight: 32),
fillColor: Theme.of(context).colorScheme.primary,
hoverColor: const Color(0xFF005483),
fontColor: AFThemeExtension.of(context).strongText,
fontHoverColor: Colors.white,
onPressed: onPressed,
);
factory FlowyTextButton.secondary({
required BuildContext context,
required String text,
VoidCallback? onPressed,
}) =>
FlowyTextButton(
text,
constraints: const BoxConstraints(minHeight: 32),
fillColor: Colors.transparent,
hoverColor: Theme.of(context).colorScheme.primary,
fontColor: Theme.of(context).colorScheme.primary,
borderColor: Theme.of(context).colorScheme.primary,
fontHoverColor: Colors.white,
onPressed: onPressed,
);
final String text;
final FontWeight? fontWeight;
final Color? fontColor;

View File

@ -53,8 +53,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: e0d673a
resolved-ref: e0d673afbbbcaf9df0276f7e0b6405d8f6e98112
ref: "61d4363"
resolved-ref: "61d4363b4675f7ef91ef5003f2f88bbcb4d9dfa9"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "3.1.0"

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.4
version: 0.6.5
environment:
flutter: ">=3.22.0"
@ -191,7 +191,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "e0d673a"
ref: "61d4363"
appflowy_editor_plugins:
git:

View File

@ -23,7 +23,7 @@ export function Toolbar({
return (
<div className={'flex items-center justify-between overflow-x-auto overflow-y-hidden'}>
<div className={'whitespace-nowrap text-base font-medium'}>{dateStr}</div>
<div className={'whitespace-nowrap text-sm font-medium'}>{dateStr}</div>
<div className={'flex items-center justify-end gap-2'}>
<IconButton size={'small'} onClick={() => onNavigate('PREV')}>
<LeftArrow />

View File

@ -21,7 +21,7 @@ function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index:
textAlign: 'left',
};
if ([FieldType.Relation, FieldType.SingleSelect, FieldType.MultiSelect].includes(Number(type))) {
if (isPrimary || [FieldType.Relation, FieldType.SingleSelect, FieldType.MultiSelect].includes(Number(type))) {
Object.assign(styleProperties, {
breakWord: 'break-word',
whiteSpace: 'normal',

View File

@ -1,4 +1,8 @@
import { YjsDatabaseKey } from '@/application/collab.type';
import { currencyFormaterMap, FieldType, parseNumberTypeOptions, useFieldSelector } from '@/application/database-yjs';
import { CalculationType } from '@/application/database-yjs/database.type';
import Decimal from 'decimal.js';
import { isNaN } from 'lodash-es';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@ -16,6 +20,16 @@ export interface CalculationCellProps {
export function CalculationCell({ cell }: CalculationCellProps) {
const { t } = useTranslation();
const fieldId = cell?.fieldId || '';
const { field } = useFieldSelector(fieldId);
const format = useMemo(
() =>
field && Number(field?.get(YjsDatabaseKey.type)) === FieldType.Number
? parseNumberTypeOptions(field).format
: undefined,
[field]
);
const prefix = useMemo(() => {
if (!cell) return '';
@ -39,10 +53,22 @@ export function CalculationCell({ cell }: CalculationCellProps) {
}
}, [cell, t]);
const num = useMemo(() => {
const value = cell?.value;
if (value === undefined || isNaN(value)) return '';
if (format && currencyFormaterMap[format]) {
return currencyFormaterMap[format](new Decimal(value).toNumber());
}
return parseFloat(value);
}, [cell?.value, format]);
return (
<div className={'h-full w-full px-1 text-right text-xs font-medium uppercase leading-[36px] text-text-caption'}>
{prefix}
<span className={'ml-2 text-text-title'}>{cell?.value ?? ''}</span>
<div className={'h-full w-full px-1 text-right uppercase leading-[36px] text-text-caption'}>
<span className={'text-xs'}>{prefix}</span>
<span className={'ml-2 text-sm font-medium text-text-title'}>{num}</span>
</div>
);
}

View File

@ -1,3 +1,4 @@
import { DEFAULT_ROW_HEIGHT } from '@/application/database-yjs';
import React, { memo, useCallback, useEffect, useRef } from 'react';
import { areEqual, GridChildComponentProps, VariableSizeGrid } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
@ -53,7 +54,7 @@ export const GridHeader = ({ scrollLeft, onScrollLeft, columnWidth, columns }: G
className={'grid-sticky-header w-full text-text-title'}
height={height}
width={width}
rowHeight={() => 36}
rowHeight={() => DEFAULT_ROW_HEIGHT}
rowCount={1}
columnCount={columns.length}
columnWidth={(index) => {

View File

@ -48,12 +48,14 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
return classList.join(' ');
}, [layout]);
const showActions = !hideConditions && layout !== DatabaseViewLayout.Calendar;
if (viewIds.length === 0) return null;
return (
<div ref={ref} className={className}>
<div
style={{
width: 'calc(100% - 120px)',
width: showActions ? 'calc(100% - 120px)' : '100%',
}}
className='flex items-center '
>
@ -90,7 +92,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
})}
</ViewTabs>
</div>
{!hideConditions && layout !== DatabaseViewLayout.Calendar ? <DatabaseActions /> : null}
{showActions ? <DatabaseActions /> : null}
</div>
);
}

View File

@ -0,0 +1,15 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 16V14.5H14V16H6ZM9.25 13V5.875L7.0625 8.0625L6 7L10 3L14 7L12.9375 8.0625L10.75 5.875V13H9.25Z" fill="#E8EAED"/>
<path d="M6 16V14.5H14V16H6ZM9.25 13V5.875L7.0625 8.0625L6 7L10 3L14 7L12.9375 8.0625L10.75 5.875V13H9.25Z" fill="url(#paint0_linear_3646_112419)"/>
<path d="M6 16V14.5H14V16H6ZM9.25 13V5.875L7.0625 8.0625L6 7L10 3L14 7L12.9375 8.0625L10.75 5.875V13H9.25Z" fill="url(#paint1_linear_3646_112419)"/>
<defs>
<linearGradient id="paint0_linear_3646_112419" x1="10" y1="3" x2="10" y2="16" gradientUnits="userSpaceOnUse">
<stop stop-color="#8132FF" stop-opacity="0"/>
<stop offset="1" stop-color="#8132FF"/>
</linearGradient>
<linearGradient id="paint1_linear_3646_112419" x1="7.54546" y1="14.8182" x2="15.0845" y2="11.9942" gradientUnits="userSpaceOnUse">
<stop stop-color="#8032FF"/>
<stop offset="1" stop-color="#EF35FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 973 B

View File

@ -283,11 +283,14 @@
"favoriteSpace": "Favorites",
"RecentSpace": "Recent",
"Spaces": "Spaces",
"upgradeToPro": "Upgrade to Pro Plan",
"upgradeToPro": "Upgrade to Pro",
"upgradeToAIMax": "Unlock unlimited AI",
"storageLimitDialogTitle": "You are running out of storage space. Upgrade to Pro Plan to get more storage",
"aiResponseLitmitDialogTitle": "You are running out of AI responses. Upgrade to Pro Plan or AI Max to get more AI responses",
"aiResponseLitmit": "You are running out of AI responses. Go to Settings -> Plan -> Click AI Max or Pro Plan to get more AI responses",
"storageLimitDialogTitle": "You have run out of free storage. Upgrade to unlock unlimited storage",
"aiResponseLimitTitle": "You have run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses",
"aiResponseLimitDialogTitle": "AI Responses limit reached",
"aiResponseLimit": "You have run out of free AI responses.\n\nGo to Settings -> Plan -> Click AI Max or Pro Plan to get more AI responses",
"askOwnerToUpgradeToPro": "Your workspace is running out of free storage. Please ask your workspace owner to upgrade to the Pro Plan",
"askOwnerToUpgradeToAIMax": "Your workspace is running out of free AI responses. Please ask your workspace owner to upgrade the plan or purchase AI add-ons",
"purchaseStorageSpace": "Purchase Storage Space",
"purchaseAIResponse": "Purchase ",
"upgradeToAILocal": "AI offline on your device"
@ -350,7 +353,10 @@
"signInDiscord": "Continue with Discord",
"more": "More",
"create": "Create",
"close": "Close"
"close": "Close",
"next": "Next",
"previous": "Previous",
"submit": "Submit"
},
"label": {
"welcome": "Welcome!",
@ -638,7 +644,7 @@
"menuLabel": "AI Settings",
"keys": {
"enableAISearchTitle": "AI Search",
"aiSettingsDescription": "Select or configure Ai models used on @:appName. For best performance we recommend using the default model options",
"aiSettingsDescription": "Select or configure AI models used on @:appName. For best performance we recommend using the default model options",
"loginToEnableAIFeature": "AI features are only enabled after logging in with @:appName Cloud. If you don't have an @:appName account, go to 'My Account' to sign up",
"llmModel": "Language Model",
"llmModelType": "Language Model Type",
@ -704,14 +710,14 @@
"title": "AI Max",
"description": "Unlock unlimited AI",
"price": "{}",
"priceInfo": "/user per month",
"priceInfo": "per user per month",
"billingInfo": "billed annually or {} billed monthly"
},
"aiOnDevice": {
"title": "AI On-device",
"description": "AI offline on your device",
"price": "{}",
"priceInfo": "/user per month",
"priceInfo": "per user per month",
"billingInfo": "billed annually or {} billed monthly"
}
},
@ -776,7 +782,6 @@
"actions": {
"upgrade": "Upgrade",
"downgrade": "Downgrade",
"downgradeDisabledTooltip": "You will automatically downgrade at the end of the billing cycle",
"current": "Current"
},
"freePlan": {
@ -789,7 +794,7 @@
"title": "Pro",
"description": "For small teams to manage projects and team knowledge",
"price": "{}",
"priceInfo": "/user per month billed annually\n\n{} billed monthly"
"priceInfo": "per user per month \nbilled annually\n\n{} billed monthly"
},
"planLabels": {
"itemOne": "Workspaces",
@ -830,6 +835,43 @@
"downgradeLabel": "Downgrade plan"
}
},
"cancelSurveyDialog": {
"title": "Sorry to see you go",
"description": "We're sorry to see you go. We'd love to hear your feedback to help us improve @:appName. Please take a moment to answer a few questions.",
"commonOther": "Other",
"otherHint": "Write your answer here",
"questionOne": {
"question": "What prompted you to cancel your AppFlowy Pro subscription?",
"answerOne": "Cost too high",
"answerTwo": "Features did not meet expectations",
"answerThree": "Found a better alternative",
"answerFour": "Did not use it enough to justify the expense",
"answerFive": "Service issue or technical difficulties"
},
"questionTwo": {
"question": "How likely are you to consider re-subscribing to AppFlowy Pro in the future?",
"answerOne": "Very likely",
"answerTwo": "Somewhat likely",
"answerThree": "Not sure",
"answerFour": "Unlikely",
"answerFive": "Very unlikely"
},
"questionThree": {
"question": "Which Pro feature did you value the most during your subscription?",
"answerOne": "Multi-user collaboration",
"answerTwo": "Longer time version history",
"answerThree": "Unlimited AI responses",
"answerFour": "Access to local AI models"
},
"questionFour": {
"question": "How would you describe your overall experience with AppFlowy?",
"answerOne": "Great",
"answerTwo": "Good",
"answerThree": "Average",
"answerFour": "Below average",
"answerFive": "Unsatisfied"
}
},
"common": {
"reset": "Reset"
},
@ -1441,7 +1483,7 @@
"image": {
"copiedToPasteBoard": "The image link has been copied to the clipboard",
"addAnImage": "Add an image",
"imageUploadFailed": "Image upload failed",
"imageUploadFailed": "Upload failed",
"errorCode": "Error code"
},
"math": {
@ -2209,4 +2251,4 @@
"replyingTo": "Replying to",
"noAccessDeleteComment": "You're not allowed to delete this comment"
}
}
}

View File

@ -148,7 +148,12 @@ impl Chat {
},
Err(err) => {
error!("[Chat] failed to stream answer: {}", err);
let _ = text_sink.send(format!("error:{}", err)).await;
if err.is_ai_response_limit_exceeded() {
let _ = text_sink.send("AI_RESPONSE_LIMIT".to_string()).await;
} else {
let _ = text_sink.send(format!("error:{}", err)).await;
}
let pb = ChatMessageErrorPB {
chat_id: chat_id.clone(),
error_message: err.to_string(),

View File

@ -1,10 +1,5 @@
{
"type": "page",
"data": {
"delta": [
{"insert": ""}
]
},
"children": [
{
"type": "heading",