Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01747f13e8 | ||
|
|
4a5eda6eeb | ||
|
|
b5d799655a | ||
|
|
43b250ec28 | ||
|
|
20e82880ff | ||
|
|
661a0879c6 | ||
|
|
2fb18dd051 | ||
|
|
6334255e15 | ||
|
|
f66821715f | ||
|
|
620e027c3e | ||
|
|
864768b3ba | ||
|
|
a8b4f22703 | ||
|
|
432db0f6d5 | ||
|
|
e8e4162a5c | ||
|
|
241234726f | ||
|
|
e426970eed | ||
|
|
d5544af6c5 | ||
|
|
9361afd573 | ||
|
|
dc93a336d6 | ||
|
|
4e5482488f | ||
|
|
5bbf174ffd |
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
- "release/*"
|
||||
paths:
|
||||
- "frontend/rust-lib/**"
|
||||
- ".github/workflows/rust_ci.yaml"
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -22,17 +23,49 @@ env:
|
||||
RUST_TOOLCHAIN: "1.77.2"
|
||||
|
||||
jobs:
|
||||
test-on-ubuntu:
|
||||
self-hosted-job:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout Appflowy Cloud
|
||||
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 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
|
||||
|
||||
ubuntu-job:
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
@@ -45,23 +78,16 @@ jobs:
|
||||
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"
|
||||
prefix-key: ${{ runner.os }}
|
||||
cache-on-failure: true
|
||||
workspaces: |
|
||||
frontend/rust-lib
|
||||
|
||||
@@ -74,7 +100,6 @@ jobs:
|
||||
- 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
|
||||
@@ -106,6 +131,12 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
-1
@@ -13,7 +13,6 @@
|
||||
"type": "dart",
|
||||
"env": {
|
||||
"RUST_LOG": "debug",
|
||||
"RUST_BACKTRACE": "1"
|
||||
},
|
||||
// uncomment the following line to testing performance.
|
||||
// "flutterMode": "profile",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_button.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -51,9 +51,8 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
final shareButton = find.byType(DocumentShareButton);
|
||||
final shareButtonState =
|
||||
tester.widget(shareButton) as DocumentShareButton;
|
||||
final shareButton = find.byType(ShareButton);
|
||||
final shareButtonState = tester.widget(shareButton) as ShareButton;
|
||||
|
||||
final path = await mockSaveFilePath(
|
||||
p.join(
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/core/config/kv_keys.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_button.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/presentation/screens/screens.dart';
|
||||
@@ -35,6 +30,10 @@ import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'emoji.dart';
|
||||
@@ -259,7 +258,7 @@ extension CommonOperations on WidgetTester {
|
||||
/// Tap the share button above the document page.
|
||||
Future<void> tapShareButton() async {
|
||||
final shareButton = find.byWidgetPredicate(
|
||||
(widget) => widget is DocumentShareButton,
|
||||
(widget) => widget is ShareButton,
|
||||
);
|
||||
await tapButton(shareButton);
|
||||
}
|
||||
|
||||
+47
-23
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@@ -9,13 +10,12 @@ part 'chat_file_bloc.freezed.dart';
|
||||
class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
ChatFileBloc({
|
||||
required String chatId,
|
||||
dynamic message,
|
||||
}) : listener = LocalLLMListener(),
|
||||
super(ChatFileState.initial(message)) {
|
||||
super(const ChatFileState()) {
|
||||
listener.start(
|
||||
stateCallback: (pluginState) {
|
||||
chatStateCallback: (chatState) {
|
||||
if (!isClosed) {
|
||||
add(ChatFileEvent.updateLocalAIState(pluginState));
|
||||
add(ChatFileEvent.updateChatState(chatState));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -24,26 +24,30 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
initial: () async {
|
||||
final result = await ChatEventGetPluginState().send();
|
||||
final result = await ChatEventGetLocalAIChatState().send();
|
||||
result.fold(
|
||||
(pluginState) {
|
||||
(chatState) {
|
||||
if (!isClosed) {
|
||||
add(ChatFileEvent.updateLocalAIState(pluginState));
|
||||
add(
|
||||
ChatFileEvent.updateChatState(chatState),
|
||||
);
|
||||
}
|
||||
},
|
||||
(err) {},
|
||||
(err) {
|
||||
Log.error(err.toString());
|
||||
},
|
||||
);
|
||||
},
|
||||
newFile: (String filePath) {
|
||||
final payload = ChatFilePB(filePath: filePath, chatId: chatId);
|
||||
ChatEventChatWithFile(payload).send();
|
||||
},
|
||||
updateLocalAIState: (PluginStatePB pluginState) {
|
||||
updateChatState: (LocalAIChatPB chatState) {
|
||||
// Only user enable chat with file and the plugin is already running
|
||||
final supportChatWithFile = chatState.fileEnabled &&
|
||||
chatState.pluginState.state == RunningStatePB.Running;
|
||||
emit(
|
||||
state.copyWith(
|
||||
supportChatWithFile:
|
||||
pluginState.state == RunningStatePB.Running,
|
||||
),
|
||||
state.copyWith(supportChatWithFile: supportChatWithFile),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -64,20 +68,19 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
class ChatFileEvent with _$ChatFileEvent {
|
||||
const factory ChatFileEvent.initial() = Initial;
|
||||
const factory ChatFileEvent.newFile(String filePath) = _NewFile;
|
||||
const factory ChatFileEvent.updateLocalAIState(PluginStatePB pluginState) =
|
||||
_UpdateLocalAIState;
|
||||
const factory ChatFileEvent.updateChatState(LocalAIChatPB chatState) =
|
||||
_UpdateChatState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatFileState with _$ChatFileState {
|
||||
const factory ChatFileState({
|
||||
required String text,
|
||||
@Default(false) bool supportChatWithFile,
|
||||
}) = _ChatFileState;
|
||||
}
|
||||
|
||||
factory ChatFileState.initial(dynamic text) {
|
||||
return ChatFileState(
|
||||
text: text is String ? text : "",
|
||||
);
|
||||
}
|
||||
@freezed
|
||||
class LocalAIChatFileIndicator with _$LocalAIChatFileIndicator {
|
||||
const factory LocalAIChatFileIndicator.ready(bool isEnabled) = _Ready;
|
||||
const factory LocalAIChatFileIndicator.loading() = _Loading;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'chat_input_bloc.freezed.dart';
|
||||
|
||||
class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
|
||||
ChatInputBloc()
|
||||
: listener = LocalLLMListener(),
|
||||
super(const ChatInputState(aiType: _AppFlowyAI())) {
|
||||
listener.start(
|
||||
stateCallback: (pluginState) {
|
||||
if (!isClosed) {
|
||||
add(ChatInputEvent.updateState(pluginState));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
on<ChatInputEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
final LocalLLMListener listener;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _handleEvent(
|
||||
ChatInputEvent event,
|
||||
Emitter<ChatInputState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
started: () async {
|
||||
final result = await ChatEventGetLocalAIPluginState().send();
|
||||
result.fold(
|
||||
(pluginState) {
|
||||
if (!isClosed) {
|
||||
add(ChatInputEvent.updateState(pluginState));
|
||||
}
|
||||
},
|
||||
(err) => Log.error(err.toString()),
|
||||
);
|
||||
},
|
||||
updateState: (LocalAIPluginStatePB aiPluginState) {
|
||||
emit(const ChatInputState(aiType: _AppFlowyAI()));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatInputEvent with _$ChatInputEvent {
|
||||
const factory ChatInputEvent.started() = _Started;
|
||||
const factory ChatInputEvent.updateState(LocalAIPluginStatePB aiPluginState) =
|
||||
_UpdatePluginState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatInputState with _$ChatInputState {
|
||||
const factory ChatInputState({required AIType aiType}) = _ChatInputState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AIType with _$AIType {
|
||||
const factory AIType.appflowyAI() = _AppFlowyAI;
|
||||
const factory AIType.localAI() = _LocalAI;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/chat_page.dart';
|
||||
import 'package:appflowy/plugins/util.dart';
|
||||
@@ -9,6 +7,7 @@ import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class AIChatPluginBuilder extends PluginBuilder {
|
||||
@@ -96,6 +95,7 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
Map<String, dynamic>? data,
|
||||
}) {
|
||||
notifier.isDeleted.addListener(() {
|
||||
final deletedView = notifier.isDeleted.value;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -67,28 +68,40 @@ class AIChatPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) {
|
||||
return BlocProvider(
|
||||
create: (context) => ChatFileBloc(chatId: view.id.toString()),
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (_) => ChatFileBloc(chatId: view.id.toString()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => ChatBloc(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
)..add(const ChatEvent.initialLoad()),
|
||||
),
|
||||
BlocProvider(create: (_) => ChatInputBloc()),
|
||||
],
|
||||
child: BlocBuilder<ChatFileBloc, ChatFileState>(
|
||||
builder: (context, state) {
|
||||
return state.supportChatWithFile
|
||||
? DropTarget(
|
||||
onDragDone: (DropDoneDetails detail) async {
|
||||
for (final file in detail.files) {
|
||||
context
|
||||
.read<ChatFileBloc>()
|
||||
.add(ChatFileEvent.newFile(file.path));
|
||||
}
|
||||
},
|
||||
child: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
)
|
||||
: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
);
|
||||
Widget child = _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
);
|
||||
|
||||
// If the chat supports file upload, wrap the chat content with a drop target
|
||||
if (state.supportChatWithFile) {
|
||||
child = DropTarget(
|
||||
onDragDone: (DropDoneDetails detail) async {
|
||||
for (final file in detail.files) {
|
||||
context
|
||||
.read<ChatFileBloc>()
|
||||
.add(ChatFileEvent.newFile(file.path));
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -146,67 +159,61 @@ class _ChatContentPageState extends State<_ChatContentPage> {
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 784),
|
||||
child: BlocProvider(
|
||||
create: (_) => ChatBloc(
|
||||
view: widget.view,
|
||||
userProfile: widget.userProfile,
|
||||
)..add(const ChatEvent.initialLoad()),
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (blocContext, state) => Chat(
|
||||
messages: state.messages,
|
||||
onSendPressed: (_) {
|
||||
// We use custom bottom widget for chat input, so
|
||||
// do not need to handle this event.
|
||||
},
|
||||
customBottomWidget: buildChatInput(blocContext),
|
||||
user: _user,
|
||||
theme: buildTheme(context),
|
||||
onEndReached: () async {
|
||||
if (state.hasMorePrevMessage &&
|
||||
state.loadingPreviousStatus !=
|
||||
const LoadingState.loading()) {
|
||||
blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.startLoadingPrevMessage());
|
||||
}
|
||||
},
|
||||
emptyState: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (_, state) => state.initialLoadingStatus ==
|
||||
const LoadingState.finish()
|
||||
? Padding(
|
||||
padding: AIChatUILayout.welcomePagePadding,
|
||||
child: ChatWelcomePage(
|
||||
onSelectedQuestion: (question) => blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(question)),
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (blocContext, state) => Chat(
|
||||
messages: state.messages,
|
||||
onSendPressed: (_) {
|
||||
// We use custom bottom widget for chat input, so
|
||||
// do not need to handle this event.
|
||||
},
|
||||
customBottomWidget: buildChatInput(blocContext),
|
||||
user: _user,
|
||||
theme: buildTheme(context),
|
||||
onEndReached: () async {
|
||||
if (state.hasMorePrevMessage &&
|
||||
state.loadingPreviousStatus !=
|
||||
const LoadingState.loading()) {
|
||||
blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.startLoadingPrevMessage());
|
||||
}
|
||||
},
|
||||
emptyState: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (_, state) =>
|
||||
state.initialLoadingStatus == const LoadingState.finish()
|
||||
? Padding(
|
||||
padding: AIChatUILayout.welcomePagePadding,
|
||||
child: ChatWelcomePage(
|
||||
onSelectedQuestion: (question) => blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(question)),
|
||||
),
|
||||
)
|
||||
: const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
)
|
||||
: const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
),
|
||||
messageWidthRatio: AIChatUILayout.messageWidthRatio,
|
||||
textMessageBuilder: (
|
||||
textMessage, {
|
||||
required messageWidth,
|
||||
required showName,
|
||||
}) =>
|
||||
_buildAITextMessage(blocContext, textMessage),
|
||||
bubbleBuilder: (
|
||||
child, {
|
||||
required message,
|
||||
required nextMessageInGroup,
|
||||
}) {
|
||||
if (message.author.id == _user.id) {
|
||||
return ChatUserMessageBubble(
|
||||
message: message,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return _buildAIBubble(message, blocContext, state, child);
|
||||
},
|
||||
),
|
||||
messageWidthRatio: AIChatUILayout.messageWidthRatio,
|
||||
textMessageBuilder: (
|
||||
textMessage, {
|
||||
required messageWidth,
|
||||
required showName,
|
||||
}) =>
|
||||
_buildAITextMessage(blocContext, textMessage),
|
||||
bubbleBuilder: (
|
||||
child, {
|
||||
required message,
|
||||
required nextMessageInGroup,
|
||||
}) {
|
||||
if (message.author.id == _user.id) {
|
||||
return ChatUserMessageBubble(
|
||||
message: message,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return _buildAIBubble(message, blocContext, state, child);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -338,31 +345,40 @@ class _ChatContentPageState extends State<_ChatContentPage> {
|
||||
return ClipRect(
|
||||
child: Padding(
|
||||
padding: AIChatUILayout.safeAreaInsets(context),
|
||||
child: Column(
|
||||
children: [
|
||||
BlocSelector<ChatBloc, ChatState, LoadingState>(
|
||||
selector: (state) => state.streamingStatus,
|
||||
builder: (context, state) {
|
||||
return ChatInput(
|
||||
chatId: widget.view.id,
|
||||
onSendPressed: (message) =>
|
||||
onSendPressed(context, message.text),
|
||||
isStreaming: state != const LoadingState.finish(),
|
||||
onStopStreaming: () {
|
||||
context.read<ChatBloc>().add(const ChatEvent.stopStream());
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const VSpace(6),
|
||||
Opacity(
|
||||
opacity: 0.6,
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_aiMistakePrompt.tr(),
|
||||
fontSize: 12,
|
||||
child: BlocBuilder<ChatInputBloc, ChatInputState>(
|
||||
builder: (context, state) {
|
||||
return state.aiType.when(
|
||||
appflowyAI: () => Column(
|
||||
children: [
|
||||
BlocSelector<ChatBloc, ChatState, LoadingState>(
|
||||
selector: (state) => state.streamingStatus,
|
||||
builder: (context, state) {
|
||||
return ChatInput(
|
||||
chatId: widget.view.id,
|
||||
onSendPressed: (message) =>
|
||||
onSendPressed(context, message.text),
|
||||
isStreaming: state != const LoadingState.finish(),
|
||||
onStopStreaming: () {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.stopStream());
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const VSpace(6),
|
||||
Opacity(
|
||||
opacity: 0.6,
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_aiMistakePrompt.tr(),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
localAI: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -49,7 +49,9 @@ class _ChatInputState extends State<ChatInput> {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (event is KeyDownEvent) {
|
||||
_handleSendPressed();
|
||||
if (!widget.isStreaming) {
|
||||
_handleSendPressed();
|
||||
}
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
@@ -78,17 +80,13 @@ class _ChatInputState extends State<ChatInput> {
|
||||
}
|
||||
|
||||
void _handleSendPressed() {
|
||||
if (widget.isStreaming) {
|
||||
widget.onStopStreaming();
|
||||
} else {
|
||||
final trimmedText = _textController.text.trim();
|
||||
if (trimmedText != '') {
|
||||
final partialText = types.PartialText(text: trimmedText);
|
||||
widget.onSendPressed(partialText);
|
||||
final trimmedText = _textController.text.trim();
|
||||
if (trimmedText != '') {
|
||||
final partialText = types.PartialText(text: trimmedText);
|
||||
widget.onSendPressed(partialText);
|
||||
|
||||
if (widget.options.inputClearMode == InputClearMode.always) {
|
||||
_textController.clear();
|
||||
}
|
||||
if (widget.options.inputClearMode == InputClearMode.always) {
|
||||
_textController.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +137,6 @@ class _ChatInputState extends State<ChatInput> {
|
||||
padding: textPadding,
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
readOnly: widget.isStreaming,
|
||||
focusNode: _inputFocusNode,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
@@ -151,7 +148,6 @@ class _ChatInputState extends State<ChatInput> {
|
||||
style: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
enabled: widget.options.enabled,
|
||||
autocorrect: widget.options.autocorrect,
|
||||
autofocus: widget.options.autofocus,
|
||||
enableSuggestions: widget.options.enableSuggestions,
|
||||
@@ -176,7 +172,10 @@ class _ChatInputState extends State<ChatInput> {
|
||||
padding: buttonPadding,
|
||||
child: AccessoryButton(
|
||||
onSendPressed: () {
|
||||
_handleSendPressed();
|
||||
if (!widget.isStreaming) {
|
||||
widget.onStopStreaming();
|
||||
_handleSendPressed();
|
||||
}
|
||||
},
|
||||
onStopStreaming: () {
|
||||
widget.onStopStreaming();
|
||||
|
||||
+28
-20
@@ -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();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
Map<String, dynamic>? data,
|
||||
}) =>
|
||||
const BlankPage();
|
||||
|
||||
|
||||
+11
-6
@@ -1,7 +1,5 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:appflowy/plugins/database/application/row/row_cache.dart';
|
||||
import 'package:appflowy/plugins/database/application/setting/setting_listener.dart';
|
||||
import 'package:appflowy/plugins/database/domain/database_view_service.dart';
|
||||
@@ -19,9 +17,9 @@ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../setting/setting_service.dart';
|
||||
|
||||
import 'field_info.dart';
|
||||
|
||||
class _GridFieldNotifier extends ChangeNotifier {
|
||||
@@ -76,7 +74,6 @@ typedef OnReceiveFields = void Function(List<FieldInfo>);
|
||||
typedef OnReceiveFilters = void Function(List<FilterInfo>);
|
||||
typedef OnReceiveSorts = void Function(List<SortInfo>);
|
||||
|
||||
|
||||
class FieldController {
|
||||
FieldController({required this.viewId})
|
||||
: _fieldListener = FieldsListener(viewId: viewId),
|
||||
@@ -446,9 +443,13 @@ class FieldController {
|
||||
|
||||
/// Listen for field setting changes in the backend.
|
||||
void _listenOnFieldSettingsChanged() {
|
||||
FieldInfo updateFieldSettings(FieldSettingsPB updatedFieldSettings) {
|
||||
FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) {
|
||||
final List<FieldInfo> newFields = fieldInfos;
|
||||
FieldInfo updatedField = newFields[0];
|
||||
var updatedField = newFields.firstOrNull;
|
||||
|
||||
if (updatedField == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final index = newFields
|
||||
.indexWhere((field) => field.id == updatedFieldSettings.fieldId);
|
||||
@@ -470,6 +471,10 @@ class FieldController {
|
||||
result.fold(
|
||||
(fieldSettings) {
|
||||
final updatedFieldInfo = updateFieldSettings(fieldSettings);
|
||||
if (updatedFieldInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final listener in _updatedFieldCallbacks.values) {
|
||||
listener([updatedFieldInfo]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/application/row/row_service.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart';
|
||||
@@ -9,6 +6,7 @@ import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -16,6 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart';
|
||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:linked_scroll_controller/linked_scroll_controller.dart';
|
||||
|
||||
@@ -24,7 +23,6 @@ import '../../application/row/row_controller.dart';
|
||||
import '../../tab_bar/tab_bar_view.dart';
|
||||
import '../../widgets/row/row_detail.dart';
|
||||
import '../application/grid_bloc.dart';
|
||||
|
||||
import 'grid_scroll.dart';
|
||||
import 'layout/layout.dart';
|
||||
import 'layout/sizes.dart';
|
||||
@@ -504,7 +502,10 @@ class _PositionedCalculationsRowState
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(left: GridSize.horizontalHeaderPadding + 40),
|
||||
margin: EdgeInsets.only(
|
||||
left:
|
||||
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
|
||||
),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).canvasColor,
|
||||
|
||||
+4
-1
@@ -4,6 +4,7 @@ import 'package:appflowy/plugins/database/application/field/field_controller.dar
|
||||
import 'package:appflowy/plugins/database/domain/field_service.dart';
|
||||
import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/grid/application/grid_header_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -139,7 +140,9 @@ class _GridHeaderState extends State<_GridHeader> {
|
||||
}
|
||||
|
||||
Widget _cellLeading() {
|
||||
return SizedBox(width: GridSize.horizontalHeaderPadding + 40);
|
||||
return SizedBox(
|
||||
width: context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -1,5 +1,3 @@
|
||||
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/database/application/cell/cell_controller.dart';
|
||||
@@ -7,18 +5,19 @@ import 'package:appflowy/plugins/database/application/field/field_controller.dar
|
||||
import 'package:appflowy/plugins/database/application/row/row_controller.dart';
|
||||
import 'package:appflowy/plugins/database/application/row/row_service.dart';
|
||||
import 'package:appflowy/plugins/database/grid/application/row/row_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.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:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../../widgets/row/accessory/cell_accessory.dart';
|
||||
import '../../../../widgets/row/cells/cell_container.dart';
|
||||
import '../../layout/sizes.dart';
|
||||
|
||||
import 'action.dart';
|
||||
|
||||
class GridRow extends StatefulWidget {
|
||||
@@ -112,7 +111,9 @@ class _RowLeadingState extends State<_RowLeading> {
|
||||
child: Consumer<RegionStateNotifier>(
|
||||
builder: (context, state, _) {
|
||||
return SizedBox(
|
||||
width: GridSize.horizontalHeaderPadding + 40,
|
||||
width: context
|
||||
.read<DatabasePluginWidgetBuilderSize>()
|
||||
.horizontalPadding,
|
||||
child: state.onEnter ? _activeWidget() : null,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
@@ -17,14 +17,17 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'tab_bar_add_button.dart';
|
||||
|
||||
class TabBarHeader extends StatelessWidget {
|
||||
const TabBarHeader({super.key});
|
||||
const TabBarHeader({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 30,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: GridSize.horizontalHeaderPadding + 40,
|
||||
horizontal:
|
||||
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:appflowy/plugins/database/application/database_controller.dart';
|
||||
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/share_button.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_button.dart';
|
||||
import 'package:appflowy/plugins/util.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
@@ -16,6 +17,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'desktop/tab_bar_header.dart';
|
||||
import 'mobile/mobile_tab_bar_header.dart';
|
||||
@@ -219,6 +221,16 @@ class DatabaseTabBarViewPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
const kDatabasePluginWidgetBuilderHorizontalPadding = 'horizontal_padding';
|
||||
|
||||
class DatabasePluginWidgetBuilderSize {
|
||||
const DatabasePluginWidgetBuilderSize({
|
||||
required this.horizontalPadding,
|
||||
});
|
||||
|
||||
final double horizontalPadding;
|
||||
}
|
||||
|
||||
class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
|
||||
DatabasePluginWidgetBuilder({
|
||||
required this.bloc,
|
||||
@@ -244,6 +256,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
Map<String, dynamic>? data,
|
||||
}) {
|
||||
notifier.isDeleted.addListener(() {
|
||||
final deletedView = notifier.isDeleted.value;
|
||||
@@ -252,11 +265,20 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
|
||||
}
|
||||
});
|
||||
|
||||
return DatabaseTabBarView(
|
||||
key: ValueKey(notifier.view.id),
|
||||
view: notifier.view,
|
||||
shrinkWrap: shrinkWrap,
|
||||
initialRowId: initialRowId,
|
||||
final horizontalPadding =
|
||||
data?[kDatabasePluginWidgetBuilderHorizontalPadding] as double? ??
|
||||
GridSize.horizontalHeaderPadding + 40;
|
||||
|
||||
return Provider(
|
||||
create: (context) => DatabasePluginWidgetBuilderSize(
|
||||
horizontalPadding: horizontalPadding,
|
||||
),
|
||||
child: DatabaseTabBarView(
|
||||
key: ValueKey(notifier.view.id),
|
||||
view: notifier.view,
|
||||
shrinkWrap: shrinkWrap,
|
||||
initialRowId: initialRowId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,7 +292,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
|
||||
value: bloc,
|
||||
child: Row(
|
||||
children: [
|
||||
DatabaseShareButton(key: ValueKey(view.id), view: view),
|
||||
ShareButton(key: ValueKey(view.id), view: view),
|
||||
const HSpace(10),
|
||||
ViewFavoriteButton(view: view),
|
||||
const HSpace(4),
|
||||
|
||||
+24
-7
@@ -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(
|
||||
|
||||
+21
-4
@@ -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()],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_listener.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DatabaseViewWidget extends StatefulWidget {
|
||||
const DatabaseViewWidget({
|
||||
@@ -55,6 +55,9 @@ class _DatabaseViewWidgetState extends State<DatabaseViewWidget> {
|
||||
builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget(
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
context: PluginContext(),
|
||||
data: {
|
||||
kDatabasePluginWidgetBuilderHorizontalPadding: 40.0,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
Map<String, dynamic>? data,
|
||||
}) {
|
||||
return BlocBuilder<DocumentAppearanceCubit, DocumentAppearance>(
|
||||
builder: (_, state) => DatabaseDocumentPage(
|
||||
|
||||
@@ -136,7 +136,7 @@ class DocumentService {
|
||||
}) async {
|
||||
final workspace = await FolderEventReadCurrentWorkspace().send();
|
||||
return workspace.fold((l) async {
|
||||
final payload = UploadedFilePB(
|
||||
final payload = DownloadFilePB(
|
||||
url: url,
|
||||
);
|
||||
final result = await DocumentEventDownloadFile(payload).send();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export '../../shared/share/share_bloc.dart';
|
||||
export 'document_bloc.dart';
|
||||
export 'document_service.dart';
|
||||
export 'document_share_bloc.dart';
|
||||
@@ -5,7 +5,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/document_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_button.dart';
|
||||
import 'package:appflowy/plugins/util.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
@@ -109,6 +109,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
Map<String, dynamic>? data,
|
||||
}) {
|
||||
notifier.isDeleted.addListener(() {
|
||||
final deletedView = notifier.isDeleted.value;
|
||||
@@ -154,7 +155,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
|
||||
const HSpace(16),
|
||||
]
|
||||
: [const HSpace(8)],
|
||||
DocumentShareButton(
|
||||
ShareButton(
|
||||
key: ValueKey('share_button_${view.id}'),
|
||||
view: view,
|
||||
),
|
||||
|
||||
+1
-5
@@ -13,9 +13,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class BuiltInPageWidget extends StatefulWidget {
|
||||
@@ -127,7 +125,6 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
icon: const FlowySvg(
|
||||
FlowySvgs.information_s,
|
||||
),
|
||||
iconColorOnHover: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
// setting
|
||||
const Space(7, 0),
|
||||
@@ -141,7 +138,6 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
width: 24,
|
||||
height: 24,
|
||||
iconPadding: const EdgeInsets.all(3),
|
||||
iconColorOnHover: Theme.of(context).colorScheme.onSecondary,
|
||||
icon: const FlowySvg(
|
||||
FlowySvgs.settings_s,
|
||||
),
|
||||
|
||||
+8
-1
@@ -6,6 +6,7 @@ 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:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
@@ -63,6 +64,12 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage(
|
||||
);
|
||||
return (s.url, null);
|
||||
},
|
||||
(e) => (null, e.msg),
|
||||
(err) {
|
||||
if (err.isStorageLimitExceeded) {
|
||||
return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr());
|
||||
} else {
|
||||
return (null, err.msg);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -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({
|
||||
|
||||
+13
-1
@@ -1,14 +1,26 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'error.freezed.dart';
|
||||
part 'error.g.dart';
|
||||
|
||||
@freezed
|
||||
class AIError with _$AIError {
|
||||
const factory AIError({
|
||||
String? code,
|
||||
required String message,
|
||||
@Default(AIErrorCode.other) AIErrorCode code,
|
||||
}) = _AIError;
|
||||
|
||||
factory AIError.fromJson(Map<String, Object?> json) =>
|
||||
_$AIErrorFromJson(json);
|
||||
}
|
||||
|
||||
enum AIErrorCode {
|
||||
@JsonValue('AIResponseLimitExceeded')
|
||||
aiResponseLimitExceeded,
|
||||
@JsonValue('Other')
|
||||
other,
|
||||
}
|
||||
|
||||
extension AIErrorExtension on AIError {
|
||||
bool get isLimitExceeded => code == AIErrorCode.aiResponseLimitExceeded;
|
||||
}
|
||||
+13
@@ -0,0 +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';
|
||||
|
||||
void showAILimitDialog(BuildContext context, String message) {
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(),
|
||||
description: message,
|
||||
);
|
||||
}
|
||||
+50
-41
@@ -1,6 +1,9 @@
|
||||
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';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
|
||||
import 'package:appflowy/user/application/ai_service.dart';
|
||||
@@ -9,14 +12,11 @@ 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';
|
||||
|
||||
class AutoCompletionBlockKeys {
|
||||
const AutoCompletionBlockKeys._();
|
||||
|
||||
@@ -43,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;
|
||||
@@ -127,7 +127,6 @@ class _AutoCompletionBlockComponentState
|
||||
_unsubscribeSelectionGesture();
|
||||
controller.dispose();
|
||||
textFieldFocusNode.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -178,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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,11 +222,16 @@ class _AutoCompletionBlockComponentState
|
||||
onError: (error) async {
|
||||
barrierDialog?.dismiss();
|
||||
if (mounted) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
error.message,
|
||||
showCancel: true,
|
||||
);
|
||||
if (error.isLimitExceeded) {
|
||||
showAILimitDialog(context, error.message);
|
||||
await _onDiscard();
|
||||
} else {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
error.message,
|
||||
showCancel: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -304,11 +306,15 @@ class _AutoCompletionBlockComponentState
|
||||
onEnd: () async {},
|
||||
onError: (error) async {
|
||||
if (mounted) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
error.message,
|
||||
showCancel: true,
|
||||
);
|
||||
if (error.isLimitExceeded) {
|
||||
showAILimitDialog(context, error.message);
|
||||
} else {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
error.message,
|
||||
showCancel: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -406,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();
|
||||
@@ -434,9 +438,7 @@ class _AutoCompletionBlockComponentState
|
||||
}
|
||||
|
||||
class AutoCompletionHeader extends StatelessWidget {
|
||||
const AutoCompletionHeader({
|
||||
super.key,
|
||||
});
|
||||
const AutoCompletionHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -460,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -501,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,
|
||||
),
|
||||
],
|
||||
|
||||
+12
-5
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
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';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
@@ -16,6 +17,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'ai_limit_dialog.dart';
|
||||
|
||||
class SmartEditBlockKeys {
|
||||
const SmartEditBlockKeys._();
|
||||
|
||||
@@ -426,11 +429,15 @@ class _SmartEditInputWidgetState extends State<SmartEditInputWidget> {
|
||||
});
|
||||
},
|
||||
onError: (error) async {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
error.message,
|
||||
showCancel: true,
|
||||
);
|
||||
if (error.isLimitExceeded) {
|
||||
showAILimitDialog(context, error.message);
|
||||
} else {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
error.message,
|
||||
showCancel: true,
|
||||
);
|
||||
}
|
||||
await _onExit();
|
||||
},
|
||||
);
|
||||
|
||||
+6
-12
@@ -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(
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
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,248 +0,0 @@
|
||||
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';
|
||||
import 'package:appflowy/workspace/application/view/view_listener.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.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_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';
|
||||
|
||||
class DocumentShareButton extends StatelessWidget {
|
||||
const DocumentShareButton({
|
||||
super.key,
|
||||
required this.view,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => getIt<DocumentShareBloc>(param1: view)
|
||||
..add(const DocumentShareEvent.initial()),
|
||||
child: BlocListener<DocumentShareBloc, DocumentShareState>(
|
||||
listener: (context, state) {
|
||||
if (state.isLoading == false && state.exportResult != null) {
|
||||
state.exportResult!.fold(
|
||||
(data) => _handleExportData(context, data),
|
||||
_handleExportError,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<DocumentShareBloc, DocumentShareState>(
|
||||
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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleExportData(BuildContext context, ExportDataPB exportData) {
|
||||
switch (exportData.exportType) {
|
||||
case ExportType.Markdown:
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.settings_files_exportFileSuccess.tr(),
|
||||
);
|
||||
break;
|
||||
case ExportType.Link:
|
||||
case ExportType.Text:
|
||||
break;
|
||||
case ExportType.HTML:
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.settings_files_exportFileSuccess.tr(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleExportError(FlowyError error) {
|
||||
showMessageToast(error.msg);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
required this.view,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
State<ShareActionList> createState() => ShareActionListState();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class ShareActionListState extends State<ShareActionList> {
|
||||
late String name;
|
||||
late final ViewListener viewListener = ViewListener(viewId: widget.view.id);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
listenOnViewUpdated();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
viewListener.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final docShareBloc = context.read<DocumentShareBloc>();
|
||||
return PopoverActionList<ShareActionWrapper>(
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
offset: const Offset(0, 8),
|
||||
actions: ShareAction.values
|
||||
.map((action) => ShareActionWrapper(action))
|
||||
.toList(),
|
||||
buildChild: (controller) => Listener(
|
||||
onPointerDown: (_) => controller.show(),
|
||||
child: RoundedTextButton(
|
||||
title: LocaleKeys.shareAction_buttonText.tr(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
onPressed: () {},
|
||||
fontSize: 14.0,
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
onSelected: (action, controller) async {
|
||||
switch (action.inner) {
|
||||
case ShareAction.markdown:
|
||||
final exportPath = await getIt<FilePickerService>().saveFile(
|
||||
dialogTitle: '',
|
||||
// encode the file name in case it contains special characters
|
||||
fileName: '${name.toFileName()}.md',
|
||||
);
|
||||
if (exportPath != null) {
|
||||
docShareBloc.add(
|
||||
DocumentShareEvent.share(
|
||||
DocumentShareType.markdown,
|
||||
exportPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case ShareAction.html:
|
||||
final exportPath = await getIt<FilePickerService>().saveFile(
|
||||
dialogTitle: '',
|
||||
fileName: '${name.toFileName()}.html',
|
||||
);
|
||||
if (exportPath != null) {
|
||||
docShareBloc.add(
|
||||
DocumentShareEvent.share(
|
||||
DocumentShareType.html,
|
||||
exportPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case ShareAction.clipboard:
|
||||
final documentExporter = DocumentExporter(widget.view);
|
||||
final result =
|
||||
await documentExporter.export(DocumentExportType.markdown);
|
||||
result.fold(
|
||||
(markdown) => getIt<ClipboardService>()
|
||||
.setData(ClipboardServiceData(plainText: markdown)),
|
||||
(error) => showMessageToast(error.msg),
|
||||
);
|
||||
break;
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void listenOnViewUpdated() {
|
||||
name = widget.view.name;
|
||||
viewListener.start(
|
||||
onViewUpdated: (view) {
|
||||
name = view.name;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ShareAction {
|
||||
markdown,
|
||||
html,
|
||||
clipboard,
|
||||
}
|
||||
|
||||
class ShareActionWrapper extends ActionCell {
|
||||
ShareActionWrapper(this.inner);
|
||||
|
||||
final ShareAction inner;
|
||||
|
||||
Widget? icon(Color iconColor) => null;
|
||||
|
||||
@override
|
||||
String get name {
|
||||
switch (inner) {
|
||||
case ShareAction.markdown:
|
||||
return LocaleKeys.shareAction_markdown.tr();
|
||||
case ShareAction.html:
|
||||
return LocaleKeys.shareAction_html.tr();
|
||||
case ShareAction.clipboard:
|
||||
return LocaleKeys.shareAction_clipboard.tr();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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';
|
||||
import 'package:appflowy/plugins/shared/share/share_menu.dart';
|
||||
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_bloc/flutter_bloc.dart';
|
||||
|
||||
class ShareMenuButton extends StatelessWidget {
|
||||
const ShareMenuButton({
|
||||
super.key,
|
||||
required this.tabs,
|
||||
});
|
||||
|
||||
final List<ShareMenuTab> tabs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shareBloc = context.read<ShareBloc>();
|
||||
final databaseBloc = context.read<DatabaseTabBarBloc?>();
|
||||
return SizedBox(
|
||||
height: 32.0,
|
||||
child: IntrinsicWidth(
|
||||
child: AppFlowyPopover(
|
||||
direction: PopoverDirection.bottomWithRightAligned,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 422,
|
||||
),
|
||||
offset: const Offset(0, 8),
|
||||
popupBuilder: (context) => MultiBlocProvider(
|
||||
providers: [
|
||||
if (databaseBloc != null)
|
||||
BlocProvider.value(
|
||||
value: databaseBloc,
|
||||
),
|
||||
BlocProvider.value(value: shareBloc),
|
||||
],
|
||||
child: ShareMenu(
|
||||
tabs: tabs,
|
||||
),
|
||||
),
|
||||
child: const _ShareButton(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareButton extends StatelessWidget {
|
||||
const _ShareButton();
|
||||
|
||||
@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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+50
-11
@@ -1,12 +1,13 @@
|
||||
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/shared/share/share_bloc.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:appflowy_backend/protobuf/flowy-folder/view.pb.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';
|
||||
@@ -20,6 +21,16 @@ class ExportTab extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final view = context.read<ShareBloc>().view;
|
||||
|
||||
if (view.layout == ViewLayoutPB.Document) {
|
||||
return _buildDocumentExportTab(context);
|
||||
}
|
||||
|
||||
return _buildDatabaseExportTab(context);
|
||||
}
|
||||
|
||||
Widget _buildDocumentExportTab(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const VSpace(10),
|
||||
@@ -44,16 +55,29 @@ class ExportTab extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDatabaseExportTab(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const VSpace(10),
|
||||
_ExportButton(
|
||||
title: LocaleKeys.shareAction_csv.tr(),
|
||||
svg: FlowySvgs.database_layout_m,
|
||||
onTap: () => _exportCSV(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportHTML(BuildContext context) async {
|
||||
final viewName = context.read<DocumentShareBloc>().state.viewName;
|
||||
final viewName = context.read<ShareBloc>().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,
|
||||
context.read<ShareBloc>().add(
|
||||
ShareEvent.share(
|
||||
ShareType.html,
|
||||
exportPath,
|
||||
),
|
||||
);
|
||||
@@ -61,15 +85,31 @@ class ExportTab extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _exportMarkdown(BuildContext context) async {
|
||||
final viewName = context.read<DocumentShareBloc>().state.viewName;
|
||||
final viewName = context.read<ShareBloc>().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,
|
||||
context.read<ShareBloc>().add(
|
||||
ShareEvent.share(
|
||||
ShareType.markdown,
|
||||
exportPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportCSV(BuildContext context) async {
|
||||
final viewName = context.read<ShareBloc>().state.viewName;
|
||||
final exportPath = await getIt<FilePickerService>().saveFile(
|
||||
dialogTitle: '',
|
||||
fileName: '${viewName.toFileName()}.csv',
|
||||
);
|
||||
if (context.mounted && exportPath != null) {
|
||||
context.read<ShareBloc>().add(
|
||||
ShareEvent.share(
|
||||
ShareType.csv,
|
||||
exportPath,
|
||||
),
|
||||
);
|
||||
@@ -77,8 +117,7 @@ class ExportTab extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _exportToClipboard(BuildContext context) async {
|
||||
final documentExporter =
|
||||
DocumentExporter(context.read<DocumentShareBloc>().view);
|
||||
final documentExporter = DocumentExporter(context.read<ShareBloc>().view);
|
||||
final result = await documentExporter.export(DocumentExportType.markdown);
|
||||
result.fold(
|
||||
(markdown) {
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,559 @@
|
||||
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/plugins/database/application/tab_bar_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/shared/share/publish_color_extension.dart';
|
||||
import 'package:appflowy/plugins/shared/share/publish_name_generator.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
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/flowy_tooltip.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<ShareBloc, ShareState>(
|
||||
listener: (context, state) {
|
||||
_showToast(context, state);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.isPublished) {
|
||||
return _PublishedWidget(
|
||||
url: state.url,
|
||||
onVisitSite: (url) => afLaunchUrlString(url),
|
||||
onUnPublish: () {
|
||||
context.read<ShareBloc>().add(const ShareEvent.unPublish());
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return _PublishWidget(
|
||||
onPublish: (selectedViews) async {
|
||||
final id = context.read<ShareBloc>().view.id;
|
||||
final publishName = await generatePublishName(
|
||||
id,
|
||||
state.viewName,
|
||||
);
|
||||
|
||||
if (selectedViews.isNotEmpty) {
|
||||
Log.info(
|
||||
'Publishing views: ${selectedViews.map((e) => e.name)}',
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<ShareBloc>().add(
|
||||
ShareEvent.publish(
|
||||
'',
|
||||
publishName,
|
||||
selectedViews.map((e) => e.id).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showToast(BuildContext context, ShareState 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 void Function(String url) 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(
|
||||
width: 184,
|
||||
height: 36,
|
||||
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(
|
||||
width: 184,
|
||||
height: 36,
|
||||
onPressed: () => widget.onVisitSite(controller.text),
|
||||
title: LocaleKeys.shareAction_visitSite.tr(),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
fillColor: Theme.of(context).colorScheme.primary,
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PublishWidget extends StatefulWidget {
|
||||
const _PublishWidget({
|
||||
required this.onPublish,
|
||||
});
|
||||
|
||||
final void Function(List<ViewPB> selectedViews) onPublish;
|
||||
|
||||
@override
|
||||
State<_PublishWidget> createState() => _PublishWidgetState();
|
||||
}
|
||||
|
||||
class _PublishWidgetState extends State<_PublishWidget> {
|
||||
List<ViewPB> _selectedViews = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const VSpace(16),
|
||||
const _PublishTabHeader(),
|
||||
const VSpace(16),
|
||||
// if current view is a database, show the database selector
|
||||
if (context.read<ShareBloc>().view.layout.isDatabaseView) ...[
|
||||
_PublishDatabaseSelector(
|
||||
view: context.read<ShareBloc>().view,
|
||||
onSelected: (selectedDatabases) {
|
||||
_selectedViews = selectedDatabases;
|
||||
},
|
||||
),
|
||||
const VSpace(16),
|
||||
],
|
||||
_PublishButton(
|
||||
onPublish: () {
|
||||
if (context.read<ShareBloc>().view.layout.isDatabaseView) {
|
||||
// check if any database is selected
|
||||
if (_selectedViews.isEmpty) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_noDatabaseSelected.tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
widget.onPublish(_selectedViews);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PublishButton extends StatelessWidget {
|
||||
const _PublishButton({
|
||||
required this.onPublish,
|
||||
});
|
||||
|
||||
final VoidCallback onPublish;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// used to select which database view should be published
|
||||
class _PublishDatabaseSelector extends StatefulWidget {
|
||||
const _PublishDatabaseSelector({
|
||||
required this.view,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
final void Function(List<ViewPB> selectedDatabases) onSelected;
|
||||
|
||||
@override
|
||||
State<_PublishDatabaseSelector> createState() =>
|
||||
_PublishDatabaseSelectorState();
|
||||
}
|
||||
|
||||
class _PublishDatabaseSelectorState extends State<_PublishDatabaseSelector> {
|
||||
final PropertyValueNotifier<List<(ViewPB, bool)>> _databaseStatus =
|
||||
PropertyValueNotifier<List<(ViewPB, bool)>>([]);
|
||||
late final _borderColor = Theme.of(context).hintColor.withOpacity(0.3);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_databaseStatus.addListener(() {
|
||||
final selectedDatabases =
|
||||
_databaseStatus.value.where((e) => e.$2).map((e) => e.$1).toList();
|
||||
widget.onSelected(selectedDatabases);
|
||||
});
|
||||
|
||||
_databaseStatus.value = context
|
||||
.read<DatabaseTabBarBloc>()
|
||||
.state
|
||||
.tabBars
|
||||
.map((e) => (e.view, true))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_databaseStatus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
builder: (context, state) {
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: ShapeDecoration(
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: _borderColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const VSpace(10),
|
||||
_buildSelectedDatabaseCount(context),
|
||||
const VSpace(10),
|
||||
_buildDivider(context),
|
||||
const VSpace(10),
|
||||
...state.tabBars.map(
|
||||
(e) => _buildDatabaseSelector(context, e),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Divider(
|
||||
color: _borderColor,
|
||||
thickness: 1,
|
||||
height: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectedDatabaseCount(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _databaseStatus,
|
||||
builder: (context, selectedDatabases, child) {
|
||||
final count = selectedDatabases.where((e) => e.$2).length;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: FlowyText(
|
||||
LocaleKeys.publish_database.plural(count).tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
fontSize: 13,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDatabaseSelector(BuildContext context, DatabaseTabBar tabBar) {
|
||||
final isPrimaryDatabase = tabBar.view.id == widget.view.id;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _databaseStatus,
|
||||
builder: (context, selectedDatabases, child) {
|
||||
final isSelected = selectedDatabases.any(
|
||||
(e) => e.$1.id == tabBar.view.id && e.$2,
|
||||
);
|
||||
return _DatabaseSelectorItem(
|
||||
tabBar: tabBar,
|
||||
isSelected: isSelected,
|
||||
isPrimaryDatabase: isPrimaryDatabase,
|
||||
onTap: () {
|
||||
// unable to deselect the primary database
|
||||
if (isPrimaryDatabase) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message:
|
||||
LocaleKeys.publish_unableToDeselectPrimaryDatabase.tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// toggle the selection status
|
||||
_databaseStatus.value = _databaseStatus.value
|
||||
.map(
|
||||
(e) =>
|
||||
e.$1.id == tabBar.view.id ? (e.$1, !e.$2) : (e.$1, e.$2),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DatabaseSelectorItem extends StatelessWidget {
|
||||
const _DatabaseSelectorItem({
|
||||
required this.tabBar,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.isPrimaryDatabase,
|
||||
});
|
||||
|
||||
final DatabaseTabBar tabBar;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final bool isPrimaryDatabase;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = _buildItem(context);
|
||||
|
||||
if (!isPrimaryDatabase) {
|
||||
child = FlowyHover(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
child = FlowyTooltip(
|
||||
message: LocaleKeys.publish_mustSelectPrimaryDatabase.tr(),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.forbidden,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(BuildContext context) {
|
||||
final svg = isPrimaryDatabase
|
||||
? FlowySvgs.unable_select_s
|
||||
: isSelected
|
||||
? FlowySvgs.check_filled_s
|
||||
: FlowySvgs.uncheck_s;
|
||||
final blendMode = isPrimaryDatabase ? BlendMode.srcIn : null;
|
||||
return Container(
|
||||
height: 30,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
svg,
|
||||
blendMode: blendMode,
|
||||
size: const Size.square(18),
|
||||
),
|
||||
const HSpace(9.0),
|
||||
FlowySvg(
|
||||
tabBar.view.layout.icon,
|
||||
size: const Size.square(16),
|
||||
),
|
||||
const HSpace(6.0),
|
||||
FlowyText.regular(
|
||||
tabBar.view.name,
|
||||
fontSize: 14,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+63
-56
@@ -2,10 +2,10 @@ 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/settings/share/export_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-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';
|
||||
@@ -13,31 +13,31 @@ 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';
|
||||
part 'share_bloc.freezed.dart';
|
||||
|
||||
const _url = 'https://appflowy.com';
|
||||
|
||||
class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
|
||||
DocumentShareBloc({
|
||||
class ShareBloc extends Bloc<ShareEvent, ShareState> {
|
||||
ShareBloc({
|
||||
required this.view,
|
||||
}) : super(DocumentShareState.initial()) {
|
||||
on<DocumentShareEvent>((event, emit) async {
|
||||
}) : super(ShareState.initial()) {
|
||||
on<ShareEvent>((event, emit) async {
|
||||
await event.when(
|
||||
initial: () async {
|
||||
viewListener = ViewListener(viewId: view.id)
|
||||
..start(
|
||||
onViewUpdated: (value) {
|
||||
add(DocumentShareEvent.updateViewName(value.name));
|
||||
add(ShareEvent.updateViewName(value.name));
|
||||
},
|
||||
onViewMoveToTrash: (p0) {
|
||||
add(const DocumentShareEvent.setPublishStatus(false));
|
||||
add(const ShareEvent.setPublishStatus(false));
|
||||
},
|
||||
);
|
||||
|
||||
add(const DocumentShareEvent.updatePublishStatus());
|
||||
add(const ShareEvent.updatePublishStatus());
|
||||
},
|
||||
share: (type, path) async {
|
||||
if (DocumentShareType.unimplemented.contains(type)) {
|
||||
if (ShareType.unimplemented.contains(type)) {
|
||||
Log.error('DocumentShareType $type is not implemented');
|
||||
return;
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
publish: (nameSpace, publishName) async {
|
||||
publish: (nameSpace, publishName, selectedViewIds) async {
|
||||
// set space name
|
||||
try {
|
||||
final result =
|
||||
@@ -62,6 +62,7 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
|
||||
await ViewBackendService.publish(
|
||||
view,
|
||||
name: publishName,
|
||||
selectedViewIds: selectedViewIds,
|
||||
).getOrThrow();
|
||||
|
||||
emit(
|
||||
@@ -72,6 +73,8 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
|
||||
url: '$_url/${result.namespace}/$publishName',
|
||||
),
|
||||
);
|
||||
|
||||
Log.info('publish success: ${result.namespace}/$publishName');
|
||||
} catch (e) {
|
||||
Log.error('publish error: $e');
|
||||
|
||||
@@ -155,7 +158,7 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
|
||||
final ViewPB view;
|
||||
late final ViewListener viewListener;
|
||||
|
||||
late final exporter = DocumentExporter(view);
|
||||
late final documentExporter = DocumentExporter(view);
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
@@ -163,19 +166,31 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<FlowyResult<ExportDataPB, FlowyError>> _export(
|
||||
DocumentShareType type,
|
||||
Future<FlowyResult<ShareType, FlowyError>> _export(
|
||||
ShareType type,
|
||||
String? path,
|
||||
) async {
|
||||
final result = await exporter.export(type.exportType);
|
||||
final FlowyResult<String, FlowyError> result;
|
||||
if (type == ShareType.csv) {
|
||||
final exportResult = await BackendExportService.exportDatabaseAsCSV(
|
||||
view.id,
|
||||
);
|
||||
result = exportResult.fold(
|
||||
(s) => FlowyResult.success(s.data),
|
||||
(f) => FlowyResult.failure(f),
|
||||
);
|
||||
} else {
|
||||
result = await documentExporter.export(type.documentExportType);
|
||||
}
|
||||
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));
|
||||
case ShareType.markdown:
|
||||
case ShareType.html:
|
||||
case ShareType.csv:
|
||||
File(path).writeAsStringSync(s);
|
||||
return FlowyResult.success(type);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -185,77 +200,69 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
|
||||
(f) => FlowyResult.failure(f),
|
||||
);
|
||||
}
|
||||
|
||||
ExportDataPB _saveMarkdownToPath(String markdown, String path) {
|
||||
File(path).writeAsStringSync(markdown);
|
||||
return ExportDataPB()
|
||||
..data = markdown
|
||||
..exportType = ExportType.Markdown;
|
||||
}
|
||||
|
||||
ExportDataPB _saveHTMLToPath(String html, String path) {
|
||||
File(path).writeAsStringSync(html);
|
||||
return ExportDataPB()
|
||||
..data = html
|
||||
..exportType = ExportType.HTML;
|
||||
}
|
||||
}
|
||||
|
||||
enum DocumentShareType {
|
||||
enum ShareType {
|
||||
// available in document
|
||||
markdown,
|
||||
html,
|
||||
text,
|
||||
link;
|
||||
link,
|
||||
|
||||
static List<DocumentShareType> get unimplemented => [text, link];
|
||||
// only available in database
|
||||
csv;
|
||||
|
||||
DocumentExportType get exportType {
|
||||
static List<ShareType> get unimplemented => [link];
|
||||
|
||||
DocumentExportType get documentExportType {
|
||||
switch (this) {
|
||||
case DocumentShareType.markdown:
|
||||
case ShareType.markdown:
|
||||
return DocumentExportType.markdown;
|
||||
case DocumentShareType.html:
|
||||
case ShareType.html:
|
||||
return DocumentExportType.html;
|
||||
case DocumentShareType.text:
|
||||
case ShareType.text:
|
||||
return DocumentExportType.text;
|
||||
case DocumentShareType.link:
|
||||
case ShareType.csv:
|
||||
throw UnsupportedError('DocumentShareType.csv is not supported');
|
||||
case ShareType.link:
|
||||
throw UnsupportedError('DocumentShareType.link is not supported');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class DocumentShareEvent with _$DocumentShareEvent {
|
||||
const factory DocumentShareEvent.initial() = _Initial;
|
||||
const factory DocumentShareEvent.share(
|
||||
DocumentShareType type,
|
||||
class ShareEvent with _$ShareEvent {
|
||||
const factory ShareEvent.initial() = _Initial;
|
||||
const factory ShareEvent.share(
|
||||
ShareType type,
|
||||
String? path,
|
||||
) = _Share;
|
||||
const factory DocumentShareEvent.publish(
|
||||
const factory ShareEvent.publish(
|
||||
String nameSpace,
|
||||
String pageId,
|
||||
List<String> selectedViewIds,
|
||||
) = _Publish;
|
||||
const factory DocumentShareEvent.unPublish() = _UnPublish;
|
||||
const factory DocumentShareEvent.updateViewName(String name) =
|
||||
_UpdateViewName;
|
||||
const factory DocumentShareEvent.updatePublishStatus() = _UpdatePublishStatus;
|
||||
const factory DocumentShareEvent.setPublishStatus(bool isPublished) =
|
||||
const factory ShareEvent.unPublish() = _UnPublish;
|
||||
const factory ShareEvent.updateViewName(String name) = _UpdateViewName;
|
||||
const factory ShareEvent.updatePublishStatus() = _UpdatePublishStatus;
|
||||
const factory ShareEvent.setPublishStatus(bool isPublished) =
|
||||
_SetPublishStatus;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class DocumentShareState with _$DocumentShareState {
|
||||
const factory DocumentShareState({
|
||||
class ShareState with _$ShareState {
|
||||
const factory ShareState({
|
||||
required bool isPublished,
|
||||
required bool isLoading,
|
||||
required String url,
|
||||
required String viewName,
|
||||
required bool enablePublish,
|
||||
FlowyResult<ExportDataPB, FlowyError>? exportResult,
|
||||
FlowyResult<ShareType, FlowyError>? exportResult,
|
||||
FlowyResult<void, FlowyError>? publishResult,
|
||||
FlowyResult<void, FlowyError>? unpublishResult,
|
||||
}) = _DocumentShareState;
|
||||
}) = _ShareState;
|
||||
|
||||
factory DocumentShareState.initial() => const DocumentShareState(
|
||||
factory ShareState.initial() => const ShareState(
|
||||
isLoading: false,
|
||||
isPublished: false,
|
||||
enablePublish: true,
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
|
||||
import 'package:appflowy/plugins/shared/share/_shared.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_menu.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ShareButton extends StatelessWidget {
|
||||
const ShareButton({
|
||||
super.key,
|
||||
required this.view,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
getIt<ShareBloc>(param1: view)..add(const ShareEvent.initial()),
|
||||
),
|
||||
if (view.layout.isDatabaseView)
|
||||
BlocProvider(
|
||||
create: (context) => DatabaseTabBarBloc(view: view)
|
||||
..add(const DatabaseTabBarEvent.initial()),
|
||||
),
|
||||
],
|
||||
child: BlocListener<ShareBloc, ShareState>(
|
||||
listener: (context, state) {
|
||||
if (!state.isLoading && state.exportResult != null) {
|
||||
state.exportResult!.fold(
|
||||
(data) => _handleExportSuccess(context, data),
|
||||
(error) => _handleExportError(context, error),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<ShareBloc, ShareState>(
|
||||
builder: (context, state) {
|
||||
final tabs = [
|
||||
if (state.enablePublish) ShareMenuTab.publish,
|
||||
ShareMenuTab.exportAs,
|
||||
];
|
||||
|
||||
return ShareMenuButton(tabs: tabs);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleExportSuccess(BuildContext context, ShareType shareType) {
|
||||
switch (shareType) {
|
||||
case ShareType.markdown:
|
||||
case ShareType.html:
|
||||
case ShareType.csv:
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_files_exportFileSuccess.tr(),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleExportError(BuildContext context, FlowyError error) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message:
|
||||
'${LocaleKeys.settings_files_exportFileFail.tr()}: ${error.code}',
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
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:appflowy/plugins/shared/share/export_tab.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -165,7 +165,7 @@ class _SegmentState extends State<_Segment> {
|
||||
);
|
||||
|
||||
if (widget.tab == ShareMenuTab.publish) {
|
||||
final isPublished = context.watch<DocumentShareBloc>().state.isPublished;
|
||||
final isPublished = context.watch<ShareBloc>().state.isPublished;
|
||||
// show checkmark icon if published
|
||||
if (isPublished) {
|
||||
child = Row(
|
||||
@@ -1,18 +1,18 @@
|
||||
export "./src/sizes.dart";
|
||||
export "./src/trash_cell.dart";
|
||||
export "./src/trash_header.dart";
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
|
||||
import 'trash_page.dart';
|
||||
|
||||
export "./src/sizes.dart";
|
||||
export "./src/trash_cell.dart";
|
||||
export "./src/trash_header.dart";
|
||||
|
||||
class TrashPluginBuilder extends PluginBuilder {
|
||||
@override
|
||||
Plugin build(dynamic data) {
|
||||
@@ -66,6 +66,7 @@ class TrashPluginDisplay extends PluginWidgetBuilder {
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
Map<String, dynamic>? data,
|
||||
}) =>
|
||||
const TrashPage(
|
||||
key: ValueKey('TrashPage'),
|
||||
|
||||
@@ -91,6 +91,7 @@ enum FeatureFlag {
|
||||
|
||||
bool get isOn {
|
||||
if ([
|
||||
// FeatureFlag.planBilling,
|
||||
// release this feature in version 0.6.1
|
||||
FeatureFlag.spaceDesign,
|
||||
// release this feature in version 0.5.9
|
||||
@@ -110,6 +111,7 @@ enum FeatureFlag {
|
||||
}
|
||||
|
||||
switch (this) {
|
||||
case FeatureFlag.planBilling:
|
||||
case FeatureFlag.search:
|
||||
case FeatureFlag.syncDocument:
|
||||
case FeatureFlag.syncDatabase:
|
||||
@@ -117,7 +119,6 @@ enum FeatureFlag {
|
||||
return true;
|
||||
case FeatureFlag.collaborativeWorkspace:
|
||||
case FeatureFlag.membersSettings:
|
||||
case FeatureFlag.planBilling:
|
||||
case FeatureFlag.unknown:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -182,8 +182,8 @@ void _resolveHomeDeps(GetIt getIt) {
|
||||
);
|
||||
|
||||
// share
|
||||
getIt.registerFactoryParam<DocumentShareBloc, ViewPB, void>(
|
||||
(view, _) => DocumentShareBloc(view: view),
|
||||
getIt.registerFactoryParam<ShareBloc, ViewPB, void>(
|
||||
(view, _) => ShareBloc(view: view),
|
||||
);
|
||||
|
||||
getIt.registerSingleton<ActionNavigationBloc>(ActionNavigationBloc());
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
library flowy_plugin;
|
||||
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
export "./src/sandbox.dart";
|
||||
|
||||
@@ -76,6 +75,7 @@ abstract class PluginWidgetBuilder with NavigationItem {
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
Map<String, dynamic>? data,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ class InitAppWidgetTask extends LaunchTask {
|
||||
Locale('el', 'GR'),
|
||||
Locale('fr', 'FR'),
|
||||
Locale('fr', 'CA'),
|
||||
Locale('he'),
|
||||
Locale('hu', 'HU'),
|
||||
Locale('id', 'ID'),
|
||||
Locale('it', 'IT'),
|
||||
|
||||
@@ -95,7 +95,9 @@ class AppFlowyCloudDeepLink {
|
||||
}
|
||||
|
||||
if (_isPaymentSuccessUri(uri)) {
|
||||
return getIt<SubscriptionSuccessListenable>().onPaymentSuccess();
|
||||
Log.debug("Payment success deep link: ${uri.toString()}");
|
||||
final plan = uri.queryParameters['plan'];
|
||||
return getIt<SubscriptionSuccessListenable>().onPaymentSuccess(plan);
|
||||
}
|
||||
|
||||
return _isAuthCallbackDeepLink(uri).fold(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
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';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart';
|
||||
@@ -9,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/wid
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fixnum/fixnum.dart' as fixnum;
|
||||
|
||||
class AppFlowyAIService implements AIRepository {
|
||||
@@ -85,6 +87,15 @@ class CompletionStream {
|
||||
_port.handler = _controller.add;
|
||||
_subscription = _controller.stream.listen(
|
||||
(event) async {
|
||||
if (event == "AI_RESPONSE_LIMIT") {
|
||||
onError(
|
||||
AIError(
|
||||
message: LocaleKeys.sideBar_aiResponseLimit.tr(),
|
||||
code: AIErrorCode.aiResponseLimitExceeded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (event.startsWith("start:")) {
|
||||
await onStart();
|
||||
}
|
||||
@@ -96,6 +107,7 @@ class CompletionStream {
|
||||
if (event.startsWith("finish:")) {
|
||||
await onEnd();
|
||||
}
|
||||
|
||||
if (event.startsWith("error:")) {
|
||||
onError(AIError(message: event.substring(6)));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
|
||||
@@ -10,13 +11,20 @@ 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,
|
||||
String? reason,
|
||||
);
|
||||
Future<FlowyResult<PaymentLinkPB, FlowyError>> createSubscription(
|
||||
String workspaceId,
|
||||
SubscriptionPlanPB plan,
|
||||
);
|
||||
}
|
||||
|
||||
const _baseBetaUrl = 'https://beta.appflowy.com';
|
||||
const _baseProdUrl = 'https://appflowy.com';
|
||||
|
||||
class UserBackendService implements IUserBackendService {
|
||||
UserBackendService({required this.userId});
|
||||
|
||||
@@ -228,9 +236,10 @@ 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();
|
||||
}
|
||||
|
||||
Future<FlowyResult<WorkspaceMemberPB, FlowyError>>
|
||||
@@ -250,15 +259,37 @@ class UserBackendService implements IUserBackendService {
|
||||
..recurringInterval = RecurringIntervalPB.Year
|
||||
..workspaceSubscriptionPlan = plan
|
||||
..successUrl =
|
||||
'${getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.base_url}/web/payment-success';
|
||||
'${kDebugMode ? _baseBetaUrl : _baseProdUrl}/after-payment?plan=${plan.toRecognizable()}';
|
||||
return UserEventSubscribeWorkspace(request).send();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<void, FlowyError>> cancelSubscription(
|
||||
String workspaceId,
|
||||
) {
|
||||
final request = UserWorkspaceIdPB()..workspaceId = workspaceId;
|
||||
SubscriptionPlanPB plan, [
|
||||
String? reason,
|
||||
]) {
|
||||
final request = CancelWorkspaceSubscriptionPB()
|
||||
..workspaceId = workspaceId
|
||||
..plan = plan;
|
||||
|
||||
if (reason != null) {
|
||||
request.reason = reason;
|
||||
}
|
||||
|
||||
return UserEventCancelWorkspaceSubscription(request).send();
|
||||
}
|
||||
|
||||
Future<FlowyResult<void, FlowyError>> updateSubscriptionPeriod(
|
||||
String workspaceId,
|
||||
SubscriptionPlanPB plan,
|
||||
RecurringIntervalPB interval,
|
||||
) {
|
||||
final request = UpdateWorkspaceSubscriptionPaymentPeriodPB()
|
||||
..workspaceId = workspaceId
|
||||
..plan = plan
|
||||
..recurringInterval = interval;
|
||||
|
||||
return UserEventUpdateWorkspaceSubscriptionPaymentPeriod(request).send();
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,8 @@ class SidebarSectionsBloc
|
||||
case ViewSectionPB.Public:
|
||||
emit(
|
||||
state.copyWith(
|
||||
containsSpace: state.containsSpace ||
|
||||
sectionViews.views.any((view) => view.isSpace),
|
||||
section: state.section.copyWith(
|
||||
publicViews: sectionViews.views,
|
||||
),
|
||||
@@ -114,6 +116,8 @@ class SidebarSectionsBloc
|
||||
case ViewSectionPB.Private:
|
||||
emit(
|
||||
state.copyWith(
|
||||
containsSpace: state.containsSpace ||
|
||||
sectionViews.views.any((view) => view.isSpace),
|
||||
section: state.section.copyWith(
|
||||
privateViews: sectionViews.views,
|
||||
),
|
||||
|
||||
+8
-1
@@ -4,6 +4,7 @@ import 'dart:isolate';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@@ -40,7 +41,7 @@ class DownloadModelBloc extends Bloc<DownloadModelEvent, DownloadModelState> {
|
||||
add(const DownloadModelEvent.downloadFinish());
|
||||
},
|
||||
onError: (err) {
|
||||
// emit(state.copyWith(downloadError: err));
|
||||
Log.error(err);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -67,6 +68,12 @@ class DownloadModelBloc extends Bloc<DownloadModelEvent, DownloadModelState> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await state.downloadStream?.dispose();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
+53
-213
@@ -1,253 +1,93 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'local_ai_bloc.freezed.dart';
|
||||
|
||||
class LocalAISettingBloc
|
||||
extends Bloc<LocalAISettingEvent, LocalAISettingState> {
|
||||
LocalAISettingBloc()
|
||||
: listener = LocalLLMListener(),
|
||||
super(const LocalAISettingState()) {
|
||||
listener.start(
|
||||
stateCallback: (newState) {
|
||||
if (!isClosed) {
|
||||
add(LocalAISettingEvent.updateLLMRunningState(newState.state));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
on<LocalAISettingEvent>(_handleEvent);
|
||||
class LocalAIToggleBloc extends Bloc<LocalAIToggleEvent, LocalAIToggleState> {
|
||||
LocalAIToggleBloc() : super(const LocalAIToggleState()) {
|
||||
on<LocalAIToggleEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
final LocalLLMListener listener;
|
||||
|
||||
/// Handles incoming events and dispatches them to the appropriate handler.
|
||||
Future<void> _handleEvent(
|
||||
LocalAISettingEvent event,
|
||||
Emitter<LocalAISettingState> emit,
|
||||
LocalAIToggleEvent event,
|
||||
Emitter<LocalAIToggleState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
started: _handleStarted,
|
||||
didLoadModelInfo: (FlowyResult<LLMModelInfoPB, FlowyError> result) {
|
||||
result.fold(
|
||||
(modelInfo) {
|
||||
_fetchCurremtLLMState();
|
||||
emit(
|
||||
state.copyWith(
|
||||
modelInfo: modelInfo,
|
||||
models: modelInfo.models,
|
||||
selectedLLMModel: modelInfo.selectedModel,
|
||||
fetchModelInfoState: const LoadingState.finish(),
|
||||
),
|
||||
);
|
||||
},
|
||||
(err) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
fetchModelInfoState: LoadingState.finish(error: err),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
started: () async {
|
||||
final result = await ChatEventGetLocalAIState().send();
|
||||
_handleResult(emit, result);
|
||||
},
|
||||
selectLLMConfig: (LLMModelPB llmModel) async {
|
||||
final result = await ChatEventUpdateLocalLLM(llmModel).send();
|
||||
result.fold(
|
||||
(llmResource) {
|
||||
// If all resources are downloaded, show reload plugin
|
||||
if (llmResource.pendingResources.isNotEmpty) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedLLMModel: llmModel,
|
||||
localAIInfo: LocalAIProgress.showDownload(
|
||||
llmResource,
|
||||
llmModel,
|
||||
),
|
||||
selectLLMState: const LoadingState.finish(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedLLMModel: llmModel,
|
||||
selectLLMState: const LoadingState.finish(),
|
||||
localAIInfo: const LocalAIProgress.checkPluginState(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectLLMState: LoadingState.finish(error: err),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
refreshLLMState: (LocalModelResourcePB llmResource) {
|
||||
if (state.selectedLLMModel == null) {
|
||||
Log.error(
|
||||
'Unexpected null selected config. It should be set already',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// reload plugin if all resources are downloaded
|
||||
if (llmResource.pendingResources.isEmpty) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
localAIInfo: const LocalAIProgress.checkPluginState(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (state.selectedLLMModel != null) {
|
||||
// Go to download page if the selected model is downloading
|
||||
if (llmResource.isDownloading) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
localAIInfo:
|
||||
LocalAIProgress.startDownloading(state.selectedLLMModel!),
|
||||
selectLLMState: const LoadingState.finish(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
localAIInfo: LocalAIProgress.showDownload(
|
||||
llmResource,
|
||||
state.selectedLLMModel!,
|
||||
),
|
||||
selectLLMState: const LoadingState.finish(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
startDownloadModel: (LLMModelPB llmModel) {
|
||||
toggle: () async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
localAIInfo: LocalAIProgress.startDownloading(llmModel),
|
||||
selectLLMState: const LoadingState.finish(),
|
||||
pageIndicator: const LocalAIToggleStateIndicator.loading(),
|
||||
),
|
||||
);
|
||||
unawaited(
|
||||
ChatEventToggleLocalAI().send().then(
|
||||
(result) {
|
||||
if (!isClosed) {
|
||||
add(LocalAIToggleEvent.handleResult(result));
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
cancelDownload: () async {
|
||||
final _ = await ChatEventCancelDownloadLLMResource().send();
|
||||
_fetchCurremtLLMState();
|
||||
handleResult: (result) {
|
||||
_handleResult(emit, result);
|
||||
},
|
||||
finishDownload: () async {
|
||||
);
|
||||
}
|
||||
|
||||
void _handleResult(
|
||||
Emitter<LocalAIToggleState> emit,
|
||||
FlowyResult<LocalAIPB, FlowyError> result,
|
||||
) {
|
||||
result.fold(
|
||||
(localAI) {
|
||||
emit(
|
||||
state.copyWith(localAIInfo: const LocalAIProgress.finishDownload()),
|
||||
state.copyWith(
|
||||
pageIndicator: LocalAIToggleStateIndicator.ready(localAI.enabled),
|
||||
),
|
||||
);
|
||||
},
|
||||
updateLLMRunningState: (RunningStatePB newRunningState) {
|
||||
if (newRunningState == RunningStatePB.Stopped) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
runningState: newRunningState,
|
||||
localAIInfo: const LocalAIProgress.checkPluginState(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(state.copyWith(runningState: newRunningState));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _fetchCurremtLLMState() async {
|
||||
final result = await ChatEventGetLocalLLMState().send();
|
||||
result.fold(
|
||||
(llmResource) {
|
||||
if (!isClosed) {
|
||||
add(LocalAISettingEvent.refreshLLMState(llmResource));
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
Log.error(err);
|
||||
emit(
|
||||
state.copyWith(
|
||||
pageIndicator: LocalAIToggleStateIndicator.error(err),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles the event to fetch local AI settings when the application starts.
|
||||
Future<void> _handleStarted() async {
|
||||
final result = await ChatEventRefreshLocalAIModelInfo().send();
|
||||
if (!isClosed) {
|
||||
add(LocalAISettingEvent.didLoadModelInfo(result));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAISettingEvent with _$LocalAISettingEvent {
|
||||
const factory LocalAISettingEvent.started() = _Started;
|
||||
const factory LocalAISettingEvent.didLoadModelInfo(
|
||||
FlowyResult<LLMModelInfoPB, FlowyError> result,
|
||||
) = _ModelInfo;
|
||||
const factory LocalAISettingEvent.selectLLMConfig(LLMModelPB config) =
|
||||
_SelectLLMConfig;
|
||||
|
||||
const factory LocalAISettingEvent.refreshLLMState(
|
||||
LocalModelResourcePB llmResource,
|
||||
) = _RefreshLLMResource;
|
||||
const factory LocalAISettingEvent.startDownloadModel(LLMModelPB llmModel) =
|
||||
_StartDownloadModel;
|
||||
|
||||
const factory LocalAISettingEvent.cancelDownload() = _CancelDownload;
|
||||
const factory LocalAISettingEvent.finishDownload() = _FinishDownload;
|
||||
const factory LocalAISettingEvent.updateLLMRunningState(
|
||||
RunningStatePB newRunningState,
|
||||
) = _RunningState;
|
||||
class LocalAIToggleEvent with _$LocalAIToggleEvent {
|
||||
const factory LocalAIToggleEvent.started() = _Started;
|
||||
const factory LocalAIToggleEvent.toggle() = _Toggle;
|
||||
const factory LocalAIToggleEvent.handleResult(
|
||||
FlowyResult<LocalAIPB, FlowyError> result,
|
||||
) = _HandleResult;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAISettingState with _$LocalAISettingState {
|
||||
const factory LocalAISettingState({
|
||||
LLMModelInfoPB? modelInfo,
|
||||
LLMModelPB? selectedLLMModel,
|
||||
LocalAIProgress? localAIInfo,
|
||||
@Default(LoadingState.loading()) LoadingState fetchModelInfoState,
|
||||
@Default(LoadingState.loading()) LoadingState selectLLMState,
|
||||
@Default([]) List<LLMModelPB> models,
|
||||
@Default(RunningStatePB.Connecting) RunningStatePB runningState,
|
||||
}) = _LocalAISettingState;
|
||||
class LocalAIToggleState with _$LocalAIToggleState {
|
||||
const factory LocalAIToggleState({
|
||||
@Default(LocalAIToggleStateIndicator.loading())
|
||||
LocalAIToggleStateIndicator pageIndicator,
|
||||
}) = _LocalAIToggleState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIProgress with _$LocalAIProgress {
|
||||
// when user select a new model, it will call requestDownload
|
||||
const factory LocalAIProgress.requestDownloadInfo(
|
||||
LocalModelResourcePB llmResource,
|
||||
LLMModelPB llmModel,
|
||||
) = _RequestDownload;
|
||||
|
||||
// when user comes back to the setting page, it will auto detect current llm state
|
||||
const factory LocalAIProgress.showDownload(
|
||||
LocalModelResourcePB llmResource,
|
||||
LLMModelPB llmModel,
|
||||
) = _DownloadNeeded;
|
||||
|
||||
class LocalAIToggleStateIndicator with _$LocalAIToggleStateIndicator {
|
||||
// when start downloading the model
|
||||
const factory LocalAIProgress.startDownloading(LLMModelPB llmModel) =
|
||||
_Downloading;
|
||||
const factory LocalAIProgress.finishDownload() = _Finish;
|
||||
const factory LocalAIProgress.checkPluginState() = _PluginState;
|
||||
const factory LocalAIToggleStateIndicator.error(FlowyError error) = _OnError;
|
||||
const factory LocalAIToggleStateIndicator.ready(bool isEnabled) = _Ready;
|
||||
const factory LocalAIToggleStateIndicator.loading() = _Loading;
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'local_ai_chat_bloc.freezed.dart';
|
||||
|
||||
class LocalAIChatSettingBloc
|
||||
extends Bloc<LocalAIChatSettingEvent, LocalAIChatSettingState> {
|
||||
LocalAIChatSettingBloc()
|
||||
: listener = LocalLLMListener(),
|
||||
super(const LocalAIChatSettingState()) {
|
||||
listener.start(
|
||||
stateCallback: (newState) {
|
||||
if (!isClosed) {
|
||||
add(LocalAIChatSettingEvent.updateLLMRunningState(newState.state));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
on<LocalAIChatSettingEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
final LocalLLMListener listener;
|
||||
|
||||
/// Handles incoming events and dispatches them to the appropriate handler.
|
||||
Future<void> _handleEvent(
|
||||
LocalAIChatSettingEvent event,
|
||||
Emitter<LocalAIChatSettingState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
refreshAISetting: _handleStarted,
|
||||
didLoadModelInfo: (FlowyResult<LLMModelInfoPB, FlowyError> result) {
|
||||
result.fold(
|
||||
(modelInfo) {
|
||||
_fetchCurremtLLMState();
|
||||
emit(
|
||||
state.copyWith(
|
||||
modelInfo: modelInfo,
|
||||
models: modelInfo.models,
|
||||
selectedLLMModel: modelInfo.selectedModel,
|
||||
fetchModelInfoState: const LoadingState.finish(),
|
||||
),
|
||||
);
|
||||
},
|
||||
(err) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
fetchModelInfoState: LoadingState.finish(error: err),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
selectLLMConfig: (LLMModelPB llmModel) async {
|
||||
final result = await ChatEventUpdateLocalLLM(llmModel).send();
|
||||
result.fold(
|
||||
(llmResource) {
|
||||
// If all resources are downloaded, show reload plugin
|
||||
if (llmResource.pendingResources.isNotEmpty) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedLLMModel: llmModel,
|
||||
localAIInfo: LocalAIProgress.showDownload(
|
||||
llmResource,
|
||||
llmModel,
|
||||
),
|
||||
selectLLMState: const LoadingState.finish(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedLLMModel: llmModel,
|
||||
selectLLMState: const LoadingState.finish(),
|
||||
localAIInfo: const LocalAIProgress.checkPluginState(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectLLMState: LoadingState.finish(error: err),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
refreshLLMState: (LocalModelResourcePB llmResource) {
|
||||
if (state.selectedLLMModel == null) {
|
||||
Log.error(
|
||||
'Unexpected null selected config. It should be set already',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// reload plugin if all resources are downloaded
|
||||
if (llmResource.pendingResources.isEmpty) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
localAIInfo: const LocalAIProgress.checkPluginState(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (state.selectedLLMModel != null) {
|
||||
// Go to download page if the selected model is downloading
|
||||
if (llmResource.isDownloading) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
localAIInfo:
|
||||
LocalAIProgress.startDownloading(state.selectedLLMModel!),
|
||||
selectLLMState: const LoadingState.finish(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
localAIInfo: LocalAIProgress.showDownload(
|
||||
llmResource,
|
||||
state.selectedLLMModel!,
|
||||
),
|
||||
selectLLMState: const LoadingState.finish(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
startDownloadModel: (LLMModelPB llmModel) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
localAIInfo: LocalAIProgress.startDownloading(llmModel),
|
||||
selectLLMState: const LoadingState.finish(),
|
||||
),
|
||||
);
|
||||
},
|
||||
cancelDownload: () async {
|
||||
final _ = await ChatEventCancelDownloadLLMResource().send();
|
||||
_fetchCurremtLLMState();
|
||||
},
|
||||
finishDownload: () async {
|
||||
emit(
|
||||
state.copyWith(localAIInfo: const LocalAIProgress.finishDownload()),
|
||||
);
|
||||
},
|
||||
updateLLMRunningState: (RunningStatePB newRunningState) {
|
||||
if (newRunningState == RunningStatePB.Stopped) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
runningState: newRunningState,
|
||||
localAIInfo: const LocalAIProgress.checkPluginState(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(state.copyWith(runningState: newRunningState));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _fetchCurremtLLMState() async {
|
||||
final result = await ChatEventGetLocalLLMState().send();
|
||||
result.fold(
|
||||
(llmResource) {
|
||||
if (!isClosed) {
|
||||
add(LocalAIChatSettingEvent.refreshLLMState(llmResource));
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
Log.error(err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles the event to fetch local AI settings when the application starts.
|
||||
Future<void> _handleStarted() async {
|
||||
final result = await ChatEventRefreshLocalAIModelInfo().send();
|
||||
if (!isClosed) {
|
||||
add(LocalAIChatSettingEvent.didLoadModelInfo(result));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIChatSettingEvent with _$LocalAIChatSettingEvent {
|
||||
const factory LocalAIChatSettingEvent.refreshAISetting() = _RefreshAISetting;
|
||||
const factory LocalAIChatSettingEvent.didLoadModelInfo(
|
||||
FlowyResult<LLMModelInfoPB, FlowyError> result,
|
||||
) = _ModelInfo;
|
||||
const factory LocalAIChatSettingEvent.selectLLMConfig(LLMModelPB config) =
|
||||
_SelectLLMConfig;
|
||||
|
||||
const factory LocalAIChatSettingEvent.refreshLLMState(
|
||||
LocalModelResourcePB llmResource,
|
||||
) = _RefreshLLMResource;
|
||||
const factory LocalAIChatSettingEvent.startDownloadModel(
|
||||
LLMModelPB llmModel,
|
||||
) = _StartDownloadModel;
|
||||
|
||||
const factory LocalAIChatSettingEvent.cancelDownload() = _CancelDownload;
|
||||
const factory LocalAIChatSettingEvent.finishDownload() = _FinishDownload;
|
||||
const factory LocalAIChatSettingEvent.updateLLMRunningState(
|
||||
RunningStatePB newRunningState,
|
||||
) = _RunningState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIChatSettingState with _$LocalAIChatSettingState {
|
||||
const factory LocalAIChatSettingState({
|
||||
LLMModelInfoPB? modelInfo,
|
||||
LLMModelPB? selectedLLMModel,
|
||||
LocalAIProgress? localAIInfo,
|
||||
@Default(LoadingState.loading()) LoadingState fetchModelInfoState,
|
||||
@Default(LoadingState.loading()) LoadingState selectLLMState,
|
||||
@Default([]) List<LLMModelPB> models,
|
||||
@Default(RunningStatePB.Connecting) RunningStatePB runningState,
|
||||
}) = _LocalAIChatSettingState;
|
||||
}
|
||||
|
||||
// @freezed
|
||||
// class LocalChatAIStateIndicator with _$LocalChatAIStateIndicator {
|
||||
// // when start downloading the model
|
||||
// const factory LocalChatAIStateIndicator.error(FlowyError error) = _OnError;
|
||||
// const factory LocalChatAIStateIndicator.ready(bool isEnabled) = _Ready;
|
||||
// }
|
||||
|
||||
@freezed
|
||||
class LocalAIProgress with _$LocalAIProgress {
|
||||
// when user select a new model, it will call requestDownload
|
||||
const factory LocalAIProgress.requestDownloadInfo(
|
||||
LocalModelResourcePB llmResource,
|
||||
LLMModelPB llmModel,
|
||||
) = _RequestDownload;
|
||||
|
||||
// when user comes back to the setting page, it will auto detect current llm state
|
||||
const factory LocalAIProgress.showDownload(
|
||||
LocalModelResourcePB llmResource,
|
||||
LLMModelPB llmModel,
|
||||
) = _DownloadNeeded;
|
||||
|
||||
// when start downloading the model
|
||||
const factory LocalAIProgress.startDownloading(LLMModelPB llmModel) =
|
||||
_Downloading;
|
||||
const factory LocalAIProgress.finishDownload() = _Finish;
|
||||
const factory LocalAIProgress.checkPluginState() = _PluginState;
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'local_ai_chat_toggle_bloc.freezed.dart';
|
||||
|
||||
class LocalAIChatToggleBloc
|
||||
extends Bloc<LocalAIChatToggleEvent, LocalAIChatToggleState> {
|
||||
LocalAIChatToggleBloc() : super(const LocalAIChatToggleState()) {
|
||||
on<LocalAIChatToggleEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
Future<void> _handleEvent(
|
||||
LocalAIChatToggleEvent event,
|
||||
Emitter<LocalAIChatToggleState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
started: () async {
|
||||
final result = await ChatEventGetLocalAIChatState().send();
|
||||
_handleResult(emit, result);
|
||||
},
|
||||
toggle: () async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
pageIndicator: const LocalAIChatToggleStateIndicator.loading(),
|
||||
),
|
||||
);
|
||||
unawaited(
|
||||
ChatEventToggleLocalAIChat().send().then(
|
||||
(result) {
|
||||
if (!isClosed) {
|
||||
add(LocalAIChatToggleEvent.handleResult(result));
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
handleResult: (result) {
|
||||
_handleResult(emit, result);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleResult(
|
||||
Emitter<LocalAIChatToggleState> emit,
|
||||
FlowyResult<LocalAIChatPB, FlowyError> result,
|
||||
) {
|
||||
result.fold(
|
||||
(localAI) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
pageIndicator:
|
||||
LocalAIChatToggleStateIndicator.ready(localAI.enabled),
|
||||
),
|
||||
);
|
||||
},
|
||||
(err) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
pageIndicator: LocalAIChatToggleStateIndicator.error(err),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIChatToggleEvent with _$LocalAIChatToggleEvent {
|
||||
const factory LocalAIChatToggleEvent.started() = _Started;
|
||||
const factory LocalAIChatToggleEvent.toggle() = _Toggle;
|
||||
const factory LocalAIChatToggleEvent.handleResult(
|
||||
FlowyResult<LocalAIChatPB, FlowyError> result,
|
||||
) = _HandleResult;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIChatToggleState with _$LocalAIChatToggleState {
|
||||
const factory LocalAIChatToggleState({
|
||||
@Default(LocalAIChatToggleStateIndicator.loading())
|
||||
LocalAIChatToggleStateIndicator pageIndicator,
|
||||
}) = _LocalAIChatToggleState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIChatToggleStateIndicator with _$LocalAIChatToggleStateIndicator {
|
||||
const factory LocalAIChatToggleStateIndicator.error(FlowyError error) =
|
||||
_OnError;
|
||||
const factory LocalAIChatToggleStateIndicator.ready(bool isEnabled) = _Ready;
|
||||
const factory LocalAIChatToggleStateIndicator.loading() = _Loading;
|
||||
}
|
||||
+11
-5
@@ -2,19 +2,19 @@ import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
|
||||
import 'package:appflowy_backend/rust_stream.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
typedef PluginStateCallback = void Function(PluginStatePB state);
|
||||
typedef PluginStateCallback = void Function(LocalAIPluginStatePB state);
|
||||
typedef LocalAIChatCallback = void Function(LocalAIChatPB chatState);
|
||||
|
||||
class LocalLLMListener {
|
||||
LocalLLMListener() {
|
||||
_parser =
|
||||
ChatNotificationParser(id: "appflowy_chat_plugin", callback: _callback);
|
||||
ChatNotificationParser(id: "appflowy_ai_plugin", callback: _callback);
|
||||
_subscription = RustStreamReceiver.listen(
|
||||
(observable) => _parser?.parse(observable),
|
||||
);
|
||||
@@ -24,12 +24,15 @@ class LocalLLMListener {
|
||||
ChatNotificationParser? _parser;
|
||||
|
||||
PluginStateCallback? stateCallback;
|
||||
LocalAIChatCallback? chatStateCallback;
|
||||
void Function()? finishStreamingCallback;
|
||||
|
||||
void start({
|
||||
PluginStateCallback? stateCallback,
|
||||
LocalAIChatCallback? chatStateCallback,
|
||||
}) {
|
||||
this.stateCallback = stateCallback;
|
||||
this.chatStateCallback = chatStateCallback;
|
||||
}
|
||||
|
||||
void _callback(
|
||||
@@ -39,7 +42,10 @@ class LocalLLMListener {
|
||||
result.map((r) {
|
||||
switch (ty) {
|
||||
case ChatNotification.UpdateChatPluginState:
|
||||
stateCallback?.call(PluginStatePB.fromBuffer(r));
|
||||
stateCallback?.call(LocalAIPluginStatePB.fromBuffer(r));
|
||||
break;
|
||||
case ChatNotification.UpdateLocalChatAI:
|
||||
chatStateCallback?.call(LocalAIChatPB.fromBuffer(r));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
+16
-7
@@ -11,7 +11,11 @@ part 'plugin_state_bloc.freezed.dart';
|
||||
class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
|
||||
PluginStateBloc()
|
||||
: listener = LocalLLMListener(),
|
||||
super(const PluginStateState(action: PluginStateAction.init())) {
|
||||
super(
|
||||
const PluginStateState(
|
||||
action: PluginStateAction.init(),
|
||||
),
|
||||
) {
|
||||
listener.start(
|
||||
stateCallback: (pluginState) {
|
||||
if (!isClosed) {
|
||||
@@ -37,7 +41,7 @@ class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
|
||||
) async {
|
||||
await event.when(
|
||||
started: () async {
|
||||
final result = await ChatEventGetPluginState().send();
|
||||
final result = await ChatEventGetLocalAIPluginState().send();
|
||||
result.fold(
|
||||
(pluginState) {
|
||||
if (!isClosed) {
|
||||
@@ -47,20 +51,24 @@ class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
|
||||
(err) => Log.error(err.toString()),
|
||||
);
|
||||
},
|
||||
updateState: (PluginStatePB pluginState) {
|
||||
updateState: (LocalAIPluginStatePB pluginState) {
|
||||
switch (pluginState.state) {
|
||||
case RunningStatePB.Connecting:
|
||||
emit(
|
||||
const PluginStateState(action: PluginStateAction.loadingPlugin()),
|
||||
);
|
||||
case RunningStatePB.Running:
|
||||
emit(const PluginStateState(action: PluginStateAction.ready()));
|
||||
break;
|
||||
default:
|
||||
emit(
|
||||
state.copyWith(action: const PluginStateAction.reloadRequired()),
|
||||
state.copyWith(action: const PluginStateAction.restart()),
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
restartLocalAI: () {
|
||||
ChatEventRestartLocalAI().send();
|
||||
ChatEventRestartLocalAIChat().send();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -69,7 +77,7 @@ class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
|
||||
@freezed
|
||||
class PluginStateEvent with _$PluginStateEvent {
|
||||
const factory PluginStateEvent.started() = _Started;
|
||||
const factory PluginStateEvent.updateState(PluginStatePB pluginState) =
|
||||
const factory PluginStateEvent.updateState(LocalAIPluginStatePB pluginState) =
|
||||
_UpdatePluginState;
|
||||
const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI;
|
||||
}
|
||||
@@ -83,6 +91,7 @@ class PluginStateState with _$PluginStateState {
|
||||
@freezed
|
||||
class PluginStateAction with _$PluginStateAction {
|
||||
const factory PluginStateAction.init() = _Init;
|
||||
const factory PluginStateAction.loadingPlugin() = _LoadingPlugin;
|
||||
const factory PluginStateAction.ready() = _Ready;
|
||||
const factory PluginStateAction.reloadRequired() = _ReloadRequired;
|
||||
const factory PluginStateAction.restart() = _Restart;
|
||||
}
|
||||
+257
-45
@@ -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,274 @@ 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,
|
||||
),
|
||||
(e) {
|
||||
// Not a Cjstomer yet
|
||||
if (e.code == ErrorCode.InvalidParams) {
|
||||
return WorkspaceSubscriptionPB(
|
||||
workspaceId: workspaceId,
|
||||
subscriptionPlan: SubscriptionPlanPB.None,
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
final result = await UserBackendService.getWorkspaceSubscriptionInfo(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
final subscriptionInfo = result.fold(
|
||||
(s) => s,
|
||||
(e) {
|
||||
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, isLoading) => emit(
|
||||
SettingsBillingState.ready(
|
||||
subscriptionInfo: subscriptionInfo,
|
||||
billingPortal: billingPortal,
|
||||
successfulPlanUpgrade: plan,
|
||||
isLoading: isLoading,
|
||||
),
|
||||
),
|
||||
),
|
||||
openCustomerPortal: () async {
|
||||
if (_billingPortalCompleter.isCompleted && _billingPortal != null) {
|
||||
return 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, reason) async {
|
||||
final s = state.mapOrNull(ready: (s) => s);
|
||||
if (s == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(s.copyWith(isLoading: true));
|
||||
|
||||
final result =
|
||||
await _userService.cancelSubscription(workspaceId, plan, reason);
|
||||
final successOrNull = result.fold(
|
||||
(_) => true,
|
||||
(f) {
|
||||
Log.error(
|
||||
'Failed to cancel subscription of ${plan.label}: ${f.msg}',
|
||||
f,
|
||||
);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
if (successOrNull != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 (plan == WorkspacePlanPB.ProPlan &&
|
||||
value.plan == WorkspacePlanPB.ProPlan) {
|
||||
value.plan = WorkspacePlanPB.FreePlan;
|
||||
value.planSubscription.freeze();
|
||||
value.planSubscription = value.planSubscription.rebuild((sub) {
|
||||
sub.status = WorkspaceSubscriptionStatusPB.Active;
|
||||
sub.subscriptionPlan = SubscriptionPlanPB.Free;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
updatePeriod: (plan, interval) async {
|
||||
final s = state.mapOrNull(ready: (s) => s);
|
||||
if (s == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(s.copyWith(isLoading: true));
|
||||
|
||||
final result = await _userService.updateSubscriptionPeriod(
|
||||
workspaceId,
|
||||
plan,
|
||||
interval,
|
||||
);
|
||||
final successOrNull = result.fold((_) => true, (f) {
|
||||
Log.error(
|
||||
'Failed to update subscription period of ${plan.label}: ${f.msg}',
|
||||
f,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (successOrNull != true) {
|
||||
return emit(s.copyWith(isLoading: false));
|
||||
}
|
||||
|
||||
// Fetch new subscription info
|
||||
final newResult =
|
||||
await UserBackendService.getWorkspaceSubscriptionInfo(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
final newSubscriptionInfo = newResult.toNullable();
|
||||
if (newSubscriptionInfo != null) {
|
||||
emit(
|
||||
SettingsBillingState.ready(
|
||||
subscriptionInfo: newSubscriptionInfo,
|
||||
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;
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_successListenable.removeListener(_onPaymentSuccessful);
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _fetchBillingPortal() async {
|
||||
final billingPortalResult = await _service.getBillingPortal();
|
||||
_billingPortalCompleter.complete(billingPortalResult);
|
||||
}
|
||||
|
||||
Future<void> _onPaymentSuccessful() async => 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, {
|
||||
@Default(null) String? reason,
|
||||
}) = _CancelSubscription;
|
||||
|
||||
const factory SettingsBillingEvent.paymentSuccessful({
|
||||
SubscriptionPlanPB? plan,
|
||||
}) = _PaymentSuccessful;
|
||||
|
||||
const factory SettingsBillingEvent.updatePeriod({
|
||||
required SubscriptionPlanPB plan,
|
||||
required RecurringIntervalPB interval,
|
||||
}) = _UpdatePeriod;
|
||||
}
|
||||
|
||||
@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 +303,22 @@ class SettingsBillingState with _$SettingsBillingState {
|
||||
}) = _Error;
|
||||
|
||||
const factory SettingsBillingState.ready({
|
||||
required WorkspaceSubscriptionPB subscription,
|
||||
required WorkspaceSubscriptionInfoPB subscriptionInfo,
|
||||
required BillingPortalPB? billingPortal,
|
||||
@Default(null) SubscriptionPlanPB? successfulPlanUpgrade,
|
||||
@Default(false) bool isLoading,
|
||||
}) = _Ready;
|
||||
|
||||
@override
|
||||
List<Object?> get props => maybeWhen(
|
||||
orElse: () => const [],
|
||||
error: (error) => [error],
|
||||
ready: (subscription, billingPortal, plan, isLoading) => [
|
||||
subscription,
|
||||
billingPortal,
|
||||
plan,
|
||||
isLoading,
|
||||
...subscription.addOns,
|
||||
],
|
||||
);
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy/core/notification/notification_helper.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-storage/notification.pb.dart';
|
||||
import 'package:appflowy_backend/rust_stream.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
class StoregeNotificationParser
|
||||
extends NotificationParser<StorageNotification, FlowyError> {
|
||||
StoregeNotificationParser({
|
||||
super.id,
|
||||
required super.callback,
|
||||
}) : super(
|
||||
tyParser: (ty, source) =>
|
||||
source == "storage" ? StorageNotification.valueOf(ty) : null,
|
||||
errorParser: (bytes) => FlowyError.fromBuffer(bytes),
|
||||
);
|
||||
}
|
||||
|
||||
class StoreageNotificationListener {
|
||||
StoreageNotificationListener({
|
||||
void Function(FlowyError error)? onError,
|
||||
}) : _parser = StoregeNotificationParser(
|
||||
callback: (
|
||||
StorageNotification ty,
|
||||
FlowyResult<Uint8List, FlowyError> result,
|
||||
) {
|
||||
result.fold(
|
||||
(data) {
|
||||
try {
|
||||
switch (ty) {
|
||||
case StorageNotification.FileStorageLimitExceeded:
|
||||
onError?.call(FlowyError.fromBuffer(data));
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error(
|
||||
"$StoreageNotificationListener deserialize PB fail",
|
||||
e,
|
||||
);
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
Log.error("Error in StoreageNotificationListener", err);
|
||||
},
|
||||
);
|
||||
},
|
||||
) {
|
||||
_subscription =
|
||||
RustStreamReceiver.listen((observable) => _parser?.parse(observable));
|
||||
}
|
||||
|
||||
StoregeNotificationParser? _parser;
|
||||
StreamSubscription<SubscribeObject>? _subscription;
|
||||
|
||||
Future<void> stop() async {
|
||||
_parser = null;
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
}
|
||||
}
|
||||
+106
-54
@@ -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,29 +84,96 @@ 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 {
|
||||
cancelSubscription: (reason) async {
|
||||
final newState = state
|
||||
.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,
|
||||
reason,
|
||||
);
|
||||
|
||||
final successOrNull = result.fold(
|
||||
(_) => true,
|
||||
(f) {
|
||||
Log.error('Failed to cancel subscription of Pro: ${f.msg}', f);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
if (successOrNull != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
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.Free;
|
||||
});
|
||||
});
|
||||
|
||||
// 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,9 +184,11 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
late final IUserBackendService _userService;
|
||||
late final SubscriptionSuccessListenable _successListenable;
|
||||
|
||||
void _onPaymentSuccessful() {
|
||||
add(const SettingsPlanEvent.paymentSuccessful());
|
||||
}
|
||||
Future<void> _onPaymentSuccessful() async => add(
|
||||
SettingsPlanEvent.paymentSuccessful(
|
||||
plan: _successListenable.subscribedPlan,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
@@ -155,12 +200,20 @@ 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.cancelSubscription({
|
||||
@Default(null) String? reason,
|
||||
}) = _CancelSubscription;
|
||||
|
||||
const factory SettingsPlanEvent.paymentSuccessful({
|
||||
@Default(null) SubscriptionPlanPB? plan,
|
||||
}) = _PaymentSuccessful;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -175,9 +228,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;
|
||||
}
|
||||
+110
-13
@@ -1,26 +1,123 @@
|
||||
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) {
|
||||
SubscriptionPlanPB.None =>
|
||||
extension SubscriptionInfoHelpers 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',
|
||||
};
|
||||
|
||||
bool get isBillingPortalEnabled {
|
||||
if (plan != WorkspacePlanPB.FreePlan || addOns.isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
extension AllSubscriptionLabels on SubscriptionPlanPB {
|
||||
String get label => switch (this) {
|
||||
SubscriptionPlanPB.Free =>
|
||||
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.Free => 'free',
|
||||
SubscriptionPlanPB.Pro => 'pro',
|
||||
SubscriptionPlanPB.Team => 'team',
|
||||
SubscriptionPlanPB.AiMax => 'ai_max',
|
||||
SubscriptionPlanPB.AiLocal => 'ai_local',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
extension PlanHelper 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,
|
||||
};
|
||||
|
||||
String get priceMonthBilling => switch (this) {
|
||||
SubscriptionPlanPB.Free => 'US\$0',
|
||||
SubscriptionPlanPB.Pro => 'US\$12.5',
|
||||
SubscriptionPlanPB.Team => 'US\$15',
|
||||
SubscriptionPlanPB.AiMax => 'US\$10',
|
||||
SubscriptionPlanPB.AiLocal => 'US\$10',
|
||||
_ => 'US\$0',
|
||||
};
|
||||
|
||||
String get priceAnnualBilling => switch (this) {
|
||||
SubscriptionPlanPB.Free => 'US\$0',
|
||||
SubscriptionPlanPB.Pro => 'US\$10',
|
||||
SubscriptionPlanPB.Team => 'US\$12.5',
|
||||
SubscriptionPlanPB.AiMax => 'US\$8',
|
||||
SubscriptionPlanPB.AiLocal => 'US\$8',
|
||||
_ => 'US\$0',
|
||||
};
|
||||
}
|
||||
|
||||
extension IntervalLabel on RecurringIntervalPB {
|
||||
String get label => switch (this) {
|
||||
RecurringIntervalPB.Month =>
|
||||
LocaleKeys.settings_billingPage_monthlyInterval.tr(),
|
||||
RecurringIntervalPB.Year =>
|
||||
LocaleKeys.settings_billingPage_annualInterval.tr(),
|
||||
_ => LocaleKeys.settings_billingPage_monthlyInterval.tr(),
|
||||
};
|
||||
|
||||
String get priceInfo => switch (this) {
|
||||
RecurringIntervalPB.Month =>
|
||||
LocaleKeys.settings_billingPage_monthlyPriceInfo.tr(),
|
||||
RecurringIntervalPB.Year =>
|
||||
LocaleKeys.settings_billingPage_annualPriceInfo.tr(),
|
||||
_ => LocaleKeys.settings_billingPage_monthlyPriceInfo.tr(),
|
||||
};
|
||||
}
|
||||
+8
-3
@@ -6,8 +6,13 @@ final _storageNumberFormat = NumberFormat()
|
||||
..minimumFractionDigits = 0;
|
||||
|
||||
extension PresentableUsage on WorkspaceUsagePB {
|
||||
String get totalBlobInGb =>
|
||||
(totalBlobBytesLimit.toInt() / 1024 / 1024 / 1024).round().toString();
|
||||
String get totalBlobInGb {
|
||||
if (storageBytesLimit == 0) {
|
||||
return '0';
|
||||
}
|
||||
return _storageNumberFormat
|
||||
.format(storageBytesLimit.toInt() / (1024 * 1024 * 1024));
|
||||
}
|
||||
|
||||
/// We use [NumberFormat] to format the current blob in GB.
|
||||
///
|
||||
@@ -16,5 +21,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);
|
||||
}
|
||||
+10
-5
@@ -26,9 +26,11 @@ enum SettingsPage {
|
||||
|
||||
class SettingsDialogBloc
|
||||
extends Bloc<SettingsDialogEvent, SettingsDialogState> {
|
||||
SettingsDialogBloc(this.userProfile)
|
||||
: _userListener = UserListener(userProfile: userProfile),
|
||||
super(SettingsDialogState.initial(userProfile)) {
|
||||
SettingsDialogBloc(
|
||||
this.userProfile, {
|
||||
SettingsPage? initPage,
|
||||
}) : _userListener = UserListener(userProfile: userProfile),
|
||||
super(SettingsDialogState.initial(userProfile, initPage)) {
|
||||
_dispatch();
|
||||
}
|
||||
|
||||
@@ -87,9 +89,12 @@ class SettingsDialogState with _$SettingsDialogState {
|
||||
required SettingsPage page,
|
||||
}) = _SettingsDialogState;
|
||||
|
||||
factory SettingsDialogState.initial(UserProfilePB userProfile) =>
|
||||
factory SettingsDialogState.initial(
|
||||
UserProfilePB userProfile,
|
||||
SettingsPage? page,
|
||||
) =>
|
||||
SettingsDialogState(
|
||||
userProfile: userProfile,
|
||||
page: SettingsPage.account,
|
||||
page: page ?? SettingsPage.account,
|
||||
);
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/file_storage/file_storage_listener.dart';
|
||||
import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.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:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'sidebar_plan_bloc.freezed.dart';
|
||||
|
||||
class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
|
||||
SidebarPlanBloc() : super(const SidebarPlanState()) {
|
||||
// 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;
|
||||
Log.info("Subscription success listenable triggered: $plan");
|
||||
|
||||
if (!isClosed) {
|
||||
// Notify the user that they have switched to a new plan. It would be better if we use websocket to
|
||||
// notify the client when plan switching.
|
||||
if (state.workspaceId != null) {
|
||||
final payload = SuccessWorkspaceSubscriptionPB(
|
||||
workspaceId: state.workspaceId,
|
||||
);
|
||||
|
||||
if (plan != null) {
|
||||
payload.plan = plan;
|
||||
}
|
||||
|
||||
UserEventNotifyDidSwitchPlan(payload).send().then((result) {
|
||||
result.fold(
|
||||
// After the user has switched to a new plan, we need to refresh the workspace usage.
|
||||
(_) => _checkWorkspaceUsage(),
|
||||
(error) => Log.error("NotifyDidSwitchPlan failed: $error"),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
Log.error(
|
||||
"Unexpected empty workspace id when subscription success listenable triggered. It should not happen. If happens, it must be a bug",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Listen to the storage notification
|
||||
_storageListener = StoreageNotificationListener(
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(SidebarPlanEvent.receiveError(error));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Listen to specific error codes
|
||||
_globalErrorListener = GlobalErrorCodeNotifier.add(
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(SidebarPlanEvent.receiveError(error));
|
||||
}
|
||||
},
|
||||
onErrorIf: (error) {
|
||||
const relevantErrorCodes = {
|
||||
ErrorCode.AIResponseLimitExceeded,
|
||||
ErrorCode.FileStorageLimitExceeded,
|
||||
};
|
||||
return relevantErrorCodes.contains(error.code);
|
||||
},
|
||||
);
|
||||
|
||||
on<SidebarPlanEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
if (_globalErrorListener != null) {
|
||||
GlobalErrorCodeNotifier.remove(_globalErrorListener!);
|
||||
}
|
||||
await _storageListener?.stop();
|
||||
_storageListener = null;
|
||||
}
|
||||
|
||||
ErrorListener? _globalErrorListener;
|
||||
StoreageNotificationListener? _storageListener;
|
||||
|
||||
Future<void> _handleEvent(
|
||||
SidebarPlanEvent event,
|
||||
Emitter<SidebarPlanState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
receiveError: (FlowyError error) async {
|
||||
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(
|
||||
state.copyWith(
|
||||
workspaceId: workspaceId,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
);
|
||||
_checkWorkspaceUsage();
|
||||
},
|
||||
updateWorkspaceUsage: (WorkspaceUsagePB usage) {
|
||||
// when the user's storage bytes are limited, show the upgrade tier button
|
||||
if (!usage.storageBytesUnlimited) {
|
||||
if (usage.storageBytes >= usage.storageBytesLimit) {
|
||||
add(
|
||||
const SidebarPlanEvent.updateTierIndicator(
|
||||
SidebarToastTierIndicator.storageLimitHit(),
|
||||
),
|
||||
);
|
||||
|
||||
/// Checks if the user needs to upgrade to the Pro Plan.
|
||||
/// If the user needs to upgrade, it means they don't need to enable the AI max tier.
|
||||
/// This function simply returns without performing any further actions.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// when user's AI responses are limited, show the AI max tier button.
|
||||
if (!usage.aiResponsesUnlimited) {
|
||||
if (usage.aiResponsesCount >= usage.aiResponsesCountLimit) {
|
||||
add(
|
||||
const SidebarPlanEvent.updateTierIndicator(
|
||||
SidebarToastTierIndicator.aiMaxiLimitHit(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// hide the tier indicator
|
||||
add(
|
||||
const SidebarPlanEvent.updateTierIndicator(
|
||||
SidebarToastTierIndicator.loading(),
|
||||
),
|
||||
);
|
||||
},
|
||||
updateTierIndicator: (SidebarToastTierIndicator indicator) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
tierIndicator: indicator,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _checkWorkspaceUsage() {
|
||||
if (state.workspaceId != null) {
|
||||
final payload = UserWorkspaceIdPB(workspaceId: state.workspaceId!);
|
||||
UserEventGetWorkspaceUsage(payload).send().then((result) {
|
||||
result.fold(
|
||||
(usage) {
|
||||
add(SidebarPlanEvent.updateWorkspaceUsage(usage));
|
||||
},
|
||||
(error) {
|
||||
Log.error("Failed to get workspace usage, error: $error");
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SidebarPlanEvent with _$SidebarPlanEvent {
|
||||
const factory SidebarPlanEvent.init(
|
||||
String workspaceId,
|
||||
UserProfilePB userProfile,
|
||||
) = _Init;
|
||||
const factory SidebarPlanEvent.updateWorkspaceUsage(
|
||||
WorkspaceUsagePB usage,
|
||||
) = _UpdateWorkspaceUsage;
|
||||
const factory SidebarPlanEvent.updateTierIndicator(
|
||||
SidebarToastTierIndicator indicator,
|
||||
) = _UpdateTierIndicator;
|
||||
const factory SidebarPlanEvent.receiveError(FlowyError error) = _ReceiveError;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SidebarPlanState with _$SidebarPlanState {
|
||||
const factory SidebarPlanState({
|
||||
FlowyError? error,
|
||||
UserProfilePB? userProfile,
|
||||
String? workspaceId,
|
||||
WorkspaceUsagePB? usage,
|
||||
@Default(SidebarToastTierIndicator.loading())
|
||||
SidebarToastTierIndicator tierIndicator,
|
||||
}) = _SidebarPlanState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SidebarToastTierIndicator with _$SidebarToastTierIndicator {
|
||||
// when start downloading the model
|
||||
const factory SidebarToastTierIndicator.storageLimitHit() = _StorageLimitHit;
|
||||
const factory SidebarToastTierIndicator.aiMaxiLimitHit() = _aiMaxLimitHit;
|
||||
const factory SidebarToastTierIndicator.loading() = _Loading;
|
||||
}
|
||||
+19
-1
@@ -1,7 +1,25 @@
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
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.Free,
|
||||
'pro' => SubscriptionPlanPB.Pro,
|
||||
'team' => SubscriptionPlanPB.Team,
|
||||
'ai_max' => SubscriptionPlanPB.AiMax,
|
||||
'ai_local' => SubscriptionPlanPB.AiLocal,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
void onPaymentSuccess(String? plan) {
|
||||
Log.info("Payment success: $plan");
|
||||
_plan = plan;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ extension ViewLayoutExtension on ViewLayoutPB {
|
||||
FlowySvgData get icon => switch (this) {
|
||||
ViewLayoutPB.Grid => FlowySvgs.grid_s,
|
||||
ViewLayoutPB.Board => FlowySvgs.board_s,
|
||||
ViewLayoutPB.Calendar => FlowySvgs.date_s,
|
||||
ViewLayoutPB.Calendar => FlowySvgs.calendar_s,
|
||||
ViewLayoutPB.Document => FlowySvgs.document_s,
|
||||
ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s,
|
||||
_ => throw Exception('Unknown layout type'),
|
||||
|
||||
@@ -302,12 +302,18 @@ class ViewBackendService {
|
||||
static Future<FlowyResult<void, FlowyError>> publish(
|
||||
ViewPB view, {
|
||||
String? name,
|
||||
List<String>? selectedViewIds,
|
||||
}) async {
|
||||
final payload = PublishViewParamsPB()..viewId = view.id;
|
||||
|
||||
if (name != null) {
|
||||
payload.publishName = name;
|
||||
}
|
||||
|
||||
if (selectedViewIds != null && selectedViewIds.isNotEmpty) {
|
||||
payload.selectedViewIds = RepeatedViewIdPB(items: selectedViewIds);
|
||||
}
|
||||
|
||||
return FolderEventPublishView(payload).send();
|
||||
}
|
||||
|
||||
|
||||
+18
-12
@@ -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) {
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
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';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart';
|
||||
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/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SidebarToast extends StatelessWidget {
|
||||
const SidebarToast({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<SidebarPlanBloc, SidebarPlanState>(
|
||||
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),
|
||||
),
|
||||
orElse: () {},
|
||||
);
|
||||
},
|
||||
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) => 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>();
|
||||
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 StatefulWidget {
|
||||
const PlanIndicator({
|
||||
super.key,
|
||||
required this.planName,
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
required this.reason,
|
||||
});
|
||||
|
||||
final String planName;
|
||||
final String reason;
|
||||
final String text;
|
||||
final Function() onTap;
|
||||
|
||||
@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) {
|
||||
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 _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: message,
|
||||
okTitle: LocaleKeys.button_ok.tr(),
|
||||
onOkPressed: onOkPressed,
|
||||
titleUpperCase: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -93,6 +94,7 @@ void showSettingsDialog(
|
||||
BuildContext context,
|
||||
UserProfilePB userProfile, [
|
||||
UserWorkspaceBloc? bloc,
|
||||
SettingsPage? initPage,
|
||||
]) {
|
||||
AFFocusManager.of(context).notifyLoseFocus();
|
||||
showDialog(
|
||||
@@ -107,6 +109,7 @@ void showSettingsDialog(
|
||||
],
|
||||
child: SettingsDialog(
|
||||
userProfile,
|
||||
initPage: initPage,
|
||||
didLogout: () async {
|
||||
// Pop the dialog using the dialog context
|
||||
Navigator.of(dialogContext).pop();
|
||||
|
||||
+14
-5
@@ -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';
|
||||
@@ -13,6 +15,7 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/recent/cached_recent_service.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/billing/sidebar_plan_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/user/user_workspace_bloc.dart';
|
||||
@@ -35,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;
|
||||
@@ -100,6 +102,9 @@ class HomeSideBar extends StatelessWidget {
|
||||
if (state.currentWorkspace == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final workspaceId =
|
||||
state.currentWorkspace?.workspaceId ?? workspaceSetting.workspaceId;
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
|
||||
@@ -108,8 +113,7 @@ class HomeSideBar extends StatelessWidget {
|
||||
..add(
|
||||
SidebarSectionsEvent.initial(
|
||||
userProfile,
|
||||
state.currentWorkspace?.workspaceId ??
|
||||
workspaceSetting.workspaceId,
|
||||
workspaceId,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -118,12 +122,15 @@ class HomeSideBar extends StatelessWidget {
|
||||
..add(
|
||||
SpaceEvent.initial(
|
||||
userProfile,
|
||||
state.currentWorkspace?.workspaceId ??
|
||||
workspaceSetting.workspaceId,
|
||||
workspaceId,
|
||||
openFirstPage: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => SidebarPlanBloc()
|
||||
..add(SidebarPlanEvent.init(workspaceId, userProfile)),
|
||||
),
|
||||
],
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
@@ -342,7 +349,9 @@ class _SidebarState extends State<_Sidebar> {
|
||||
child: const Divider(height: 0.5, color: Color(0x141F2329)),
|
||||
),
|
||||
const VSpace(8),
|
||||
|
||||
_renderUpgradeSpaceButton(menuHorizontalInset),
|
||||
|
||||
const VSpace(8),
|
||||
Padding(
|
||||
padding: menuHorizontalInset +
|
||||
|
||||
+4
-3
@@ -1,3 +1,7 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
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/util/theme_extension.dart';
|
||||
@@ -18,9 +22,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/decoration.dart';
|
||||
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 {
|
||||
|
||||
+4
-9
@@ -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,
|
||||
|
||||
+1
-2
@@ -1,5 +1,3 @@
|
||||
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/presentation/editor_configuration.dart';
|
||||
@@ -14,6 +12,7 @@ import 'package:fixnum/fixnum.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:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class NotificationItem extends StatefulWidget {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
@@ -22,7 +22,7 @@ class InitLocalAIIndicator extends StatelessWidget {
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: BlocBuilder<LocalAISettingBloc, LocalAISettingState>(
|
||||
child: BlocBuilder<LocalAIChatSettingBloc, LocalAIChatSettingState>(
|
||||
builder: (context, state) {
|
||||
switch (state.runningState) {
|
||||
case RunningStatePB.Connecting:
|
||||
|
||||
+168
-69
@@ -1,73 +1,168 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/downloading.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:expandable/expandable.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class LocalModelConfig extends StatelessWidget {
|
||||
const LocalModelConfig({super.key});
|
||||
class LocalAIChatSetting extends StatelessWidget {
|
||||
const LocalAIChatSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<SettingsAIBloc, SettingsAIState>(
|
||||
builder: (context, state) {
|
||||
if (state.aiSettings == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(create: (context) => LocalAIChatSettingBloc()),
|
||||
BlocProvider(
|
||||
create: (context) => LocalAIChatToggleBloc()
|
||||
..add(const LocalAIChatToggleEvent.started()),
|
||||
),
|
||||
],
|
||||
child: ExpandableNotifier(
|
||||
child: BlocListener<LocalAIChatToggleBloc, LocalAIChatToggleState>(
|
||||
listener: (context, state) {
|
||||
// Listen to the toggle state and expand the panel if the state is ready.
|
||||
final controller = ExpandableController.of(
|
||||
context,
|
||||
required: true,
|
||||
)!;
|
||||
|
||||
if (state.aiSettings!.aiModel != AIModelPB.LocalAIModel) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
LocalAISettingBloc()..add(const LocalAISettingEvent.started()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: FlowyText.medium(
|
||||
LocaleKeys.settings_aiPage_keys_llmModel.tr(),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
BlocBuilder<LocalAISettingBloc, LocalAISettingState>(
|
||||
builder: (context, state) {
|
||||
return state.fetchModelInfoState.when(
|
||||
loading: () =>
|
||||
const CircularProgressIndicator.adaptive(),
|
||||
finish: (err) {
|
||||
return (err == null)
|
||||
? const _SelectLocalModelDropdownMenu()
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
// Neet to wrap with WidgetsBinding.instance.addPostFrameCallback otherwise the
|
||||
// ExpandablePanel not expanded sometimes. Maybe because the ExpandablePanel is not
|
||||
// built yet when the listener is called.
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
state.pageIndicator.when(
|
||||
error: (_) => controller.expanded = false,
|
||||
ready: (enabled) {
|
||||
controller.expanded = enabled;
|
||||
context.read<LocalAIChatSettingBloc>().add(
|
||||
const LocalAIChatSettingEvent.refreshAISetting(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const IntrinsicHeight(child: _LocalLLMInfoWidget()),
|
||||
],
|
||||
},
|
||||
loading: () => controller.expanded = false,
|
||||
);
|
||||
},
|
||||
debugLabel: 'LocalAI.showLocalAIChatSetting',
|
||||
);
|
||||
},
|
||||
child: ExpandablePanel(
|
||||
theme: const ExpandableThemeData(
|
||||
headerAlignment: ExpandablePanelHeaderAlignment.center,
|
||||
tapBodyToCollapse: false,
|
||||
hasIcon: false,
|
||||
tapBodyToExpand: false,
|
||||
tapHeaderToExpand: false,
|
||||
),
|
||||
header: const LocalAIChatSettingHeader(),
|
||||
collapsed: const SizedBox.shrink(),
|
||||
expanded: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: FlowyText.medium(
|
||||
LocaleKeys.settings_aiPage_keys_llmModel.tr(),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
BlocBuilder<LocalAIChatSettingBloc,
|
||||
LocalAIChatSettingState>(
|
||||
builder: (context, state) {
|
||||
return state.fetchModelInfoState.when(
|
||||
loading: () => Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
LocaleKeys
|
||||
.settings_aiPage_keys_fetchLocalModel
|
||||
.tr(),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const CircularProgressIndicator.adaptive(),
|
||||
],
|
||||
),
|
||||
),
|
||||
finish: (err) {
|
||||
return (err == null)
|
||||
? const _SelectLocalModelDropdownMenu()
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const IntrinsicHeight(child: _LocalLLMInfoWidget()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LocalAIChatSettingHeader extends StatelessWidget {
|
||||
const LocalAIChatSettingHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<LocalAIChatToggleBloc, LocalAIChatToggleState>(
|
||||
builder: (context, state) {
|
||||
return state.pageIndicator.when(
|
||||
error: (error) {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
loading: () {
|
||||
return Row(
|
||||
children: [
|
||||
FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAIStart.tr(),
|
||||
),
|
||||
const Spacer(),
|
||||
const CircularProgressIndicator.adaptive(),
|
||||
const HSpace(8),
|
||||
],
|
||||
);
|
||||
},
|
||||
ready: (isEnabled) {
|
||||
return Row(
|
||||
children: [
|
||||
const FlowyText('Enable Local AI Chat'),
|
||||
const Spacer(),
|
||||
Toggle(
|
||||
value: isEnabled,
|
||||
onChanged: (value) {
|
||||
context
|
||||
.read<LocalAIChatToggleBloc>()
|
||||
.add(const LocalAIChatToggleEvent.toggle());
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -79,13 +174,13 @@ class _SelectLocalModelDropdownMenu extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<LocalAISettingBloc, LocalAISettingState>(
|
||||
return BlocBuilder<LocalAIChatSettingBloc, LocalAIChatSettingState>(
|
||||
builder: (context, state) {
|
||||
return Flexible(
|
||||
child: SettingsDropdown<LLMModelPB>(
|
||||
key: const Key('_SelectLocalModelDropdownMenu'),
|
||||
onChanged: (model) => context.read<LocalAISettingBloc>().add(
|
||||
LocalAISettingEvent.selectLLMConfig(model),
|
||||
onChanged: (model) => context.read<LocalAIChatSettingBloc>().add(
|
||||
LocalAIChatSettingEvent.selectLLMConfig(model),
|
||||
),
|
||||
selectedOption: state.selectedLLMModel!,
|
||||
options: state.models
|
||||
@@ -110,7 +205,7 @@ class _LocalLLMInfoWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<LocalAISettingBloc, LocalAISettingState>(
|
||||
return BlocBuilder<LocalAIChatSettingBloc, LocalAIChatSettingState>(
|
||||
builder: (context, state) {
|
||||
final error = errorFromState(state);
|
||||
if (error == null) {
|
||||
@@ -137,11 +232,11 @@ class _LocalLLMInfoWidget extends StatelessWidget {
|
||||
key: UniqueKey(),
|
||||
llmModel: llmModel,
|
||||
onFinish: () => context
|
||||
.read<LocalAISettingBloc>()
|
||||
.add(const LocalAISettingEvent.finishDownload()),
|
||||
.read<LocalAIChatSettingBloc>()
|
||||
.add(const LocalAIChatSettingEvent.finishDownload()),
|
||||
onCancel: () => context
|
||||
.read<LocalAISettingBloc>()
|
||||
.add(const LocalAISettingEvent.cancelDownload()),
|
||||
.read<LocalAIChatSettingBloc>()
|
||||
.add(const LocalAIChatSettingEvent.cancelDownload()),
|
||||
);
|
||||
},
|
||||
finishDownload: () => const InitLocalAIIndicator(),
|
||||
@@ -149,16 +244,19 @@ class _LocalLLMInfoWidget extends StatelessWidget {
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: child,
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
} else {
|
||||
return FlowyText(
|
||||
error.msg,
|
||||
maxLines: 10,
|
||||
return Opacity(
|
||||
opacity: 0.5,
|
||||
child: FlowyText(
|
||||
error.msg,
|
||||
maxLines: 10,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -180,15 +278,15 @@ class _LocalLLMInfoWidget extends StatelessWidget {
|
||||
return _LLMModelDownloadDialog(
|
||||
llmResource: llmResource,
|
||||
onOkPressed: () {
|
||||
context.read<LocalAISettingBloc>().add(
|
||||
LocalAISettingEvent.startDownloadModel(
|
||||
context.read<LocalAIChatSettingBloc>().add(
|
||||
LocalAIChatSettingEvent.startDownloadModel(
|
||||
llmModel,
|
||||
),
|
||||
);
|
||||
},
|
||||
onCancelPressed: () {
|
||||
context.read<LocalAISettingBloc>().add(
|
||||
const LocalAISettingEvent.cancelDownload(),
|
||||
context.read<LocalAIChatSettingBloc>().add(
|
||||
const LocalAIChatSettingEvent.cancelDownload(),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -199,7 +297,7 @@ class _LocalLLMInfoWidget extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
FlowyError? errorFromState(LocalAISettingState state) {
|
||||
FlowyError? errorFromState(LocalAIChatSettingState state) {
|
||||
final err = state.fetchModelInfoState.when(
|
||||
loading: () => null,
|
||||
finish: (err) => err,
|
||||
@@ -261,7 +359,7 @@ class _ShowDownloadIndicator extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<LocalAISettingBloc, LocalAISettingState>(
|
||||
return BlocBuilder<LocalAIChatSettingBloc, LocalAIChatSettingState>(
|
||||
builder: (context, state) {
|
||||
return Row(
|
||||
children: [
|
||||
@@ -288,15 +386,16 @@ class _ShowDownloadIndicator extends StatelessWidget {
|
||||
return _LLMModelDownloadDialog(
|
||||
llmResource: llmResource,
|
||||
onOkPressed: () {
|
||||
context.read<LocalAISettingBloc>().add(
|
||||
LocalAISettingEvent.startDownloadModel(
|
||||
context.read<LocalAIChatSettingBloc>().add(
|
||||
LocalAIChatSettingEvent.startDownloadModel(
|
||||
llmModel,
|
||||
),
|
||||
);
|
||||
},
|
||||
onCancelPressed: () {
|
||||
context.read<LocalAISettingBloc>().add(
|
||||
const LocalAISettingEvent.cancelDownload(),
|
||||
context.read<LocalAIChatSettingBloc>().add(
|
||||
const LocalAIChatSettingEvent
|
||||
.cancelDownload(),
|
||||
);
|
||||
},
|
||||
);
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:expandable/expandable.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class LocalAISetting extends StatefulWidget {
|
||||
const LocalAISetting({super.key});
|
||||
|
||||
@override
|
||||
State<LocalAISetting> createState() => _LocalAISettingState();
|
||||
}
|
||||
|
||||
class _LocalAISettingState extends State<LocalAISetting> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<SettingsAIBloc, SettingsAIState>(
|
||||
builder: (context, state) {
|
||||
if (state.aiSettings == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
LocalAIToggleBloc()..add(const LocalAIToggleEvent.started()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: ExpandableNotifier(
|
||||
child: BlocListener<LocalAIToggleBloc, LocalAIToggleState>(
|
||||
listener: (context, state) {
|
||||
final controller =
|
||||
ExpandableController.of(context, required: true)!;
|
||||
state.pageIndicator.when(
|
||||
error: (_) => controller.expanded = false,
|
||||
ready: (enabled) => controller.expanded = enabled,
|
||||
loading: () => controller.expanded = false,
|
||||
);
|
||||
},
|
||||
child: ExpandablePanel(
|
||||
theme: const ExpandableThemeData(
|
||||
headerAlignment: ExpandablePanelHeaderAlignment.center,
|
||||
tapBodyToCollapse: false,
|
||||
hasIcon: false,
|
||||
tapBodyToExpand: false,
|
||||
tapHeaderToExpand: false,
|
||||
),
|
||||
header: const LocalAISettingHeader(),
|
||||
collapsed: const SizedBox.shrink(),
|
||||
expanded: Column(
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 12.0,
|
||||
top: 6,
|
||||
bottom: 6,
|
||||
),
|
||||
child: LocalAIChatSetting(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LocalAISettingHeader extends StatelessWidget {
|
||||
const LocalAISettingHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<LocalAIToggleBloc, LocalAIToggleState>(
|
||||
builder: (context, state) {
|
||||
return state.pageIndicator.when(
|
||||
error: (error) {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
loading: () {
|
||||
return const CircularProgressIndicator.adaptive();
|
||||
},
|
||||
ready: (isEnabled) {
|
||||
return Row(
|
||||
children: [
|
||||
FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(),
|
||||
),
|
||||
const Spacer(),
|
||||
Toggle(
|
||||
value: isEnabled,
|
||||
onChanged: (value) {
|
||||
if (isEnabled) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
useRootNavigator: false,
|
||||
builder: (dialogContext) {
|
||||
return _ToggleLocalAIDialog(
|
||||
onOkPressed: () {
|
||||
context
|
||||
.read<LocalAIToggleBloc>()
|
||||
.add(const LocalAIToggleEvent.toggle());
|
||||
},
|
||||
onCancelPressed: () {},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
context
|
||||
.read<LocalAIToggleBloc>()
|
||||
.add(const LocalAIToggleEvent.toggle());
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToggleLocalAIDialog extends StatelessWidget {
|
||||
const _ToggleLocalAIDialog({
|
||||
required this.onOkPressed,
|
||||
required this.onCancelPressed,
|
||||
});
|
||||
final VoidCallback onOkPressed;
|
||||
final VoidCallback onCancelPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NavigatorOkCancelDialog(
|
||||
message: LocaleKeys.settings_aiPage_keys_disableLocalAIDialog.tr(),
|
||||
okTitle: LocaleKeys.button_confirm.tr(),
|
||||
cancelTitle: LocaleKeys.button_cancel.tr(),
|
||||
onOkPressed: onOkPressed,
|
||||
onCancelPressed: onCancelPressed,
|
||||
titleUpperCase: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
-3
@@ -61,7 +61,6 @@ List<AIModelPB> _availableModels = [
|
||||
AIModelPB.Claude3Sonnet,
|
||||
AIModelPB.GPT35,
|
||||
AIModelPB.GPT4o,
|
||||
// AIModelPB.LocalAIModel,
|
||||
];
|
||||
|
||||
String _titleForAIModel(AIModelPB model) {
|
||||
@@ -76,8 +75,6 @@ String _titleForAIModel(AIModelPB model) {
|
||||
return "GPT-3.5";
|
||||
case AIModelPB.GPT4o:
|
||||
return "GPT-4o";
|
||||
case AIModelPB.LocalAIModel:
|
||||
return "Local";
|
||||
default:
|
||||
Log.error("Unknown AI model: $model, fallback to default");
|
||||
return "Default";
|
||||
|
||||
+24
-26
@@ -21,7 +21,8 @@ class CheckPluginStateIndicator extends StatelessWidget {
|
||||
return state.action.when(
|
||||
init: () => const _InitPlugin(),
|
||||
ready: () => const _ReadyToUse(),
|
||||
reloadRequired: () => const _ReloadButton(),
|
||||
restart: () => const _ReloadButton(),
|
||||
loadingPlugin: () => const _InitPlugin(),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -78,32 +79,29 @@ class _ReadyToUse extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFEDF7ED),
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(4),
|
||||
),
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFEDF7ED),
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(4),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(8),
|
||||
const FlowySvg(
|
||||
FlowySvgs.download_success_s,
|
||||
color: Color(0xFF2E7D32),
|
||||
),
|
||||
const HSpace(6),
|
||||
FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAILoaded.tr(),
|
||||
fontSize: 11,
|
||||
color: const Color(0xFF1E4620),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(8),
|
||||
const FlowySvg(
|
||||
FlowySvgs.download_success_s,
|
||||
color: Color(0xFF2E7D32),
|
||||
),
|
||||
const HSpace(6),
|
||||
FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAILoaded.tr(),
|
||||
fontSize: 11,
|
||||
color: const Color(0xFF1E4620),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+1
-5
@@ -1,4 +1,3 @@
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_config.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -44,10 +43,7 @@ class SettingsAIView extends StatelessWidget {
|
||||
const AIModelSelection(),
|
||||
];
|
||||
|
||||
if (state.aiSettings != null &&
|
||||
state.aiSettings!.aiModel == AIModelPB.LocalAIModel) {
|
||||
children.add(const LocalModelConfig());
|
||||
}
|
||||
// children.add(const LocalAISetting());
|
||||
|
||||
children.add(const _AISearchToggle(value: false));
|
||||
|
||||
|
||||
+5
-5
@@ -10,7 +10,6 @@ import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/application/prelude.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart';
|
||||
import 'package:appflowy/workspace/application/user/settings_user_bloc.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/settings_input_field.dart';
|
||||
@@ -210,18 +209,19 @@ class SignInOutButton extends StatelessWidget {
|
||||
if (signIn) {
|
||||
_showSignInDialog(context);
|
||||
} else {
|
||||
SettingsAlertDialog(
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(),
|
||||
subtitle: switch (userProfile.encryptionType) {
|
||||
description: switch (userProfile.encryptionType) {
|
||||
EncryptionTypePB.Symmetric =>
|
||||
LocaleKeys.settings_menu_selfEncryptionLogoutPrompt.tr(),
|
||||
_ => LocaleKeys.settings_menu_logoutPrompt.tr(),
|
||||
},
|
||||
confirm: () async {
|
||||
onConfirm: () async {
|
||||
await getIt<AuthService>().signOut();
|
||||
onAction();
|
||||
},
|
||||
).show(context);
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
+465
-18
@@ -1,22 +1,34 @@
|
||||
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/home/menu/sidebar/space/shared_widget.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/settings_dashed_divider.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/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../generated/locale_keys.g.dart';
|
||||
import '../../../../plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
|
||||
|
||||
class SettingsBillingView extends StatelessWidget {
|
||||
const _buttonsMinWidth = 100.0;
|
||||
|
||||
class SettingsBillingView extends StatefulWidget {
|
||||
const SettingsBillingView({
|
||||
super.key,
|
||||
required this.workspaceId,
|
||||
@@ -26,12 +38,34 @@ class SettingsBillingView extends StatelessWidget {
|
||||
final String workspaceId;
|
||||
final UserProfilePB user;
|
||||
|
||||
@override
|
||||
State<SettingsBillingView> createState() => _SettingsBillingViewState();
|
||||
}
|
||||
|
||||
class _SettingsBillingViewState extends State<SettingsBillingView> {
|
||||
Loading? loadingIndicator;
|
||||
RecurringIntervalPB? selectedInterval;
|
||||
final ValueNotifier<bool> enablePlanChangeNotifier = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<SettingsBillingBloc>(
|
||||
create: (context) => SettingsBillingBloc(workspaceId: workspaceId)
|
||||
..add(const SettingsBillingEvent.started()),
|
||||
child: BlocBuilder<SettingsBillingBloc, SettingsBillingState>(
|
||||
create: (_) => SettingsBillingBloc(
|
||||
workspaceId: widget.workspaceId,
|
||||
userId: widget.user.id,
|
||||
)..add(const SettingsBillingEvent.started()),
|
||||
child: BlocConsumer<SettingsBillingBloc, SettingsBillingState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.mapOrNull(ready: (s) => s.isLoading) !=
|
||||
current.mapOrNull(ready: (s) => s.isLoading),
|
||||
listener: (context, state) {
|
||||
if (state.mapOrNull(ready: (s) => s.isLoading) == true) {
|
||||
loadingIndicator = Loading(context)..start();
|
||||
} else {
|
||||
loadingIndicator?.stop();
|
||||
loadingIndicator = null;
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return state.map(
|
||||
initial: (_) => const SizedBox.shrink(),
|
||||
@@ -56,8 +90,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.isBillingPortalEnabled;
|
||||
|
||||
return SettingsBody(
|
||||
title: LocaleKeys.settings_billingPage_title.tr(),
|
||||
@@ -68,27 +102,67 @@ class SettingsBillingView extends StatelessWidget {
|
||||
SingleSettingAction(
|
||||
onPressed: () => _openPricingDialog(
|
||||
context,
|
||||
workspaceId,
|
||||
user.id,
|
||||
state.subscription,
|
||||
widget.workspaceId,
|
||||
widget.user.id,
|
||||
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: () {
|
||||
SettingsAlertDialog(
|
||||
title: LocaleKeys
|
||||
.settings_billingPage_changePeriod
|
||||
.tr(),
|
||||
enableConfirmNotifier: enablePlanChangeNotifier,
|
||||
children: [
|
||||
ChangePeriod(
|
||||
plan: state.subscriptionInfo.planSubscription
|
||||
.subscriptionPlan,
|
||||
selectedInterval: state.subscriptionInfo
|
||||
.planSubscription.interval,
|
||||
onSelected: (interval) {
|
||||
enablePlanChangeNotifier.value = interval !=
|
||||
state.subscriptionInfo.planSubscription
|
||||
.interval;
|
||||
selectedInterval = interval;
|
||||
},
|
||||
),
|
||||
],
|
||||
confirm: () {
|
||||
if (selectedInterval !=
|
||||
state.subscriptionInfo.planSubscription
|
||||
.interval) {
|
||||
context.read<SettingsBillingBloc>().add(
|
||||
SettingsBillingEvent.updatePeriod(
|
||||
plan: state
|
||||
.subscriptionInfo
|
||||
.planSubscription
|
||||
.subscriptionPlan,
|
||||
interval: selectedInterval!,
|
||||
),
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
).show(context);
|
||||
},
|
||||
label: LocaleKeys
|
||||
.settings_billingPage_plan_billingPeriod
|
||||
.tr(),
|
||||
description: state
|
||||
.subscriptionInfo.planSubscription.interval.label,
|
||||
fontWeight: FontWeight.w500,
|
||||
buttonLabel: LocaleKeys
|
||||
.settings_billingPage_plan_periodButtonLabel
|
||||
.tr(),
|
||||
minWidth: _buttonsMinWidth,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -99,8 +173,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 +185,48 @@ 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,
|
||||
),
|
||||
),
|
||||
const SettingsDashedDivider(),
|
||||
_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,17 +240,17 @@ class SettingsBillingView extends StatelessWidget {
|
||||
BuildContext context,
|
||||
String workspaceId,
|
||||
Int64 userId,
|
||||
WorkspaceSubscriptionPB subscription,
|
||||
WorkspaceSubscriptionInfoPB subscriptionInfo,
|
||||
) =>
|
||||
showDialog<bool?>(
|
||||
context: context,
|
||||
builder: (_) => BlocProvider<SettingsPlanBloc>(
|
||||
create: (_) =>
|
||||
SettingsPlanBloc(workspaceId: workspaceId, userId: user.id)
|
||||
SettingsPlanBloc(workspaceId: workspaceId, userId: widget.user.id)
|
||||
..add(const SettingsPlanEvent.started()),
|
||||
child: SettingsPlanComparisonDialog(
|
||||
workspaceId: workspaceId,
|
||||
subscription: subscription,
|
||||
subscriptionInfo: subscriptionInfo,
|
||||
),
|
||||
),
|
||||
).then((didChangePlan) {
|
||||
@@ -145,3 +261,334 @@ class SettingsBillingView extends StatelessWidget {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class _AITile extends StatefulWidget {
|
||||
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
|
||||
State<_AITile> createState() => _AITileState();
|
||||
}
|
||||
|
||||
class _AITileState extends State<_AITile> {
|
||||
RecurringIntervalPB? selectedInterval;
|
||||
|
||||
final enableConfirmNotifier = ValueNotifier<bool>(false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCanceled = widget.subscriptionInfo?.addOnSubscription.status ==
|
||||
WorkspaceSubscriptionStatusPB.Canceled;
|
||||
|
||||
final dateFormat = context.read<AppearanceSettingsCubit>().state.dateFormat;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SingleSettingAction(
|
||||
label: widget.label,
|
||||
description: widget.subscriptionInfo != null && isCanceled
|
||||
? widget.canceledDescription.tr(
|
||||
args: [
|
||||
dateFormat.formatDate(
|
||||
widget.subscriptionInfo!.addOnSubscription.endDate
|
||||
.toDateTime(),
|
||||
false,
|
||||
),
|
||||
],
|
||||
)
|
||||
: widget.subscriptionInfo != null
|
||||
? widget.activeDescription.tr(
|
||||
args: [
|
||||
dateFormat.formatDate(
|
||||
widget.subscriptionInfo!.addOnSubscription.endDate
|
||||
.toDateTime(),
|
||||
false,
|
||||
),
|
||||
],
|
||||
)
|
||||
: widget.description.tr(),
|
||||
buttonLabel: widget.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: () async {
|
||||
if (widget.subscriptionInfo != null) {
|
||||
await showConfirmDialog(
|
||||
context: context,
|
||||
style: ConfirmPopupStyle.cancelAndOk,
|
||||
title: LocaleKeys.settings_billingPage_addons_removeDialog_title
|
||||
.tr(args: [widget.plan.label]).tr(),
|
||||
description: LocaleKeys
|
||||
.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)),
|
||||
);
|
||||
} else {
|
||||
// Add the addon
|
||||
context
|
||||
.read<SettingsBillingBloc>()
|
||||
.add(SettingsBillingEvent.addSubscription(widget.plan));
|
||||
}
|
||||
},
|
||||
),
|
||||
if (widget.subscriptionInfo != null) ...[
|
||||
const VSpace(10),
|
||||
SingleSettingAction(
|
||||
label: LocaleKeys.settings_billingPage_planPeriod.tr(
|
||||
args: [
|
||||
widget
|
||||
.subscriptionInfo!.addOnSubscription.subscriptionPlan.label,
|
||||
],
|
||||
),
|
||||
description:
|
||||
widget.subscriptionInfo!.addOnSubscription.interval.label,
|
||||
buttonLabel:
|
||||
LocaleKeys.settings_billingPage_plan_periodButtonLabel.tr(),
|
||||
minWidth: _buttonsMinWidth,
|
||||
onPressed: () {
|
||||
enableConfirmNotifier.value = false;
|
||||
SettingsAlertDialog(
|
||||
title: LocaleKeys.settings_billingPage_changePeriod.tr(),
|
||||
enableConfirmNotifier: enableConfirmNotifier,
|
||||
children: [
|
||||
ChangePeriod(
|
||||
plan: widget
|
||||
.subscriptionInfo!.addOnSubscription.subscriptionPlan,
|
||||
selectedInterval:
|
||||
widget.subscriptionInfo!.addOnSubscription.interval,
|
||||
onSelected: (interval) {
|
||||
enableConfirmNotifier.value = interval !=
|
||||
widget.subscriptionInfo!.addOnSubscription.interval;
|
||||
selectedInterval = interval;
|
||||
},
|
||||
),
|
||||
],
|
||||
confirm: () {
|
||||
if (selectedInterval !=
|
||||
widget.subscriptionInfo!.addOnSubscription.interval) {
|
||||
context.read<SettingsBillingBloc>().add(
|
||||
SettingsBillingEvent.updatePeriod(
|
||||
plan: widget.subscriptionInfo!.addOnSubscription
|
||||
.subscriptionPlan,
|
||||
interval: selectedInterval!,
|
||||
),
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
).show(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChangePeriod extends StatefulWidget {
|
||||
const ChangePeriod({
|
||||
super.key,
|
||||
required this.plan,
|
||||
required this.selectedInterval,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final SubscriptionPlanPB plan;
|
||||
final RecurringIntervalPB selectedInterval;
|
||||
final Function(RecurringIntervalPB interval) onSelected;
|
||||
|
||||
@override
|
||||
State<ChangePeriod> createState() => _ChangePeriodState();
|
||||
}
|
||||
|
||||
class _ChangePeriodState extends State<ChangePeriod> {
|
||||
RecurringIntervalPB? _selectedInterval;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedInterval = widget.selectedInterval;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_selectedInterval = widget.selectedInterval;
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_PeriodSelector(
|
||||
price: widget.plan.priceMonthBilling,
|
||||
interval: RecurringIntervalPB.Month,
|
||||
isSelected: _selectedInterval == RecurringIntervalPB.Month,
|
||||
isCurrent: widget.selectedInterval == RecurringIntervalPB.Month,
|
||||
onSelected: () {
|
||||
widget.onSelected(RecurringIntervalPB.Month);
|
||||
setState(
|
||||
() => _selectedInterval = RecurringIntervalPB.Month,
|
||||
);
|
||||
},
|
||||
),
|
||||
const VSpace(16),
|
||||
_PeriodSelector(
|
||||
price: widget.plan.priceAnnualBilling,
|
||||
interval: RecurringIntervalPB.Year,
|
||||
isSelected: _selectedInterval == RecurringIntervalPB.Year,
|
||||
isCurrent: widget.selectedInterval == RecurringIntervalPB.Year,
|
||||
onSelected: () {
|
||||
widget.onSelected(RecurringIntervalPB.Year);
|
||||
setState(
|
||||
() => _selectedInterval = RecurringIntervalPB.Year,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PeriodSelector extends StatelessWidget {
|
||||
const _PeriodSelector({
|
||||
required this.price,
|
||||
required this.interval,
|
||||
required this.onSelected,
|
||||
required this.isSelected,
|
||||
required this.isCurrent,
|
||||
});
|
||||
|
||||
final String price;
|
||||
final RecurringIntervalPB interval;
|
||||
final VoidCallback onSelected;
|
||||
final bool isSelected;
|
||||
final bool isCurrent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Opacity(
|
||||
opacity: isCurrent && !isSelected ? 0.7 : 1,
|
||||
child: GestureDetector(
|
||||
onTap: isCurrent ? null : onSelected,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).dividerColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FlowyText(
|
||||
interval.label,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
if (isCurrent) ...[
|
||||
const HSpace(8),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 1,
|
||||
),
|
||||
child: FlowyText(
|
||||
LocaleKeys
|
||||
.settings_billingPage_currentPeriodBadge
|
||||
.tr(),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const VSpace(8),
|
||||
FlowyText(
|
||||
price,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
const VSpace(4),
|
||||
FlowyText(
|
||||
interval.priceInfo,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 12,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
if (!isCurrent && !isSelected || isSelected) ...[
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
width: 1.5,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+8
-10
@@ -1,5 +1,8 @@
|
||||
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';
|
||||
@@ -12,7 +15,6 @@ import 'package:appflowy/workspace/application/settings/settings_location_cubit.
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/fix_data_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/setting_action.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';
|
||||
@@ -27,8 +29,6 @@ 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';
|
||||
|
||||
@@ -63,15 +63,15 @@ class SettingsManageDataView extends StatelessWidget {
|
||||
size: Size.square(20),
|
||||
),
|
||||
label: LocaleKeys.settings_common_reset.tr(),
|
||||
onPressed: () => SettingsAlertDialog(
|
||||
onPressed: () => showConfirmDialog(
|
||||
context: context,
|
||||
title: LocaleKeys
|
||||
.settings_manageDataPage_dataStorage_resetDialog_title
|
||||
.tr(),
|
||||
subtitle: LocaleKeys
|
||||
description: LocaleKeys
|
||||
.settings_manageDataPage_dataStorage_resetDialog_description
|
||||
.tr(),
|
||||
implyLeading: true,
|
||||
confirm: () async {
|
||||
onConfirm: () async {
|
||||
final directory =
|
||||
await appFlowyApplicationDataDirectory();
|
||||
final path = directory.path;
|
||||
@@ -85,10 +85,8 @@ class SettingsManageDataView extends StatelessWidget {
|
||||
.read<SettingsLocationCubit>()
|
||||
.resetDataStoragePathToApplicationDefault();
|
||||
await runAppFlowy(isAnon: true);
|
||||
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
).show(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
children: state
|
||||
|
||||
+169
-161
@@ -1,27 +1,31 @@
|
||||
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/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/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/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../generated/locale_keys.g.dart';
|
||||
import '../../../../plugins/document/presentation/editor_plugins/openai/widgets/loading.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() =>
|
||||
@@ -33,7 +37,9 @@ class _SettingsPlanComparisonDialogState
|
||||
final horizontalController = ScrollController();
|
||||
final verticalController = ScrollController();
|
||||
|
||||
late WorkspaceSubscriptionPB currentSubscription = widget.subscription;
|
||||
late WorkspaceSubscriptionInfoPB currentInfo = widget.subscriptionInfo;
|
||||
|
||||
Loading? loadingIndicator;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -54,32 +60,27 @@ class _SettingsPlanComparisonDialogState
|
||||
return;
|
||||
}
|
||||
|
||||
if (readyState.showSuccessDialog) {
|
||||
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]),
|
||||
subtitle: LocaleKeys
|
||||
.settings_comparePlanDialog_paymentSuccess_description
|
||||
.tr(args: [readyState.subscription.label]),
|
||||
hideCancelButton: true,
|
||||
confirm: Navigator.of(context).pop,
|
||||
confirmLabel: LocaleKeys.button_close.tr(),
|
||||
).show(context);
|
||||
if (readyState.downgradeProcessing) {
|
||||
loadingIndicator = Loading(context)..start();
|
||||
} else {
|
||||
loadingIndicator?.stop();
|
||||
loadingIndicator = null;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
currentSubscription = readyState.subscription;
|
||||
});
|
||||
if (readyState.successfulPlanUpgrade != null) {
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.settings_comparePlanDialog_paymentSuccess_title
|
||||
.tr(args: [readyState.successfulPlanUpgrade!.label]),
|
||||
description: LocaleKeys
|
||||
.settings_comparePlanDialog_paymentSuccess_description
|
||||
.tr(args: [readyState.successfulPlanUpgrade!.label]),
|
||||
confirmLabel: LocaleKeys.button_close.tr(),
|
||||
onConfirm: () {},
|
||||
);
|
||||
}
|
||||
|
||||
setState(() => currentInfo = readyState.subscriptionInfo);
|
||||
},
|
||||
builder: (context, state) => FlowyDialog(
|
||||
constraints: const BoxConstraints(maxWidth: 784, minWidth: 674),
|
||||
@@ -99,8 +100,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,
|
||||
@@ -142,7 +142,7 @@ class _SettingsPlanComparisonDialogState
|
||||
children: [
|
||||
const VSpace(30),
|
||||
SizedBox(
|
||||
height: 100,
|
||||
height: 116,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys
|
||||
.settings_comparePlanDialog_planFeatures
|
||||
@@ -154,7 +154,7 @@ class _SettingsPlanComparisonDialogState
|
||||
: const Color(0xFFE8E0FF),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 64),
|
||||
const SizedBox(height: 116),
|
||||
const SizedBox(height: 56),
|
||||
..._planLabels.map(
|
||||
(e) => _ComparisonCell(
|
||||
@@ -172,56 +172,54 @@ class _SettingsPlanComparisonDialogState
|
||||
description: LocaleKeys
|
||||
.settings_comparePlanDialog_freePlan_description
|
||||
.tr(),
|
||||
// TODO(Mathias): the price should be dynamic based on the country and currency
|
||||
price: LocaleKeys
|
||||
.settings_comparePlanDialog_freePlan_price
|
||||
.tr(args: ['\$0']),
|
||||
.tr(
|
||||
args: [
|
||||
SubscriptionPlanPB.Free.priceMonthBilling,
|
||||
],
|
||||
),
|
||||
priceInfo: LocaleKeys
|
||||
.settings_comparePlanDialog_freePlan_priceInfo
|
||||
.tr(),
|
||||
cells: _freeLabels,
|
||||
isCurrent: currentSubscription.subscriptionPlan ==
|
||||
SubscriptionPlanPB.None,
|
||||
canDowngrade:
|
||||
currentSubscription.subscriptionPlan !=
|
||||
SubscriptionPlanPB.None,
|
||||
currentCanceled: currentSubscription.hasCanceled ||
|
||||
(context
|
||||
.watch<SettingsPlanBloc>()
|
||||
.state
|
||||
.mapOrNull(
|
||||
loading: (_) => true,
|
||||
ready: (state) =>
|
||||
state.downgradeProcessing,
|
||||
) ??
|
||||
false),
|
||||
isCurrent:
|
||||
currentInfo.plan == WorkspacePlanPB.FreePlan,
|
||||
buttonType: WorkspacePlanPB.FreePlan.buttonTypeFor(
|
||||
currentInfo.plan,
|
||||
),
|
||||
onSelected: () async {
|
||||
if (currentSubscription.subscriptionPlan ==
|
||||
SubscriptionPlanPB.None ||
|
||||
currentSubscription.hasCanceled) {
|
||||
if (currentInfo.plan ==
|
||||
WorkspacePlanPB.FreePlan ||
|
||||
currentInfo.isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await SettingsAlertDialog(
|
||||
final reason =
|
||||
await showCancelSurveyDialog(context);
|
||||
if (reason == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await showConfirmDialog(
|
||||
context: context,
|
||||
title: LocaleKeys
|
||||
.settings_comparePlanDialog_downgradeDialog_title
|
||||
.tr(args: [currentSubscription.label]),
|
||||
subtitle: LocaleKeys
|
||||
.tr(args: [currentInfo.label]),
|
||||
description: LocaleKeys
|
||||
.settings_comparePlanDialog_downgradeDialog_description
|
||||
.tr(),
|
||||
isDangerous: true,
|
||||
confirm: () {
|
||||
context.read<SettingsPlanBloc>().add(
|
||||
const SettingsPlanEvent
|
||||
.cancelSubscription(),
|
||||
);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
confirmLabel: LocaleKeys
|
||||
.settings_comparePlanDialog_downgradeDialog_downgradeLabel
|
||||
.tr(),
|
||||
).show(context);
|
||||
style: ConfirmPopupStyle.cancelAndOk,
|
||||
onConfirm: () =>
|
||||
context.read<SettingsPlanBloc>().add(
|
||||
SettingsPlanEvent.cancelSubscription(
|
||||
reason: reason,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_PlanTable(
|
||||
@@ -231,19 +229,22 @@ class _SettingsPlanComparisonDialogState
|
||||
description: LocaleKeys
|
||||
.settings_comparePlanDialog_proPlan_description
|
||||
.tr(),
|
||||
// TODO(Mathias): the price should be dynamic based on the country and currency
|
||||
price: LocaleKeys
|
||||
.settings_comparePlanDialog_proPlan_price
|
||||
.tr(args: ['\$10 ']),
|
||||
.tr(
|
||||
args: [SubscriptionPlanPB.Pro.priceAnnualBilling],
|
||||
),
|
||||
priceInfo: LocaleKeys
|
||||
.settings_comparePlanDialog_proPlan_priceInfo
|
||||
.tr(),
|
||||
.tr(
|
||||
args: [SubscriptionPlanPB.Pro.priceMonthBilling],
|
||||
),
|
||||
cells: _proLabels,
|
||||
isCurrent: currentSubscription.subscriptionPlan ==
|
||||
SubscriptionPlanPB.Pro,
|
||||
canUpgrade: currentSubscription.subscriptionPlan ==
|
||||
SubscriptionPlanPB.None,
|
||||
currentCanceled: currentSubscription.hasCanceled,
|
||||
isCurrent:
|
||||
currentInfo.plan == WorkspacePlanPB.ProPlan,
|
||||
buttonType: WorkspacePlanPB.ProPlan.buttonTypeFor(
|
||||
currentInfo.plan,
|
||||
),
|
||||
onSelected: () =>
|
||||
context.read<SettingsPlanBloc>().add(
|
||||
const SettingsPlanEvent.addSubscription(
|
||||
@@ -265,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,
|
||||
@@ -274,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;
|
||||
@@ -287,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(
|
||||
@@ -335,37 +361,29 @@ class _PlanTable extends StatelessWidget {
|
||||
title: price,
|
||||
description: priceInfo,
|
||||
isPrimary: !highlightPlan,
|
||||
height: 64,
|
||||
),
|
||||
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(
|
||||
@@ -466,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;
|
||||
@@ -486,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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -537,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,
|
||||
);
|
||||
@@ -578,40 +588,47 @@ 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: 175,
|
||||
height: height,
|
||||
width: 185,
|
||||
height: 116,
|
||||
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),
|
||||
FlowyText.regular(
|
||||
description!,
|
||||
fontSize: 12,
|
||||
maxLines: 3,
|
||||
lineHeight: 1.5,
|
||||
Flexible(
|
||||
child: FlowyText.regular(
|
||||
description!,
|
||||
fontSize: 12,
|
||||
maxLines: 5,
|
||||
lineHeight: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -637,24 +654,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(),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -677,20 +690,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(),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -706,19 +716,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(),
|
||||
),
|
||||
];
|
||||
+423
-230
@@ -1,3 +1,5 @@
|
||||
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/int64_extension.dart';
|
||||
@@ -14,13 +16,15 @@ 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';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SettingsPlanView extends StatelessWidget {
|
||||
import '../../../../plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
|
||||
|
||||
class SettingsPlanView extends StatefulWidget {
|
||||
const SettingsPlanView({
|
||||
super.key,
|
||||
required this.workspaceId,
|
||||
@@ -30,14 +34,32 @@ class SettingsPlanView extends StatelessWidget {
|
||||
final String workspaceId;
|
||||
final UserProfilePB user;
|
||||
|
||||
@override
|
||||
State<SettingsPlanView> createState() => _SettingsPlanViewState();
|
||||
}
|
||||
|
||||
class _SettingsPlanViewState extends State<SettingsPlanView> {
|
||||
Loading? loadingIndicator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<SettingsPlanBloc>(
|
||||
create: (context) => SettingsPlanBloc(
|
||||
workspaceId: workspaceId,
|
||||
userId: user.id,
|
||||
workspaceId: widget.workspaceId,
|
||||
userId: widget.user.id,
|
||||
)..add(const SettingsPlanEvent.started()),
|
||||
child: BlocBuilder<SettingsPlanBloc, SettingsPlanState>(
|
||||
child: BlocConsumer<SettingsPlanBloc, SettingsPlanState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.mapOrNull(ready: (s) => s.downgradeProcessing) !=
|
||||
current.mapOrNull(ready: (s) => s.downgradeProcessing),
|
||||
listener: (context, state) {
|
||||
if (state.mapOrNull(ready: (s) => s.downgradeProcessing) == true) {
|
||||
loadingIndicator = Loading(context)..start();
|
||||
} else {
|
||||
loadingIndicator?.stop();
|
||||
loadingIndicator = null;
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return state.map(
|
||||
initial: (_) => const SizedBox.shrink(),
|
||||
@@ -67,10 +89,87 @@ 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: [SubscriptionPlanPB.AiMax.priceAnnualBilling],
|
||||
),
|
||||
priceInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiMax_priceInfo
|
||||
.tr(),
|
||||
billingInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiMax_billingInfo
|
||||
.tr(
|
||||
args: [SubscriptionPlanPB.AiMax.priceMonthBilling],
|
||||
),
|
||||
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: [SubscriptionPlanPB.AiLocal.priceAnnualBilling],
|
||||
),
|
||||
priceInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_priceInfo
|
||||
.tr(),
|
||||
billingInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_billingInfo
|
||||
.tr(
|
||||
args: [SubscriptionPlanPB.AiLocal.priceMonthBilling],
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -81,9 +180,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();
|
||||
@@ -115,68 +214,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: 14,
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -184,14 +282,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,
|
||||
),
|
||||
),
|
||||
@@ -204,16 +309,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,
|
||||
@@ -221,101 +325,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) {
|
||||
@@ -331,61 +354,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 && !usage.aiResponsesUnlimited) ...[
|
||||
_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), () {});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -398,15 +461,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) {
|
||||
@@ -418,19 +484,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),
|
||||
],
|
||||
],
|
||||
@@ -442,14 +516,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();
|
||||
@@ -471,29 +545,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),
|
||||
@@ -553,7 +615,9 @@ class _PlanProgressIndicator extends StatelessWidget {
|
||||
widthFactor: progress,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
color: progress >= 1
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -574,6 +638,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).strongText,
|
||||
),
|
||||
const VSpace(10),
|
||||
FlowyText.regular(
|
||||
description,
|
||||
fontSize: 12,
|
||||
color: AFThemeExtension.of(context).secondaryTextColor,
|
||||
maxLines: 4,
|
||||
),
|
||||
const VSpace(10),
|
||||
FlowyText(
|
||||
price,
|
||||
fontSize: 24,
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
),
|
||||
FlowyText(
|
||||
priceInfo,
|
||||
fontSize: 12,
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
),
|
||||
const VSpace(12),
|
||||
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();
|
||||
|
||||
+11
-14
@@ -22,7 +22,6 @@ import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/document_color_setting_button.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/setting_action.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.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/settings_category_spacer.dart';
|
||||
@@ -183,7 +182,8 @@ class SettingsWorkspaceView extends StatelessWidget {
|
||||
.tr(),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
onPressed: () => SettingsAlertDialog(
|
||||
onPressed: () => showConfirmDialog(
|
||||
context: context,
|
||||
title: workspaceMember?.role.isOwner ?? false
|
||||
? LocaleKeys
|
||||
.settings_workspacePage_deleteWorkspacePrompt_title
|
||||
@@ -191,24 +191,21 @@ class SettingsWorkspaceView extends StatelessWidget {
|
||||
: LocaleKeys
|
||||
.settings_workspacePage_leaveWorkspacePrompt_title
|
||||
.tr(),
|
||||
subtitle: workspaceMember?.role.isOwner ?? false
|
||||
description: workspaceMember?.role.isOwner ?? false
|
||||
? LocaleKeys
|
||||
.settings_workspacePage_deleteWorkspacePrompt_content
|
||||
.tr()
|
||||
: LocaleKeys
|
||||
.settings_workspacePage_leaveWorkspacePrompt_content
|
||||
.tr(),
|
||||
isDangerous: true,
|
||||
confirm: () {
|
||||
context.read<WorkspaceSettingsBloc>().add(
|
||||
workspaceMember?.role.isOwner ?? false
|
||||
? const WorkspaceSettingsEvent.deleteWorkspace()
|
||||
: const WorkspaceSettingsEvent.leaveWorkspace(),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
).show(context),
|
||||
isDangerous: true,
|
||||
style: ConfirmPopupStyle.cancelAndOk,
|
||||
onConfirm: () => context.read<WorkspaceSettingsBloc>().add(
|
||||
workspaceMember?.role.isOwner ?? false
|
||||
? const WorkspaceSettingsEvent.deleteWorkspace()
|
||||
: const WorkspaceSettingsEvent.leaveWorkspace(),
|
||||
),
|
||||
),
|
||||
buttonType: SingleSettingsButtonType.danger,
|
||||
buttonLabel: workspaceMember?.role.isOwner ?? false
|
||||
? LocaleKeys
|
||||
.settings_workspacePage_manageWorkspace_deleteWorkspace
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
|
||||
@@ -26,18 +25,22 @@ class SettingsDialog extends StatelessWidget {
|
||||
required this.dismissDialog,
|
||||
required this.didLogout,
|
||||
required this.restartApp,
|
||||
this.initPage,
|
||||
}) : super(key: ValueKey(user.id));
|
||||
|
||||
final VoidCallback dismissDialog;
|
||||
final VoidCallback didLogout;
|
||||
final VoidCallback restartApp;
|
||||
final UserProfilePB user;
|
||||
final SettingsPage? initPage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<SettingsDialogBloc>(
|
||||
create: (context) => getIt<SettingsDialogBloc>(param1: user)
|
||||
..add(const SettingsDialogEvent.initial()),
|
||||
create: (context) => SettingsDialogBloc(
|
||||
user,
|
||||
initPage: initPage,
|
||||
)..add(const SettingsDialogEvent.initial()),
|
||||
child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>(
|
||||
builder: (context, state) => FlowyDialog(
|
||||
width: MediaQuery.of(context).size.width * 0.7,
|
||||
@@ -120,7 +123,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
-1
@@ -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,
|
||||
|
||||
+48
-8
@@ -21,6 +21,7 @@ class SettingsAlertDialog extends StatefulWidget {
|
||||
this.hideCancelButton = false,
|
||||
this.isDangerous = false,
|
||||
this.implyLeading = false,
|
||||
this.enableConfirmNotifier,
|
||||
});
|
||||
|
||||
final Widget? icon;
|
||||
@@ -32,6 +33,7 @@ class SettingsAlertDialog extends StatefulWidget {
|
||||
final String? confirmLabel;
|
||||
final bool hideCancelButton;
|
||||
final bool isDangerous;
|
||||
final ValueNotifier<bool>? enableConfirmNotifier;
|
||||
|
||||
/// If true, a back button will show in the top left corner
|
||||
final bool implyLeading;
|
||||
@@ -41,6 +43,37 @@ class SettingsAlertDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsAlertDialogState extends State<SettingsAlertDialog> {
|
||||
bool enableConfirm = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.enableConfirmNotifier != null) {
|
||||
widget.enableConfirmNotifier!.addListener(_updateEnableConfirm);
|
||||
enableConfirm = widget.enableConfirmNotifier!.value;
|
||||
}
|
||||
}
|
||||
|
||||
void _updateEnableConfirm() {
|
||||
setState(() => enableConfirm = widget.enableConfirmNotifier!.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.enableConfirmNotifier != null) {
|
||||
widget.enableConfirmNotifier!.removeListener(_updateEnableConfirm);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant SettingsAlertDialog oldWidget) {
|
||||
oldWidget.enableConfirmNotifier?.removeListener(_updateEnableConfirm);
|
||||
widget.enableConfirmNotifier?.addListener(_updateEnableConfirm);
|
||||
enableConfirm = widget.enableConfirmNotifier?.value ?? true;
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StyledDialog(
|
||||
@@ -136,6 +169,7 @@ class _SettingsAlertDialogState extends State<SettingsAlertDialog> {
|
||||
cancel: widget.cancel,
|
||||
confirm: widget.confirm,
|
||||
isDangerous: widget.isDangerous,
|
||||
enableConfirm: enableConfirm,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -150,6 +184,7 @@ class _Actions extends StatelessWidget {
|
||||
this.cancel,
|
||||
this.confirm,
|
||||
this.isDangerous = false,
|
||||
this.enableConfirm = true,
|
||||
});
|
||||
|
||||
final bool hideCancelButton;
|
||||
@@ -157,6 +192,7 @@ class _Actions extends StatelessWidget {
|
||||
final VoidCallback? cancel;
|
||||
final VoidCallback? confirm;
|
||||
final bool isDangerous;
|
||||
final bool enableConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -197,14 +233,18 @@ class _Actions extends StatelessWidget {
|
||||
),
|
||||
radius: Corners.s12Border,
|
||||
fontColor: isDangerous ? Colors.white : null,
|
||||
fontHoverColor: Colors.white,
|
||||
fillColor: isDangerous
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
hoverColor: isDangerous
|
||||
? Theme.of(context).colorScheme.error
|
||||
: const Color(0xFF005483),
|
||||
onPressed: confirm,
|
||||
fontHoverColor: !enableConfirm ? null : Colors.white,
|
||||
fillColor: !enableConfirm
|
||||
? Theme.of(context).dividerColor
|
||||
: isDangerous
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
hoverColor: !enableConfirm
|
||||
? Theme.of(context).dividerColor
|
||||
: isDangerous
|
||||
? Theme.of(context).colorScheme.error
|
||||
: const Color(0xFF005483),
|
||||
onPressed: enableConfirm ? confirm : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+103
-21
@@ -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;
|
||||
}
|
||||
}
|
||||
+431
@@ -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);
|
||||
}
|
||||
+50
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
+114
-59
@@ -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,103 @@ 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;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user