Compare commits

..
Author SHA1 Message Date
Nathan b23f6be47d chore: import user data 2025-04-24 10:23:45 +08:00
293 changed files with 3683 additions and 8547 deletions

No files matched your search

+1 -1
View File
@@ -65,7 +65,7 @@ runs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libcurl4-openssl-dev
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
;;
Windows)
vcpkg integrate install
@@ -52,7 +52,7 @@ runs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager libcurl4-openssl-dev
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager
shell: bash
- name: Enable Flutter Desktop
+13 -13
View File
@@ -28,7 +28,7 @@ env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.9.37-amd64
CLOUD_VERSION: 0.6.54-amd64
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -40,7 +40,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -74,7 +74,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ windows-latest ]
os: [windows-latest]
include:
- os: windows-latest
flutter_profile: development-windows-x86
@@ -101,7 +101,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ macos-latest ]
os: [macos-latest]
include:
- os: macos-latest
flutter_profile: development-mac-x86_64
@@ -123,12 +123,12 @@ jobs:
flutter_profile: ${{ matrix.flutter_profile }}
unit_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -174,7 +174,7 @@ jobs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libcurl4-openssl-dev
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
fi
shell: bash
@@ -217,11 +217,11 @@ jobs:
shell: bash
cloud_integration_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -308,7 +308,7 @@ jobs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libcurl4-openssl-dev
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
shell: bash
- name: Enable Flutter Desktop
@@ -340,13 +340,13 @@ jobs:
shell: bash
integration_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
test_number: [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
os: [ubuntu-latest]
test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9]
include:
- os: ubuntu-latest
target: "x86_64-unknown-linux-gnu"
+21 -21
View File
@@ -94,26 +94,26 @@ jobs:
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
- uses: futureware-tech/simulator-action@v3
id: simulator-action
with:
model: "iPhone 15"
shutdown_after_job: false
# - name: Run AppFlowy on simulator
# working-directory: frontend/appflowy_flutter
# run: |
# flutter run -d ${{ steps.simulator-action.outputs.udid }} &
# pid=$!
# sleep 500
# kill $pid
# continue-on-error: true
- name: Run AppFlowy on simulator
working-directory: frontend/appflowy_flutter
run: |
flutter run -d ${{ steps.simulator-action.outputs.udid }} &
pid=$!
sleep 500
kill $pid
continue-on-error: true
# # Integration tests
# - name: Run integration tests
# working-directory: frontend/appflowy_flutter
# # The integration tests are flaky and sometimes fail with "Connection timed out":
# # Don't block the CI. If the tests fail, the CI will still pass.
# # Instead, we're using Code Magic to re-run the tests to check if they pass.
# continue-on-error: true
# run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }}
# Integration tests
- name: Run integration tests
working-directory: frontend/appflowy_flutter
# The integration tests are flaky and sometimes fail with "Connection timed out":
# Don't block the CI. If the tests fail, the CI will still pass.
# Instead, we're using Code Magic to re-run the tests to check if they pass.
continue-on-error: true
run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }}
+1 -2
View File
@@ -366,10 +366,9 @@ jobs:
run: |
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo apt-get update
sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev libcurl4-openssl-dev
sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
sudo apt-get install keybinder-3.0
sudo apt-get install -y alien libnotify-dev
sudo apt install libfuse2
source $HOME/.cargo/env
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
-21
View File
@@ -1,25 +1,4 @@
# Release Notes
## Version 0.9.0 - 30/04/2025
### Desktop
#### New Features
- Added AppFlowy Prompt Library to AI Chat and Document's Ask AI
- Revamped the desktop in-app notification center
- Supported login with password, as well as forgot and change password options
- Supported copying link to invite members
- Improved the Settings' Members tab with new metadata: member avatar and joined time
#### Bug Fixes
- Fixed crash when trying to delete an emoji
- Fixed Windows scaling issue
- Correctly displayed mention text by decoding web content
### Mobile
#### New Features
- Supported workspace search
- Improved UX for links in documents
- Supported changing password in Mobile Settings
- Added support for inviting members via links
#### Bug Fixes
- Correctly displayed mention text by decoding web content
## Version 0.8.9 - 16/04/2025
### Desktop
#### New Features
File diff suppressed because it is too large. Load diff
@@ -12,38 +12,24 @@ import 'uncategorized/uncategorized_test_runner.dart'
import 'workspace/workspace_test_runner.dart' as workspace_test_runner;
Future<void> main() async {
// don't remove this test, it can prevent the test from failing.
{
preset_af_cloud_env_test.main();
data_migration_test_runner.main();
preset_af_cloud_env_test.main();
// uncategorized
uncategorized_test_runner.main();
data_migration_test_runner.main();
// workspace
workspace_test_runner.main();
}
// uncategorized
uncategorized_test_runner.main();
// sidebar
// don't remove this test, it can prevent the test from failing.
{
preset_af_cloud_env_test.main();
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
sidebar_icon_test.main();
}
// database
// don't remove this test, it can prevent the test from failing.
{
preset_af_cloud_env_test.main();
database_test_runner.main();
}
// workspace
workspace_test_runner.main();
// document
// don't remove this test, it can prevent the test from failing.
{
preset_af_cloud_env_test.main();
document_test_runner.main();
}
document_test_runner.main();
// sidebar
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
sidebar_icon_test.main();
// database
database_test_runner.main();
}
@@ -1,19 +1,12 @@
import 'package:appflowy/ai/service/ai_entities.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../../shared/ai_test_op.dart';
import '../../../shared/constants.dart';
import '../../../shared/mock/mock_ai.dart';
import '../../../shared/util.dart';
void main() {
@@ -24,7 +17,6 @@ void main() {
(tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
aiRepositoryBuilder: () => MockAIRepository(),
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
@@ -51,184 +43,5 @@ void main() {
// expect the ai writer block is not in the document
expect(find.byType(AiWriterBlockComponent), findsNothing);
});
testWidgets('Improve writing', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const pageName = 'Document';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
await tester.editor.tapLineOfEditorAt(0);
// insert a paragraph
final text = 'I have an apple';
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: text.length),
),
);
await tester.pumpAndSettle();
await tester.tapButton(find.byType(ImproveWritingButton));
final editorState = tester.editor.getCurrentEditorState();
final document = editorState.document;
expect(document.root.children.length, 3);
expect(document.root.children[1].type, ParagraphBlockKeys.type);
expect(
document.root.children[1].delta!.toPlainText(),
'I have an apple and a banana',
);
});
testWidgets('fix grammar', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const pageName = 'Document';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
await tester.editor.tapLineOfEditorAt(0);
// insert a paragraph
final text = 'We didn’t had enough money';
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: text.length),
),
);
await tester.pumpAndSettle();
await tester.selectAIWriter(AiWriterCommand.fixSpellingAndGrammar);
final editorState = tester.editor.getCurrentEditorState();
final document = editorState.document;
expect(document.root.children.length, 3);
expect(document.root.children[1].type, ParagraphBlockKeys.type);
expect(
document.root.children[1].delta!.toPlainText(),
'We didn’t have enough money',
);
});
testWidgets('ask ai', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudDevelop,
aiRepositoryBuilder: () => MockAIRepository(
validator: _CompletionHistoryValidator(),
),
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const pageName = 'Document';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
await tester.editor.tapLineOfEditorAt(0);
// insert a paragraph
final text = 'What is TPU?';
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: text.length),
),
);
await tester.pumpAndSettle();
await tester.selectAIWriter(AiWriterCommand.userQuestion);
await tester.enterTextInPromptTextField("Explain the concept of TPU");
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
// await tester.selectModel("GPT-4o-mini");
await tester.enterTextInPromptTextField("How about GPU?");
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
});
});
}
class _CompletionHistoryValidator extends StreamCompletionValidator {
static const _expectedResponses = {
1: "What is TPU?",
3: [
"What is TPU?",
"Explain the concept of TPU",
"TPU is a tensor processing unit that is designed to accelerate machine",
],
5: [
"What is TPU?",
"Explain the concept of TPU",
"TPU is a tensor processing unit that is designed to accelerate machine",
"How about GPU?",
"GPU is a graphics processing unit that is designed to accelerate machine learning tasks.",
],
};
@override
bool validate(
String text,
String? objectId,
CompletionTypePB completionType,
PredefinedFormat? format,
List<String> sourceIds,
List<AiWriterRecord> history,
) {
assert(completionType == CompletionTypePB.UserQuestion);
if (history.isEmpty) return false;
final expectedMessages = _expectedResponses[history.length];
if (expectedMessages == null) return false;
if (expectedMessages is String) {
_assertMessage(history[0].content, expectedMessages);
return true;
} else if (expectedMessages is List<String>) {
for (var i = 0; i < expectedMessages.length; i++) {
_assertMessage(history[i].content, expectedMessages[i]);
}
return true;
}
return false;
}
void _assertMessage(String actual, String expected) {
assert(
actual.trim() == expected,
"expected '$expected', but got '${actual.trim()}'",
);
}
}
@@ -8,13 +8,11 @@ import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Preset cloud env', () {
testWidgets('use self-hosted cloud', (tester) async {
group('Empty', () {
testWidgets('set appflowy cloud', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.pumpAndSettle();
});
});
}
@@ -39,8 +39,8 @@ void main() {
);
await tester.tapButton(find.byType(AccountSignInOutButton));
tester.expectToSeeText(LocaleKeys.button_yes.tr());
await tester.tapButtonWithName(LocaleKeys.button_yes.tr());
tester.expectToSeeText(LocaleKeys.button_ok.tr());
await tester.tapButtonWithName(LocaleKeys.button_ok.tr());
// Go to the sign in page again
await tester.pumpAndSettle(const Duration(seconds: 5));
@@ -80,8 +80,8 @@ void main() {
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
expect(find.byType(AppFlowyEditorPage), findsOneWidget);
expect(find.text('Blank page'), findsNothing);
expect(find.byType(AppFlowyEditorPage), findsNothing);
expect(find.text('Blank page'), findsOne);
});
});
}
@@ -237,11 +237,11 @@ void main() {
await tester.tapButton(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// set date to be fifteenth of the last month
// set date to be fifteenth of the next month
await tester.tap(
find.descendant(
of: find.byType(DesktopAppFlowyDatePicker),
matching: find.byFlowySvg(FlowySvgs.arrow_left_s),
matching: find.byFlowySvg(FlowySvgs.arrow_right_s),
),
);
await tester.pumpAndSettle();
@@ -275,9 +275,9 @@ void main() {
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final now = DateTime.now();
final fifteenthOfLastMonth = DateTime(now.year, now.month - 1, 15);
final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15);
final formattedDate =
dateTimeSettings.dateFormat.formatDate(fifteenthOfLastMonth, false);
dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
@@ -378,11 +378,11 @@ void main() {
await tester.tapButton(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// set date to be fifteenth of the last month
// set date to be fifteenth of the next month
await tester.tap(
find.descendant(
of: find.byType(DesktopAppFlowyDatePicker),
matching: find.byFlowySvg(FlowySvgs.arrow_left_s),
matching: find.byFlowySvg(FlowySvgs.arrow_right_s),
),
);
await tester.pumpAndSettle();
@@ -416,9 +416,9 @@ void main() {
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final now = DateTime.now();
final fifteenthOfLastMonth = DateTime(now.year, now.month - 1, 15);
final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15);
final formattedDate =
dateTimeSettings.dateFormat.formatDate(fifteenthOfLastMonth, false);
dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
@@ -1,8 +1,13 @@
import 'dart:io';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/prelude.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -86,24 +91,23 @@ void main() {
// }
// });
// Disable this test because it failed after executing.
// testWidgets('reset to default location', (tester) async {
// await tester.initializeAppFlowy();
testWidgets('reset to default location', (tester) async {
await tester.initializeAppFlowy();
// await tester.tapAnonymousSignInButton();
await tester.tapAnonymousSignInButton();
// // home and readme document
// await tester.expectToSeeHomePageWithGetStartedPage();
// home and readme document
await tester.expectToSeeHomePageWithGetStartedPage();
// // open settings and restore the location
// await tester.openSettings();
// await tester.openSettingsPage(SettingsPage.manageData);
// await tester.restoreLocation();
// open settings and restore the location
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.manageData);
await tester.restoreLocation();
// expect(
// await appFlowyApplicationDataDirectory().then((value) => value.path),
// await getIt<ApplicationDataStorage>().getPath(),
// );
// });
expect(
await appFlowyApplicationDataDirectory().then((value) => value.path),
await getIt<ApplicationDataStorage>().getPath(),
);
});
});
}
@@ -128,12 +128,7 @@ void main() {
/// remove the link
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
final node = editorState.getNodeAtPath([0]);
expect(
node?.delta?.toJson(),
[
{'insert': testText},
],
);
expect(node?.delta?.toJson(), [{'insert': testText}]);
});
});
}
@@ -36,7 +36,7 @@ void main() {
expect(find.byType(MobileSearchRecentList), findsNothing);
expect(find.byType(MobileSearchResultList), findsOneWidget);
expect(
find.text(LocaleKeys.search_noResultForSearching.tr()),
find.text(LocaleKeys.search_noResultForSearching.tr(args: [query])),
findsOneWidget,
);
@@ -1,41 +0,0 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'util.dart';
extension AppFlowyAITest on WidgetTester {
Future<void> selectAIWriter(AiWriterCommand command) async {
await tapButton(find.byType(AiWriterToolbarActionList));
await tapButton(find.text(command.i18n));
await pumpAndSettle();
}
Future<void> selectModel(String modelName) async {
await tapButton(find.byType(SelectModelMenu));
await tapButton(find.text(modelName));
await pumpAndSettle();
}
Future<void> enterTextInPromptTextField(String text) async {
// Wait for the text field to be visible
await pumpAndSettle();
// Find the ExtendedTextField widget
final textField = find.descendant(
of: find.byType(PromptInputTextField),
matching: find.byType(TextField),
);
expect(textField, findsOneWidget, reason: 'ExtendedTextField not found');
final widget = element(textField).widget as TextField;
expect(widget.enabled, isTrue, reason: 'TextField is not enabled');
await tap(textField);
testTextInput.enterText(text);
await pumpAndSettle(const Duration(milliseconds: 300));
}
}
@@ -28,8 +28,8 @@ extension AppFlowyAuthTest on WidgetTester {
await tapButton(find.byType(AccountSignInOutButton));
expectToSeeText(LocaleKeys.button_yes.tr());
await tapButtonWithName(LocaleKeys.button_yes.tr());
expectToSeeText(LocaleKeys.button_ok.tr());
await tapButtonWithName(LocaleKeys.button_ok.tr());
}
Future<void> tapSignInAsGuest() async {
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:appflowy/ai/service/appflowy_ai_service.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/env/cloud_env_test.dart';
import 'package:appflowy/startup/entry_point.dart';
@@ -21,8 +20,6 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:universal_platform/universal_platform.dart';
import 'mock/mock_ai.dart';
class FlowyTestContext {
FlowyTestContext({required this.applicationDataDirectory});
@@ -36,14 +33,13 @@ extension AppFlowyTestBase on WidgetTester {
// use to specify the application data directory, if not specified, a temporary directory will be used.
String? dataDirectory,
Size windowSize = const Size(1600, 1200),
String? email,
AuthenticatorType? cloudType,
AIRepository Function()? aiRepositoryBuilder,
String? email,
}) async {
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
// Set the window size
await binding.setSurfaceSize(windowSize);
}
//cloudType = AuthenticatorType.appflowyCloudDevelop;
mockHotKeyManagerHandlers();
final applicationDataDirectory = dataDirectory ??
@@ -54,12 +50,43 @@ extension AppFlowyTestBase on WidgetTester {
await FlowyRunner.run(
AppFlowyApplication(),
IntegrationMode.integrationTest,
rustEnvsBuilder: () => _buildRustEnvs(cloudType),
didInitGetItCallback: () => _initializeCloudServices(
cloudType: cloudType,
email: email,
aiRepositoryBuilder: aiRepositoryBuilder,
),
rustEnvsBuilder: () {
final rustEnvs = <String, String>{};
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
break;
case AuthenticatorType.appflowyCloudSelfHost:
rustEnvs["GOTRUE_ADMIN_EMAIL"] = "admin@example.com";
rustEnvs["GOTRUE_ADMIN_PASSWORD"] = "password";
break;
default:
throw Exception("not supported");
}
}
return rustEnvs;
},
didInitGetItCallback: () {
return Future(
() async {
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
await useLocalServer();
break;
case AuthenticatorType.appflowyCloudSelfHost:
await useTestSelfHostedAppFlowyCloud();
getIt.unregister<AuthService>();
getIt.registerFactory<AuthService>(
() => AppFlowyCloudMockAuthService(email: email),
);
default:
throw Exception("not supported");
}
}
},
);
},
);
await waitUntilSignInPageShow();
@@ -68,76 +95,6 @@ extension AppFlowyTestBase on WidgetTester {
);
}
Map<String, String> _buildRustEnvs(AuthenticatorType? cloudType) {
final rustEnvs = <String, String>{};
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
break;
case AuthenticatorType.appflowyCloudSelfHost:
case AuthenticatorType.appflowyCloudDevelop:
rustEnvs["GOTRUE_ADMIN_EMAIL"] = "admin@example.com";
rustEnvs["GOTRUE_ADMIN_PASSWORD"] = "password";
break;
default:
throw Exception("Unsupported cloud type: $cloudType");
}
}
return rustEnvs;
}
Future<void> _initializeCloudServices({
required AuthenticatorType? cloudType,
String? email,
AIRepository Function()? aiRepositoryBuilder,
}) async {
if (cloudType == null) return;
switch (cloudType) {
case AuthenticatorType.local:
await useLocalServer();
break;
case AuthenticatorType.appflowyCloudSelfHost:
await _setupAppFlowyCloud(
useLocal: false,
email: email,
aiRepositoryBuilder: aiRepositoryBuilder,
);
break;
case AuthenticatorType.appflowyCloudDevelop:
await _setupAppFlowyCloud(
useLocal: integrationMode().isDevelop,
email: email,
aiRepositoryBuilder: aiRepositoryBuilder,
);
break;
default:
throw Exception("Unsupported cloud type: $cloudType");
}
}
Future<void> _setupAppFlowyCloud({
required bool useLocal,
String? email,
AIRepository Function()? aiRepositoryBuilder,
}) async {
if (useLocal) {
await useAppFlowyCloudDevelop("http://localhost");
} else {
await useSelfHostedAppFlowyCloud(TestEnv.afCloudUrl);
}
getIt.unregister<AuthService>();
getIt.unregister<AIRepository>();
getIt.registerFactory<AuthService>(
() => AppFlowyCloudMockAuthService(email: email),
);
getIt.registerFactory<AIRepository>(
aiRepositoryBuilder ?? () => MockAIRepository(),
);
}
void mockHotKeyManagerHandlers() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(const MethodChannel('hotkey_manager'),
@@ -318,6 +275,10 @@ extension AppFlowyFinderTestBase on CommonFinders {
}
}
Future<void> useTestSelfHostedAppFlowyCloud() async {
await useSelfHostedAppFlowyCloudWithURL(TestEnv.afCloudUrl);
}
Future<String> mockApplicationDataStorage({
// use to append after the application data directory
String? pathExtension,
@@ -1,128 +0,0 @@
import 'dart:async';
import 'package:appflowy/ai/service/ai_entities.dart';
import 'package:appflowy/ai/service/appflowy_ai_service.dart';
import 'package:appflowy/ai/service/error.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:mocktail/mocktail.dart';
final _mockAiMap = <CompletionTypePB, Map<String, List<String>>>{
CompletionTypePB.ImproveWriting: {
"I have an apple": [
"I",
"have",
"an",
"apple",
"and",
"a",
"banana",
],
},
CompletionTypePB.SpellingAndGrammar: {
"We didn’t had enough money": [
"We",
"didn’t",
"have",
"enough",
"money",
],
},
CompletionTypePB.UserQuestion: {
"Explain the concept of TPU": [
"TPU",
"is",
"a",
"tensor",
"processing",
"unit",
"that",
"is",
"designed",
"to",
"accelerate",
"machine",
],
"How about GPU?": [
"GPU",
"is",
"a",
"graphics",
"processing",
"unit",
"that",
"is",
"designed",
"to",
"accelerate",
"machine",
"learning",
"tasks",
],
},
};
abstract class StreamCompletionValidator {
bool validate(
String text,
String? objectId,
CompletionTypePB completionType,
PredefinedFormat? format,
List<String> sourceIds,
List<AiWriterRecord> history,
);
}
class MockCompletionStream extends Mock implements CompletionStream {}
class MockAIRepository extends Mock implements AppFlowyAIService {
MockAIRepository({this.validator});
StreamCompletionValidator? validator;
@override
Future<(String, CompletionStream)?> streamCompletion({
String? objectId,
required String text,
PredefinedFormat? format,
List<String> sourceIds = const [],
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) processMessage,
required Future<void> Function(String text) processAssistMessage,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
required void Function(LocalAIStreamingState state)
onLocalAIStreamingStateChange,
}) async {
if (validator != null) {
if (!validator!.validate(
text,
objectId,
completionType,
format,
sourceIds,
history,
)) {
throw Exception('Invalid completion');
}
}
final stream = MockCompletionStream();
unawaited(
Future(() async {
await onStart();
final lines = _mockAiMap[completionType]?[text.trim()];
if (lines == null) {
throw Exception('No mock ai found for $text and $completionType');
}
for (final line in lines) {
await processMessage('$line ');
}
await onEnd();
}),
);
return ('mock_id', stream);
}
}
@@ -9,6 +9,7 @@ import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -85,7 +86,7 @@ extension AppFlowySettings on WidgetTester {
final userNameFinder = find.descendant(
of: find.byType(AccountUserProfile),
matching: find.byType(TextField),
matching: find.byType(FlowyTextField),
);
await enterText(userNameFinder, name);
await pumpAndSettle();
+30 -30
View File
@@ -8,35 +8,35 @@ PODS:
- ReachabilitySwift
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/Core (4.3.4):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/ImageDataManager (4.3.4)
- DKImagePickerController/PhotoGallery (4.3.4):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- DKImagePickerController/Resource (4.3.4)
- DKPhotoGallery (0.0.17):
- DKPhotoGallery/Core (= 0.0.17)
- DKPhotoGallery/Model (= 0.0.17)
- DKPhotoGallery/Preview (= 0.0.17)
- DKPhotoGallery/Resource (= 0.0.17)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Core (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- DKPhotoGallery/Model (0.0.17):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Preview (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- DKPhotoGallery/Resource (0.0.17):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
@@ -65,17 +65,17 @@ PODS:
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- ReachabilitySwift (5.2.4)
- ReachabilitySwift (5.0.0)
- saver_gallery (0.0.1):
- Flutter
- SDWebImage (5.21.0):
- SDWebImage/Core (= 5.21.0)
- SDWebImage/Core (5.21.0)
- Sentry/HybridSDK (8.46.0)
- sentry_flutter (8.14.2):
- SDWebImage (5.14.2):
- SDWebImage/Core (= 5.14.2)
- SDWebImage/Core (5.14.2)
- Sentry/HybridSDK (8.35.1)
- sentry_flutter (8.8.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.46.0)
- Sentry/HybridSDK (= 8.35.1)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -86,8 +86,8 @@ PODS:
- FlutterMacOS
- super_native_extensions (0.0.1):
- Flutter
- SwiftyGif (5.4.5)
- Toast (4.1.1)
- SwiftyGif (5.4.3)
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
@@ -185,8 +185,8 @@ SPEC CHECKSUMS:
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
@@ -199,17 +199,17 @@ SPEC CHECKSUMS:
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
@@ -122,6 +122,8 @@ enum AiPromptCategory {
business,
@JsonValue("marketing")
marketing,
@JsonValue("learning")
learning,
@JsonValue("travel")
travel,
@JsonValue("contentSeo")
@@ -143,17 +145,7 @@ enum AiPromptCategory {
@JsonValue("caseStudies")
caseStudies,
@JsonValue("salesCopy")
salesCopy,
@JsonValue("education")
education,
@JsonValue("work")
work,
@JsonValue("podcastProduction")
podcastProduction,
@JsonValue("copyWriting")
copyWriting,
@JsonValue("customerSuccess")
customerSuccess;
salesCopy;
String get i18n {
return switch (this) {
@@ -163,6 +155,7 @@ enum AiPromptCategory {
healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness.tr(),
business => LocaleKeys.ai_customPrompt_business.tr(),
marketing => LocaleKeys.ai_customPrompt_marketing.tr(),
learning => LocaleKeys.ai_customPrompt_learning.tr(),
travel => LocaleKeys.ai_customPrompt_travel.tr(),
contentSeo => LocaleKeys.ai_customPrompt_contentSeo.tr(),
emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing.tr(),
@@ -174,11 +167,6 @@ enum AiPromptCategory {
strategy => LocaleKeys.ai_customPrompt_strategy.tr(),
caseStudies => LocaleKeys.ai_customPrompt_caseStudies.tr(),
salesCopy => LocaleKeys.ai_customPrompt_salesCopy.tr(),
education => LocaleKeys.ai_customPrompt_education.tr(),
work => LocaleKeys.ai_customPrompt_work.tr(),
podcastProduction => LocaleKeys.ai_customPrompt_podcastProduction.tr(),
copyWriting => LocaleKeys.ai_customPrompt_copyWriting.tr(),
customerSuccess => LocaleKeys.ai_customPrompt_customerSuccess.tr(),
};
}
}
@@ -191,6 +179,7 @@ class AiPrompt extends Equatable {
required this.content,
required this.category,
required this.example,
required this.sampleResponse,
required this.isFeatured,
});
@@ -202,16 +191,14 @@ class AiPrompt extends Equatable {
final String id;
final String name;
final String content;
@JsonKey(
unknownEnumValue: AiPromptCategory.other,
defaultValue: AiPromptCategory.other,
)
final AiPromptCategory category;
@JsonKey(defaultValue: "")
final String example;
@JsonKey(defaultValue: "")
final String sampleResponse;
@JsonKey(defaultValue: false)
final bool isFeatured;
@override
List<Object?> get props => [id, name, content, category, example, isFeatured];
List<Object?> get props => [id, name, content, category, sampleResponse];
}
@@ -7,6 +7,7 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:protobuf/protobuf.dart';
import 'package:universal_platform/universal_platform.dart';
typedef OnModelStateChangedCallback = void Function(AIModelState state);
@@ -51,29 +52,25 @@ class AIModelStateNotifier {
final String objectId;
final LocalAIStateListener? _localAIListener;
final AIModelSwitchListener _aiModelSwitchListener;
LocalAIPB? _localAIState;
ModelSelectionPB? _modelSelection;
AIModelState _currentState = _defaultState();
List<AIModelPB> _availableModels = [];
AIModelPB? _selectedModel;
AvailableModelsPB? _availableModels;
// callbacks
final List<OnModelStateChangedCallback> _stateChangedCallbacks = [];
final List<OnAvailableModelsChangedCallback>
_availableModelsChangedCallbacks = [];
/// Starts platform-specific listeners
void _startListening() {
if (UniversalPlatform.isDesktop) {
_localAIListener?.start(
stateCallback: (state) async {
_localAIState = state;
_updateAll();
_notifyStateChanged();
if (state.state == RunningStatePB.Running ||
state.state == RunningStatePB.Stopped) {
await _loadModelSelection();
_updateAll();
await _loadAvailableModels();
_notifyAvailableModelsChanged();
}
},
);
@@ -81,25 +78,25 @@ class AIModelStateNotifier {
_aiModelSwitchListener.start(
onUpdateSelectedModel: (model) async {
_selectedModel = model;
_updateAll();
final updatedModels = _availableModels?.deepCopy()
?..selectedModel = model;
_availableModels = updatedModels;
_notifyAvailableModelsChanged();
if (model.isLocal && UniversalPlatform.isDesktop) {
await _loadLocalState();
_updateAll();
await _loadLocalAiState();
}
_notifyStateChanged();
},
);
}
Future<void> _init() async {
await Future.wait([
if (UniversalPlatform.isDesktop) _loadLocalState(),
_loadModelSelection(),
]);
_updateAll();
void _init() async {
await Future.wait([_loadLocalAiState(), _loadAvailableModels()]);
_notifyStateChanged();
_notifyAvailableModelsChanged();
}
/// Register callbacks for state or available-models changes
void addListener({
OnModelStateChangedCallback? onStateChanged,
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
@@ -112,7 +109,6 @@ class AIModelStateNotifier {
}
}
/// Remove previously registered callbacks
void removeListener({
OnModelStateChangedCallback? onStateChanged,
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
@@ -132,88 +128,116 @@ class AIModelStateNotifier {
await _aiModelSwitchListener.stop();
}
/// Returns current AIModelState
AIModelState getState() => _currentState;
/// Returns available models and the selected model
(List<AIModelPB>, AIModelPB?) getModelSelection() =>
(_availableModels, _selectedModel);
void _updateAll() {
_currentState = _computeState();
for (final cb in _stateChangedCallbacks) {
cb(_currentState);
}
for (final cb in _availableModelsChangedCallbacks) {
cb(_availableModels, _selectedModel);
}
}
Future<void> _loadModelSelection() async {
await AIEventGetSourceModelSelection(
ModelSourcePB(source: objectId),
).send().fold(
(ms) {
_modelSelection = ms;
_availableModels = ms.models;
_selectedModel = ms.selectedModel;
},
(e) => Log.error("Failed to fetch models: \$e"),
);
}
Future<void> _loadLocalState() async {
await AIEventGetLocalAIState().send().fold(
(s) => _localAIState = s,
(e) => Log.error("Failed to fetch local AI state: \$e"),
);
}
static AIModelState _defaultState() => AIModelState(
AIModelState getState() {
if (UniversalPlatform.isMobile) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
tooltip: null,
isEditable: true,
localAIEnabled: false,
);
/// Core logic computing the state from local and selection data
AIModelState _computeState() {
if (UniversalPlatform.isMobile) return _defaultState();
if (_modelSelection == null || _localAIState == null) {
return _defaultState();
}
if (!_selectedModel!.isLocal) {
return _defaultState();
final availableModels = _availableModels;
final localAiState = _localAIState;
if (availableModels == null) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
isEditable: true,
tooltip: null,
localAIEnabled: false,
);
}
if (localAiState == null) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
tooltip: null,
isEditable: true,
localAIEnabled: false,
);
}
final enabled = _localAIState!.enabled;
final running = _localAIState!.state == RunningStatePB.Running;
final hintKey = enabled
? (running
? LocaleKeys.chat_inputLocalAIMessageHint
: LocaleKeys.settings_aiPage_keys_localAIInitializing)
: LocaleKeys.settings_aiPage_keys_localAIDisabled;
final tooltipKey = enabled
? (running
if (!availableModels.selectedModel.isLocal) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
tooltip: null,
isEditable: true,
localAIEnabled: false,
);
}
final editable = localAiState.state == RunningStatePB.Running;
final tooltip = localAiState.enabled
? (editable
? null
: LocaleKeys.settings_aiPage_keys_localAINotReadyTextFieldPrompt)
: LocaleKeys.settings_aiPage_keys_localAIDisabledTextFieldPrompt;
: LocaleKeys.settings_aiPage_keys_localAINotReadyTextFieldPrompt
.tr())
: LocaleKeys.settings_aiPage_keys_localAIDisabledTextFieldPrompt.tr();
final hintText = localAiState.enabled
? (editable
? LocaleKeys.chat_inputLocalAIMessageHint.tr()
: LocaleKeys.settings_aiPage_keys_localAIInitializing.tr())
: LocaleKeys.settings_aiPage_keys_localAIDisabled.tr();
return AIModelState(
type: AiType.local,
hintText: hintKey.tr(),
tooltip: tooltipKey?.tr(),
isEditable: running,
localAIEnabled: enabled,
hintText: hintText,
tooltip: tooltip,
isEditable: editable,
localAIEnabled: localAiState.enabled,
);
}
(List<AIModelPB>, AIModelPB?) getAvailableModels() {
final availableModels = _availableModels;
if (availableModels == null) {
return ([], null);
}
return (availableModels.models, availableModels.selectedModel);
}
void _notifyAvailableModelsChanged() {
final (models, selectedModel) = getAvailableModels();
for (final callback in _availableModelsChangedCallbacks) {
callback(models, selectedModel);
}
}
void _notifyStateChanged() {
final state = getState();
for (final callback in _stateChangedCallbacks) {
callback(state);
}
}
Future<void> _loadAvailableModels() {
final payload = AvailableModelsQueryPB(source: objectId);
return AIEventGetAvailableModels(payload).send().fold(
(models) => _availableModels = models,
(err) => Log.error("Failed to get available models: $err"),
);
}
Future<void> _loadLocalAiState() {
return AIEventGetLocalAIState().send().fold(
(localAIState) => _localAIState = localAIState,
(error) => Log.error("Failed to get local AI state: $error"),
);
}
}
extension AIModelPBExtension on AIModelPB {
bool get isDefault => name == 'Auto';
String get i18n =>
isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name;
extension AiModelExtension on AIModelPB {
bool get isDefault {
return name == "Auto";
}
String get i18n {
return isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name;
}
}
@@ -29,14 +29,12 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
void _loadPrompts() async {
availablePrompts.addAll(await _aiService.getBuiltInPrompts());
final featuredPrompts =
availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(featuredPrompts);
final visiblePrompts = _getFilteredPrompts(availablePrompts);
emit(
AiPromptSelectorState.ready(
visiblePrompts: visiblePrompts,
showCustomPrompts: false,
isFeaturedCategorySelected: true,
isFeaturedCategorySelected: false,
selectedPromptId: visiblePrompts.firstOrNull?.id,
selectedCategory: null,
favoritePrompts: [],
@@ -47,13 +45,12 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
void selectFeaturedCategory() {
state.maybeMap(
ready: (readyState) {
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(prompts);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts,
// TODO(RS): Add logic to filter prompts based on the featured category
// visiblePrompts: prompts,
isFeaturedCategorySelected: true,
selectedPromptId: visiblePrompts.firstOrNull?.id,
// selectedPromptId: prompts.firstOrNull?.id,
),
);
},
@@ -139,13 +136,22 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
);
}
AiPrompt? get selectedPrompt {
return state.maybeMap(
ready: (readyState) {
return readyState.visiblePrompts.firstWhereOrNull(
(prompt) => prompt.id == readyState.selectedPromptId,
);
},
orElse: () => null,
);
}
void _filterTextChanged() {
state.maybeMap(
ready: (readyState) {
final unfilteredPrompts = readyState.selectedCategory == null
? readyState.isFeaturedCategorySelected
? availablePrompts.where((prompt) => prompt.isFeatured)
: availablePrompts
? availablePrompts
: availablePrompts.where(
(prompt) => prompt.category == readyState.selectedCategory,
);
@@ -23,7 +23,7 @@ enum LocalAIStreamingState {
}
abstract class AIRepository {
Future<(String, CompletionStream)?> streamCompletion({
Future<void> streamCompletion({
String? objectId,
required String text,
PredefinedFormat? format,
@@ -45,6 +45,8 @@ abstract class AIRepository {
}
class AppFlowyAIService implements AIRepository {
final List<AiPrompt> _builtInPrompts = [];
@override
Future<(String, CompletionStream)?> streamCompletion({
String? objectId,
@@ -96,6 +98,10 @@ class AppFlowyAIService implements AIRepository {
@override
Future<List<AiPrompt>> getBuiltInPrompts() async {
if (_builtInPrompts.isNotEmpty) {
return _builtInPrompts;
}
final prompts = <AiPrompt>[];
try {
@@ -114,6 +120,8 @@ class AppFlowyAIService implements AIRepository {
Log.error(e);
}
_builtInPrompts.addAll(prompts);
return prompts;
}
@@ -83,7 +83,7 @@ class SelectModelState with _$SelectModelState {
}) = _SelectModelState;
factory SelectModelState.initial(AIModelStateNotifier notifier) {
final (models, selectedModel) = notifier.getModelSelection();
final (models, selectedModel) = notifier.getAvailableModels();
return SelectModelState(
models: models,
selectedModel: selectedModel,
@@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class AiPromptCategoryList extends StatefulWidget {
class AiPromptCategoryList extends StatelessWidget {
const AiPromptCategoryList({
super.key,
required this.padding,
@@ -13,26 +13,19 @@ class AiPromptCategoryList extends StatefulWidget {
final EdgeInsetsGeometry padding;
@override
State<AiPromptCategoryList> createState() => _AiPromptCategoryListState();
}
class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
bool isSearching = false;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: EdgeInsets.all(
theme.spacing.l,
),
padding: padding,
child: TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: theme.spacing.s,
children: [
_buildSearchField(context),
_buildFeaturedCategory(context),
const AFDivider(),
Expanded(
@@ -44,6 +37,7 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
_buildCategoryItem(context, AiPromptCategory.writing),
_buildCategoryItem(context, AiPromptCategory.business),
_buildCategoryItem(context, AiPromptCategory.marketing),
_buildCategoryItem(context, AiPromptCategory.learning),
_buildCategoryItem(
context,
AiPromptCategory.healthAndFitness,
@@ -59,15 +53,6 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
_buildCategoryItem(context, AiPromptCategory.strategy),
_buildCategoryItem(context, AiPromptCategory.caseStudies),
_buildCategoryItem(context, AiPromptCategory.salesCopy),
_buildCategoryItem(context, AiPromptCategory.education),
_buildCategoryItem(context, AiPromptCategory.work),
_buildCategoryItem(
context,
AiPromptCategory.podcastProduction,
),
_buildCategoryItem(context, AiPromptCategory.copyWriting),
_buildCategoryItem(context, AiPromptCategory.customerSuccess),
_buildCategoryItem(context, AiPromptCategory.other),
],
),
),
@@ -77,6 +62,16 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
);
}
Widget _buildSearchField(BuildContext context) {
return AFTextField(
groupId: "ai_prompt_category_list",
hintText: "Search",
size: AFTextFieldSize.m,
controller: context.read<AiPromptSelectorCubit>().filterTextController,
autoFocus: true,
);
}
Widget _buildCategoryItem(
BuildContext context,
AiPromptCategory? category,
@@ -103,12 +98,15 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
}
},
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_featured.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
return Row(
children: [
Text(
LocaleKeys.ai_customPrompt_featured.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
],
);
},
borderRadius: theme.borderRadius.m,
@@ -154,12 +152,15 @@ class AiPromptCategoryItem extends StatelessWidget {
return AFBaseButton(
onTap: onSelect,
builder: (context, isHovering, disabled) {
return Text(
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
return Row(
children: [
Text(
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
],
);
},
borderRadius: theme.borderRadius.m,
@@ -88,7 +88,12 @@ class AiPromptModal extends StatelessWidget {
),
Expanded(
flex: 2,
child: AiPromptVisibleList(),
child: AiPromptVisibleList(
padding: EdgeInsets.symmetric(
vertical: theme.spacing.l,
horizontal: theme.spacing.l,
),
),
),
Expanded(
flex: 3,
@@ -110,6 +115,10 @@ class AiPromptModal extends StatelessWidget {
}
return AiPromptPreview(
prompt: selectedPrompt,
padding: EdgeInsets.symmetric(
vertical: theme.spacing.l,
horizontal: theme.spacing.l,
),
);
},
),
@@ -122,6 +131,20 @@ class AiPromptModal extends StatelessWidget {
),
),
),
AFModalFooter(
trailing: [
AFFilledTextButton.primary(
text: LocaleKeys.ai_customPrompt_usePrompt.tr(),
onTap: () {
final selectedPrompt =
context.read<AiPromptSelectorCubit>().selectedPrompt;
if (selectedPrompt != null) {
Navigator.of(context).pop(selectedPrompt);
}
},
),
],
),
],
),
);
@@ -10,83 +10,71 @@ class AiPromptPreview extends StatelessWidget {
const AiPromptPreview({
super.key,
required this.prompt,
required this.padding,
});
final AiPrompt prompt;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SelectionArea(
child: Column(
child: ListView(
padding: padding,
children: [
Padding(
padding: EdgeInsets.only(
top: theme.spacing.l,
left: theme.spacing.l,
right: theme.spacing.l,
),
child: SelectionContainer.disabled(
child: Row(
children: [
Expanded(
child: Text(
prompt.name,
style: theme.textStyle.headline.standard(
color: theme.textColorScheme.primary,
),
),
),
HSpace(theme.spacing.s),
AFFilledTextButton.primary(
text: LocaleKeys.ai_customPrompt_usePrompt.tr(),
onTap: () {
Navigator.of(context).pop(prompt);
},
),
],
SelectionContainer.disabled(
child: Text(
prompt.name,
style: theme.textStyle.headline.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
Expanded(
child: ListView(
padding: EdgeInsets.all(
theme.spacing.l,
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_prompt.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
children: [
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_prompt.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
_PromptContent(
prompt: prompt,
),
VSpace(theme.spacing.xl),
if (prompt.example.isNotEmpty) ...[
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_example.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
_PromptExample(
prompt: prompt,
),
VSpace(theme.spacing.xl),
],
],
),
),
VSpace(theme.spacing.xs),
_PromptContent(
prompt: prompt,
),
VSpace(theme.spacing.xl),
if (prompt.example.isNotEmpty) ...[
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_example.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
_PromptExample(
prompt: prompt,
),
VSpace(theme.spacing.xl),
],
if (prompt.sampleResponse.isNotEmpty) ...[
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_sampleOutput.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
_PromptSampleOutput(
prompt: prompt,
),
],
],
),
);
@@ -187,3 +175,27 @@ class _PromptExample extends StatelessWidget {
);
}
}
class _PromptSampleOutput extends StatelessWidget {
const _PromptSampleOutput({
required this.prompt,
});
final AiPrompt prompt;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Container(
padding: EdgeInsets.all(theme.spacing.l),
decoration: BoxDecoration(
color: theme.fillColorScheme.quaternary,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: AIMarkdownText(
markdown: prompt.sampleResponse,
),
);
}
}
@@ -1,14 +1,9 @@
import 'dart:async';
import 'package:appflowy/ai/ai.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_ui/appflowy_ui.dart';
import 'package:diffutil_dart/diffutil.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
@@ -16,8 +11,11 @@ const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
class AiPromptVisibleList extends StatefulWidget {
const AiPromptVisibleList({
super.key,
required this.padding,
});
final EdgeInsetsGeometry padding;
@override
State<AiPromptVisibleList> createState() => _AiPromptVisibleListState();
}
@@ -27,26 +25,18 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
final scrollController = ScrollController();
final List<AiPrompt> oldList = [];
late AiPromptSelectorCubit cubit;
late bool filterIsEmpty;
@override
void initState() {
super.initState();
cubit = context.read<AiPromptSelectorCubit>();
final textController = cubit.filterTextController;
final prompts = cubit.state.maybeMap(
ready: (value) => value.visiblePrompts,
orElse: () => <AiPrompt>[],
);
filterIsEmpty = textController.text.isEmpty;
textController.addListener(handleFilterTextChanged);
final prompts = context.read<AiPromptSelectorCubit>().state.maybeMap(
ready: (value) => value.visiblePrompts,
orElse: () => <AiPrompt>[],
);
oldList.addAll(prompts);
}
@override
void dispose() {
cubit.filterTextController.removeListener(handleFilterTextChanged);
scrollController.dispose();
super.dispose();
}
@@ -62,82 +52,34 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
);
handleVisiblePromptListChanged(list);
},
child: Column(
children: [
VSpace(spacing.l),
Padding(
padding: EdgeInsets.symmetric(horizontal: spacing.l),
child: buildSearchField(context),
),
VSpace(spacing.s),
Expanded(
child: AnimatedList(
controller: scrollController,
padding: EdgeInsets.all(spacing.l),
key: listKey,
initialItemCount: oldList.length,
itemBuilder: (context, index, animation) {
final cubit = context.read<AiPromptSelectorCubit>();
child: AnimatedList(
controller: scrollController,
padding: widget.padding,
key: listKey,
initialItemCount: oldList.length,
itemBuilder: (context, index, animation) {
final cubit = context.read<AiPromptSelectorCubit>();
return cubit.state.maybeMap(
ready: (state) {
final prompt = state.visiblePrompts[index];
return Padding(
padding: EdgeInsets.only(
top: index == 0 ? 0 : spacing.s,
bottom: index == state.visiblePrompts.length - 1
? 0
: spacing.s,
),
child: _AiPromptListItem(
animation: animation,
prompt: prompt,
isSelected: state.selectedPromptId == prompt.id,
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
),
],
),
);
}
Widget buildSearchField(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final iconSize = 20.0;
return AFTextField(
groupId: "ai_prompt_category_list",
hintText: "Search",
controller: context.read<AiPromptSelectorCubit>().filterTextController,
autoFocus: true,
suffixIconConstraints: BoxConstraints.tightFor(
width: iconSize + theme.spacing.m,
height: iconSize,
),
suffixIconBuilder: filterIsEmpty
? null
: (context, isObscured) => TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: Padding(
padding: EdgeInsets.only(right: theme.spacing.m),
child: GestureDetector(
onTap: () => context
.read<AiPromptSelectorCubit>()
.filterTextController
.clear(),
child: FlowySvg(
FlowySvgs.toast_close_s,
color: theme.textColorScheme.secondary,
size: const Size.square(20),
),
),
return cubit.state.maybeMap(
ready: (state) {
final prompt = state.visiblePrompts[index];
return Padding(
padding: EdgeInsets.only(
top: index == 0 ? 0 : spacing.s,
bottom:
index == state.visiblePrompts.length - 1 ? 0 : spacing.s,
),
),
child: _AiPromptListItem(
animation: animation,
prompt: prompt,
isSelected: state.selectedPromptId == prompt.id,
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
);
}
@@ -180,12 +122,6 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
..clear()
..addAll(newList);
}
void handleFilterTextChanged() {
setState(() {
filterIsEmpty = cubit.filterTextController.text.isEmpty;
});
}
}
class _AiPromptListItem extends StatefulWidget {
@@ -210,7 +146,6 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
@override
void dispose() {
timer?.cancel();
timer = null;
super.dispose();
}
@@ -224,9 +159,6 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
curve: Curves.easeIn,
);
final surfacePrimaryHover =
Theme.of(context).isLightMode ? Color(0xFFF8FAFF) : Color(0xFF3C3F4E);
return FadeTransition(
opacity: curvedAnimation,
child: SizeTransition(
@@ -235,103 +167,74 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
onEnter: (_) {
setState(() {
isHovering = true;
timer = Timer(const Duration(milliseconds: 300), () {
if (mounted) {
cubit.selectPrompt(widget.prompt.id);
}
});
timer = Timer(
const Duration(milliseconds: 300),
() {
if (isHovering) {
cubit.selectPrompt(widget.prompt.id);
}
},
);
});
},
onExit: (_) {
setState(() {
isHovering = false;
timer?.cancel();
timer = null;
});
},
child: GestureDetector(
onTap: () {
cubit.selectPrompt(widget.prompt.id);
},
child: Stack(
children: [
Container(
padding: EdgeInsets.all(theme.spacing.m),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.m),
border: Border.all(
color: widget.isSelected
? isHovering
? theme.borderColorScheme.themeThickHover
: theme.borderColorScheme.themeThick
: isHovering
? theme.borderColorScheme.greyTertiaryHover
: theme.borderColorScheme.greyTertiary,
),
color: theme.surfaceColorScheme.primary,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
onDoubleTap: () {
Navigator.of(context).pop(widget.prompt);
},
child: Container(
padding: EdgeInsets.all(theme.spacing.m),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.m),
border: Border.all(
color: widget.isSelected
? isHovering
? theme.borderColorScheme.themeThickHover
: theme.borderColorScheme.themeThick
: isHovering
? theme.borderColorScheme.greyTertiaryHover
: theme.borderColorScheme.greyTertiary,
),
color: theme.surfaceColorScheme.primary,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Row(
children: [
Expanded(
child: Text(
widget.prompt.name,
maxLines: 1,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
softWrap: true,
),
Expanded(
child: Text(
widget.prompt.name,
maxLines: 1,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
],
),
Text(
widget.prompt.content,
maxLines: 2,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
overflow: TextOverflow.ellipsis,
softWrap: true,
),
overflow: TextOverflow.ellipsis,
softWrap: true,
),
],
),
),
if (isHovering)
Positioned(
top: theme.spacing.s,
right: theme.spacing.s,
child: DecoratedBox(
decoration: BoxDecoration(boxShadow: theme.shadow.small),
child: AFBaseButton(
onTap: () {
Navigator.of(context).pop(widget.prompt);
},
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_usePrompt.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
);
},
backgroundColor: (context, isHovering, disabled) {
if (isHovering) {
return surfacePrimaryHover;
}
return theme.surfaceColorScheme.primary;
},
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
borderRadius: theme.borderRadius.m,
),
Text(
widget.prompt.content,
maxLines: 2,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
),
overflow: TextOverflow.ellipsis,
softWrap: true,
),
],
],
),
),
),
),
@@ -260,12 +260,11 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
if (widget.isStreaming) {
return;
}
String userInput = widget.textController.text.trim();
userInput = inputControlCubit.formatIntputText(userInput);
userInput = AiPromptInputTextEditingController.restore(userInput);
final trimmedText = inputControlCubit.formatIntputText(
widget.textController.text.trim(),
);
widget.textController.clear();
if (userInput.isEmpty) {
if (trimmedText.isEmpty) {
return;
}
@@ -277,7 +276,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
final predefinedFormat = bloc.state.predefinedFormat;
widget.onSubmitted(
userInput,
trimmedText,
showPredefinedFormats ? predefinedFormat : null,
metadata,
);
@@ -57,8 +57,7 @@ class AiPromptInputTextEditingController extends TextEditingController {
text: restore(string),
style: theme.textStyle.body.standard().copyWith(
color: theme.textColorScheme.purple,
backgroundColor:
theme.fillColorScheme.purpleThick.withAlpha(51),
backgroundColor: theme.fillColorScheme.purpleLight,
),
),
);
@@ -90,40 +90,38 @@ class SelectModelPopoverContent extends StatelessWidget {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (localModels.isNotEmpty) ...[
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_localModel.tr(),
),
const VSpace(4.0),
],
...localModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
),
if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[
const VSpace(8.0),
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_cloudModel.tr(),
),
const VSpace(4.0),
],
...cloudModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (localModels.isNotEmpty) ...[
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_localModel.tr(),
),
const VSpace(4.0),
],
),
...localModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
),
if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[
const VSpace(8.0),
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_cloudModel.tr(),
),
const VSpace(4.0),
],
...cloudModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
),
],
),
);
}
@@ -217,41 +215,45 @@ class _CurrentModelButton extends StatelessWidget {
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: DesktopAIPromptSizes.actionBarButtonSize,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsetsDirectional.all(4.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
// TODO: remove this after change icon to 20px
padding: EdgeInsets.all(2),
child: FlowySvg(
FlowySvgs.ai_sparks_s,
color: Theme.of(context).hintColor,
size: Size.square(16),
),
),
if (model != null && !model!.isDefault)
child: AnimatedSize(
duration: const Duration(milliseconds: 50),
curve: Curves.easeInOut,
alignment: AlignmentDirectional.centerStart,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsetsDirectional.all(4.0),
child: Row(
children: [
Padding(
padding: EdgeInsetsDirectional.only(end: 2.0),
child: FlowyText(
model!.i18n,
fontSize: 12,
figmaLineHeight: 16,
// TODO: remove this after change icon to 20px
padding: EdgeInsets.all(2),
child: FlowySvg(
FlowySvgs.ai_sparks_s,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
size: Size.square(16),
),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(8),
),
],
if (model != null && !model!.isDefault)
Padding(
padding: EdgeInsetsDirectional.only(end: 2.0),
child: FlowyText(
model!.i18n,
fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(8),
),
],
),
),
),
),
@@ -54,7 +54,7 @@ class _PromptInputMobileSelectSourcesButtonState
Widget build(BuildContext context) {
return BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
builder: (context, state) {
final userProfile = context.read<UserWorkspaceBloc>().state.userProfile;
final userProfile = context.read<UserWorkspaceBloc>().userProfile;
final workspaceId = state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
@@ -60,7 +60,7 @@ class _PromptInputDesktopSelectSourcesButtonState
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
@@ -39,11 +39,8 @@ Future<bool> afLaunchUri(
);
}
// on Linux or Android or Windows, add http scheme to the url if it is not present
if ((UniversalPlatform.isLinux ||
UniversalPlatform.isAndroid ||
UniversalPlatform.isWindows) &&
!isURL(url, {'require_protocol': true})) {
// on Linux, add http scheme to the url if it is not present
if (UniversalPlatform.isLinux && !isURL(url, {'require_protocol': true})) {
uri = Uri.parse('https://$url');
}
+1 -6
View File
@@ -167,16 +167,11 @@ Future<void> useBaseWebDomain(String? url) async {
);
}
Future<void> useSelfHostedAppFlowyCloud(String url) async {
Future<void> useSelfHostedAppFlowyCloudWithURL(String url) async {
await _setAuthenticatorType(AuthenticatorType.appflowyCloudSelfHost);
await _setAppFlowyCloudUrl(url);
}
Future<void> useAppFlowyCloudDevelop(String url) async {
await _setAuthenticatorType(AuthenticatorType.appflowyCloudDevelop);
await _setAppFlowyCloudUrl(url);
}
Future<void> useAppFlowyBetaCloudWithURL(
String url,
AuthenticatorType authenticatorType,
@@ -30,7 +30,7 @@ class NotificationReminderBloc
add(const NotificationReminderEvent.reset());
},
reset: () async {
final scheduledAt = await _getScheduledAt(
final createdAt = await _getCreatedAt(
reminder,
dateFormat,
timeFormat,
@@ -40,24 +40,23 @@ class NotificationReminderBloc
if (view == null) {
emit(
NotificationReminderState(
scheduledAt: scheduledAt,
createdAt: createdAt,
pageTitle: '',
reminderContent: '',
isLocked: false,
status: NotificationReminderStatus.error,
),
);
return;
}
final layout = view.layout;
final layout = view!.layout;
if (layout.isDocumentView) {
final node = await _getContent(reminder);
if (node != null) {
emit(
NotificationReminderState(
scheduledAt: scheduledAt,
createdAt: createdAt,
pageTitle: view.nameOrDefault,
isLocked: view.isLocked,
view: view,
@@ -71,7 +70,7 @@ class NotificationReminderBloc
} else if (layout.isDatabaseView) {
emit(
NotificationReminderState(
scheduledAt: scheduledAt,
createdAt: createdAt,
pageTitle: view.nameOrDefault,
isLocked: view.isLocked,
view: view,
@@ -89,16 +88,20 @@ class NotificationReminderBloc
late final UserDateFormatPB dateFormat;
late final UserTimeFormatPB timeFormat;
Future<String> _getScheduledAt(
Future<String> _getCreatedAt(
ReminderPB reminder,
UserDateFormatPB dateFormat,
UserTimeFormatPB timeFormat,
) async {
return _formatTimestamp(
reminder.scheduledAt.toInt() * 1000,
timeFormat: timeFormat,
dateFormate: dateFormat,
);
final rCreatedAt = reminder.createdAt;
final createdAt = rCreatedAt != null
? _formatTimestamp(
rCreatedAt,
timeFormat: timeFormat,
dateFormate: dateFormat,
)
: '';
return createdAt;
}
Future<ViewPB?> _getView(ReminderPB reminder) async {
@@ -200,7 +203,7 @@ class NotificationReminderState with _$NotificationReminderState {
const NotificationReminderState._();
const factory NotificationReminderState({
required String scheduledAt,
required String createdAt,
required String pageTitle,
required String reminderContent,
required bool isLocked,
@@ -213,7 +216,7 @@ class NotificationReminderState with _$NotificationReminderState {
factory NotificationReminderState.initial() =>
const NotificationReminderState(
scheduledAt: '',
createdAt: '',
pageTitle: '',
reminderContent: '',
isLocked: false,
@@ -53,7 +53,6 @@ class _MobileBottomSheetEditLinkWidgetState
ViewPB? currentView;
bool showErrorText = false;
bool showRemoveLink = false;
String title = LocaleKeys.editor_editLink.tr();
AppFlowyThemeData get theme => AppFlowyTheme.of(context);
@@ -74,7 +73,6 @@ class _MobileBottomSheetEditLinkWidgetState
)..searchRecentViews();
if (linkInfo.link.isEmpty) {
isShowingSearchResult = true;
title = LocaleKeys.toolbar_addLink.tr();
} else {
showRemoveLink = true;
textFocusNode.requestFocus();
@@ -106,7 +104,7 @@ class _MobileBottomSheetEditLinkWidgetState
child: Column(
children: [
BottomSheetHeader(
title: title,
title: LocaleKeys.editor_editLink.tr(),
onClose: () => context.pop(),
confirmButton: FlowyTextButton(
LocaleKeys.button_done.tr(),
@@ -165,7 +163,6 @@ class _MobileBottomSheetEditLinkWidgetState
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkNameHint.tr(),
contentPadding: EdgeInsets.all(14),
radius: 12,
context,
),
),
@@ -189,11 +186,6 @@ class _MobileBottomSheetEditLinkWidgetState
autofocus: true,
context: context,
contentPadding: EdgeInsets.all(14),
textStyle: TextStyle(
fontSize: 16,
height: 20 / 16,
fontWeight: FontWeight.w400,
),
),
VSpace(6),
searchTextField.buildResultContainer(
@@ -275,7 +267,6 @@ class _MobileBottomSheetEditLinkWidgetState
height: 48,
decoration: buildBorderDecoration(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showSearchResult,
child: Padding(
padding: EdgeInsets.all(12),
@@ -300,7 +291,6 @@ class _MobileBottomSheetEditLinkWidgetState
Widget buildRemoveLink() {
if (!showRemoveLink) return SizedBox.shrink();
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
widget.onRemoveLink(linkInfo);
context.pop();
@@ -203,7 +203,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
final userProfile = context.read<MobileViewPageBloc>().state.userProfilePB;
// the publish feature is only available for AppFlowy Cloud
if (userProfile == null ||
userProfile.workspaceType != WorkspaceTypePB.ServerW) {
userProfile.workspaceAuthType != AuthTypePB.Server) {
return [];
}
@@ -1,6 +1,5 @@
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -236,7 +235,6 @@ class BottomSheetHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: SizedBox(
@@ -267,11 +265,11 @@ class BottomSheetHeader extends StatelessWidget {
Align(
child: Container(
constraints: const BoxConstraints(maxWidth: 250),
child: Text(
child: FlowyText(
title,
style: theme.textStyle.heading4.prominent(
color: theme.textColorScheme.primary,
),
fontSize: 17.0,
fontWeight: FontWeight.w500,
overflow: TextOverflow.ellipsis,
),
),
),
@@ -194,7 +194,7 @@ class _MobileWorkspace extends StatelessWidget {
context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.openWorkspace(
workspace.workspaceId,
workspace.workspaceType,
workspace.workspaceAuthType,
),
);
},
@@ -85,8 +85,7 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
PersonalInfoSettingGroup(
userProfile: userProfile,
),
if (state.userProfile.userAuthType == AuthTypePB.Server)
const WorkspaceSettingGroup(),
const WorkspaceSettingGroup(),
const AppearanceSettingGroup(),
const LanguageSettingGroup(),
if (Env.enableCustomCloud) const CloudSettingGroup(),
@@ -48,7 +48,7 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
text: LocaleKeys.settings_popupMenuItem_settings.tr(),
),
// only show the member items in cloud mode
if (userProfile.workspaceType == WorkspaceTypePB.ServerW) ...[
if (userProfile.workspaceAuthType == AuthTypePB.Server) ...[
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.members,
@@ -200,9 +200,7 @@ class MobileViewPage extends StatelessWidget {
const WidgetSpan(child: HSpace(8.0)),
],
TextSpan(
text: name.orDefault(
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
),
text: name,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16.0,
fontWeight: FontWeight.w600,
@@ -164,7 +164,7 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
return const MobileRecentSpace();
case MobileSpaceTabType.spaces:
final showAIFloatingButton =
widget.userProfile.workspaceType == WorkspaceTypePB.ServerW;
widget.userProfile.workspaceAuthType == AuthTypePB.Server;
return Stack(
children: [
MobileHomeSpace(userProfile: widget.userProfile),
@@ -123,7 +123,7 @@ class _CreateWorkspaceButton extends StatelessWidget {
context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.createWorkspace(
name,
WorkspaceTypePB.ServerW,
AuthTypePB.Server,
),
);
},
@@ -6,10 +6,10 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
import 'package:appflowy/mobile/presentation/widgets/navigation_bar_button.dart';
import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart';
import 'package:appflowy/shared/red_dot.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/presentation/notifications/number_red_dot.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -175,28 +175,23 @@ class _NotificationNavigationBarItemIcon extends StatelessWidget {
final hasUnreads = state.reminders.any(
(reminder) => !reminder.isRead,
);
return SizedBox(
width: 40,
height: 40,
child: Stack(
children: [
Center(
child: isActive
? const FlowySvg(
FlowySvgs.m_home_active_notification_m,
blendMode: null,
)
: const FlowySvg(
FlowySvgs.m_home_notification_m,
),
return Stack(
children: [
isActive
? const FlowySvg(
FlowySvgs.m_home_active_notification_m,
blendMode: null,
)
: const FlowySvg(
FlowySvgs.m_home_notification_m,
),
if (hasUnreads)
const Positioned(
top: 2,
right: 4,
child: NotificationRedDot(),
),
if (hasUnreads)
const Align(
alignment: Alignment.topRight,
child: NumberedRedDot.mobile(),
),
],
),
],
);
},
),
@@ -126,6 +126,7 @@ class _NotificationScreenContent extends StatelessWidget {
onAction: _onAction,
onReadChanged: _onReadChanged,
actionBar: InboxActionBar(
hasUnreads: state.hasUnreads,
showUnreadsOnly: filterState.showUnreadsOnly,
),
),
@@ -101,7 +101,9 @@ class _InnerNotificationItem extends StatelessWidget {
NotificationCheckIcon(
isSelected: mSelectedNotificationIds.value.contains(reminder.id),
),
const HSpace(12.0),
const HSpace(3.0),
!reminder.isRead ? const UnreadRedDot() : const HSpace(6.0),
const HSpace(3.0),
NotificationIcon(reminder: reminder),
const HSpace(12.0),
Expanded(
@@ -48,19 +48,14 @@ class NotificationItem extends StatelessWidget {
return const SizedBox.shrink();
}
final child = GestureDetector(
onLongPress: () {
context.onMoreAction();
},
child: Padding(
padding: const EdgeInsets.all(8),
child: _SlidableNotificationItem(
final child = Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: _SlidableNotificationItem(
tabType: tabType,
reminder: reminder,
child: _InnerNotificationItem(
tabType: tabType,
reminder: reminder,
child: _InnerNotificationItem(
tabType: tabType,
reminder: reminder,
),
),
),
);
@@ -107,11 +102,14 @@ class _InnerNotificationItem extends StatelessWidget {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const HSpace(10.0),
const HSpace(8.0),
!reminder.isRead ? const UnreadRedDot() : const HSpace(6.0),
const HSpace(4.0),
NotificationIcon(reminder: reminder),
const HSpace(12.0),
Expanded(child: NotificationContent(reminder: reminder)),
const HSpace(6.0),
Expanded(
child: NotificationContent(reminder: reminder),
),
],
);
}
@@ -10,11 +10,11 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
const _kNotificationIconHeight = 36.0;
@@ -22,15 +22,12 @@ class NotificationIcon extends StatelessWidget {
const NotificationIcon({
super.key,
required this.reminder,
this.atSize = 12,
});
final ReminderPB reminder;
final double atSize;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SizedBox(
width: 42,
height: 36,
@@ -48,13 +45,13 @@ class NotificationIcon extends StatelessWidget {
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.fillColorScheme.primary,
color: Theme.of(context).cardColor,
),
child: Center(
child: FlowySvg(
FlowySvgs.notification_icon_at_s,
size: Size.square(atSize),
color: theme.iconColorScheme.primary,
child: FlowyText(
'@',
fontSize: 12,
figmaLineHeight: 14,
),
),
),
@@ -91,15 +88,14 @@ class UnreadRedDot extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SizedBox(
return const SizedBox(
height: _kNotificationIconHeight,
child: Center(
child: SizedBox.square(
dimension: 7.0,
dimension: 6.0,
child: DecoratedBox(
decoration: ShapeDecoration(
color: theme.borderColorScheme.errorThick,
color: Color(0xFFFF6331),
shape: OvalBorder(),
),
),
@@ -122,8 +118,6 @@ class NotificationContent extends StatefulWidget {
}
class _NotificationContentState extends State<NotificationContent> {
AppFlowyThemeData get theme => AppFlowyTheme.of(context);
@override
void didUpdateWidget(covariant NotificationContent oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -147,11 +141,14 @@ class _NotificationContentState extends State<NotificationContent> {
mainAxisSize: MainAxisSize.min,
children: [
// title & time
_buildHeader(state.scheduledAt, !widget.reminder.isRead),
_buildHeader(state.createdAt, !widget.reminder.isRead),
// page name
_buildPageName(context, state.isLocked, state.pageTitle),
// content
_buildContent(view, nodes: state.nodes),
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: _buildContent(view, nodes: state.nodes),
),
],
);
},
@@ -178,8 +175,6 @@ class _NotificationContentState extends State<NotificationContent> {
fontSize: 14,
figmaLineHeight: 22,
color: context.notificationItemTextColor,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
);
}
@@ -196,7 +191,6 @@ class _NotificationContentState extends State<NotificationContent> {
LocaleKeys.settings_notifications_titles_reminder.tr(),
fontSize: 14,
figmaLineHeight: 20,
color: theme.textColorScheme.primary,
),
Spacer(),
if (createAt.isNotEmpty)
@@ -204,7 +198,7 @@ class _NotificationContentState extends State<NotificationContent> {
createAt,
fontSize: 12,
figmaLineHeight: 18,
color: theme.textColorScheme.secondary,
color: context.notificationItemTextColor,
),
if (unread) ...[
HSpace(4),
@@ -231,25 +225,19 @@ class _NotificationContentState extends State<NotificationContent> {
LocaleKeys.notificationHub_mentionedYou.tr(),
fontSize: 12,
figmaLineHeight: 18,
color: theme.textColorScheme.secondary,
color: context.notificationItemTextColor,
),
const NotificationEllipse(),
if (isLocked)
Padding(
padding: EdgeInsets.only(right: 5),
child: FlowySvg(
FlowySvgs.notification_lock_s,
color: theme.iconColorScheme.secondary,
),
),
Flexible(
child: FlowyText.regular(
pageTitle,
fontSize: 12,
figmaLineHeight: 18,
color: theme.textColorScheme.secondary,
overflow: TextOverflow.ellipsis,
child: FlowySvg(FlowySvgs.notification_lock_s),
),
FlowyText.regular(
pageTitle,
fontSize: 12,
figmaLineHeight: 18,
color: context.notificationItemTextColor,
),
],
),
@@ -266,7 +254,7 @@ class NotificationEllipse extends StatelessWidget {
return Container(
width: 2.50,
height: 2.50,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
margin: const EdgeInsets.symmetric(horizontal: 5.0),
decoration: ShapeDecoration(
color: context.notificationItemTextColor,
shape: const OvalBorder(),
@@ -322,7 +310,11 @@ class NotificationDocumentContent extends StatelessWidget {
styleCustomizer: styleCustomizer,
// the editor is not editable in the chat
editable: false,
customPadding: (node) => EdgeInsets.zero,
customHeadingPadding: UniversalPlatform.isDesktop
? EdgeInsets.zero
: EdgeInsets.symmetric(
vertical: EditorStyleCustomizer.nodeHorizontalPadding,
),
);
return IgnorePointer(
@@ -76,43 +76,41 @@ enum NotificationPaneActionType {
topLeft: Radius.circular(10),
bottomLeft: Radius.circular(10),
),
onPressed: (context) => context.onMoreAction(),
onPressed: (context) {
final reminderBloc = context.read<ReminderBloc>();
final notificationReminderBloc =
context.read<NotificationReminderBloc>();
showMobileBottomSheet(
context,
showDragHandle: true,
showDivider: false,
useRootNavigator: true,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (_) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: reminderBloc),
BlocProvider.value(value: notificationReminderBloc),
],
child: _NotificationMoreActions(
onClickMultipleChoice: () {
Future.delayed(const Duration(milliseconds: 250), () {
bottomNavigationBarType.value =
BottomNavigationBarActionType
.notificationMultiSelect;
});
},
),
);
},
);
},
);
}
}
}
extension NotificationPaneActionExtension on BuildContext {
void onMoreAction() {
final reminderBloc = read<ReminderBloc>();
final notificationReminderBloc = read<NotificationReminderBloc>();
showMobileBottomSheet(
this,
showDragHandle: true,
showDivider: false,
useRootNavigator: true,
backgroundColor: Theme.of(this).colorScheme.surface,
builder: (_) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: reminderBloc),
BlocProvider.value(value: notificationReminderBloc),
],
child: _NotificationMoreActions(
onClickMultipleChoice: () {
Future.delayed(const Duration(milliseconds: 250), () {
bottomNavigationBarType.value =
BottomNavigationBarActionType.notificationMultiSelect;
});
},
),
);
},
);
}
}
class _NotificationMoreActions extends StatelessWidget {
const _NotificationMoreActions({
required this.onClickMultipleChoice,
@@ -1,3 +1,4 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
@@ -22,18 +23,12 @@ class MobileSearchResultCell extends StatelessWidget {
final theme = AppFlowyTheme.of(context),
textColor = theme.textColorScheme.primary;
final commandPaletteState = context.read<CommandPaletteBloc>().state;
final displayName = item.displayName.isEmpty
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
: item.displayName;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox.square(
dimension: 24,
child: Center(child: buildIcon(theme)),
),
buildIcon(),
HSpace(12),
Flexible(
child: Column(
@@ -43,7 +38,7 @@ class MobileSearchResultCell extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: buildHighLightSpan(
content: displayName,
content: item.displayName,
normal: theme.textStyle.heading4.standard(color: textColor),
highlight: theme.textStyle.heading4
.standard(color: textColor)
@@ -53,7 +48,7 @@ class MobileSearchResultCell extends StatelessWidget {
),
),
buildPath(commandPaletteState, theme),
...buildSummary(theme),
buildSummary(theme),
],
),
),
@@ -62,55 +57,74 @@ class MobileSearchResultCell extends StatelessWidget {
);
}
Widget buildIcon(AppFlowyThemeData theme) {
Widget buildIcon() {
final icon = item.icon;
if (icon.ty == ResultIconTypePB.Emoji) {
return icon.getIcon(size: 20) ?? SizedBox.shrink();
return icon.getIcon(size: 16.0, lineHeight: 20 / 16) ?? SizedBox.shrink();
} else {
return icon.getIcon(size: 20, iconColor: theme.iconColorScheme.primary) ??
SizedBox.shrink();
return icon.getIcon(size: 20) ?? SizedBox.shrink();
}
}
Widget buildPath(CommandPaletteState state, AppFlowyThemeData theme) {
return BlocProvider(
create: (context) => ViewAncestorBloc(item.id),
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
builder: (context, state) {
final ancestors = state.ancestor.ancestors;
List<String> displayPath = ancestors.map((e) => e.name).toList();
if (ancestors.length > 2) {
displayPath = [ancestors.first.name, '...', ancestors.last.name];
}
return Text(
displayPath.join(' / '),
bool isInTrash = false;
for (final view in state.trash) {
if (view.id == item.id) {
isInTrash = true;
break;
}
}
if (isInTrash) {
return Row(
children: [
const FlowySvg(FlowySvgs.trash_s, size: Size.square(20)),
const HSpace(4.0),
Text(
'${LocaleKeys.trash_text.tr()} / ${item.displayName}',
style: theme.textStyle.body
.standard(color: theme.textColorScheme.secondary),
);
},
),
);
),
],
);
} else {
return BlocProvider(
create: (context) => ViewAncestorBloc(item.id),
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
builder: (context, state) {
final ancestors = state.ancestor.ancestors;
List<String> displayPath = ancestors.map((e) => e.name).toList();
if (ancestors.length > 2) {
displayPath = [ancestors.first.name, '...', ancestors.last.name];
}
return Text(
displayPath.join(' / '),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.secondary),
);
},
),
);
}
}
List<Widget> buildSummary(AppFlowyThemeData theme) {
if (item.content.isEmpty) return [];
return [
VSpace(theme.spacing.m),
RichText(
maxLines: 3,
overflow: TextOverflow.ellipsis,
text: buildHighLightSpan(
content: item.content,
normal: theme.textStyle.heading4
.standard(color: theme.textColorScheme.secondary),
highlight: theme.textStyle.heading4
.standard(color: theme.textColorScheme.primary)
.copyWith(
backgroundColor: theme.fillColorScheme.themeSelect,
),
),
Widget buildSummary(AppFlowyThemeData theme) {
if (item.content.isEmpty) {
return const SizedBox.shrink();
}
return RichText(
maxLines: 3,
overflow: TextOverflow.ellipsis,
text: buildHighLightSpan(
content: item.content,
normal: theme.textStyle.heading4
.standard(color: theme.textColorScheme.secondary),
highlight: theme.textStyle.heading4
.standard(color: theme.textColorScheme.primary)
.copyWith(
backgroundColor: theme.fillColorScheme.themeSelect,
),
),
];
);
}
TextSpan buildHighLightSpan({
@@ -61,11 +61,16 @@ class MobileSearchScreen extends StatelessWidget {
),
),
);
return Provider.value(
value: userProfile,
child: MobileSearchPage(
userProfile: userProfile,
workspaceLatestPB: latest,
return Scaffold(
body: SafeArea(
bottom: false,
child: Provider.value(
value: userProfile,
child: MobileSearchPage(
userProfile: userProfile,
workspaceLatestPB: latest,
),
),
),
);
},
@@ -73,7 +78,7 @@ class MobileSearchScreen extends StatelessWidget {
}
}
class MobileSearchPage extends StatefulWidget {
class MobileSearchPage extends StatelessWidget {
const MobileSearchPage({
super.key,
required this.userProfile,
@@ -83,62 +88,31 @@ class MobileSearchPage extends StatefulWidget {
final UserProfilePB userProfile;
final WorkspaceLatestPB workspaceLatestPB;
@override
State<MobileSearchPage> createState() => _MobileSearchPageState();
}
class _MobileSearchPageState extends State<MobileSearchPage> {
bool get enableShowAISearch =>
widget.userProfile.workspaceType == WorkspaceTypePB.ServerW;
final focusNode = FocusNode();
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
userProfile.workspaceAuthType == AuthTypePB.Server;
@override
Widget build(BuildContext context) {
return BlocBuilder<CommandPaletteBloc, CommandPaletteState>(
builder: (context, state) {
return SafeArea(
child: Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MobileSearchTextfield(
focusNode: focusNode,
hintText: enableShowAISearch
? LocaleKeys.search_searchOrAskAI.tr()
: LocaleKeys.search_label.tr(),
query: state.query ?? '',
onChanged: (value) =>
context.read<CommandPaletteBloc>().add(
CommandPaletteEvent.searchChanged(search: value),
),
),
if (enableShowAISearch)
MobileSearchAskAiEntrance(query: state.query),
Flexible(
child: NotificationListener(
child: MobileSearchResult(),
onNotification: (t) {
if (t is ScrollUpdateNotification) {
if (focusNode.hasFocus) {
focusNode.unfocus();
}
}
return true;
},
),
),
],
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MobileSearchTextfield(
hintText: enableShowAISearch
? LocaleKeys.search_searchOrAskAI.tr()
: LocaleKeys.search_label.tr(),
query: state.query ?? '',
onChanged: (value) => context
.read<CommandPaletteBloc>()
.add(CommandPaletteEvent.searchChanged(search: value)),
),
),
if (enableShowAISearch)
MobileSearchAskAiEntrance(query: state.query),
Flexible(child: SafeArea(child: MobileSearchResult())),
],
),
);
},
@@ -6,7 +6,6 @@ import 'package:appflowy/workspace/application/command_palette/command_palette_b
import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
@@ -37,18 +36,13 @@ class MobileSearchRecentList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final commandPaletteState = context.read<CommandPaletteBloc>().state;
final trashIdSet = commandPaletteState.trash.map((e) => e.id).toSet();
return BlocProvider(
create: (context) =>
RecentViewsBloc()..add(const RecentViewsEvent.initial()),
child: BlocBuilder<RecentViewsBloc, RecentViewsState>(
builder: (context, state) {
final List<ViewPB> recentViews = state.views
.map((e) => e.item)
.where((e) => !trashIdSet.contains(e.id))
.toList();
final List<ViewPB> recentViews =
state.views.map((e) => e.item).toSet().toList();
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -121,7 +115,7 @@ class _MobileSearchResultListState extends State<MobileSearchResultList> {
children: [
const VSpace(16),
Text(
LocaleKeys.commandPalette_bestMatches.tr(),
LocaleKeys.search_bestMatch.tr(),
style: theme.textStyle.body
.enhanced(color: theme.textColorScheme.secondary),
),
@@ -136,10 +130,6 @@ class _MobileSearchResultListState extends State<MobileSearchResultList> {
.fold((s) => s, (s) => null);
if (view != null && context.mounted) {
await _goToView(context, view);
} else {
Log.error(
'tapping search result, view not found: ${item.id}',
);
}
},
child: MobileSearchResultCell(
@@ -171,13 +161,12 @@ class _MobileSearchResultListState extends State<MobileSearchResultList> {
),
const VSpace(12),
Text(
LocaleKeys.search_noResultForSearching.tr(),
LocaleKeys.search_noResultForSearching.tr(args: [query]),
style: theme.textStyle.body.enhanced(color: textColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
textAlign: TextAlign.center,
LocaleKeys.search_noResultForSearchingHint.tr(),
style: theme.textStyle.caption.standard(color: textColor),
),
@@ -11,13 +11,11 @@ class MobileSearchTextfield extends StatefulWidget {
this.onChanged,
required this.hintText,
required this.query,
required this.focusNode,
});
final String hintText;
final String query;
final ValueChanged<String>? onChanged;
final FocusNode focusNode;
@override
State<MobileSearchTextfield> createState() => _MobileSearchTextfieldState();
@@ -25,15 +23,18 @@ class MobileSearchTextfield extends StatefulWidget {
class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
late final TextEditingController controller;
late final FocusNode focusNode;
final ValueNotifier<bool> hasFocusValueNotifier = ValueNotifier(true);
FocusNode get focusNode => widget.focusNode;
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.query);
focusNode.addListener(onFocusChanged);
focusNode = FocusNode();
focusNode.addListener(() {
if (!mounted) return;
hasFocusValueNotifier.value = focusNode.hasFocus;
});
controller.addListener(() {
if (!mounted) return;
widget.onChanged?.call(controller.text);
@@ -45,7 +46,7 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
@override
void dispose() {
controller.dispose();
focusNode.removeListener(onFocusChanged);
focusNode.dispose();
hasFocusValueNotifier.dispose();
bottomNavigationBarItemType.removeListener(onBackOrLeave);
super.dispose();
@@ -55,7 +56,7 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SizedBox(
height: 42,
height: 40,
child: ValueListenableBuilder(
valueListenable: controller,
builder: (context, _, __) {
@@ -84,17 +85,12 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
if (!hasFocus || !hasText) return SizedBox.shrink();
return GestureDetector(
onTap: () => focusNode.unfocus(),
behavior: HitTestBehavior.opaque,
child: Container(
height: 42,
child: Padding(
padding: EdgeInsets.only(left: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
LocaleKeys.button_cancel.tr(),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.action),
),
child: Text(
LocaleKeys.button_cancel.tr(),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.action),
),
),
);
@@ -161,17 +157,11 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
);
}
void onFocusChanged() {
if (!mounted) return;
hasFocusValueNotifier.value = focusNode.hasFocus;
}
void onBackOrLeave() {
final label = bottomNavigationBarItemType.value;
if (label == BottomNavigationBarItemType.search.label) {
focusNode.requestFocus();
} else {
focusNode.unfocus();
controller.clear();
}
}
@@ -2,7 +2,6 @@ import 'dart:math';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -329,7 +328,6 @@ class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
}
Widget _buildNoResultsWidget(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
@@ -352,10 +350,7 @@ class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
child: Center(
child: Text(
LocaleKeys.inlineActions_noResults.tr(),
style: TextStyle(
fontSize: 18.0,
color: theme.textColorScheme.primary,
),
style: TextStyle(fontSize: 18.0, color: Color(0x801F2225)),
textAlign: TextAlign.center,
),
),
@@ -1,9 +1,9 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/startup/tasks/device_info_task.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/mobile_feature_flag_screen.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@@ -22,23 +22,23 @@ class AboutSettingGroup extends StatelessWidget {
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_mobile_privacyPolicy.tr(),
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.com/privacy'),
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_termsAndConditions.tr(),
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.com/terms'),
),
if (kDebugMode)
MobileSettingItem(
name: 'Feature Flags',
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () {
context.push(FeatureFlagScreen.routeName);
@@ -46,10 +46,9 @@ class AboutSettingGroup extends StatelessWidget {
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_version.tr(),
trailing: MobileSettingTrailing(
text:
'${ApplicationInfo.applicationVersion} (${ApplicationInfo.buildNumber})',
showArrow: false,
trailing: FlowyText(
'${ApplicationInfo.applicationVersion} (${ApplicationInfo.buildNumber})',
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -2,11 +2,11 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart';
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -23,6 +23,7 @@ class AiSettingsGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocProvider(
create: (context) => SettingsAIBloc(
userProfile,
@@ -35,8 +36,21 @@ class AiSettingsGroup extends StatelessWidget {
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
trailing: MobileSettingTrailing(
text: state.availableModels?.selectedModel.name ?? "",
trailing: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: FlowyText(
state.availableModels?.selectedModel.name ?? "",
color: theme.colorScheme.onSurface,
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.chevron_right),
],
),
),
onTap: () => _onLLMModelTypeTap(context, state),
),
@@ -1,10 +1,10 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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';
@@ -17,12 +17,20 @@ class RTLSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textDirection =
context.watch<AppearanceSettingsCubit>().state.textDirection;
return MobileSettingItem(
name: LocaleKeys.settings_appearance_textDirection_label.tr(),
trailing: MobileSettingTrailing(
text: _textDirectionLabelText(textDirection),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
_textDirectionLabelText(textDirection),
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
),
onTap: () {
showMobileBottomSheet(
@@ -1,11 +1,11 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/app_window_size_manager.dart';
import 'package:appflowy/workspace/presentation/home/hotkeys.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:scaled_app/scaled_app.dart';
@@ -42,10 +42,18 @@ class _DisplaySizeSettingState extends State<DisplaySizeSetting> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return MobileSettingItem(
name: LocaleKeys.settings_appearance_displaySize.tr(),
trailing: MobileSettingTrailing(
text: scaleFactor.toStringAsFixed(1),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
scaleFactor.toStringAsFixed(2),
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
),
onTap: () {
showMobileBottomSheet(
@@ -1,11 +1,11 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/util/theme_mode_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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';
@@ -18,11 +18,19 @@ class ThemeSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final themeMode = context.watch<AppearanceSettingsCubit>().state.themeMode;
return MobileSettingItem(
name: LocaleKeys.settings_appearance_themeMode_label.tr(),
trailing: MobileSettingTrailing(
text: themeMode.labelText,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
themeMode.labelText,
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
),
onTap: () {
showMobileBottomSheet(
@@ -1,11 +1,11 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
class CloudSettingGroup extends StatelessWidget {
const CloudSettingGroup({
@@ -15,23 +15,19 @@ class CloudSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: getAuthenticatorType(),
builder: (context, snapshot) {
final cloudType = snapshot.data ?? AuthenticatorType.appflowyCloud;
final name = titleFromCloudType(cloudType);
return MobileSettingGroup(
groupTitle: 'Cloud settings',
settingItemList: [
MobileSettingItem(
name: 'Cloud server',
trailing: MobileSettingTrailing(
text: name,
),
onTap: () => context.push(AppFlowyCloudPage.routeName),
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) => MobileSettingGroup(
groupTitle: LocaleKeys.settings_menu_cloudSettings.tr(),
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_menu_cloudAppFlowy.tr(),
trailing: const Icon(
Icons.chevron_right,
),
],
);
},
onTap: () => context.push(AppFlowyCloudPage.routeName),
),
],
),
);
}
}
@@ -2,11 +2,11 @@ import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/util/font_family_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -20,12 +20,21 @@ class FontSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final selectedFont = context.watch<AppearanceSettingsCubit>().state.font;
final name = selectedFont.fontFamilyDisplayName;
return MobileSettingItem(
name: LocaleKeys.settings_appearance_fontFamily_label.tr(),
trailing: MobileSettingTrailing(
text: name,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
lineHeight: 1.0,
name,
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
),
onTap: () async {
final newFont = await context.push<String>(FontPickerScreen.routeName);
@@ -1,9 +1,9 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/language/language_picker_screen.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/language.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -28,13 +28,22 @@ class _LanguageSettingGroupState extends State<LanguageSettingGroup> {
return state.locale;
},
builder: (context, locale) {
final theme = Theme.of(context);
return MobileSettingGroup(
groupTitle: LocaleKeys.settings_menu_language.tr(),
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_menu_language.tr(),
trailing: MobileSettingTrailing(
text: languageFromLocale(locale),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
lineHeight: 1.0,
languageFromLocale(locale),
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
),
onTap: () async {
final newLocale =
@@ -1,5 +1,4 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -65,11 +64,12 @@ class _EditUsernameBottomSheetState extends State<EditUsernameBottomSheet> {
),
),
const VSpace(16),
AFFilledTextButton.primary(
text: LocaleKeys.button_update.tr(),
onTap: submitUserName,
size: AFButtonSize.l,
alignment: Alignment.center,
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: submitUserName,
child: Text(LocaleKeys.button_update.tr()),
),
),
],
);
@@ -1,16 +1,10 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/password/password_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/pages/account/password/change_password.dart';
import 'package:appflowy/workspace/presentation/settings/pages/account/password/setup_password.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -27,19 +21,11 @@ class PersonalInfoSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<SettingsUserViewBloc>(
create: (context) => getIt<SettingsUserViewBloc>(
param1: userProfile,
)..add(const SettingsUserEvent.initial()),
),
BlocProvider(
create: (context) => PasswordBloc(userProfile)
..add(PasswordEvent.init())
..add(PasswordEvent.checkHasPassword()),
),
],
final theme = Theme.of(context);
return BlocProvider<SettingsUserViewBloc>(
create: (context) => getIt<SettingsUserViewBloc>(
param1: userProfile,
)..add(const SettingsUserEvent.initial()),
child: BlocSelector<SettingsUserViewBloc, SettingsUserState, String>(
selector: (state) => state.userProfile.name,
builder: (context, userName) {
@@ -47,10 +33,16 @@ class PersonalInfoSettingGroup extends StatelessWidget {
groupTitle: LocaleKeys.settings_accountPage_title.tr(),
settingItemList: [
MobileSettingItem(
name: 'User name',
trailing: MobileSettingTrailing(
text: userName,
),
name: userName,
subtitle: isAuthEnabled && userProfile.email.isNotEmpty
? Text(
userProfile.email,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
)
: null,
trailing: const Icon(Icons.chevron_right),
onTap: () {
showMobileBottomSheet(
context,
@@ -72,111 +64,10 @@ class PersonalInfoSettingGroup extends StatelessWidget {
);
},
),
...userProfile.userAuthType == AuthTypePB.Server
? [
_buildEmailItem(context, userProfile),
_buildPasswordItem(context, userProfile),
]
: [
_buildLoginItem(context, userProfile),
],
],
);
},
),
);
}
Widget _buildEmailItem(BuildContext context, UserProfilePB userProfile) {
final theme = AppFlowyTheme.of(context);
return MobileSettingItem(
name: LocaleKeys.settings_accountPage_email_title.tr(),
trailing: Text(
userProfile.email,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
),
);
}
Widget _buildPasswordItem(BuildContext context, UserProfilePB userProfile) {
return BlocBuilder<PasswordBloc, PasswordState>(
builder: (context, state) {
final hasPassword = state.hasPassword;
final title = hasPassword
? LocaleKeys.newSettings_myAccount_password_changePassword.tr()
: LocaleKeys.newSettings_myAccount_password_setupPassword.tr();
final passwordBloc = context.read<PasswordBloc>();
return MobileSettingItem(
name: LocaleKeys.newSettings_myAccount_password_title.tr(),
trailing: MobileSettingTrailing(
text: '',
),
onTap: () {
showMobileBottomSheet(
context,
showHeader: true,
title: title,
showCloseButton: true,
showDragHandle: true,
showDivider: false,
padding: const EdgeInsets.symmetric(horizontal: 16),
builder: (_) {
Widget child;
if (hasPassword) {
child = ChangePasswordDialogContent(
userProfile: userProfile,
showTitle: false,
showCloseAndSaveButton: false,
showSaveButton: true,
padding: EdgeInsets.zero,
);
} else {
child = SetupPasswordDialogContent(
userProfile: userProfile,
showTitle: false,
showCloseAndSaveButton: false,
showSaveButton: true,
padding: EdgeInsets.zero,
);
}
return BlocProvider.value(
value: passwordBloc,
child: child,
);
},
);
},
);
},
);
}
Widget _buildLoginItem(BuildContext context, UserProfilePB userProfile) {
final theme = AppFlowyTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
LocaleKeys.signIn_youAreInLocalMode.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
VSpace(theme.spacing.m),
AFOutlinedTextButton.normal(
text: LocaleKeys.signIn_loginToAppFlowyCloud.tr(),
size: AFButtonSize.l,
alignment: Alignment.center,
onTap: () async {
// logout and restart the app
await getIt<AuthService>().signOut();
await runAppFlowy();
},
),
VSpace(theme.spacing.m),
],
);
}
}
@@ -87,7 +87,7 @@ class _SelfHostUrlBottomSheetState extends State<SelfHostUrlBottomSheet> {
case SelfHostUrlBottomSheetType.shareDomain:
await useBaseWebDomain(url);
case SelfHostUrlBottomSheetType.cloudURL:
await useSelfHostedAppFlowyCloud(url);
await useSelfHostedAppFlowyCloudWithURL(url);
}
await runAppFlowy();
},
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/shared/appflowy_cache_manager.dart';
import 'package:appflowy/startup/startup.dart';
@@ -31,15 +30,15 @@ class SupportSettingGroup extends StatelessWidget {
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_mobile_joinDiscord.tr(),
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://discord.gg/JucBXeU2FE'),
),
MobileSettingItem(
name: LocaleKeys.workspace_errorActions_reportIssue.tr(),
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () {
showMobileBottomSheet(
@@ -58,8 +57,8 @@ class SupportSettingGroup extends StatelessWidget {
),
MobileSettingItem(
name: LocaleKeys.settings_files_clearCache.tr(),
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () async {
await showFlowyMobileConfirmDialog(
@@ -9,7 +9,6 @@ import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widget
import 'package:appflowy/workspace/presentation/settings/pages/account/account_deletion.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -27,12 +26,11 @@ class UserSessionSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
children: [
// third party sign in buttons
if (showThirdPartyLogin) _buildThirdPartySignInButtons(context),
VSpace(theme.spacing.xxl),
const VSpace(8.0),
// logout button
MobileLogoutButton(
@@ -42,20 +40,14 @@ class UserSessionSettingGroup extends StatelessWidget {
// delete account button
// only show the delete account button in cloud mode
if (userProfile.userAuthType == AuthTypePB.Server) ...[
VSpace(theme.spacing.xxl),
AFOutlinedTextButton.destructive(
alignment: Alignment.center,
if (userProfile.workspaceAuthType == AuthTypePB.Server) ...[
const VSpace(16.0),
MobileLogoutButton(
text: LocaleKeys.button_deleteAccount.tr(),
textStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.error,
),
onTap: () => _showDeleteAccountDialog(context),
size: AFButtonSize.l,
textColor: Theme.of(context).colorScheme.error,
onPressed: () => _showDeleteAccountDialog(context),
),
],
VSpace(theme.spacing.xxl),
],
);
}
@@ -1,4 +1,3 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -16,22 +15,16 @@ class MobileSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
VSpace(theme.spacing.s),
Text(
const VSpace(4.0),
FlowyText.semibold(
groupTitle,
style: theme.textStyle.heading4.enhanced(
color: theme.textColorScheme.primary,
),
),
VSpace(theme.spacing.s),
const VSpace(4.0),
...settingItemList,
showDivider
? AFDivider(spacing: theme.spacing.m)
: const SizedBox.shrink(),
showDivider ? const Divider() : const SizedBox.shrink(),
],
);
}
@@ -1,4 +1,3 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -27,18 +26,17 @@ class MobileSettingItem extends StatelessWidget {
return Padding(
padding: padding,
child: ListTile(
title: title ?? _buildDefaultTitle(context, name),
title: title ?? _buildDefaultTitle(name),
subtitle: subtitle,
trailing: trailing,
onTap: onTap,
visualDensity: VisualDensity.compact,
contentPadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.only(left: 8.0),
),
);
}
Widget _buildDefaultTitle(BuildContext context, String? name) {
final theme = AppFlowyTheme.of(context);
Widget _buildDefaultTitle(String? name) {
return Row(
children: [
if (leadingIcon != null) ...[
@@ -46,11 +44,9 @@ class MobileSettingItem extends StatelessWidget {
const HSpace(8),
],
Expanded(
child: Text(
child: FlowyText.medium(
name ?? '',
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
fontSize: 14.0,
overflow: TextOverflow.ellipsis,
),
),
@@ -1,42 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class MobileSettingTrailing extends StatelessWidget {
const MobileSettingTrailing({
super.key,
required this.text,
this.showArrow = true,
});
final String text;
final bool showArrow;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
text,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
overflow: TextOverflow.ellipsis,
),
),
if (showArrow) ...[
const HSpace(8),
FlowySvg(
FlowySvgs.toolbar_arrow_right_m,
size: Size.square(24),
color: theme.iconColorScheme.tertiary,
),
],
],
);
}
}
@@ -1,324 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_email.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'member_list.dart';
class AddMembersScreen extends StatelessWidget {
const AddMembersScreen({
super.key,
});
static const routeName = '/add_member';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FlowyAppBar(
titleText: 'Add members',
),
body: const _InviteMemberPage(),
resizeToAvoidBottomInset: false,
);
}
}
class _InviteMemberPage extends StatefulWidget {
const _InviteMemberPage();
@override
State<_InviteMemberPage> createState() => _InviteMemberPageState();
}
class _InviteMemberPageState extends State<_InviteMemberPage> {
final emailController = TextEditingController();
late final Future<UserProfilePB?> userProfile;
bool exceededLimit = false;
@override
void initState() {
super.initState();
userProfile = UserBackendService.getCurrentUserProfile().fold(
(s) => s,
(f) => null,
);
}
@override
void dispose() {
emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder(
future: userProfile,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox.shrink();
}
if (snapshot.hasError || snapshot.data == null) {
return _buildError(context);
}
final userProfile = snapshot.data!;
return BlocProvider<WorkspaceMemberBloc>(
create: (context) => WorkspaceMemberBloc(userProfile: userProfile)
..add(const WorkspaceMemberEvent.initial())
..add(const WorkspaceMemberEvent.getInviteCode()),
child: BlocConsumer<WorkspaceMemberBloc, WorkspaceMemberState>(
listener: _onListener,
builder: (context, state) {
return Column(
children: [
if (state.myRole.isOwner) ...[
Container(
width: double.infinity,
padding: EdgeInsets.all(theme.spacing.xl),
child: const MInviteMemberByEmail(),
),
VSpace(theme.spacing.m),
],
if (state.members.isNotEmpty) ...[
const AFDivider(),
VSpace(theme.spacing.xl),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
if (state.myRole.isMember) ...[
Spacer(),
const _LeaveWorkspaceButton(),
],
const VSpace(48),
],
);
},
),
);
},
);
}
Widget _buildError(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 48.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.medium(
LocaleKeys.settings_appearance_members_workspaceMembersError.tr(),
fontSize: 18.0,
textAlign: TextAlign.center,
),
const VSpace(8.0),
FlowyText.regular(
LocaleKeys
.settings_appearance_members_workspaceMembersErrorDescription
.tr(),
fontSize: 17.0,
maxLines: 10,
textAlign: TextAlign.center,
lineHeight: 1.3,
color: Theme.of(context).hintColor,
),
],
),
),
);
}
void _onListener(BuildContext context, WorkspaceMemberState state) {
final actionResult = state.actionResult;
if (actionResult == null) {
return;
}
final actionType = actionResult.actionType;
final result = actionResult.result;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.addByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
);
},
(f) {
Log.error('add workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToAddMember.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
message: message,
);
},
);
} else if (actionType == WorkspaceMemberActionType.inviteByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
);
},
(f) {
Log.error('invite workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToInviteMember
.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
message: message,
);
},
);
} else if (actionType == WorkspaceMemberActionType.removeByEmail) {
result.fold(
(s) {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
);
},
(f) {
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
.tr(),
);
},
);
} else if (actionType == WorkspaceMemberActionType.generateInviteLink) {
result.fold(
(s) async {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_generatedLinkSuccessfully
.tr(),
);
// copy the invite link to the clipboard
final inviteLink = state.inviteLink;
if (inviteLink != null) {
await getIt<ClipboardService>().setPlainText(inviteLink);
showToastNotification(
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
},
(f) {
Log.error('generate invite link failed: $f');
showToastNotification(
type: ToastificationType.error,
message:
LocaleKeys.settings_appearance_members_generatedLinkFailed.tr(),
);
},
);
} else if (actionType == WorkspaceMemberActionType.resetInviteLink) {
result.fold(
(s) async {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_resetLinkSuccessfully
.tr(),
);
// copy the invite link to the clipboard
final inviteLink = state.inviteLink;
if (inviteLink != null) {
await getIt<ClipboardService>().setPlainText(inviteLink);
showToastNotification(
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
},
(f) {
Log.error('generate invite link failed: $f');
showToastNotification(
type: ToastificationType.error,
message:
LocaleKeys.settings_appearance_members_resetLinkFailed.tr(),
);
},
);
}
}
}
class _LeaveWorkspaceButton extends StatelessWidget {
const _LeaveWorkspaceButton();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AFOutlinedTextButton.destructive(
alignment: Alignment.center,
text: LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
onTap: () => _leaveWorkspace(context),
size: AFButtonSize.l,
),
);
}
void _leaveWorkspace(BuildContext context) {
showFlowyCupertinoConfirmDialog(
title: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(),
leftButton: FlowyText(
LocaleKeys.button_cancel.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
color: const Color(0xFF007AFF),
),
rightButton: FlowyText(
LocaleKeys.button_confirm.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w400,
color: const Color(0xFFFE0220),
),
onRightButtonPressed: (buttonContext) async {},
);
}
}
@@ -1,357 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
import 'member_list.dart';
ValueNotifier<int> mobileLeaveWorkspaceNotifier = ValueNotifier(0);
class InviteMembersScreen extends StatelessWidget {
const InviteMembersScreen({
super.key,
});
static const routeName = '/invite_member';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FlowyAppBar(
titleText: LocaleKeys.settings_appearance_members_label.tr(),
),
body: const _InviteMemberPage(),
resizeToAvoidBottomInset: false,
);
}
}
class _InviteMemberPage extends StatefulWidget {
const _InviteMemberPage();
@override
State<_InviteMemberPage> createState() => _InviteMemberPageState();
}
class _InviteMemberPageState extends State<_InviteMemberPage> {
final emailController = TextEditingController();
late final Future<UserProfilePB?> userProfile;
bool exceededLimit = false;
@override
void initState() {
super.initState();
userProfile = UserBackendService.getCurrentUserProfile().fold(
(s) => s,
(f) => null,
);
}
@override
void dispose() {
emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder(
future: userProfile,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox.shrink();
}
if (snapshot.hasError || snapshot.data == null) {
return _buildError(context);
}
final userProfile = snapshot.data!;
return BlocProvider<WorkspaceMemberBloc>(
create: (context) => WorkspaceMemberBloc(userProfile: userProfile)
..add(const WorkspaceMemberEvent.initial())
..add(const WorkspaceMemberEvent.getInviteCode()),
child: BlocConsumer<WorkspaceMemberBloc, WorkspaceMemberState>(
listener: _onListener,
builder: (context, state) {
return Column(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (state.myRole.isOwner) ...[
Padding(
padding: EdgeInsets.all(theme.spacing.xl),
child: _buildInviteMemberArea(context),
),
const VSpace(16),
],
if (state.members.isNotEmpty) ...[
const AFDivider(),
VSpace(theme.spacing.xl),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
],
),
),
if (state.myRole.isMember) const _LeaveWorkspaceButton(),
const VSpace(48),
],
);
},
),
);
},
);
}
Widget _buildInviteMemberArea(BuildContext context) {
return Column(
children: [
TextFormField(
autofocus: true,
controller: emailController,
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(),
),
),
const VSpace(16),
if (exceededLimit) ...[
FlowyText.regular(
LocaleKeys.settings_appearance_members_inviteFailedMemberLimitMobile
.tr(),
fontSize: 14.0,
maxLines: 3,
color: Theme.of(context).colorScheme.error,
),
const VSpace(16),
],
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _inviteMember(context),
child: Text(
LocaleKeys.settings_appearance_members_sendInvite.tr(),
),
),
),
],
);
}
Widget _buildError(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 48.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.medium(
LocaleKeys.settings_appearance_members_workspaceMembersError.tr(),
fontSize: 18.0,
textAlign: TextAlign.center,
),
const VSpace(8.0),
FlowyText.regular(
LocaleKeys
.settings_appearance_members_workspaceMembersErrorDescription
.tr(),
fontSize: 17.0,
maxLines: 10,
textAlign: TextAlign.center,
lineHeight: 1.3,
color: Theme.of(context).hintColor,
),
],
),
),
);
}
void _onListener(BuildContext context, WorkspaceMemberState state) {
final actionResult = state.actionResult;
if (actionResult == null) {
return;
}
final actionType = actionResult.actionType;
final result = actionResult.result;
// get keyboard height
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.addByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
Log.error('add workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToAddMember.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
bottomPadding: keyboardHeight,
message: message,
);
},
);
} else if (actionType == WorkspaceMemberActionType.inviteByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
Log.error('invite workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToInviteMember
.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
message: message,
bottomPadding: keyboardHeight,
);
},
);
} else if (actionType == WorkspaceMemberActionType.removeByEmail) {
result.fold(
(s) {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
.tr(),
bottomPadding: keyboardHeight,
);
},
);
}
}
void _inviteMember(BuildContext context) {
final email = emailController.text;
if (!isEmail(email)) {
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
);
return;
}
context
.read<WorkspaceMemberBloc>()
.add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email));
// clear the email field after inviting
emailController.clear();
}
}
class _LeaveWorkspaceButton extends StatelessWidget {
const _LeaveWorkspaceButton();
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.error,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: BorderSide(
color: Theme.of(context).colorScheme.error,
width: 0.5,
),
),
),
onPressed: () => _leaveWorkspace(context),
child: FlowyText(
LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
fontSize: 14.0,
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w500,
),
),
);
}
void _leaveWorkspace(BuildContext context) {
showFlowyCupertinoConfirmDialog(
title: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(),
leftButton: FlowyText(
LocaleKeys.button_cancel.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
color: const Color(0xFF007AFF),
),
rightButton: FlowyText(
LocaleKeys.button_confirm.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w400,
color: const Color(0xFFFE0220),
),
onRightButtonPressed: (buttonContext) async {
// try to use popUntil with a specific route name but failed
// so use pop twice as a workaround
Navigator.of(buttonContext).pop();
Navigator.of(context).pop();
Navigator.of(context).pop();
mobileLeaveWorkspaceNotifier.value =
mobileLeaveWorkspaceNotifier.value + 1;
},
);
}
}
@@ -1,25 +1,19 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/setting/workspace/add_members_screen.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_link.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:string_validator/string_validator.dart';
import 'member_list.dart';
@@ -37,26 +31,11 @@ class InviteMembersScreen extends StatelessWidget {
return Scaffold(
appBar: FlowyAppBar(
titleText: LocaleKeys.settings_appearance_members_label.tr(),
actions: [
_buildAddMemberButton(context),
],
),
body: const _InviteMemberPage(),
resizeToAvoidBottomInset: false,
);
}
Widget _buildAddMemberButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 20),
child: GestureDetector(
onTap: () {
context.push(AddMembersScreen.routeName);
},
child: FlowySvg(FlowySvgs.add_thin_s),
),
);
}
}
class _InviteMemberPage extends StatefulWidget {
@@ -88,7 +67,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder(
future: userProfile,
builder: (context, snapshot) {
@@ -103,38 +81,37 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
return BlocProvider<WorkspaceMemberBloc>(
create: (context) => WorkspaceMemberBloc(userProfile: userProfile)
..add(const WorkspaceMemberEvent.initial())
..add(const WorkspaceMemberEvent.getInviteCode()),
..add(const WorkspaceMemberEvent.initial()),
child: BlocConsumer<WorkspaceMemberBloc, WorkspaceMemberState>(
listener: _onListener,
builder: (context, state) {
return SingleChildScrollView(
child: Column(
children: [
if (state.myRole.isOwner) ...[
Container(
width: double.infinity,
padding: EdgeInsets.all(theme.spacing.xl),
child: const MInviteMemberByLink(),
),
VSpace(theme.spacing.m),
],
if (state.members.isNotEmpty) ...[
const AFDivider(),
VSpace(theme.spacing.xl),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
if (state.myRole.isMember) ...[
Spacer(),
const _LeaveWorkspaceButton(),
],
const VSpace(48),
],
),
return Column(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (state.myRole.isOwner) ...[
Padding(
padding: const EdgeInsets.all(16.0),
child: _buildInviteMemberArea(context),
),
const VSpace(16),
],
if (state.members.isNotEmpty) ...[
const VSpace(8),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
],
),
),
if (state.myRole.isMember) const _LeaveWorkspaceButton(),
const VSpace(48),
],
);
},
),
@@ -143,6 +120,41 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
);
}
Widget _buildInviteMemberArea(BuildContext context) {
return Column(
children: [
TextFormField(
autofocus: true,
controller: emailController,
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(),
),
),
const VSpace(16),
if (exceededLimit) ...[
FlowyText.regular(
LocaleKeys.settings_appearance_members_inviteFailedMemberLimitMobile
.tr(),
fontSize: 14.0,
maxLines: 3,
color: Theme.of(context).colorScheme.error,
),
const VSpace(16),
],
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _inviteMember(context),
child: Text(
LocaleKeys.settings_appearance_members_sendInvite.tr(),
),
),
),
],
);
}
Widget _buildError(BuildContext context) {
return Center(
child: Padding(
@@ -181,6 +193,9 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
final actionType = actionResult.actionType;
final result = actionResult.result;
// get keyboard height
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.addByEmail) {
result.fold(
@@ -188,6 +203,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
@@ -202,6 +218,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
});
showToastNotification(
type: ToastificationType.error,
bottomPadding: keyboardHeight,
message: message,
);
},
@@ -212,6 +229,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
@@ -225,10 +243,10 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
message: message,
bottomPadding: keyboardHeight,
);
},
);
@@ -239,6 +257,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
@@ -247,65 +266,28 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
.tr(),
);
},
);
} else if (actionType == WorkspaceMemberActionType.generateInviteLink) {
result.fold(
(s) {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_generatedLinkSuccessfully
.tr(),
);
// copy the invite link to the clipboard
final inviteLink = state.inviteLink;
if (inviteLink != null) {
getIt<ClipboardService>().setPlainText(inviteLink);
showToastNotification(
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
},
(f) {
Log.error('generate invite link failed: $f');
showToastNotification(
type: ToastificationType.error,
message:
LocaleKeys.settings_appearance_members_generatedLinkFailed.tr(),
);
},
);
} else if (actionType == WorkspaceMemberActionType.resetInviteLink) {
result.fold(
(s) {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_resetLinkSuccessfully
.tr(),
);
// copy the invite link to the clipboard
final inviteLink = state.inviteLink;
if (inviteLink != null) {
getIt<ClipboardService>().setPlainText(inviteLink);
showToastNotification(
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
},
(f) {
Log.error('generate invite link failed: $f');
showToastNotification(
type: ToastificationType.error,
message:
LocaleKeys.settings_appearance_members_resetLinkFailed.tr(),
bottomPadding: keyboardHeight,
);
},
);
}
}
void _inviteMember(BuildContext context) {
final email = emailController.text;
if (!isEmail(email)) {
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
);
return;
}
context
.read<WorkspaceMemberBloc>()
.add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email));
// clear the email field after inviting
emailController.clear();
}
}
class _LeaveWorkspaceButton extends StatelessWidget {
@@ -313,13 +295,28 @@ class _LeaveWorkspaceButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AFOutlinedTextButton.destructive(
alignment: Alignment.center,
text: LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
onTap: () => _leaveWorkspace(context),
size: AFButtonSize.l,
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.error,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: BorderSide(
color: Theme.of(context).colorScheme.error,
width: 0.5,
),
),
),
onPressed: () => _leaveWorkspace(context),
child: FlowyText(
LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
fontSize: 14.0,
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w500,
),
),
);
}
@@ -5,7 +5,6 @@ import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -28,34 +27,31 @@ class MobileMemberList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SingleChildScrollView(
child: SlidableAutoCloseBehavior(
child: SeparatedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
separatorBuilder: () => SizedBox.shrink(),
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Text(
'Joined',
style: theme.textStyle.heading4.enhanced(
color: theme.textColorScheme.primary,
),
),
),
...members.map(
(member) => _MemberItem(
member: member,
myRole: myRole,
userProfile: userProfile,
),
),
],
return SlidableAutoCloseBehavior(
child: SeparatedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
separatorBuilder: () => const FlowyDivider(
padding: EdgeInsets.symmetric(horizontal: 16.0),
),
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: FlowyText.semibold(
LocaleKeys.settings_appearance_members_label.tr(),
fontSize: 16.0,
),
),
...members.map(
(member) => _MemberItem(
member: member,
myRole: myRole,
userProfile: userProfile,
),
),
],
),
);
}
@@ -74,8 +70,8 @@ class _MemberItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final canDelete = myRole.canDelete && member.email != userProfile.email;
final textColor = member.role.isOwner ? Theme.of(context).hintColor : null;
Widget child;
@@ -83,19 +79,17 @@ class _MemberItem extends StatelessWidget {
child = Row(
children: [
Expanded(
child: Text(
child: FlowyText.medium(
member.name,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
color: textColor,
fontSize: 15.0,
),
),
Expanded(
child: Text(
child: FlowyText.medium(
member.role.description,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
color: textColor,
fontSize: 15.0,
textAlign: TextAlign.end,
),
),
@@ -105,19 +99,18 @@ class _MemberItem extends StatelessWidget {
child = Row(
children: [
Expanded(
child: Text(
child: FlowyText.medium(
member.name,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
color: textColor,
fontSize: 15.0,
overflow: TextOverflow.ellipsis,
),
),
Text(
const HSpace(36.0),
FlowyText.medium(
member.role.description,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
color: textColor,
fontSize: 15.0,
textAlign: TextAlign.end,
),
],
@@ -125,10 +118,8 @@ class _MemberItem extends StatelessWidget {
}
child = Container(
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.xl,
vertical: theme.spacing.l,
),
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: child,
);
@@ -1,10 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../widgets/widgets.dart';
@@ -17,32 +13,17 @@ class WorkspaceSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
builder: (context, state) {
final currentWorkspace = state.workspaces.firstWhereOrNull(
(e) => e.workspaceId == state.currentWorkspace?.workspaceId,
);
final memberCount = currentWorkspace?.memberCount;
String memberCountText = '';
// if the member count is greater than 0, show the member count
if (memberCount != null && memberCount > 0) {
memberCountText = memberCount.toString();
}
return MobileSettingGroup(
groupTitle: LocaleKeys.settings_appearance_members_label.tr(),
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_appearance_members_label.tr(),
trailing: MobileSettingTrailing(
text: memberCountText,
),
onTap: () {
context.push(InviteMembersScreen.routeName);
},
),
],
);
},
return MobileSettingGroup(
groupTitle: LocaleKeys.settings_appearance_members_label.tr(),
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_appearance_members_label.tr(),
trailing: const Icon(Icons.chevron_right),
onTap: () {
context.push(InviteMembersScreen.routeName);
},
),
],
);
}
}
@@ -72,7 +72,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final request = ViewIdPB(value: chatId);
unawaited(FolderEventCloseView(request).send());
selectedSourcesNotifier.dispose();
chatController.dispose();
return super.close();
}
@@ -577,7 +576,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
if (temporaryMessageIDMap.containsKey(messageId)) {
messageId = temporaryMessageIDMap[messageId]!;
}
final metadata = message.metadata == 'null' ? '[]' : message.metadata;
return TextMessage(
author: User(id: message.authorId),
@@ -585,7 +583,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
text: message.content,
createdAt: message.createdAt.toDateTime(),
metadata: {
messageRefSourceJsonStringKey: metadata,
messageRefSourceJsonStringKey: message.metadata,
},
);
}
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
@@ -289,7 +288,7 @@ class _ChatContentPage extends StatelessWidget {
return ChatMessage(
message: message,
animation: animation,
padding: const EdgeInsets.symmetric(vertical: 18.0),
padding: const EdgeInsets.symmetric(vertical: 12.0),
receivedMessageScaleAnimationAlignment: Alignment.center,
child: child,
);
@@ -414,7 +413,7 @@ class _InputState extends State<_Input> {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) {
return NonClippingSizeTransition(
return SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
@@ -134,7 +134,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
@@ -448,7 +448,7 @@ class _ChangeModelButtonState extends State<ChangeModelButton> {
child: buildButton(context),
popupBuilder: (_) {
final bloc = context.read<AIPromptInputBloc>();
final (models, _) = bloc.aiModelStateNotifier.getModelSelection();
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
return SelectModelPopoverContent(
models: models,
selectedModel: null,
@@ -516,7 +516,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
@@ -407,7 +407,7 @@ class ChatAIMessagePopup extends StatelessWidget {
return MobileQuickActionButton(
onTap: () async {
final bloc = context.read<AIPromptInputBloc>();
final (models, _) = bloc.aiModelStateNotifier.getModelSelection();
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
final result = await showChangeModelBottomSheet(context, models);
if (result != null) {
onChangeModel?.call(result);
@@ -45,7 +45,6 @@ class ChatUserMessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Spacer(),
_buildBubble(context),
const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing),
_buildAvatar(),
@@ -3,9 +3,9 @@ import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart';
import 'package:appflowy/plugins/database/domain/date_cell_service.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/util/int64_extension.dart';
@@ -55,11 +55,10 @@ class DateCellEditorBloc
didReceiveCellUpdate: (DateCellDataPB? cellData) {
final dateCellData = DateCellData.fromPB(cellData);
ReminderOption reminderOption = state.reminderOption;
ReminderOption reminderOption = ReminderOption.none;
if (dateCellData.reminderId.isNotEmpty &&
dateCellData.dateTime != null &&
reminderOption != ReminderOption.none) {
dateCellData.dateTime != null) {
final reminder = _reminderBloc.state.reminders
.firstWhereOrNull((r) => r.id == dateCellData.reminderId);
if (reminder != null) {
@@ -113,15 +112,13 @@ class DateCellEditorBloc
clearDate: () async {
// Remove reminder if neccessary
if (state.reminderId.isNotEmpty) {
_reminderBloc.add(
ReminderEvent.removeReminder(reminderId: state.reminderId),
);
_reminderBloc
.add(ReminderEvent.remove(reminderId: state.reminderId));
}
await _clearDate();
},
setReminderOption: (ReminderOption option) async {
emit(state.copyWith(reminderOption: option));
await _setReminderOption(option);
},
);
@@ -219,7 +216,7 @@ class DateCellEditorBloc
} else {
if (option == ReminderOption.none) {
// remove reminder from reminder bloc and cell data
_reminderBloc.add(ReminderEvent.removeReminder(reminderId: state.reminderId));
_reminderBloc.add(ReminderEvent.remove(reminderId: state.reminderId));
await _updateCellReminderId("");
} else {
// Update reminder
@@ -249,7 +246,9 @@ class DateCellEditorBloc
void _updateReminderIfNecessary(
DateTime? dateTime,
) {
if (state.reminderId.isEmpty || dateTime == null) {
if (state.reminderId.isEmpty ||
state.reminderOption == ReminderOption.none ||
dateTime == null) {
return;
}
@@ -401,7 +400,7 @@ class DateCellEditorState with _$DateCellEditorState {
ReminderOption reminderOption = ReminderOption.none;
if (dateCellData.reminderId.isNotEmpty && dateCellData.dateTime != null) {
final reminder = reminderBloc.state.allReminders
final reminder = reminderBloc.state.reminders
.firstWhereOrNull((r) => r.id == dateCellData.reminderId);
if (reminder != null) {
final eventDate = dateCellData.includeTime
@@ -31,7 +31,7 @@ class DatabaseSyncBloc extends Bloc<DatabaseSyncEvent, DatabaseSyncBlocState> {
emit(
state.copyWith(
shouldShowIndicator:
userProfile?.workspaceType == WorkspaceTypePB.ServerW &&
userProfile?.workspaceAuthType == AuthTypePB.Server &&
databaseId != null,
),
);
@@ -280,7 +280,6 @@ class _ChecklistItemState extends State<ChecklistItem> {
child: ChecklistCellTextfield(
textController: textController,
focusNode: textFieldFocusNode,
lineHeight: Platform.isWindows ? 1.2 : 1.1,
onChanged: () {
_debounceOnChanged.call(() {
if (!isComposing) {
@@ -46,7 +46,6 @@ class ChecklistCellTextfield extends StatelessWidget {
horizontal: 2,
),
this.onSubmitted,
this.lineHeight,
});
final TextEditingController textController;
@@ -54,17 +53,13 @@ class ChecklistCellTextfield extends StatelessWidget {
final EdgeInsetsGeometry contentPadding;
final VoidCallback? onSubmitted;
final VoidCallback? onChanged;
final double? lineHeight;
@override
Widget build(BuildContext context) {
final textStyle = Theme.of(context).textTheme.bodyMedium;
return TextField(
controller: textController,
focusNode: focusNode,
style: textStyle?.copyWith(
height: lineHeight,
),
style: Theme.of(context).textTheme.bodyMedium,
maxLines: null,
decoration: InputDecoration(
border: InputBorder.none,
@@ -69,8 +69,8 @@ class RowBanner extends StatefulWidget {
class _RowBannerState extends State<RowBanner> {
final _isHovering = ValueNotifier(false);
late final isLocalMode =
(widget.userProfile?.workspaceType ?? WorkspaceTypePB.LocalW) ==
WorkspaceTypePB.LocalW;
(widget.userProfile?.workspaceAuthType ?? AuthTypePB.Local) ==
AuthTypePB.Local;
@override
void dispose() {
@@ -101,8 +101,8 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
bool get isLocalMode {
final userProfilePB = state.userProfilePB;
final type = userProfilePB?.workspaceType ?? WorkspaceTypePB.LocalW;
return type == WorkspaceTypePB.LocalW;
final type = userProfilePB?.workspaceAuthType ?? AuthTypePB.Local;
return type == AuthTypePB.Local;
}
@override
@@ -32,7 +32,7 @@ class DocumentCollaboratorsBloc
emit(
state.copyWith(
shouldShowIndicator:
userProfile?.workspaceType == WorkspaceTypePB.ServerW,
userProfile?.workspaceAuthType == AuthTypePB.Server,
),
);
final deviceId = ApplicationInfo.deviceId;
@@ -31,7 +31,7 @@ class DocumentSyncBloc extends Bloc<DocumentSyncEvent, DocumentSyncBlocState> {
emit(
state.copyWith(
shouldShowIndicator:
userProfile?.workspaceType == WorkspaceTypePB.ServerW,
userProfile?.workspaceAuthType == AuthTypePB.Server,
),
);
_syncStateListener.start(
@@ -68,13 +68,10 @@ Map<String, BlockComponentBuilder> buildBlockComponentBuilders({
bool editable = true,
ShowPlaceholder? showParagraphPlaceholder,
String Function(Node)? placeholderText,
EdgeInsets Function(Node node)? customPadding,
EdgeInsets? customHeadingPadding,
bool alwaysDistributeSimpleTableColumnWidths = false,
}) {
final configuration = _buildDefaultConfiguration(
context,
padding: customPadding,
);
final configuration = _buildDefaultConfiguration(context);
final builders = _buildBlockComponentBuilderMap(
context,
configuration: configuration,
@@ -100,10 +97,7 @@ Map<String, BlockComponentBuilder> buildBlockComponentBuilders({
return builders;
}
BlockComponentConfiguration _buildDefaultConfiguration(
BuildContext context, {
EdgeInsets Function(Node node)? padding,
}) {
BlockComponentConfiguration _buildDefaultConfiguration(BuildContext context) {
final configuration = BlockComponentConfiguration(
padding: (node) {
if (UniversalPlatform.isMobile) {
@@ -123,7 +117,7 @@ BlockComponentConfiguration _buildDefaultConfiguration(
right: EditorStyleCustomizer.nodeHorizontalPadding,
);
}
return padding?.call(node) ?? edgeInsets;
return edgeInsets;
}
return const EdgeInsets.symmetric(vertical: 5.0);
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
@@ -29,14 +28,15 @@ class AiWriterCubit extends Cubit<AiWriterState> {
this.onCreateNode,
this.onRemoveNode,
this.onAppendToDocument,
}) : _aiService = getIt<AIRepository>(),
AppFlowyAIService? aiService,
}) : _aiService = aiService ?? AppFlowyAIService(),
_textRobot = MarkdownTextRobot(editorState: editorState),
selectedSourcesNotifier = ValueNotifier([documentId]),
super(IdleAiWriterState());
final String documentId;
final EditorState editorState;
final AIRepository _aiService;
final AppFlowyAIService _aiService;
final MarkdownTextRobot _textRobot;
final void Function()? onCreateNode;
final void Function()? onRemoveNode;
@@ -306,14 +306,12 @@ class AiWriterCubit extends Cubit<AiWriterState> {
// check the node is registered
if (node == null) {
Log.warn('[AI writer] Node is null');
return (false, '');
}
// check the selection is valid
final selection = node.aiWriterSelection?.normalized;
if (selection == null) {
Log.warn('[AI writer]Selection is null');
return (false, '');
}
@@ -58,7 +58,6 @@ class _AiWriterScrollWrapperState extends State<AiWriterScrollWrapper> {
@override
void dispose() {
aiWriterCubit.close();
throttler.dispose();
super.dispose();
}
@@ -31,22 +31,15 @@ Future<bool> _convertBacktickToCodeBlock({
}
// only active when the backtick is at the beginning of the line
final keyword = '``';
final plainText = delta.toPlainText();
if (!plainText.startsWith(keyword)) {
if (plainText != '``') {
return false;
}
final transaction = editorState.transaction;
final deltaWithoutKeyword = delta.compose(Delta()..delete(keyword.length));
transaction.insertNodes(
transaction.insertNode(
selection.end.path,
[
codeBlockNode(
delta: deltaWithoutKeyword,
),
if (node.children.isNotEmpty) ...node.children.map((e) => e.copyWith()),
],
codeBlockNode(),
);
transaction.deleteNode(node);
transaction.afterSelection = Selection.collapsed(
@@ -423,7 +423,6 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
return;
}
widget.onApply.call(linkInfo);
onDismiss();
}
void onConfirm() {
@@ -70,7 +70,6 @@ class LinkSearchTextField {
bool showError = false,
required BuildContext context,
EdgeInsets contentPadding = const EdgeInsets.fromLTRB(8, 6, 8, 6),
TextStyle? textStyle,
}) {
return TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
@@ -78,12 +77,11 @@ class LinkSearchTextField {
focusNode: focusNode,
textAlign: TextAlign.left,
controller: textEditingController,
style: textStyle ??
TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
),
style: TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
),
onChanged: (text) {
if (text.isEmpty) {
searchedViews.clear();
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff. Show more