Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d9d0e9358 | ||
|
|
7f44b181bd | ||
|
|
01c3fec5aa | ||
|
|
94a3a59001 | ||
|
|
4a433a3176 | ||
|
|
f7749bdccc | ||
|
|
c864e836ee | ||
|
|
0738b5f87d | ||
|
|
6198c3907f | ||
|
|
891d794900 | ||
|
|
890f52a00a | ||
|
|
d007f98af6 | ||
|
|
15e9d65798 | ||
|
|
0626f0deb7 | ||
|
|
007c649c31 | ||
|
|
34ea508f5b | ||
|
|
f70aef68be | ||
|
|
61a31c90ee | ||
|
|
c7e3ed805a |
No files matched your search
@@ -44,7 +44,7 @@ test "" = "$(grep '^Signed-off-by: ' "$1" |
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
printError "Please fix your commit message to match AppFlowy coding standards"
|
||||
printError "https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/software-contributions/submitting-code/code-submission-guidelines#commit-message-guidelines"
|
||||
printError "https://docs.appflowy.io/docs/documentation/software-contributions/conventions/git-conventions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
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
|
||||
sudo apt-get install keybinder-3.0
|
||||
sudo apt-get install keybinder-3.0 libnotify-dev
|
||||
elif [ "$RUNNER_OS" == "Windows" ]; then
|
||||
vcpkg integrate install
|
||||
elif [ "$RUNNER_OS" == "macOS" ]; then
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
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
|
||||
sudo apt-get install keybinder-3.0
|
||||
sudo apt-get install keybinder-3.0 libnotify-dev
|
||||
elif [ "$RUNNER_OS" == "Windows" ]; then
|
||||
vcpkg integrate install
|
||||
elif [ "$RUNNER_OS" == "macOS" ]; then
|
||||
|
||||
@@ -388,7 +388,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 apt-get update
|
||||
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 keybinder-3.0 libnotify-dev
|
||||
sudo apt-get -y install alien
|
||||
source $HOME/.cargo/env
|
||||
cargo install --force cargo-make
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Release Notes
|
||||
|
||||
## Version 0.3.4 - 10/02/2023
|
||||
|
||||
### New Features
|
||||
- Added support for creating a reminder.
|
||||
- Added support for finding and replacing in the document page.
|
||||
- Added support for showing the hidden fields in row detail page.
|
||||
- Adjust the toolbar style in RTL mode.
|
||||
|
||||
### Bug fixes
|
||||
- Improve snackbar UI design.
|
||||
- Improve dandelion theme.
|
||||
- Improve id-ID and pl-PL language translations.
|
||||
|
||||
## Version 0.3.3 - 09/24/2023
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -25,7 +25,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
|
||||
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
|
||||
CARGO_MAKE_CRATE_NAME = "dart-ffi"
|
||||
LIB_NAME = "dart_ffi"
|
||||
CURRENT_APP_VERSION = "0.3.3"
|
||||
CURRENT_APP_VERSION = "0.3.4"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart,rev-sqlite"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
# CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html
|
||||
|
||||
@@ -69,8 +69,8 @@ windows/flutter/dart_ffi/
|
||||
**/.sandbox
|
||||
**/.vscode/
|
||||
|
||||
*.env
|
||||
*.env.*
|
||||
.env
|
||||
.env.*
|
||||
|
||||
coverage/
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Initial Setup
|
||||
# 1. Copy the dev.env file to .env:
|
||||
# cp dev.env .env
|
||||
# 2. Alternatively, you can generate the .env file using the "Generate Env File" task in VSCode.
|
||||
|
||||
# Configuring Cloud Type
|
||||
# This configuration file is used to specify the cloud type and the necessary configurations for each cloud type. The available options are:
|
||||
# Supabase: Set CLOUD_TYPE to 1
|
||||
# AppFlowy Cloud: Set CLOUD_TYPE to 2
|
||||
|
||||
CLOUD_TYPE=1
|
||||
|
||||
# Supabase Configuration
|
||||
# If you're using Supabase (CLOUD_TYPE=1), you need to provide the following configurations:
|
||||
SUPABASE_URL=replace-with-your-supabase-url
|
||||
SUPABASE_ANON_KEY=replace-with-your-supabase-key
|
||||
|
||||
# AppFlowy Cloud Configuration
|
||||
# If you're using AppFlowy Cloud (CLOUD_TYPE=2), you need to provide the following configurations:
|
||||
APPFLOWY_CLOUD_BASE_URL=replace-with-your-appflowy-cloud-url
|
||||
APPFLOWY_CLOUD_BASE_WS_URL=replace-with-your-appflowy-cloud-ws-url
|
||||
@@ -146,7 +146,7 @@ void main() {
|
||||
await tester.openFirstRowDetailPage();
|
||||
|
||||
// Assert that the first field in the row details page is the select
|
||||
// option tyoe
|
||||
// option type
|
||||
tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect);
|
||||
|
||||
// Reorder first field in list
|
||||
@@ -168,6 +168,54 @@ void main() {
|
||||
tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect);
|
||||
});
|
||||
|
||||
testWidgets('hide and show hidden fields', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create a new grid
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Hover first row and then open the row page
|
||||
await tester.openFirstRowDetailPage();
|
||||
|
||||
// Assert that the show hidden fields button isn't visible
|
||||
tester.assertToggleShowHiddenFieldsVisibility(false);
|
||||
|
||||
// Hide the first field in the field list
|
||||
await tester.tapGridFieldWithNameInRowDetailPage("Type");
|
||||
await tester.tapHidePropertyButtonInFieldEditor();
|
||||
|
||||
// Assert that the field is now hidden
|
||||
tester.noFieldWithName("Type");
|
||||
|
||||
// Assert that the show hidden fields button appears
|
||||
tester.assertToggleShowHiddenFieldsVisibility(true);
|
||||
|
||||
// Click on the show hidden fields button
|
||||
await tester.toggleShowHiddenFields();
|
||||
|
||||
// Assert that the hidden field is shown again and that the show
|
||||
// hidden fields button is still present
|
||||
tester.findFieldWithName("Type");
|
||||
tester.assertToggleShowHiddenFieldsVisibility(true);
|
||||
|
||||
// Click hide hidden fields
|
||||
await tester.toggleShowHiddenFields();
|
||||
|
||||
// Assert that the hidden field has vanished
|
||||
tester.noFieldWithName("Type");
|
||||
|
||||
// Click show hidden fields
|
||||
await tester.toggleShowHiddenFields();
|
||||
|
||||
// delete the hidden field
|
||||
await tester.tapGridFieldWithNameInRowDetailPage("Type");
|
||||
await tester.tapDeletePropertyInFieldEditor();
|
||||
|
||||
// Assert that the that the show hidden fields button is gone
|
||||
tester.assertToggleShowHiddenFieldsVisibility(false);
|
||||
});
|
||||
|
||||
testWidgets('check document exists in row detail page', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy/user/application/user_settings_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:calendar_view/calendar_view.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text_field.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../util/base.dart';
|
||||
import '../util/common_operations.dart';
|
||||
import '../util/editor_test_operations.dart';
|
||||
import '../util/keyboard.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Reminder in Document', () {
|
||||
testWidgets('Add reminder for tomorrow, and include time', (tester) async {
|
||||
const time = "23:59";
|
||||
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
final dateTimeSettings =
|
||||
await UserSettingsBackendService().getDateTimeSettings();
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.getCurrentEditorState().insertNewLine();
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Trigger iline action menu and type 'remind tomorrow'
|
||||
final tomorrow = await _insertReminderTomorrow(tester);
|
||||
|
||||
Node node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!;
|
||||
Map<String, dynamic> mentionAttr =
|
||||
node.delta!.first.attributes![MentionBlockKeys.mention];
|
||||
|
||||
expect(node.type, 'paragraph');
|
||||
expect(mentionAttr['type'], MentionType.reminder.name);
|
||||
expect(mentionAttr['date'], tomorrow.toIso8601String());
|
||||
|
||||
await tester.tap(
|
||||
find.text(dateTimeSettings.dateFormat.formatDate(tomorrow, false)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(Toggle));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(FlowyTextField), time);
|
||||
|
||||
// Leave text field to submit
|
||||
await tester.tap(find.text(LocaleKeys.grid_field_includeTime.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!;
|
||||
mentionAttr = node.delta!.first.attributes![MentionBlockKeys.mention];
|
||||
|
||||
final tomorrowWithTime =
|
||||
_dateWithTime(dateTimeSettings.timeFormat, tomorrow, time);
|
||||
|
||||
expect(node.type, 'paragraph');
|
||||
expect(mentionAttr['type'], MentionType.reminder.name);
|
||||
expect(mentionAttr['date'], tomorrowWithTime.toIso8601String());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<DateTime> _insertReminderTomorrow(WidgetTester tester) async {
|
||||
await tester.editor.showAtMenu();
|
||||
|
||||
await FlowyTestKeyboard.simulateKeyDownEvent(
|
||||
[
|
||||
LogicalKeyboardKey.keyR,
|
||||
LogicalKeyboardKey.keyE,
|
||||
LogicalKeyboardKey.keyM,
|
||||
LogicalKeyboardKey.keyI,
|
||||
LogicalKeyboardKey.keyN,
|
||||
LogicalKeyboardKey.keyD,
|
||||
LogicalKeyboardKey.space,
|
||||
LogicalKeyboardKey.keyT,
|
||||
LogicalKeyboardKey.keyO,
|
||||
LogicalKeyboardKey.keyM,
|
||||
LogicalKeyboardKey.keyO,
|
||||
LogicalKeyboardKey.keyR,
|
||||
LogicalKeyboardKey.keyR,
|
||||
LogicalKeyboardKey.keyO,
|
||||
LogicalKeyboardKey.keyW,
|
||||
],
|
||||
tester: tester,
|
||||
);
|
||||
|
||||
await FlowyTestKeyboard.simulateKeyDownEvent(
|
||||
[LogicalKeyboardKey.enter],
|
||||
tester: tester,
|
||||
);
|
||||
|
||||
return DateTime.now().add(const Duration(days: 1)).withoutTime;
|
||||
}
|
||||
|
||||
DateTime _dateWithTime(UserTimeFormatPB format, DateTime date, String time) {
|
||||
final t = format == UserTimeFormatPB.TwelveHour
|
||||
? DateFormat.jm().parse(time)
|
||||
: DateFormat.Hm().parse(time);
|
||||
|
||||
return DateTime.parse(
|
||||
'${date.year}${_padZeroLeft(date.month)}${_padZeroLeft(date.day)} ${_padZeroLeft(t.hour)}:${_padZeroLeft(t.minute)}',
|
||||
);
|
||||
}
|
||||
|
||||
String _padZeroLeft(int a) => a.toString().padLeft(2, '0');
|
||||
@@ -22,6 +22,8 @@ import 'tabs_test.dart' as tabs_test;
|
||||
import 'hotkeys_test.dart' as hotkeys_test;
|
||||
import 'appearance_settings_test.dart' as appearance_test_runner;
|
||||
import 'auth/auth_test.dart' as auth_test_runner;
|
||||
import 'settings/user_icon_test.dart' as user_icon_test;
|
||||
import 'settings/user_language_test.dart' as user_language_test;
|
||||
|
||||
/// The main task runner for all integration tests in AppFlowy.
|
||||
///
|
||||
@@ -67,7 +69,11 @@ void main() {
|
||||
// Appearance integration test
|
||||
appearance_test_runner.main();
|
||||
|
||||
if (isSupabaseEnabled) {
|
||||
// User settings
|
||||
user_icon_test.main();
|
||||
user_language_test.main();
|
||||
|
||||
if (isCloudEnabled) {
|
||||
auth_test_runner.main();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import '../util/util.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Settings: user language tests', () {
|
||||
testWidgets('select language, language changed', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
|
||||
await tester.tapGoButton();
|
||||
tester.expectToSeeHomePage();
|
||||
await tester.openSettings();
|
||||
|
||||
await tester.openSettingsPage(SettingsPage.language);
|
||||
|
||||
final userLanguageFinder = find.descendant(
|
||||
of: find.byType(SettingsLanguageView),
|
||||
matching: find.byType(LanguageSelector),
|
||||
);
|
||||
|
||||
// Grab current locale
|
||||
LanguageSelector userLanguage =
|
||||
tester.widget<LanguageSelector>(userLanguageFinder);
|
||||
Locale currentLocale = userLanguage.currentLocale;
|
||||
|
||||
// Open language selector
|
||||
await tester.tap(userLanguageFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Select first option that isn't default
|
||||
await tester.tap(find.byType(LanguageItem).at(1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Make sure the new locale is not the same as previous one
|
||||
userLanguage = tester.widget<LanguageSelector>(userLanguageFinder);
|
||||
expect(
|
||||
userLanguage.currentLocale,
|
||||
isNot(equals(currentLocale)),
|
||||
reason: "new language shouldn't equal the previous selected language",
|
||||
);
|
||||
|
||||
// Update the current locale to a new one
|
||||
currentLocale = userLanguage.currentLocale;
|
||||
|
||||
// Tried the same flow for the second time
|
||||
// Open language selector
|
||||
await tester.tap(userLanguageFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Select second option that isn't default
|
||||
await tester.tap(find.byType(LanguageItem).at(2));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Make sure the new locale is not the same as previous one
|
||||
userLanguage = tester.widget<LanguageSelector>(userLanguageFinder);
|
||||
expect(
|
||||
userLanguage.currentLocale,
|
||||
isNot(equals(currentLocale)),
|
||||
reason: "new language shouldn't equal the previous selected language",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import 'package:appflowy/plugins/database_view/widgets/row/row_property.dart';
|
||||
import 'package:appflowy/plugins/database_view/widgets/setting/database_setting.dart';
|
||||
import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_menu_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
|
||||
@@ -673,6 +674,31 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
void assertToggleShowHiddenFieldsVisibility(bool shown) {
|
||||
final button = find.byType(ToggleHiddenFieldsVisibilityButton);
|
||||
if (shown) {
|
||||
expect(button, findsOneWidget);
|
||||
} else {
|
||||
expect(button, findsNothing);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleShowHiddenFields() async {
|
||||
final button = find.byType(ToggleHiddenFieldsVisibilityButton);
|
||||
await tapButton(button);
|
||||
}
|
||||
|
||||
Future<void> tapDeletePropertyInFieldEditor() async {
|
||||
final deleteButton = find.byType(DeleteFieldButton);
|
||||
await tapButton(deleteButton);
|
||||
|
||||
final confirmButton = find.descendant(
|
||||
of: find.byType(NavigatorAlertDialog),
|
||||
matching: find.byType(PrimaryTextButton),
|
||||
);
|
||||
await tapButton(confirmButton);
|
||||
}
|
||||
|
||||
Future<void> scrollGridByOffset(Offset offset) async {
|
||||
await drag(find.byType(GridPage), offset);
|
||||
await pumpAndSettle();
|
||||
@@ -746,7 +772,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
}
|
||||
|
||||
Future<void> tapHidePropertyButtonInFieldEditor() async {
|
||||
final button = find.byType(HideFieldButton);
|
||||
final button = find.byType(FieldVisibilityToggleButton);
|
||||
await tapButton(button);
|
||||
}
|
||||
|
||||
@@ -899,7 +925,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
}
|
||||
|
||||
Future<void> assertRowCountInGridPage(int num) async {
|
||||
final text = find.text('${rowCountString()} $num',findRichText: true);
|
||||
final text = find.text('${rowCountString()} $num', findRichText: true);
|
||||
expect(text, findsOneWidget);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cus
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_popover.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -160,7 +161,7 @@ class EditorOperations {
|
||||
/// Must call [showAtMenu] first.
|
||||
Future<void> tapAtMenuItemWithName(String name) async {
|
||||
final atMenuItem = find.descendant(
|
||||
of: find.byType(SelectionMenuWidget),
|
||||
of: find.byType(InlineActionsHandler),
|
||||
matching: find.text(name, findRichText: true),
|
||||
);
|
||||
await tester.tapButton(atMenuItem);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-date/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
class DateService {
|
||||
static Future<Either<FlowyError, DateTime>> queryDate(String search) async {
|
||||
final query = DateQueryPB.create()..query = search;
|
||||
final result = (await DateEventQueryDate(query).send()).swap();
|
||||
return result.fold((l) => left(l), (r) {
|
||||
final date = DateTime.tryParse(r.date);
|
||||
if (date != null) {
|
||||
return right(date);
|
||||
}
|
||||
|
||||
return left(FlowyError(msg: 'Could not parse Date (NLP) from String'));
|
||||
});
|
||||
}
|
||||
}
|
||||
+57
-2
@@ -1,5 +1,6 @@
|
||||
// lib/env/env.dart
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:envied/envied.dart';
|
||||
|
||||
part 'env.g.dart';
|
||||
@@ -17,6 +18,29 @@ part 'env.g.dart';
|
||||
///
|
||||
@Envied(path: '.env')
|
||||
abstract class Env {
|
||||
@EnviedField(
|
||||
obfuscate: true,
|
||||
varName: 'CLOUD_TYPE',
|
||||
defaultValue: '0',
|
||||
)
|
||||
static final int cloudType = _Env.cloudType;
|
||||
|
||||
/// AppFlowy Cloud Configuration
|
||||
@EnviedField(
|
||||
obfuscate: true,
|
||||
varName: 'APPFLOWY_CLOUD_BASE_URL',
|
||||
defaultValue: '',
|
||||
)
|
||||
static final String afCloudBaseUrl = _Env.afCloudBaseUrl;
|
||||
|
||||
@EnviedField(
|
||||
obfuscate: true,
|
||||
varName: 'APPFLOWY_CLOUD_BASE_WS_URL',
|
||||
defaultValue: '',
|
||||
)
|
||||
static final String afCloudBaseWSUrl = _Env.afCloudBaseWSUrl;
|
||||
|
||||
// Supabase Configuration:
|
||||
@EnviedField(
|
||||
obfuscate: true,
|
||||
varName: 'SUPABASE_URL',
|
||||
@@ -31,11 +55,42 @@ abstract class Env {
|
||||
static final String supabaseAnonKey = _Env.supabaseAnonKey;
|
||||
}
|
||||
|
||||
bool get isSupabaseEnabled {
|
||||
bool get isCloudEnabled {
|
||||
// Only enable supabase in release and develop mode.
|
||||
if (integrationMode().isRelease || integrationMode().isDevelop) {
|
||||
return Env.supabaseUrl.isNotEmpty && Env.supabaseAnonKey.isNotEmpty;
|
||||
return currentCloudType().isEnabled;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
enum CloudType {
|
||||
unknown,
|
||||
supabase,
|
||||
appflowyCloud;
|
||||
|
||||
bool get isEnabled => this != CloudType.unknown;
|
||||
}
|
||||
|
||||
CloudType currentCloudType() {
|
||||
final value = Env.cloudType;
|
||||
if (value == 1) {
|
||||
if (Env.supabaseUrl.isEmpty || Env.supabaseAnonKey.isEmpty) {
|
||||
Log.error("Supabase is not configured");
|
||||
return CloudType.unknown;
|
||||
} else {
|
||||
return CloudType.supabase;
|
||||
}
|
||||
}
|
||||
|
||||
if (value == 2) {
|
||||
if (Env.afCloudBaseUrl.isEmpty || Env.afCloudBaseWSUrl.isEmpty) {
|
||||
Log.error("AppFlowy cloud is not configured");
|
||||
return CloudType.unknown;
|
||||
} else {
|
||||
return CloudType.appflowyCloud;
|
||||
}
|
||||
}
|
||||
|
||||
return CloudType.unknown;
|
||||
}
|
||||
+14
-2
@@ -23,7 +23,20 @@ ThemeData getMobileThemeData() {
|
||||
primaryColor: mobileColorTheme.primary, //primary 100
|
||||
primaryColorLight: const Color(0xFF57B5F8), //primary 80
|
||||
dividerColor: mobileColorTheme.outline, //caption
|
||||
scaffoldBackgroundColor: Colors.white,
|
||||
scaffoldBackgroundColor: mobileColorTheme.background,
|
||||
appBarTheme: AppBarTheme(
|
||||
foregroundColor: mobileColorTheme.onBackground,
|
||||
backgroundColor: mobileColorTheme.background,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
color: mobileColorTheme.onBackground,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.05,
|
||||
),
|
||||
),
|
||||
// button
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
@@ -72,7 +85,6 @@ ThemeData getMobileThemeData() {
|
||||
displayLarge: TextStyle(
|
||||
color: Color(0xFF57B5F8),
|
||||
fontSize: 32,
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.20,
|
||||
letterSpacing: 0.16,
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
// TODO(yijing): delete this after implementing the real screen inside bottom navigation bar.
|
||||
/// For demonstration purposes
|
||||
class DetailsPlaceholderScreen extends StatefulWidget {
|
||||
/// Constructs a [DetailsScreen].
|
||||
const DetailsPlaceholderScreen({
|
||||
required this.label,
|
||||
this.param,
|
||||
this.extra,
|
||||
this.withScaffold = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The label to display in the center of the screen.
|
||||
final String label;
|
||||
|
||||
/// Optional param
|
||||
final String? param;
|
||||
|
||||
/// Optional extra object
|
||||
final Object? extra;
|
||||
|
||||
/// Wrap in scaffold
|
||||
final bool withScaffold;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => DetailsPlaceholderScreenState();
|
||||
}
|
||||
|
||||
/// The state for DetailsScreen
|
||||
class DetailsPlaceholderScreenState extends State<DetailsPlaceholderScreen> {
|
||||
int _counter = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.withScaffold) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Details Screen - ${widget.label}'),
|
||||
),
|
||||
body: _build(context),
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: _build(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Details for ${widget.label} - Counter: $_counter',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(4)),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_counter++;
|
||||
});
|
||||
},
|
||||
child: const Text('Increment counter'),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(8)),
|
||||
if (widget.param != null)
|
||||
Text(
|
||||
'Parameter: ${widget.param!}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(8)),
|
||||
if (widget.extra != null)
|
||||
Text(
|
||||
'Extra: ${widget.extra!}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
if (!widget.withScaffold) ...<Widget>[
|
||||
const Padding(padding: EdgeInsets.all(16)),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pop();
|
||||
},
|
||||
child: const Text(
|
||||
'< Back',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'mobile_home_page.dart';
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// TODO(yijing): This is just a placeholder for now.
|
||||
class MobileHomeScreen extends StatelessWidget {
|
||||
const MobileHomeScreen({super.key});
|
||||
|
||||
static const routeName = "/MobileHomeScreen";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: Future.wait([
|
||||
FolderEventGetCurrentWorkspace().send(),
|
||||
getIt<AuthService>().getUser(),
|
||||
]),
|
||||
builder: (context, snapshots) {
|
||||
if (!snapshots.hasData) {
|
||||
return const Center(child: CircularProgressIndicator.adaptive());
|
||||
}
|
||||
|
||||
final workspaceSetting = snapshots.data?[0].fold(
|
||||
(workspaceSettingPB) {
|
||||
return workspaceSettingPB as WorkspaceSettingPB?;
|
||||
},
|
||||
(error) => null,
|
||||
);
|
||||
final userProfile =
|
||||
snapshots.data?[1].fold((error) => null, (userProfilePB) {
|
||||
return userProfilePB as UserProfilePB?;
|
||||
});
|
||||
// TODO(yijing): implement home page later
|
||||
return Scaffold(
|
||||
key: ValueKey(userProfile?.id),
|
||||
// TODO(yijing):Need to change to workspace when it is ready
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
userProfile?.email.toString() ?? 'No email found',
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
// TODO(yijing): Navigate to setting page
|
||||
},
|
||||
icon: const FlowySvg(
|
||||
FlowySvgs.m_setting_m,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'User',
|
||||
),
|
||||
Text(
|
||||
userProfile.toString(),
|
||||
),
|
||||
Text('Workspace name: ${workspaceSetting?.workspace.name}'),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await getIt<AuthService>().signOut();
|
||||
runAppFlowy();
|
||||
},
|
||||
child: const Text('Logout'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Builds the "shell" for the app by building a Scaffold with a
|
||||
/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
|
||||
class MobileBottomNavigationBar extends StatelessWidget {
|
||||
/// Constructs an [MobileBottomNavigationBar].
|
||||
const MobileBottomNavigationBar({
|
||||
required this.navigationShell,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The navigation shell and container for the branch Navigators.
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: navigationShell,
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
showSelectedLabels: false,
|
||||
showUnselectedLabels: false,
|
||||
// Here, the items of BottomNavigationBar are hard coded. In a real
|
||||
// world scenario, the items would most likely be generated from the
|
||||
// branches of the shell route, which can be fetched using
|
||||
// `navigationShell.route.branches`.
|
||||
type: BottomNavigationBarType.fixed,
|
||||
items: <BottomNavigationBarItem>[
|
||||
BottomNavigationBarItem(
|
||||
// There is no text shown on the bottom navigation bar, but Exception will be thrown if label is null here.
|
||||
label: 'home',
|
||||
icon: const FlowySvg(FlowySvgs.m_home_unselected_lg),
|
||||
activeIcon: FlowySvg(
|
||||
FlowySvgs.m_home_selected_lg,
|
||||
color: style.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
label: 'favorite',
|
||||
icon: FlowySvg(FlowySvgs.m_favorite_unselected_lg),
|
||||
activeIcon: FlowySvg(
|
||||
FlowySvgs.m_favorite_selected_lg,
|
||||
blendMode: null,
|
||||
),
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
label: 'add',
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.m_add_circle_xl,
|
||||
blendMode: null,
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'search',
|
||||
icon: const FlowySvg(FlowySvgs.m_search_lg),
|
||||
activeIcon: FlowySvg(
|
||||
FlowySvgs.m_search_lg,
|
||||
color: style.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'notification',
|
||||
icon: const FlowySvg(FlowySvgs.m_notification_unselected_lg),
|
||||
activeIcon: FlowySvg(
|
||||
FlowySvgs.m_notification_selected_lg,
|
||||
color: style.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
currentIndex: navigationShell.currentIndex,
|
||||
onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate to the current location of the branch at the provided index when
|
||||
/// tapping an item in the BottomNavigationBar.
|
||||
void _onTap(BuildContext context, int bottomBarIndex) {
|
||||
// When navigating to a new branch, it's recommended to use the goBranch
|
||||
// method, as doing so makes sure the last navigation state of the
|
||||
// Navigator for the branch is restored.
|
||||
navigationShell.goBranch(
|
||||
bottomBarIndex,
|
||||
// A common pattern when using bottom navigation bars is to support
|
||||
// navigating to the initial location when tapping the item that is
|
||||
// already active. This example demonstrates how to support this behavior,
|
||||
// using the initialLocation parameter of goBranch.
|
||||
initialLocation: bottomBarIndex == navigationShell.currentIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MobileHomeScreen extends StatelessWidget {
|
||||
const MobileHomeScreen({
|
||||
super.key,
|
||||
required this.userProfile,
|
||||
required this.workspaceSetting,
|
||||
});
|
||||
|
||||
static const routeName = "/MobileHomeScreen";
|
||||
final UserProfilePB userProfile;
|
||||
final WorkspaceSettingPB workspaceSetting;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("MobileHomeScreen"),
|
||||
),
|
||||
// TODO(yijing): implement home page later
|
||||
body: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'User',
|
||||
),
|
||||
Text(
|
||||
userProfile.toString(),
|
||||
),
|
||||
Text('Workspace name: ${workspaceSetting.workspace.name}'),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await getIt<AuthService>().signOut();
|
||||
runAppFlowy();
|
||||
},
|
||||
child: const Text('Logout'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export 'home/home.dart';
|
||||
export 'root_placeholder_page.dart';
|
||||
export 'details_placeholder_page.dart';
|
||||
export 'mobile_bottom_navigation_bar.dart';
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Widget for the root/initial pages in the bottom navigation bar.
|
||||
class RootPlaceholderScreen extends StatelessWidget {
|
||||
/// Creates a RootScreen
|
||||
const RootPlaceholderScreen({
|
||||
required this.label,
|
||||
required this.detailsPath,
|
||||
this.secondDetailsPath,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The label
|
||||
final String label;
|
||||
|
||||
/// The path to the detail page
|
||||
final String detailsPath;
|
||||
|
||||
/// The path to another detail page
|
||||
final String? secondDetailsPath;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Root of section $label'),
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text('$label Page', style: Theme.of(context).textTheme.titleLarge),
|
||||
const Padding(padding: EdgeInsets.all(4)),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go(detailsPath, extra: '$label-XYZ');
|
||||
},
|
||||
child: const Text('View details'),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(4)),
|
||||
if (secondDetailsPath != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go(secondDetailsPath!);
|
||||
},
|
||||
child: const Text('View more details'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart';
|
||||
@@ -73,4 +74,13 @@ class DatabaseCellContext with _$DatabaseCellContext {
|
||||
|
||||
/// Only the primary field can have an emoji.
|
||||
String? get emoji => fieldInfo.field.isPrimary ? rowMeta.icon : null;
|
||||
|
||||
/// Determines whether a database cell context should be visible.
|
||||
/// It will be visible when the field is not hidden or when hidden fields
|
||||
/// should be shown.
|
||||
bool isVisible({bool showHiddenFields = false}) {
|
||||
return fieldInfo.visibility != null &&
|
||||
(showHiddenFields ||
|
||||
fieldInfo.visibility != FieldVisibility.AlwaysHidden);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -15,16 +15,16 @@ class FieldActionSheetBloc
|
||||
final FieldSettingsBackendService fieldSettingsService;
|
||||
|
||||
FieldActionSheetBloc({required FieldContext fieldCellContext})
|
||||
: fieldId = fieldCellContext.field.id,
|
||||
: fieldId = fieldCellContext.fieldInfo.id,
|
||||
fieldService = FieldBackendService(
|
||||
viewId: fieldCellContext.viewId,
|
||||
fieldId: fieldCellContext.field.id,
|
||||
fieldId: fieldCellContext.fieldInfo.id,
|
||||
),
|
||||
fieldSettingsService =
|
||||
FieldSettingsBackendService(viewId: fieldCellContext.viewId),
|
||||
super(
|
||||
FieldActionSheetState.initial(
|
||||
TypeOptionPB.create()..field_2 = fieldCellContext.field,
|
||||
TypeOptionPB.create()..field_2 = fieldCellContext.fieldInfo.field,
|
||||
),
|
||||
) {
|
||||
on<FieldActionSheetEvent>(
|
||||
|
||||
+9
-8
@@ -15,13 +15,14 @@ class FieldCellBloc extends Bloc<FieldCellEvent, FieldCellState> {
|
||||
final FieldBackendService _fieldBackendSvc;
|
||||
|
||||
FieldCellBloc({
|
||||
required FieldContext cellContext,
|
||||
}) : _fieldListener = SingleFieldListener(fieldId: cellContext.field.id),
|
||||
required FieldContext fieldContext,
|
||||
}) : _fieldListener =
|
||||
SingleFieldListener(fieldId: fieldContext.fieldInfo.id),
|
||||
_fieldBackendSvc = FieldBackendService(
|
||||
viewId: cellContext.viewId,
|
||||
fieldId: cellContext.field.id,
|
||||
viewId: fieldContext.viewId,
|
||||
fieldId: fieldContext.fieldInfo.id,
|
||||
),
|
||||
super(FieldCellState.initial(cellContext)) {
|
||||
super(FieldCellState.initial(fieldContext)) {
|
||||
on<FieldCellEvent>(
|
||||
(event, emit) async {
|
||||
event.when(
|
||||
@@ -29,7 +30,7 @@ class FieldCellBloc extends Bloc<FieldCellEvent, FieldCellState> {
|
||||
_startListening();
|
||||
},
|
||||
didReceiveFieldUpdate: (field) {
|
||||
emit(state.copyWith(field: cellContext.field));
|
||||
emit(state.copyWith(field: fieldContext.fieldInfo.field));
|
||||
},
|
||||
onResizeStart: () {
|
||||
emit(state.copyWith(resizeStart: state.width));
|
||||
@@ -88,8 +89,8 @@ class FieldCellState with _$FieldCellState {
|
||||
|
||||
factory FieldCellState.initial(FieldContext cellContext) => FieldCellState(
|
||||
viewId: cellContext.viewId,
|
||||
field: cellContext.field,
|
||||
width: cellContext.field.width.toDouble(),
|
||||
field: cellContext.fieldInfo.field,
|
||||
width: cellContext.fieldInfo.field.width.toDouble(),
|
||||
resizeStart: 0,
|
||||
);
|
||||
}
|
||||
+37
-32
@@ -367,28 +367,13 @@ class FieldController {
|
||||
|
||||
/// Listen for field changes in the backend.
|
||||
void _listenOnFieldChanges() {
|
||||
void deleteFields(List<FieldIdPB> deletedFields) {
|
||||
if (deletedFields.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final List<FieldInfo> newFields = fieldInfos;
|
||||
final Map<String, FieldIdPB> deletedFieldMap = {
|
||||
for (var fieldOrder in deletedFields) fieldOrder.fieldId: fieldOrder
|
||||
};
|
||||
|
||||
newFields.retainWhere((field) => (deletedFieldMap[field.id] == null));
|
||||
_fieldNotifier.fieldInfos = newFields;
|
||||
}
|
||||
|
||||
Future<FieldInfo> attachFieldSettings(FieldInfo fieldInfo) async {
|
||||
return _fieldSettingsBackendSvc
|
||||
.getFieldSettings(fieldInfo.id)
|
||||
.then((result) {
|
||||
final fieldSettings = result.fold(
|
||||
(fieldSettings) => fieldSettings,
|
||||
(err) {
|
||||
return null;
|
||||
},
|
||||
(err) => null,
|
||||
);
|
||||
if (fieldSettings == null) {
|
||||
return fieldInfo;
|
||||
@@ -400,9 +385,25 @@ class FieldController {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> insertFields(List<IndexFieldPB> insertedFields) async {
|
||||
List<FieldInfo> deleteFields(List<FieldIdPB> deletedFields) {
|
||||
if (deletedFields.isEmpty) {
|
||||
return fieldInfos;
|
||||
}
|
||||
final List<FieldInfo> newFields = fieldInfos;
|
||||
final Map<String, FieldIdPB> deletedFieldMap = {
|
||||
for (final fieldOrder in deletedFields) fieldOrder.fieldId: fieldOrder
|
||||
};
|
||||
|
||||
newFields.retainWhere((field) => (deletedFieldMap[field.id] == null));
|
||||
return newFields;
|
||||
}
|
||||
|
||||
Future<List<FieldInfo>> insertFields(
|
||||
List<IndexFieldPB> insertedFields,
|
||||
List<FieldInfo> fieldInfos,
|
||||
) async {
|
||||
if (insertedFields.isEmpty) {
|
||||
return;
|
||||
return fieldInfos;
|
||||
}
|
||||
final List<FieldInfo> newFieldInfos = fieldInfos;
|
||||
for (final indexField in insertedFields) {
|
||||
@@ -414,32 +415,32 @@ class FieldController {
|
||||
newFieldInfos.add(fieldInfo);
|
||||
}
|
||||
}
|
||||
_fieldNotifier.fieldInfos = newFieldInfos;
|
||||
return newFieldInfos;
|
||||
}
|
||||
|
||||
Future<List<FieldInfo>> updateFields(List<FieldPB> updatedFieldPBs) async {
|
||||
Future<(List<FieldInfo>, List<FieldInfo>)> updateFields(
|
||||
List<FieldPB> updatedFieldPBs,
|
||||
List<FieldInfo> fieldInfos,
|
||||
) async {
|
||||
if (updatedFieldPBs.isEmpty) {
|
||||
return [];
|
||||
return (<FieldInfo>[], fieldInfos);
|
||||
}
|
||||
|
||||
final List<FieldInfo> newFields = fieldInfos;
|
||||
final List<FieldInfo> newFieldInfo = fieldInfos;
|
||||
final List<FieldInfo> updatedFields = [];
|
||||
for (final updatedFieldPB in updatedFieldPBs) {
|
||||
final index =
|
||||
newFields.indexWhere((field) => field.id == updatedFieldPB.id);
|
||||
newFieldInfo.indexWhere((field) => field.id == updatedFieldPB.id);
|
||||
if (index != -1) {
|
||||
newFields.removeAt(index);
|
||||
newFieldInfo.removeAt(index);
|
||||
final initial = FieldInfo.initial(updatedFieldPB);
|
||||
final fieldInfo = await attachFieldSettings(initial);
|
||||
newFields.insert(index, fieldInfo);
|
||||
newFieldInfo.insert(index, fieldInfo);
|
||||
updatedFields.add(fieldInfo);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedFields.isNotEmpty) {
|
||||
_fieldNotifier.fieldInfos = newFields;
|
||||
}
|
||||
return updatedFields;
|
||||
return (updatedFields, newFieldInfo);
|
||||
}
|
||||
|
||||
// Listen on field's changes
|
||||
@@ -450,10 +451,14 @@ class FieldController {
|
||||
if (_isDisposed) {
|
||||
return;
|
||||
}
|
||||
deleteFields(changeset.deletedFields);
|
||||
insertFields(changeset.insertedFields);
|
||||
List<FieldInfo> updatedFields;
|
||||
List<FieldInfo> fieldInfos = deleteFields(changeset.deletedFields);
|
||||
fieldInfos =
|
||||
await insertFields(changeset.insertedFields, fieldInfos);
|
||||
(updatedFields, fieldInfos) =
|
||||
await updateFields(changeset.updatedFields, fieldInfos);
|
||||
|
||||
final updatedFields = await updateFields(changeset.updatedFields);
|
||||
_fieldNotifier.fieldInfos = fieldInfos;
|
||||
for (final listener in _updatedFieldCallbacks.values) {
|
||||
listener(updatedFields);
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/database_view/application/field/field_info.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
@@ -108,6 +109,6 @@ class FieldBackendService {
|
||||
class FieldContext with _$FieldContext {
|
||||
const factory FieldContext({
|
||||
required String viewId,
|
||||
required FieldPB field,
|
||||
required FieldInfo fieldInfo,
|
||||
}) = _FieldCellContext;
|
||||
}
|
||||
+20
-2
@@ -20,7 +20,14 @@ class FieldSettingsBackendService {
|
||||
|
||||
return DatabaseEventGetFieldSettings(payload).send().then((result) {
|
||||
return result.fold(
|
||||
(fieldSettings) => left(fieldSettings.items.first),
|
||||
(repeatedFieldSettings) {
|
||||
final fieldSetting = repeatedFieldSettings.items.first;
|
||||
if (!fieldSetting.hasVisibility()) {
|
||||
fieldSetting.visibility = FieldVisibility.AlwaysShown;
|
||||
}
|
||||
|
||||
return left(fieldSetting);
|
||||
},
|
||||
(r) => right(r),
|
||||
);
|
||||
});
|
||||
@@ -31,7 +38,18 @@ class FieldSettingsBackendService {
|
||||
|
||||
return DatabaseEventGetAllFieldSettings(payload).send().then((result) {
|
||||
return result.fold(
|
||||
(fieldSettings) => left(fieldSettings.items),
|
||||
(repeatedFieldSettings) {
|
||||
final fieldSettings = <FieldSettingsPB>[];
|
||||
|
||||
for (final fieldSetting in repeatedFieldSettings.items) {
|
||||
if (!fieldSetting.hasVisibility()) {
|
||||
fieldSetting.visibility = FieldVisibility.AlwaysShown;
|
||||
}
|
||||
fieldSettings.add(fieldSetting);
|
||||
}
|
||||
|
||||
return left(fieldSettings);
|
||||
},
|
||||
(r) => right(r),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -246,14 +246,11 @@ class RowCache {
|
||||
// ignore: prefer_collection_literals
|
||||
final cellContextMap = CellContextByFieldId();
|
||||
for (final fieldInfo in _fieldDelegate.fieldInfos) {
|
||||
if (fieldInfo.visibility != null &&
|
||||
fieldInfo.visibility != FieldVisibility.AlwaysHidden) {
|
||||
cellContextMap[fieldInfo.id] = DatabaseCellContext(
|
||||
rowMeta: rowMeta,
|
||||
viewId: viewId,
|
||||
fieldInfo: fieldInfo,
|
||||
);
|
||||
}
|
||||
cellContextMap[fieldInfo.id] = DatabaseCellContext(
|
||||
rowMeta: rowMeta,
|
||||
viewId: viewId,
|
||||
fieldInfo: fieldInfo,
|
||||
);
|
||||
}
|
||||
return cellContextMap;
|
||||
}
|
||||
|
||||
+10
-5
@@ -23,12 +23,14 @@ class CalendarEventEditorBloc
|
||||
await event.when(
|
||||
initial: () {
|
||||
_startListening();
|
||||
final cells = rowController.loadData();
|
||||
final cells = rowController
|
||||
.loadData()
|
||||
.values
|
||||
.where((cellContext) => cellContext.isVisible())
|
||||
.toList();
|
||||
if (!isClosed) {
|
||||
add(
|
||||
CalendarEventEditorEvent.didReceiveCellDatas(
|
||||
cells.values.toList(),
|
||||
),
|
||||
CalendarEventEditorEvent.didReceiveCellDatas(cells),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -47,8 +49,11 @@ class CalendarEventEditorBloc
|
||||
rowController.addListener(
|
||||
onRowChanged: (cells, reason) {
|
||||
if (!isClosed) {
|
||||
final cellData = cells.values
|
||||
.where((cellContext) => cellContext.isVisible())
|
||||
.toList();
|
||||
add(
|
||||
CalendarEventEditorEvent.didReceiveCellDatas(cells.values.toList()),
|
||||
CalendarEventEditorEvent.didReceiveCellDatas(cellData),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
+2
-2
@@ -249,8 +249,9 @@ class _PropertyCellState extends State<PropertyCell> {
|
||||
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
);
|
||||
case FieldType.Checklist:
|
||||
return SelectOptionCellStyle(
|
||||
return ChecklistCellStyle(
|
||||
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
|
||||
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
);
|
||||
case FieldType.Number:
|
||||
return GridNumberCellStyle(
|
||||
@@ -265,7 +266,6 @@ class _PropertyCellState extends State<PropertyCell> {
|
||||
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
|
||||
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
);
|
||||
|
||||
case FieldType.URL:
|
||||
return GridURLCellStyle(
|
||||
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
|
||||
|
||||
+14
-10
@@ -39,6 +39,8 @@ class RowBloc extends Bloc<RowEvent, RowState> {
|
||||
_rowBackendSvc.createRowAfterRow(rowId);
|
||||
},
|
||||
didReceiveCells: (cellByFieldId, reason) async {
|
||||
cellByFieldId
|
||||
.removeWhere((_, cellContext) => !cellContext.isVisible());
|
||||
final cells = cellByFieldId.values
|
||||
.map((e) => GridCellEquatable(e.fieldInfo))
|
||||
.toList();
|
||||
@@ -106,16 +108,18 @@ class RowState with _$RowState {
|
||||
|
||||
factory RowState.initial(
|
||||
CellContextByFieldId cellByFieldId,
|
||||
) =>
|
||||
RowState(
|
||||
cellByFieldId: cellByFieldId,
|
||||
cells: UnmodifiableListView(
|
||||
cellByFieldId.values
|
||||
.map((e) => GridCellEquatable(e.fieldInfo))
|
||||
.toList(),
|
||||
),
|
||||
rowSource: const RowSourece.disk(),
|
||||
);
|
||||
) {
|
||||
cellByFieldId.removeWhere((_, cellContext) => !cellContext.isVisible());
|
||||
return RowState(
|
||||
cellByFieldId: cellByFieldId,
|
||||
cells: UnmodifiableListView(
|
||||
cellByFieldId.values
|
||||
.map((e) => GridCellEquatable(e.fieldInfo))
|
||||
.toList(),
|
||||
),
|
||||
rowSource: const RowSourece.disk(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GridCellEquatable extends Equatable {
|
||||
|
||||
+124
-47
@@ -14,49 +14,73 @@ class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
|
||||
|
||||
RowDetailBloc({
|
||||
required this.rowController,
|
||||
}) : super(RowDetailState.initial()) {
|
||||
}) : super(RowDetailState.initial(rowController.loadData())) {
|
||||
on<RowDetailEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
initial: () async {
|
||||
await _startListening();
|
||||
final cells = rowController.loadData();
|
||||
if (!isClosed) {
|
||||
add(RowDetailEvent.didReceiveCellDatas(cells.values.toList()));
|
||||
}
|
||||
},
|
||||
didReceiveCellDatas: (cells) {
|
||||
emit(state.copyWith(cells: cells));
|
||||
didReceiveCellDatas: (visibleCells, allCells, numHiddenFields) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
visibleCells: visibleCells,
|
||||
allCells: allCells,
|
||||
numHiddenFields: numHiddenFields,
|
||||
),
|
||||
);
|
||||
},
|
||||
deleteField: (fieldId) {
|
||||
_fieldBackendService(fieldId).deleteField();
|
||||
final fieldService = FieldBackendService(
|
||||
viewId: rowController.viewId,
|
||||
fieldId: fieldId,
|
||||
);
|
||||
fieldService.deleteField();
|
||||
},
|
||||
showField: (fieldId) async {
|
||||
toggleFieldVisibility: (fieldId) async {
|
||||
final fieldInfo = state.allCells
|
||||
.where((cellContext) => cellContext.fieldId == fieldId)
|
||||
.first
|
||||
.fieldInfo;
|
||||
final fieldVisibility =
|
||||
fieldInfo.visibility == FieldVisibility.AlwaysShown
|
||||
? FieldVisibility.AlwaysHidden
|
||||
: FieldVisibility.AlwaysShown;
|
||||
final result =
|
||||
await FieldSettingsBackendService(viewId: rowController.viewId)
|
||||
.updateFieldSettings(
|
||||
fieldId: fieldId,
|
||||
fieldVisibility: FieldVisibility.AlwaysShown,
|
||||
fieldVisibility: fieldVisibility,
|
||||
);
|
||||
result.fold(
|
||||
(l) {},
|
||||
(err) => Log.error(err),
|
||||
);
|
||||
},
|
||||
hideField: (fieldId) async {
|
||||
final result =
|
||||
await FieldSettingsBackendService(viewId: rowController.viewId)
|
||||
.updateFieldSettings(
|
||||
fieldId: fieldId,
|
||||
fieldVisibility: FieldVisibility.AlwaysHidden,
|
||||
);
|
||||
result.fold(
|
||||
(l) {},
|
||||
(err) => Log.error(err),
|
||||
reorderField:
|
||||
(reorderedFieldId, targetFieldId, fromIndex, toIndex) async {
|
||||
await _reorderField(
|
||||
reorderedFieldId,
|
||||
targetFieldId,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
emit,
|
||||
);
|
||||
},
|
||||
reorderField: (fieldId, fromIndex, toIndex) async {
|
||||
await _reorderField(fieldId, fromIndex, toIndex, emit);
|
||||
toggleHiddenFieldVisibility: () {
|
||||
final showHiddenFields = !state.showHiddenFields;
|
||||
final visibleCells = List<DatabaseCellContext>.from(state.allCells);
|
||||
visibleCells.retainWhere(
|
||||
(cellContext) =>
|
||||
!cellContext.fieldInfo.isPrimary &&
|
||||
cellContext.isVisible(showHiddenFields: showHiddenFields),
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
showHiddenFields: showHiddenFields,
|
||||
visibleCells: visibleCells,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -71,36 +95,60 @@ class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
|
||||
|
||||
Future<void> _startListening() async {
|
||||
rowController.addListener(
|
||||
onRowChanged: (cells, reason) {
|
||||
if (!isClosed) {
|
||||
add(RowDetailEvent.didReceiveCellDatas(cells.values.toList()));
|
||||
onRowChanged: (cellMap, reason) {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
final allCells = cellMap.values.toList();
|
||||
int numHiddenFields = 0;
|
||||
final visibleCells = <DatabaseCellContext>[];
|
||||
for (final cell in allCells) {
|
||||
final isPrimary = cell.fieldInfo.isPrimary;
|
||||
|
||||
if (cell.isVisible(showHiddenFields: state.showHiddenFields) &&
|
||||
!isPrimary) {
|
||||
visibleCells.add(cell);
|
||||
}
|
||||
|
||||
if (!cell.isVisible() && !isPrimary) {
|
||||
numHiddenFields++;
|
||||
}
|
||||
}
|
||||
|
||||
add(
|
||||
RowDetailEvent.didReceiveCellDatas(
|
||||
visibleCells,
|
||||
allCells,
|
||||
numHiddenFields,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
FieldBackendService _fieldBackendService(String fieldId) {
|
||||
return FieldBackendService(
|
||||
viewId: rowController.viewId,
|
||||
fieldId: fieldId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _reorderField(
|
||||
String fieldId,
|
||||
String reorderedFieldId,
|
||||
String targetFieldId,
|
||||
int fromIndex,
|
||||
int toIndex,
|
||||
Emitter<RowDetailState> emit,
|
||||
) async {
|
||||
final cells = List<DatabaseCellContext>.from(state.cells);
|
||||
final cells = List<DatabaseCellContext>.from(state.visibleCells);
|
||||
cells.insert(toIndex, cells.removeAt(fromIndex));
|
||||
emit(state.copyWith(cells: cells));
|
||||
emit(state.copyWith(visibleCells: cells));
|
||||
|
||||
final fieldService =
|
||||
FieldBackendService(viewId: rowController.viewId, fieldId: fieldId);
|
||||
final fromIndexInAllFields =
|
||||
state.allCells.indexWhere((cell) => cell.fieldId == reorderedFieldId);
|
||||
final toIndexInAllFields =
|
||||
state.allCells.indexWhere((cell) => cell.fieldId == targetFieldId);
|
||||
|
||||
final fieldService = FieldBackendService(
|
||||
viewId: rowController.viewId,
|
||||
fieldId: reorderedFieldId,
|
||||
);
|
||||
final result = await fieldService.moveField(
|
||||
fromIndex,
|
||||
toIndex,
|
||||
fromIndexInAllFields,
|
||||
toIndexInAllFields,
|
||||
);
|
||||
result.fold((l) {}, (err) => Log.error(err));
|
||||
}
|
||||
@@ -110,25 +158,54 @@ class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
|
||||
class RowDetailEvent with _$RowDetailEvent {
|
||||
const factory RowDetailEvent.initial() = _Initial;
|
||||
const factory RowDetailEvent.deleteField(String fieldId) = _DeleteField;
|
||||
const factory RowDetailEvent.showField(String fieldId) = _ShowField;
|
||||
const factory RowDetailEvent.hideField(String fieldId) = _HideField;
|
||||
const factory RowDetailEvent.toggleFieldVisibility(String fieldId) =
|
||||
_ToggleFieldVisibility;
|
||||
const factory RowDetailEvent.reorderField(
|
||||
String fieldId,
|
||||
String reorderFieldID,
|
||||
String targetFieldID,
|
||||
int fromIndex,
|
||||
int toIndex,
|
||||
) = _ReorderField;
|
||||
const factory RowDetailEvent.toggleHiddenFieldVisibility() =
|
||||
_ToggleHiddenFieldVisibility;
|
||||
const factory RowDetailEvent.didReceiveCellDatas(
|
||||
List<DatabaseCellContext> gridCells,
|
||||
List<DatabaseCellContext> visibleCells,
|
||||
List<DatabaseCellContext> allCells,
|
||||
int numHiddenFields,
|
||||
) = _DidReceiveCellDatas;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class RowDetailState with _$RowDetailState {
|
||||
const factory RowDetailState({
|
||||
required List<DatabaseCellContext> cells,
|
||||
required List<DatabaseCellContext> visibleCells,
|
||||
required List<DatabaseCellContext> allCells,
|
||||
required bool showHiddenFields,
|
||||
required int numHiddenFields,
|
||||
}) = _RowDetailState;
|
||||
|
||||
factory RowDetailState.initial() => RowDetailState(
|
||||
cells: List.empty(),
|
||||
);
|
||||
factory RowDetailState.initial(CellContextByFieldId cellByFieldId) {
|
||||
final allCells = cellByFieldId.values.toList();
|
||||
int numHiddenFields = 0;
|
||||
final visibleCells = <DatabaseCellContext>[];
|
||||
for (final cell in allCells) {
|
||||
final isVisible = cell.isVisible();
|
||||
final isPrimary = cell.fieldInfo.isPrimary;
|
||||
|
||||
if (isVisible && !isPrimary) {
|
||||
visibleCells.add(cell);
|
||||
}
|
||||
|
||||
if (!isVisible && !isPrimary) {
|
||||
numHiddenFields++;
|
||||
}
|
||||
}
|
||||
|
||||
return RowDetailState(
|
||||
visibleCells: visibleCells,
|
||||
allCells: allCells,
|
||||
showHiddenFields: false,
|
||||
numHiddenFields: numHiddenFields,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -40,7 +40,7 @@ class GridRowBottomBar extends StatelessWidget {
|
||||
return Container(
|
||||
padding: GridSize.footerContentInsets,
|
||||
height: GridSize.footerHeight,
|
||||
margin: const EdgeInsets.only(bottom: 200),
|
||||
margin: const EdgeInsets.only(bottom: 200, top: 8),
|
||||
child: const GridAddRowButton(),
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ class _GridFieldCellState extends State<GridFieldCell> {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) {
|
||||
return FieldCellBloc(cellContext: widget.cellContext);
|
||||
return FieldCellBloc(fieldContext: widget.cellContext);
|
||||
},
|
||||
child: BlocBuilder<FieldCellBloc, FieldCellState>(
|
||||
builder: (context, state) {
|
||||
@@ -54,7 +54,7 @@ class _GridFieldCellState extends State<GridFieldCell> {
|
||||
);
|
||||
},
|
||||
child: FieldCellButton(
|
||||
field: widget.cellContext.field,
|
||||
field: widget.cellContext.fieldInfo.field,
|
||||
onTap: () => popoverController.show(),
|
||||
),
|
||||
);
|
||||
|
||||
+11
-11
@@ -34,14 +34,14 @@ class _GridFieldCellActionSheetState extends State<GridFieldCellActionSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_showFieldEditor) {
|
||||
final field = widget.cellContext.field;
|
||||
return SizedBox(
|
||||
width: 400,
|
||||
child: FieldEditor(
|
||||
viewId: widget.cellContext.viewId,
|
||||
fieldInfo: widget.cellContext.fieldInfo,
|
||||
typeOptionLoader: FieldTypeOptionLoader(
|
||||
viewId: widget.cellContext.viewId,
|
||||
field: field,
|
||||
field: widget.cellContext.fieldInfo.field,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -96,8 +96,8 @@ class _EditFieldButton extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _FieldOperationList extends StatelessWidget {
|
||||
final FieldContext fieldInfo;
|
||||
const _FieldOperationList(this.fieldInfo, {Key? key}) : super(key: key);
|
||||
final FieldContext fieldContext;
|
||||
const _FieldOperationList(this.fieldContext, {Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -128,7 +128,7 @@ class _FieldOperationList extends StatelessWidget {
|
||||
bool enable = true;
|
||||
|
||||
// If the field is primary, delete and duplicate are disabled.
|
||||
if (fieldInfo.field.isPrimary) {
|
||||
if (fieldContext.fieldInfo.isPrimary) {
|
||||
switch (action) {
|
||||
case FieldAction.hide:
|
||||
break;
|
||||
@@ -145,7 +145,7 @@ class _FieldOperationList extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
height: GridSize.popoverItemHeight,
|
||||
child: FieldActionCell(
|
||||
fieldInfo: fieldInfo,
|
||||
fieldInfo: fieldContext,
|
||||
action: action,
|
||||
enable: enable,
|
||||
),
|
||||
@@ -217,7 +217,7 @@ extension _FieldActionExtension on FieldAction {
|
||||
}
|
||||
}
|
||||
|
||||
void run(BuildContext context, FieldContext fieldInfo) {
|
||||
void run(BuildContext context, FieldContext fieldContext) {
|
||||
switch (this) {
|
||||
case FieldAction.hide:
|
||||
context
|
||||
@@ -228,8 +228,8 @@ extension _FieldActionExtension on FieldAction {
|
||||
PopoverContainer.of(context).close();
|
||||
|
||||
FieldBackendService(
|
||||
viewId: fieldInfo.viewId,
|
||||
fieldId: fieldInfo.field.id,
|
||||
viewId: fieldContext.viewId,
|
||||
fieldId: fieldContext.fieldInfo.id,
|
||||
).duplicateField();
|
||||
|
||||
break;
|
||||
@@ -240,8 +240,8 @@ extension _FieldActionExtension on FieldAction {
|
||||
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
|
||||
confirm: () {
|
||||
FieldBackendService(
|
||||
viewId: fieldInfo.viewId,
|
||||
fieldId: fieldInfo.field.id,
|
||||
viewId: fieldContext.viewId,
|
||||
fieldId: fieldContext.fieldInfo.field.id,
|
||||
).deleteField();
|
||||
},
|
||||
).show(context);
|
||||
|
||||
+35
-23
@@ -1,6 +1,8 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/field/field_editor_bloc.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/field/field_info.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
@@ -19,17 +21,19 @@ class FieldEditor extends StatefulWidget {
|
||||
final String viewId;
|
||||
final bool isGroupingField;
|
||||
final Function(String)? onDeleted;
|
||||
final Function(String)? onHidden;
|
||||
final Function(String)? onToggleVisibility;
|
||||
final FieldTypeOptionLoader typeOptionLoader;
|
||||
final FieldInfo? fieldInfo;
|
||||
|
||||
const FieldEditor({
|
||||
required this.viewId,
|
||||
required this.typeOptionLoader,
|
||||
this.fieldInfo,
|
||||
this.isGroupingField = false,
|
||||
this.onDeleted,
|
||||
this.onHidden,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
this.onToggleVisibility,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _FieldEditorState();
|
||||
@@ -53,14 +57,14 @@ class _FieldEditorState extends State<FieldEditor> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool requireSpace = widget.onDeleted != null ||
|
||||
widget.onHidden != null ||
|
||||
widget.onToggleVisibility != null ||
|
||||
!widget.typeOptionLoader.field.isPrimary;
|
||||
|
||||
final List<Widget> children = [
|
||||
FieldNameTextField(popoverMutex: popoverMutex),
|
||||
if (requireSpace) const VSpace(4),
|
||||
if (widget.onDeleted != null) _addDeleteFieldButton(),
|
||||
if (widget.onHidden != null) _addHideFieldButton(),
|
||||
if (widget.onToggleVisibility != null) _addHideFieldButton(),
|
||||
if (!widget.typeOptionLoader.field.isPrimary)
|
||||
FieldTypeOptionCell(popoverMutex: popoverMutex),
|
||||
];
|
||||
@@ -88,7 +92,7 @@ class _FieldEditorState extends State<FieldEditor> {
|
||||
builder: (context, state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: _DeleteFieldButton(
|
||||
child: DeleteFieldButton(
|
||||
popoverMutex: popoverMutex,
|
||||
onDeleted: () {
|
||||
state.field.fold(
|
||||
@@ -107,12 +111,14 @@ class _FieldEditorState extends State<FieldEditor> {
|
||||
builder: (context, state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: HideFieldButton(
|
||||
child: FieldVisibilityToggleButton(
|
||||
isFieldHidden:
|
||||
widget.fieldInfo!.visibility == FieldVisibility.AlwaysHidden,
|
||||
popoverMutex: popoverMutex,
|
||||
onHidden: () {
|
||||
onTap: () {
|
||||
state.field.fold(
|
||||
() => Log.error('Can not hidden the field'),
|
||||
(field) => widget.onHidden?.call(field.id),
|
||||
(field) => widget.onToggleVisibility?.call(field.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -218,15 +224,16 @@ class _FieldNameTextFieldState extends State<FieldNameTextField> {
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteFieldButton extends StatelessWidget {
|
||||
@visibleForTesting
|
||||
class DeleteFieldButton extends StatelessWidget {
|
||||
final PopoverMutex popoverMutex;
|
||||
final VoidCallback? onDeleted;
|
||||
|
||||
const _DeleteFieldButton({
|
||||
const DeleteFieldButton({
|
||||
required this.popoverMutex,
|
||||
required this.onDeleted,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -253,15 +260,17 @@ class _DeleteFieldButton extends StatelessWidget {
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class HideFieldButton extends StatelessWidget {
|
||||
class FieldVisibilityToggleButton extends StatelessWidget {
|
||||
final bool isFieldHidden;
|
||||
final PopoverMutex popoverMutex;
|
||||
final VoidCallback? onHidden;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const HideFieldButton({
|
||||
const FieldVisibilityToggleButton({
|
||||
required this.isFieldHidden,
|
||||
required this.popoverMutex,
|
||||
required this.onHidden,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -270,10 +279,13 @@ class HideFieldButton extends StatelessWidget {
|
||||
builder: (context, state) {
|
||||
final Widget button = FlowyButton(
|
||||
text: FlowyText.medium(
|
||||
LocaleKeys.grid_field_hide.tr(),
|
||||
isFieldHidden
|
||||
? LocaleKeys.grid_field_show.tr()
|
||||
: LocaleKeys.grid_field_hide.tr(),
|
||||
),
|
||||
leftIcon: const FlowySvg(FlowySvgs.hide_s),
|
||||
onTap: () => onHidden?.call(),
|
||||
leftIcon:
|
||||
FlowySvg(isFieldHidden ? FlowySvgs.show_m : FlowySvgs.hide_m),
|
||||
onTap: onTap,
|
||||
onHover: (_) => popoverMutex.close(),
|
||||
);
|
||||
return SizedBox(height: GridSize.popoverItemHeight, child: button);
|
||||
|
||||
+3
-3
@@ -98,12 +98,12 @@ class _GridHeaderState extends State<_GridHeader> {
|
||||
.map(
|
||||
(field) => FieldContext(
|
||||
viewId: widget.viewId,
|
||||
field: field.field,
|
||||
fieldInfo: field,
|
||||
),
|
||||
)
|
||||
.map(
|
||||
(ctx) => GridFieldCell(
|
||||
key: _getKeyById(ctx.field.id),
|
||||
key: _getKeyById(ctx.fieldInfo.id),
|
||||
cellContext: ctx,
|
||||
),
|
||||
)
|
||||
@@ -136,7 +136,7 @@ class _GridHeaderState extends State<_GridHeader> {
|
||||
int newIndex,
|
||||
) {
|
||||
if (cells.length > oldIndex) {
|
||||
final field = cells[oldIndex].cellContext.field;
|
||||
final field = cells[oldIndex].cellContext.fieldInfo.field;
|
||||
context
|
||||
.read<GridHeaderBloc>()
|
||||
.add(GridHeaderEvent.moveField(field, oldIndex, newIndex));
|
||||
|
||||
+1
-1
@@ -224,7 +224,7 @@ class _SelectOptionColorCell extends StatelessWidget {
|
||||
|
||||
final colorIcon = SizedBox.square(
|
||||
dimension: 16,
|
||||
child: Container(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color.toColor(context),
|
||||
shape: BoxShape.circle,
|
||||
|
||||
@@ -93,6 +93,8 @@ List<DatabaseCellContext> _makeCells(
|
||||
CellContextByFieldId originalCellMap,
|
||||
) {
|
||||
final List<DatabaseCellContext> cells = [];
|
||||
originalCellMap
|
||||
.removeWhere((fieldId, cellContext) => !cellContext.isVisible());
|
||||
for (final entry in originalCellMap.entries) {
|
||||
// Filter out the cell if it's fieldId equal to the groupFieldId
|
||||
if (groupFieldId != null) {
|
||||
|
||||
@@ -151,6 +151,7 @@ class _GridPropertyCellState extends State<GridPropertyCell> {
|
||||
popupBuilder: (BuildContext context) {
|
||||
return FieldEditor(
|
||||
viewId: widget.viewId,
|
||||
fieldInfo: widget.fieldInfo,
|
||||
typeOptionLoader: FieldTypeOptionLoader(
|
||||
viewId: widget.viewId,
|
||||
field: widget.fieldInfo.field,
|
||||
|
||||
+8
-20
@@ -3,17 +3,17 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
|
||||
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/timestamp.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_calendar.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:dartz/dartz.dart' show Either;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra/time/duration.dart';
|
||||
import 'package:flowy_infra/time/prelude.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -24,9 +24,6 @@ import '../../../../grid/presentation/widgets/common/type_option_separator.dart'
|
||||
import '../../../../grid/presentation/widgets/header/type_option/date.dart';
|
||||
import 'date_cal_bloc.dart';
|
||||
|
||||
final kFirstDay = DateTime.utc(1970, 1, 1);
|
||||
final kLastDay = DateTime.utc(2100, 1, 1);
|
||||
|
||||
class DateCellEditor extends StatefulWidget {
|
||||
final VoidCallback onDismissed;
|
||||
final DateCellController cellController;
|
||||
@@ -51,9 +48,9 @@ class _DateCellEditor extends State<DateCellEditor> {
|
||||
builder: (BuildContext context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return _buildWidget(snapshot);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -81,22 +78,14 @@ class _CellCalendarWidget extends StatefulWidget {
|
||||
const _CellCalendarWidget({
|
||||
required this.cellContext,
|
||||
required this.dateTypeOptionPB,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CellCalendarWidget> createState() => _CellCalendarWidgetState();
|
||||
}
|
||||
|
||||
class _CellCalendarWidgetState extends State<_CellCalendarWidget> {
|
||||
late PopoverMutex popoverMutex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
popoverMutex = PopoverMutex();
|
||||
|
||||
super.initState();
|
||||
}
|
||||
final PopoverMutex popoverMutex = PopoverMutex();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -387,8 +376,7 @@ class _TimeTextField extends StatefulWidget {
|
||||
required this.timeStr,
|
||||
required this.popoverMutex,
|
||||
required this.isEndTime,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TimeTextField> createState() => _TimeTextFieldState();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
|
||||
@@ -35,9 +37,8 @@ class RowPropertyList extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<RowDetailBloc, RowDetailState>(
|
||||
buildWhen: (previous, current) => previous.cells != current.cells,
|
||||
builder: (context, state) {
|
||||
final children = state.cells
|
||||
final children = state.visibleCells
|
||||
.where((element) => !element.fieldInfo.field.isPrimary)
|
||||
.mapIndexed(
|
||||
(index, cell) => _PropertyCell(
|
||||
@@ -48,18 +49,26 @@ class RowPropertyList extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return ReorderableListView(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
final reorderedField = children[oldIndex].cellContext.fieldId;
|
||||
_reorderField(
|
||||
context,
|
||||
state.cells,
|
||||
reorderedField,
|
||||
oldIndex,
|
||||
newIndex,
|
||||
);
|
||||
// when reorderiing downwards, need to update index
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex--;
|
||||
}
|
||||
final reorderedFieldId = children[oldIndex].cellContext.fieldId;
|
||||
final targetFieldId = children[newIndex].cellContext.fieldId;
|
||||
|
||||
context.read<RowDetailBloc>().add(
|
||||
RowDetailEvent.reorderField(
|
||||
reorderedFieldId,
|
||||
targetFieldId,
|
||||
oldIndex,
|
||||
newIndex,
|
||||
),
|
||||
);
|
||||
},
|
||||
buildDefaultDragHandles: false,
|
||||
proxyDecorator: (child, index, animation) => Material(
|
||||
@@ -67,47 +76,37 @@ class RowPropertyList extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
child,
|
||||
const MouseRegion(cursor: SystemMouseCursors.grabbing),
|
||||
MouseRegion(
|
||||
cursor: Platform.isWindows
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.grabbing,
|
||||
child: const SizedBox(
|
||||
width: 16,
|
||||
height: 30,
|
||||
child: FlowySvg(FlowySvgs.drag_element_s),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
footer: Padding(
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
child: CreateRowFieldButton(viewId: viewId),
|
||||
child: Column(
|
||||
children: [
|
||||
if (context.read<RowDetailBloc>().state.numHiddenFields != 0)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 4.0),
|
||||
child: ToggleHiddenFieldsVisibilityButton(),
|
||||
),
|
||||
CreateRowFieldButton(viewId: viewId),
|
||||
],
|
||||
),
|
||||
),
|
||||
children: children,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _reorderField(
|
||||
BuildContext context,
|
||||
List<DatabaseCellContext> cells,
|
||||
String reorderedFieldId,
|
||||
int oldIndex,
|
||||
int newIndex,
|
||||
) {
|
||||
// when reorderiing downwards, need to update index
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex--;
|
||||
}
|
||||
|
||||
// also update index when the index is after the index of the primary field
|
||||
// in the original list of DatabaseCellContext's
|
||||
final primaryFieldIndex =
|
||||
cells.indexWhere((element) => element.fieldInfo.isPrimary);
|
||||
if (oldIndex >= primaryFieldIndex) {
|
||||
oldIndex++;
|
||||
}
|
||||
if (newIndex >= primaryFieldIndex) {
|
||||
newIndex++;
|
||||
}
|
||||
|
||||
context.read<RowDetailBloc>().add(
|
||||
RowDetailEvent.reorderField(reorderedFieldId, oldIndex, newIndex),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PropertyCell extends StatefulWidget {
|
||||
@@ -135,7 +134,9 @@ class _PropertyCellState extends State<_PropertyCell> {
|
||||
final cell = widget.cellBuilder.build(widget.cellContext, style: style);
|
||||
|
||||
final dragThumb = MouseRegion(
|
||||
cursor: SystemMouseCursors.grab,
|
||||
cursor: Platform.isWindows
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.grab,
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 30,
|
||||
@@ -195,14 +196,17 @@ class _PropertyCellState extends State<_PropertyCell> {
|
||||
Widget buildFieldEditor() {
|
||||
return FieldEditor(
|
||||
viewId: widget.cellContext.viewId,
|
||||
fieldInfo: widget.cellContext.fieldInfo,
|
||||
isGroupingField: widget.cellContext.fieldInfo.isGroupField,
|
||||
typeOptionLoader: FieldTypeOptionLoader(
|
||||
viewId: widget.cellContext.viewId,
|
||||
field: widget.cellContext.fieldInfo.field,
|
||||
),
|
||||
onHidden: (fieldId) {
|
||||
onToggleVisibility: (fieldId) {
|
||||
_popoverController.close();
|
||||
context.read<RowDetailBloc>().add(RowDetailEvent.hideField(fieldId));
|
||||
context
|
||||
.read<RowDetailBloc>()
|
||||
.add(RowDetailEvent.toggleFieldVisibility(fieldId));
|
||||
},
|
||||
onDeleted: (fieldId) {
|
||||
_popoverController.close();
|
||||
@@ -275,6 +279,43 @@ GridCellStyle? _customCellStyle(FieldType fieldType) {
|
||||
throw UnimplementedError;
|
||||
}
|
||||
|
||||
class ToggleHiddenFieldsVisibilityButton extends StatelessWidget {
|
||||
const ToggleHiddenFieldsVisibilityButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<RowDetailBloc, RowDetailState>(
|
||||
builder: (context, state) {
|
||||
final text = switch (state.showHiddenFields) {
|
||||
false => LocaleKeys.grid_rowPage_showHiddenFields
|
||||
.plural(state.numHiddenFields),
|
||||
true => LocaleKeys.grid_rowPage_hideHiddenFields
|
||||
.plural(state.numHiddenFields),
|
||||
};
|
||||
|
||||
return SizedBox(
|
||||
height: 30,
|
||||
child: FlowyButton(
|
||||
text: FlowyText.medium(text, color: Theme.of(context).hintColor),
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
leftIcon: RotatedBox(
|
||||
quarterTurns: state.showHiddenFields ? 1 : 3,
|
||||
child: FlowySvg(
|
||||
FlowySvgs.arrow_left_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
|
||||
onTap: () => context.read<RowDetailBloc>().add(
|
||||
const RowDetailEvent.toggleHiddenFieldVisibility(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CreateRowFieldButton extends StatefulWidget {
|
||||
final String viewId;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/export_page_widget.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/base64_string.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart'
|
||||
hide DocumentEvent;
|
||||
@@ -17,7 +18,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -145,18 +145,8 @@ class _DocumentPageState extends State<DocumentPage> {
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
final json = encoder.convert(data.toProto3Json());
|
||||
await File(path).writeAsString(json.base64.base64);
|
||||
|
||||
_showMessage('Export success to $path');
|
||||
}
|
||||
|
||||
void _showMessage(String message) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
if (mounted) {
|
||||
showSnackBarMessage(context, 'Export success to $path');
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: FlowyText(message),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/handlers/reminder_reference.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
|
||||
import 'package:appflowy/workspace/application/appearance.dart';
|
||||
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
@@ -11,6 +15,19 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
final List<CommandShortcutEvent> commandShortcutEvents = [
|
||||
toggleToggleListCommand,
|
||||
...codeBlockCommands,
|
||||
customCopyCommand,
|
||||
customPasteCommand,
|
||||
customCutCommand,
|
||||
...standardCommandShortcutEvents,
|
||||
];
|
||||
|
||||
final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
|
||||
...commandShortcutEvents.map((e) => e.copyWith()).toList(),
|
||||
];
|
||||
|
||||
/// Wrapper for the appflowy editor.
|
||||
class AppFlowyEditorPage extends StatefulWidget {
|
||||
const AppFlowyEditorPage({
|
||||
@@ -34,27 +51,26 @@ class AppFlowyEditorPage extends StatefulWidget {
|
||||
State<AppFlowyEditorPage> createState() => _AppFlowyEditorPageState();
|
||||
}
|
||||
|
||||
final List<CommandShortcutEvent> commandShortcutEvents = [
|
||||
...codeBlockCommands,
|
||||
...standardCommandShortcutEvents,
|
||||
];
|
||||
|
||||
final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
|
||||
...commandShortcutEvents.map((e) => e.copyWith()).toList(),
|
||||
];
|
||||
|
||||
class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
late final ScrollController effectiveScrollController;
|
||||
|
||||
final inlinePageReferenceService = InlinePageReferenceService();
|
||||
late final InlineActionsService inlineActionsService = InlineActionsService(
|
||||
context: context,
|
||||
handlers: [
|
||||
InlinePageReferenceService().inlinePageReferenceDelegate,
|
||||
DateReferenceService(context).dateReferenceDelegate,
|
||||
ReminderReferenceService(context).reminderReferenceDelegate,
|
||||
],
|
||||
);
|
||||
|
||||
final List<CommandShortcutEvent> commandShortcutEvents = [
|
||||
late final List<CommandShortcutEvent> commandShortcutEvents = [
|
||||
toggleToggleListCommand,
|
||||
...codeBlockCommands,
|
||||
customCopyCommand,
|
||||
customPasteCommand,
|
||||
customCutCommand,
|
||||
...standardCommandShortcutEvents,
|
||||
..._buildFindAndReplaceCommands(),
|
||||
];
|
||||
|
||||
final List<ToolbarItem> toolbarItems = [
|
||||
@@ -84,9 +100,6 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
_customAppFlowyBlockComponentBuilders();
|
||||
|
||||
List<CharacterShortcutEvent> get characterShortcutEvents => [
|
||||
// inline page reference list
|
||||
...inlinePageReferenceShortcuts,
|
||||
|
||||
// code block
|
||||
...codeBlockCharacterEvents,
|
||||
|
||||
@@ -104,19 +117,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
..removeWhere(
|
||||
(element) => element == slashCommand,
|
||||
), // remove the default slash command.
|
||||
];
|
||||
|
||||
late final inlinePageReferenceShortcuts = [
|
||||
inlinePageReferenceService.customPageLinkMenu(
|
||||
character: '@',
|
||||
style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
),
|
||||
// uncomment this to enable the inline page reference list
|
||||
// inlinePageReferenceService.customPageLinkMenu(
|
||||
// character: '+',
|
||||
// style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
// ),
|
||||
];
|
||||
/// Inline Actions
|
||||
/// - Reminder
|
||||
/// - Inline-page reference
|
||||
inlineActionsCommand(
|
||||
inlineActionsService,
|
||||
style: styleCustomizer.inlineActionsMenuStyleBuilder(),
|
||||
),
|
||||
];
|
||||
|
||||
late final showSlashMenu = customSlashCommand(
|
||||
slashMenuItems,
|
||||
@@ -146,6 +155,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
if (widget.scrollController == null) {
|
||||
effectiveScrollController.dispose();
|
||||
}
|
||||
inlineActionsService.dispose();
|
||||
|
||||
widget.editorState.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
@@ -158,7 +170,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
final isRTL =
|
||||
context.read<AppearanceSettingsCubit>().state.layoutDirection ==
|
||||
LayoutDirection.rtlLayout;
|
||||
final layoutDirection = isRTL ? TextDirection.rtl : TextDirection.ltr;
|
||||
final textDirection = isRTL ? TextDirection.rtl : TextDirection.ltr;
|
||||
|
||||
_setRTLToolbarItems(isRTL);
|
||||
|
||||
@@ -195,8 +207,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
items: toolbarItems,
|
||||
editorState: widget.editorState,
|
||||
editorScrollController: editorScrollController,
|
||||
textDirection: textDirection,
|
||||
child: Directionality(
|
||||
textDirection: layoutDirection,
|
||||
textDirection: textDirection,
|
||||
child: editor,
|
||||
),
|
||||
),
|
||||
@@ -217,6 +230,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
final configuration = BlockComponentConfiguration(
|
||||
padding: (_) => const EdgeInsets.symmetric(vertical: 5.0),
|
||||
);
|
||||
|
||||
final customBlockComponentBuilderMap = {
|
||||
PageBlockKeys.type: PageBlockComponentBuilder(),
|
||||
ParagraphBlockKeys.type: TextBlockComponentBuilder(
|
||||
@@ -458,13 +472,13 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
}
|
||||
|
||||
Future<void> _initializeShortcuts() async {
|
||||
//TODO(Xazin): Refactor lazy initialization
|
||||
// TODO(Xazin): Refactor lazy initialization
|
||||
defaultCommandShortcutEvents;
|
||||
final settingsShortcutService = SettingsShortcutService();
|
||||
final customizeShortcuts =
|
||||
await settingsShortcutService.getCustomizeShortcuts();
|
||||
await settingsShortcutService.updateCommandShortcuts(
|
||||
standardCommandShortcutEvents,
|
||||
commandShortcutEvents,
|
||||
customizeShortcuts,
|
||||
);
|
||||
}
|
||||
@@ -480,4 +494,33 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
toolbarItems.addAll(textDirectionItems);
|
||||
}
|
||||
}
|
||||
|
||||
List<CommandShortcutEvent> _buildFindAndReplaceCommands() {
|
||||
return findAndReplaceCommands(
|
||||
context: context,
|
||||
style: FindReplaceStyle(
|
||||
findMenuBuilder: (
|
||||
context,
|
||||
editorState,
|
||||
localizations,
|
||||
style,
|
||||
showReplaceMenu,
|
||||
onDismiss,
|
||||
) {
|
||||
return Material(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: FindAndReplaceMenuWidget(
|
||||
editorState: editorState,
|
||||
onDismiss: onDismiss,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -1,5 +1,6 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/decoration.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -32,8 +33,8 @@ void showEmojiPickerMenu(
|
||||
|
||||
final alignment = menuService.alignment;
|
||||
final offset = menuService.offset;
|
||||
final top = alignment == Alignment.bottomLeft ? offset.dy : null;
|
||||
final bottom = alignment == Alignment.topLeft ? offset.dy : null;
|
||||
final top = alignment == Alignment.topLeft ? offset.dy : null;
|
||||
final bottom = alignment == Alignment.bottomLeft ? offset.dy : null;
|
||||
|
||||
keepEditorFocusNotifier.value += 1;
|
||||
final emojiPickerMenuEntry = FullScreenOverlayEntry(
|
||||
@@ -42,10 +43,15 @@ void showEmojiPickerMenu(
|
||||
left: offset.dx,
|
||||
dismissCallback: () => keepEditorFocusNotifier.value -= 1,
|
||||
builder: (context) => Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Container(
|
||||
width: 300,
|
||||
height: 250,
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
decoration: FlowyDecoration.decoration(
|
||||
Theme.of(context).cardColor,
|
||||
Theme.of(context).colorScheme.shadow,
|
||||
),
|
||||
child: EmojiSelectionMenu(
|
||||
onSubmitted: (emoji) {
|
||||
editorState.insertTextAtCurrentSelection(emoji.emoji);
|
||||
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart' hide WidgetBuilder;
|
||||
import 'package:flowy_infra_ui/style_widget/text_input.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FindAndReplaceMenuWidget extends StatefulWidget {
|
||||
const FindAndReplaceMenuWidget({
|
||||
super.key,
|
||||
required this.onDismiss,
|
||||
required this.editorState,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
State<FindAndReplaceMenuWidget> createState() =>
|
||||
_FindAndReplaceMenuWidgetState();
|
||||
}
|
||||
|
||||
class _FindAndReplaceMenuWidgetState extends State<FindAndReplaceMenuWidget> {
|
||||
bool showReplaceMenu = false;
|
||||
|
||||
late SearchServiceV2 searchService = SearchServiceV2(
|
||||
editorState: widget.editorState,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: FindMenu(
|
||||
onDismiss: widget.onDismiss,
|
||||
editorState: widget.editorState,
|
||||
searchService: searchService,
|
||||
onShowReplace: (value) => setState(
|
||||
() => showReplaceMenu = value,
|
||||
),
|
||||
),
|
||||
),
|
||||
showReplaceMenu
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 8.0,
|
||||
),
|
||||
child: ReplaceMenu(
|
||||
editorState: widget.editorState,
|
||||
searchService: searchService,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FindMenu extends StatefulWidget {
|
||||
const FindMenu({
|
||||
super.key,
|
||||
required this.onDismiss,
|
||||
required this.editorState,
|
||||
required this.searchService,
|
||||
required this.onShowReplace,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final VoidCallback onDismiss;
|
||||
final SearchServiceV2 searchService;
|
||||
final void Function(bool value) onShowReplace;
|
||||
|
||||
@override
|
||||
State<FindMenu> createState() => _FindMenuState();
|
||||
}
|
||||
|
||||
class _FindMenuState extends State<FindMenu> {
|
||||
late final FocusNode findTextFieldFocusNode;
|
||||
|
||||
final findTextEditingController = TextEditingController();
|
||||
|
||||
String queriedPattern = '';
|
||||
|
||||
bool showReplaceMenu = false;
|
||||
bool caseSensitive = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
widget.searchService.matchedPositions.addListener(_setState);
|
||||
widget.searchService.currentSelectedIndex.addListener(_setState);
|
||||
|
||||
findTextEditingController.addListener(_searchPattern);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
findTextFieldFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.searchService.matchedPositions.removeListener(_setState);
|
||||
widget.searchService.currentSelectedIndex.removeListener(_setState);
|
||||
widget.searchService.dispose();
|
||||
findTextEditingController.removeListener(_searchPattern);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// the selectedIndex from searchService is 0-based
|
||||
final selectedIndex = widget.searchService.selectedIndex + 1;
|
||||
final matches = widget.searchService.matchedPositions.value;
|
||||
return Row(
|
||||
children: [
|
||||
const HSpace(4.0),
|
||||
// expand/collapse button
|
||||
_FindAndReplaceIcon(
|
||||
icon: showReplaceMenu
|
||||
? FlowySvgs.drop_menu_show_s
|
||||
: FlowySvgs.drop_menu_hide_s,
|
||||
tooltipText: '',
|
||||
onPressed: () {
|
||||
widget.onShowReplace(!showReplaceMenu);
|
||||
setState(
|
||||
() => showReplaceMenu = !showReplaceMenu,
|
||||
);
|
||||
},
|
||||
),
|
||||
const HSpace(4.0),
|
||||
// find text input
|
||||
SizedBox(
|
||||
width: 150,
|
||||
height: 30,
|
||||
child: FlowyFormTextInput(
|
||||
onFocusCreated: (focusNode) {
|
||||
findTextFieldFocusNode = focusNode;
|
||||
},
|
||||
onEditingComplete: () {
|
||||
widget.searchService.navigateToMatch();
|
||||
// after update selection or navigate to match, the editor
|
||||
// will request focus, here's a workaround to request the
|
||||
// focus back to the findTextField
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
FocusScope.of(context).requestFocus(
|
||||
findTextFieldFocusNode,
|
||||
);
|
||||
});
|
||||
},
|
||||
controller: findTextEditingController,
|
||||
hintText: LocaleKeys.findAndReplace_find.tr(),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
),
|
||||
// the count of matches
|
||||
Container(
|
||||
constraints: const BoxConstraints(minWidth: 80),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FlowyText(
|
||||
matches.isEmpty
|
||||
? LocaleKeys.findAndReplace_noResult.tr()
|
||||
: '$selectedIndex of ${matches.length}',
|
||||
),
|
||||
),
|
||||
const HSpace(4.0),
|
||||
// case sensitive button
|
||||
_FindAndReplaceIcon(
|
||||
icon: FlowySvgs.text_s,
|
||||
tooltipText: LocaleKeys.findAndReplace_caseSensitive.tr(),
|
||||
onPressed: () => setState(() {
|
||||
caseSensitive = !caseSensitive;
|
||||
widget.searchService.caseSensitive = caseSensitive;
|
||||
}),
|
||||
isSelected: caseSensitive,
|
||||
),
|
||||
const HSpace(4.0),
|
||||
// previous match button
|
||||
_FindAndReplaceIcon(
|
||||
onPressed: () => widget.searchService.navigateToMatch(moveUp: true),
|
||||
icon: FlowySvgs.arrow_up_s,
|
||||
tooltipText: LocaleKeys.findAndReplace_previousMatch.tr(),
|
||||
),
|
||||
const HSpace(4.0),
|
||||
// next match button
|
||||
_FindAndReplaceIcon(
|
||||
onPressed: () => widget.searchService.navigateToMatch(),
|
||||
icon: FlowySvgs.arrow_down_s,
|
||||
tooltipText: LocaleKeys.findAndReplace_nextMatch.tr(),
|
||||
),
|
||||
const HSpace(4.0),
|
||||
_FindAndReplaceIcon(
|
||||
onPressed: widget.onDismiss,
|
||||
icon: FlowySvgs.close_s,
|
||||
tooltipText: LocaleKeys.findAndReplace_close.tr(),
|
||||
),
|
||||
const HSpace(4.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _searchPattern() {
|
||||
if (findTextEditingController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
widget.searchService.findAndHighlight(findTextEditingController.text);
|
||||
setState(() => queriedPattern = findTextEditingController.text);
|
||||
}
|
||||
|
||||
void _setState() {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
class ReplaceMenu extends StatefulWidget {
|
||||
const ReplaceMenu({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
required this.searchService,
|
||||
this.localizations,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
|
||||
/// The localizations of the find and replace menu
|
||||
final FindReplaceLocalizations? localizations;
|
||||
|
||||
final SearchServiceV2 searchService;
|
||||
|
||||
@override
|
||||
State<ReplaceMenu> createState() => _ReplaceMenuState();
|
||||
}
|
||||
|
||||
class _ReplaceMenuState extends State<ReplaceMenu> {
|
||||
late final FocusNode replaceTextFieldFocusNode;
|
||||
final replaceTextEditingController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// placeholder for aligning the replace menu
|
||||
const HSpace(30),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
height: 30,
|
||||
child: FlowyFormTextInput(
|
||||
onFocusCreated: (focusNode) {
|
||||
replaceTextFieldFocusNode = focusNode;
|
||||
},
|
||||
onEditingComplete: () {
|
||||
widget.searchService.navigateToMatch();
|
||||
// after update selection or navigate to match, the editor
|
||||
// will request focus, here's a workaround to request the
|
||||
// focus back to the findTextField
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
FocusScope.of(context).requestFocus(
|
||||
replaceTextFieldFocusNode,
|
||||
);
|
||||
});
|
||||
},
|
||||
controller: replaceTextEditingController,
|
||||
hintText: LocaleKeys.findAndReplace_replace.tr(),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
),
|
||||
const HSpace(4.0),
|
||||
_FindAndReplaceIcon(
|
||||
onPressed: _replaceSelectedWord,
|
||||
iconBuilder: (_) => const Icon(
|
||||
Icons.find_replace_outlined,
|
||||
size: 16,
|
||||
),
|
||||
tooltipText: LocaleKeys.findAndReplace_replace.tr(),
|
||||
),
|
||||
const HSpace(4.0),
|
||||
_FindAndReplaceIcon(
|
||||
iconBuilder: (_) => const Icon(
|
||||
Icons.change_circle_outlined,
|
||||
size: 16,
|
||||
),
|
||||
tooltipText: LocaleKeys.findAndReplace_replaceAll.tr(),
|
||||
onPressed: () => widget.searchService.replaceAllMatches(
|
||||
replaceTextEditingController.text,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _replaceSelectedWord() {
|
||||
widget.searchService.replaceSelectedWord(replaceTextEditingController.text);
|
||||
}
|
||||
}
|
||||
|
||||
class _FindAndReplaceIcon extends StatelessWidget {
|
||||
const _FindAndReplaceIcon({
|
||||
required this.onPressed,
|
||||
required this.tooltipText,
|
||||
this.icon,
|
||||
this.iconBuilder,
|
||||
this.isSelected,
|
||||
});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final FlowySvgData? icon;
|
||||
final WidgetBuilder? iconBuilder;
|
||||
final String tooltipText;
|
||||
final bool? isSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyIconButton(
|
||||
width: 24,
|
||||
height: 24,
|
||||
onPressed: onPressed,
|
||||
icon: iconBuilder?.call(context) ??
|
||||
(icon != null ? FlowySvg(icon!) : const Placeholder()),
|
||||
tooltipText: tooltipText,
|
||||
isSelected: isSelected,
|
||||
iconColorOnHover: Theme.of(context).colorScheme.onSecondary,
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -180,7 +180,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||
builtInAssetImages[index],
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(builtInAssetImages[index]),
|
||||
@@ -299,7 +299,7 @@ class NewCustomCoverButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
@@ -484,7 +484,7 @@ class _ImageGridItemState extends State<ImageGridItem> {
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: widget.onImageSelect,
|
||||
child: Container(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: FileImage(File(widget.imagePath)),
|
||||
@@ -544,7 +544,7 @@ class ColorItem extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: SizedBox.square(
|
||||
dimension: 25,
|
||||
child: Container(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: option.colorHex.tryToColor(),
|
||||
shape: BoxShape.circle,
|
||||
|
||||
+2
-2
@@ -201,7 +201,7 @@ class CoverImagePreviewWidget extends StatefulWidget {
|
||||
|
||||
class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
|
||||
_buildFilePickerWidget(BuildContext ctx) {
|
||||
return Container(
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: Corners.s6Border,
|
||||
@@ -263,7 +263,7 @@ class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
|
||||
onTap: () {
|
||||
ctx.read<CoverImagePickerBloc>().add(const DeleteImage());
|
||||
},
|
||||
child: Container(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
|
||||
+4
@@ -324,6 +324,7 @@ class DocumentCover extends StatefulWidget {
|
||||
class DocumentCoverState extends State<DocumentCover> {
|
||||
bool isOverlayButtonsHidden = true;
|
||||
bool isPopoverOpen = false;
|
||||
final PopoverController popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -382,12 +383,15 @@ class DocumentCoverState extends State<DocumentCover> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppFlowyPopover(
|
||||
controller: popoverController,
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
offset: const Offset(0, 8),
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
constraints: BoxConstraints.loose(const Size(380, 450)),
|
||||
margin: EdgeInsets.zero,
|
||||
onClose: () => isPopoverOpen = false,
|
||||
child: RoundedTextButton(
|
||||
onPressed: () => popoverController.show(),
|
||||
hoverColor: Theme.of(context).colorScheme.surface,
|
||||
textColor: Theme.of(context).colorScheme.tertiary,
|
||||
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
|
||||
|
||||
+4
-6
@@ -1,5 +1,6 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -65,12 +66,9 @@ class _ImageMenuState extends State<ImageMenu> {
|
||||
final url = widget.node.attributes[ImageBlockKeys.url];
|
||||
if (url != null) {
|
||||
Clipboard.setData(ClipboardData(text: url));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: FlowyText(
|
||||
LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(),
|
||||
),
|
||||
),
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
enum MentionType {
|
||||
page;
|
||||
|
||||
static MentionType fromString(String value) {
|
||||
switch (value) {
|
||||
case 'page':
|
||||
return page;
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MentionBlockKeys {
|
||||
const MentionBlockKeys._();
|
||||
|
||||
static const mention = 'mention';
|
||||
static const type = 'type'; // MentionType, String
|
||||
static const pageId = 'page_id';
|
||||
}
|
||||
|
||||
class InlinePageReferenceService {
|
||||
customPageLinkMenu({
|
||||
bool shouldInsertKeyword = false,
|
||||
SelectionMenuStyle style = SelectionMenuStyle.light,
|
||||
String character = '@',
|
||||
}) {
|
||||
return CharacterShortcutEvent(
|
||||
key: 'show page link menu',
|
||||
character: character,
|
||||
handler: (editorState) async {
|
||||
final items = await generatePageItems(character);
|
||||
return _showPageSelectionMenu(
|
||||
editorState,
|
||||
items,
|
||||
shouldInsertKeyword: shouldInsertKeyword,
|
||||
style: style,
|
||||
character: character,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SelectionMenuService? _selectionMenuService;
|
||||
Future<bool> _showPageSelectionMenu(
|
||||
EditorState editorState,
|
||||
List<SelectionMenuItem> items, {
|
||||
bool shouldInsertKeyword = true,
|
||||
SelectionMenuStyle style = SelectionMenuStyle.light,
|
||||
String character = '@',
|
||||
}) async {
|
||||
if (PlatformExtension.isMobile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// delete the selection
|
||||
await editorState.deleteSelection(selection);
|
||||
|
||||
final afterSelection = editorState.selection;
|
||||
if (afterSelection == null || !afterSelection.isCollapsed) {
|
||||
assert(false, 'the selection should be collapsed');
|
||||
return true;
|
||||
}
|
||||
await editorState.insertTextAtPosition(
|
||||
character,
|
||||
position: selection.start,
|
||||
);
|
||||
|
||||
() {
|
||||
final context = editorState.getNodeAtPath(selection.start.path)?.context;
|
||||
if (context != null) {
|
||||
_selectionMenuService = SelectionMenu(
|
||||
context: context,
|
||||
editorState: editorState,
|
||||
selectionMenuItems: items,
|
||||
deleteSlashByDefault: false,
|
||||
style: style,
|
||||
itemCountFilter: 5,
|
||||
);
|
||||
_selectionMenuService?.show();
|
||||
}
|
||||
}();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<List<SelectionMenuItem>> generatePageItems(String character) async {
|
||||
final service = ViewBackendService();
|
||||
final views = await service.fetchViews();
|
||||
if (views.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
final List<SelectionMenuItem> pages = [];
|
||||
views.sort(((a, b) => b.createTime.compareTo(a.createTime)));
|
||||
|
||||
for (final view in views) {
|
||||
final SelectionMenuItem pageSelectionMenuItem = SelectionMenuItem(
|
||||
icon: (editorState, isSelected, style) => SelectableSvgWidget(
|
||||
data: view.iconData,
|
||||
isSelected: isSelected,
|
||||
style: style,
|
||||
),
|
||||
keywords: [
|
||||
view.name.toLowerCase(),
|
||||
],
|
||||
name: view.name,
|
||||
handler: (editorState, menuService, context) async {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
final delta = node?.delta;
|
||||
if (node == null || delta == null) {
|
||||
return;
|
||||
}
|
||||
final index = selection.endIndex;
|
||||
final lastKeywordIndex =
|
||||
delta.toPlainText().substring(0, index).lastIndexOf(character);
|
||||
// @page name -> $
|
||||
// preload the page infos
|
||||
pageMemorizer[view.id] = view;
|
||||
final transaction = editorState.transaction
|
||||
..replaceText(
|
||||
node,
|
||||
lastKeywordIndex,
|
||||
index - lastKeywordIndex,
|
||||
'\$',
|
||||
attributes: {
|
||||
MentionBlockKeys.mention: {
|
||||
MentionBlockKeys.type: MentionType.page.name,
|
||||
MentionBlockKeys.pageId: view.id,
|
||||
}
|
||||
},
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
},
|
||||
);
|
||||
pages.add(pageSelectionMenuItem);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
+67
-6
@@ -1,22 +1,83 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
enum MentionType {
|
||||
page,
|
||||
date,
|
||||
reminder;
|
||||
|
||||
static MentionType fromString(String value) {
|
||||
switch (value) {
|
||||
case 'page':
|
||||
return page;
|
||||
case 'date':
|
||||
return date;
|
||||
case 'reminder':
|
||||
return reminder;
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MentionBlockKeys {
|
||||
const MentionBlockKeys._();
|
||||
|
||||
static const uid = 'uid'; // UniqueID
|
||||
static const mention = 'mention';
|
||||
static const type = 'type'; // MentionType, String
|
||||
static const pageId = 'page_id';
|
||||
|
||||
// Related to Reminder and Date blocks
|
||||
static const date = 'date';
|
||||
static const includeTime = 'include_time';
|
||||
}
|
||||
|
||||
class MentionBlock extends StatelessWidget {
|
||||
const MentionBlock({
|
||||
super.key,
|
||||
required this.mention,
|
||||
required this.node,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
final Map mention;
|
||||
final Map<String, dynamic> mention;
|
||||
final Node node;
|
||||
final int index;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = MentionType.fromString(mention[MentionBlockKeys.type]);
|
||||
if (type == MentionType.page) {
|
||||
final pageId = mention[MentionBlockKeys.pageId];
|
||||
return MentionPageBlock(key: ValueKey(pageId), pageId: pageId);
|
||||
|
||||
switch (type) {
|
||||
case MentionType.page:
|
||||
final String pageId = mention[MentionBlockKeys.pageId];
|
||||
return MentionPageBlock(
|
||||
key: ValueKey(pageId),
|
||||
pageId: pageId,
|
||||
);
|
||||
case MentionType.reminder:
|
||||
case MentionType.date:
|
||||
final String date = mention[MentionBlockKeys.date];
|
||||
final BuildContext editorContext =
|
||||
context.read<EditorState>().document.root.context!;
|
||||
return MentionDateBlock(
|
||||
key: ValueKey(date),
|
||||
editorContext: editorContext,
|
||||
date: date,
|
||||
node: node,
|
||||
index: index,
|
||||
isReminder: type == MentionType.reminder,
|
||||
reminderId: type == MentionType.reminder
|
||||
? mention[MentionBlockKeys.uid]
|
||||
: null,
|
||||
includeTime: mention[MentionBlockKeys.includeTime] ?? false,
|
||||
);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/appearance.dart';
|
||||
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:calendar_view/calendar_view.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class MentionDateBlock extends StatelessWidget {
|
||||
const MentionDateBlock({
|
||||
super.key,
|
||||
required this.editorContext,
|
||||
required this.date,
|
||||
required this.index,
|
||||
required this.node,
|
||||
this.isReminder = false,
|
||||
this.reminderId,
|
||||
this.includeTime = false,
|
||||
});
|
||||
|
||||
final BuildContext editorContext;
|
||||
final String date;
|
||||
final int index;
|
||||
final Node node;
|
||||
|
||||
final bool isReminder;
|
||||
|
||||
/// If [isReminder] is true, then this must not be
|
||||
/// null or empty
|
||||
final String? reminderId;
|
||||
|
||||
final bool includeTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
DateTime? parsedDate = DateTime.tryParse(date);
|
||||
if (parsedDate == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<ReminderBloc>.value(value: context.read<ReminderBloc>()),
|
||||
BlocProvider<AppearanceSettingsCubit>.value(
|
||||
value: context.read<AppearanceSettingsCubit>(),
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.dateFormat != current.dateFormat ||
|
||||
previous.timeFormat != current.timeFormat,
|
||||
builder: (context, appearance) =>
|
||||
BlocBuilder<ReminderBloc, ReminderState>(
|
||||
builder: (context, state) {
|
||||
final reminder =
|
||||
state.reminders.firstWhereOrNull((r) => r.id == reminderId);
|
||||
final noReminder = reminder == null && isReminder;
|
||||
|
||||
final formattedDate = appearance.dateFormat
|
||||
.formatDate(parsedDate!, includeTime, appearance.timeFormat);
|
||||
|
||||
final options = DatePickerOptions(
|
||||
selectedDay: parsedDate,
|
||||
focusedDay: parsedDate,
|
||||
firstDay: isReminder
|
||||
? noReminder
|
||||
? parsedDate
|
||||
: DateTime.now()
|
||||
: null,
|
||||
lastDay: noReminder ? parsedDate : null,
|
||||
includeTime: includeTime,
|
||||
timeFormat: appearance.timeFormat,
|
||||
onIncludeTimeChanged: (includeTime) {
|
||||
_updateBlock(parsedDate!.withoutTime, includeTime);
|
||||
|
||||
// We can remove time from the date/reminder
|
||||
// block when toggled off.
|
||||
if (!includeTime && isReminder) {
|
||||
_updateScheduledAt(
|
||||
reminderId: reminderId!,
|
||||
selectedDay: parsedDate!.withoutTime,
|
||||
);
|
||||
}
|
||||
},
|
||||
onDaySelected: (selectedDay, focusedDay, includeTime) {
|
||||
parsedDate = selectedDay;
|
||||
|
||||
_updateBlock(selectedDay, includeTime);
|
||||
|
||||
if (isReminder && date != selectedDay.toIso8601String()) {
|
||||
_updateScheduledAt(
|
||||
reminderId: reminderId!,
|
||||
selectedDay: selectedDay,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: (details) => DatePickerMenu(
|
||||
context: context,
|
||||
editorState: context.read<EditorState>(),
|
||||
).show(details.globalPosition, options: options),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
isReminder ? FlowySvgs.clock_alarm_s : FlowySvgs.date_s,
|
||||
size: const Size.square(18.0),
|
||||
color: isReminder && reminder?.isAck == true
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
const HSpace(2),
|
||||
FlowyText(
|
||||
formattedDate,
|
||||
fontSize: fontSize,
|
||||
color: isReminder && reminder?.isAck == true
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateBlock(
|
||||
DateTime date, [
|
||||
bool includeTime = false,
|
||||
]) {
|
||||
final editorState = editorContext.read<EditorState>();
|
||||
final transaction = editorState.transaction
|
||||
..formatText(node, index, 1, {
|
||||
MentionBlockKeys.mention: {
|
||||
MentionBlockKeys.type:
|
||||
isReminder ? MentionType.reminder.name : MentionType.date.name,
|
||||
MentionBlockKeys.date: date.toIso8601String(),
|
||||
MentionBlockKeys.uid: reminderId,
|
||||
MentionBlockKeys.includeTime: includeTime,
|
||||
},
|
||||
});
|
||||
|
||||
editorState.apply(transaction, withUpdateSelection: false);
|
||||
|
||||
// Length of rendered block changes, this synchronizes
|
||||
// the cursor with the new block render
|
||||
editorState.updateSelectionWithReason(
|
||||
editorState.selection,
|
||||
reason: SelectionUpdateReason.transaction,
|
||||
);
|
||||
}
|
||||
|
||||
void _updateScheduledAt({
|
||||
required String reminderId,
|
||||
required DateTime selectedDay,
|
||||
}) {
|
||||
editorContext.read<ReminderBloc>().add(
|
||||
ReminderEvent.update(
|
||||
ReminderUpdate(id: reminderId, scheduledAt: selectedDay),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+25
-22
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/uti
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
@@ -196,9 +197,13 @@ class _AutoCompletionBlockComponentState
|
||||
.then((value) => value.toOption().toNullable());
|
||||
if (userProfile == null) {
|
||||
loading.stop();
|
||||
await _showError(
|
||||
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
|
||||
);
|
||||
if (mounted) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
|
||||
showCancel: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -231,7 +236,11 @@ class _AutoCompletionBlockComponentState
|
||||
},
|
||||
onError: (error) async {
|
||||
loading.stop();
|
||||
await _showError(error.message);
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
error.message,
|
||||
showCancel: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
await _updateGenerationCount();
|
||||
@@ -282,9 +291,13 @@ class _AutoCompletionBlockComponentState
|
||||
.then((value) => value.toOption().toNullable());
|
||||
if (userProfile == null) {
|
||||
loading.stop();
|
||||
await _showError(
|
||||
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
|
||||
);
|
||||
if (mounted) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
|
||||
showCancel: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final textRobot = TextRobot(editorState: editorState);
|
||||
@@ -311,7 +324,11 @@ class _AutoCompletionBlockComponentState
|
||||
onEnd: () async {},
|
||||
onError: (error) async {
|
||||
loading.stop();
|
||||
await _showError(error.message);
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
error.message,
|
||||
showCancel: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
await _updateGenerationCount();
|
||||
@@ -399,20 +416,6 @@ class _AutoCompletionBlockComponentState
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> _showError(String message) async {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
action: SnackBarAction(
|
||||
label: LocaleKeys.button_Cancel.tr(),
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
},
|
||||
),
|
||||
content: FlowyText(message),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _subscribeSelectionGesture() {
|
||||
interceptor = SelectionGestureInterceptor(
|
||||
key: AutoCompletionBlockKeys.type,
|
||||
|
||||
+6
-15
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/uti
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -454,24 +455,14 @@ class _SmartEditInputWidgetState extends State<SmartEditInputWidget> {
|
||||
});
|
||||
},
|
||||
onError: (error) async {
|
||||
await _showError(error.message);
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
error.message,
|
||||
showCancel: true,
|
||||
);
|
||||
await _onExit();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showError(String message) async {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
action: SnackBarAction(
|
||||
label: LocaleKeys.button_Cancel.tr(),
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
},
|
||||
),
|
||||
content: FlowyText(message),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-16
@@ -2,12 +2,12 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final ToolbarItem smartEditItem = ToolbarItem(
|
||||
@@ -71,7 +71,11 @@ class _SmartEditActionListState extends State<SmartEditActionList> {
|
||||
if (isOpenAIEnabled) {
|
||||
controller.show();
|
||||
} else {
|
||||
_showError(LocaleKeys.document_plugins_smartEditDisabled.tr());
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_plugins_smartEditDisabled.tr(),
|
||||
showCancel: true,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -111,18 +115,4 @@ class _SmartEditActionListState extends State<SmartEditActionList> {
|
||||
withUpdateSelection: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showError(String message) async {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
action: SnackBarAction(
|
||||
label: LocaleKeys.button_Cancel.tr(),
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
},
|
||||
),
|
||||
content: FlowyText(message),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -135,7 +135,7 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
color: backgroundColor,
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ export 'database/inline_database_menu_item.dart';
|
||||
export 'database/referenced_database_menu_item.dart';
|
||||
export 'emoji_picker/emoji_menu_item.dart';
|
||||
export 'extensions/flowy_tint_extension.dart';
|
||||
export 'find_and_replace/find_and_replace_menu.dart';
|
||||
export 'font/customize_font_toolbar_item.dart';
|
||||
export 'header/cover_editor_bloc.dart';
|
||||
export 'header/custom_cover_picker.dart';
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
|
||||
|
||||
import 'package:appflowy/util/google_font_family_extension.dart';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -161,6 +164,17 @@ class EditorStyleCustomizer {
|
||||
);
|
||||
}
|
||||
|
||||
InlineActionsMenuStyle inlineActionsMenuStyleBuilder() {
|
||||
final theme = Theme.of(context);
|
||||
return InlineActionsMenuStyle(
|
||||
backgroundColor: theme.cardColor,
|
||||
groupTextColor: theme.colorScheme.onBackground.withOpacity(.8),
|
||||
menuItemTextColor: theme.colorScheme.onBackground,
|
||||
menuItemSelectedColor: theme.hoverColor,
|
||||
menuItemSelectedTextColor: theme.colorScheme.onSurface,
|
||||
);
|
||||
}
|
||||
|
||||
FloatingToolbarStyle floatingToolbarStyleBuilder() {
|
||||
final theme = Theme.of(context);
|
||||
return FloatingToolbarStyle(
|
||||
@@ -203,19 +217,28 @@ class EditorStyleCustomizer {
|
||||
}
|
||||
}
|
||||
|
||||
// customize the inline mention block, like inline page
|
||||
final mention = attributes[MentionBlockKeys.mention] as Map?;
|
||||
// Inline Mentions (Page Reference, Date, Reminder, etc.)
|
||||
final mention =
|
||||
attributes[MentionBlockKeys.mention] as Map<String, dynamic>?;
|
||||
if (mention != null) {
|
||||
final type = mention[MentionBlockKeys.type];
|
||||
if (type == MentionType.page.name) {
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: MentionBlock(
|
||||
key: ValueKey(mention[MentionBlockKeys.pageId]),
|
||||
mention: mention,
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: MentionBlock(
|
||||
key: ValueKey(
|
||||
switch (type) {
|
||||
MentionType.page => mention[MentionBlockKeys.pageId],
|
||||
MentionType.date ||
|
||||
MentionType.reminder =>
|
||||
mention[MentionBlockKeys.date],
|
||||
_ => MentionBlockKeys.mention,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
node: node,
|
||||
index: index,
|
||||
mention: mention,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// customize the inline math equation block
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'package:appflowy/date/date_service.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final _keywords = [
|
||||
LocaleKeys.inlineActions_date.tr().toLowerCase(),
|
||||
];
|
||||
|
||||
class DateReferenceService {
|
||||
DateReferenceService(this.context) {
|
||||
// Initialize locale
|
||||
_locale = context.locale.toLanguageTag();
|
||||
|
||||
// Initializes options
|
||||
_setOptions();
|
||||
}
|
||||
|
||||
final BuildContext context;
|
||||
|
||||
late String _locale;
|
||||
late List<InlineActionsMenuItem> _allOptions;
|
||||
|
||||
List<InlineActionsMenuItem> options = [];
|
||||
|
||||
Future<InlineActionsResult> dateReferenceDelegate([
|
||||
String? search,
|
||||
]) async {
|
||||
// Checks if Locale has changed since last
|
||||
_setLocale();
|
||||
|
||||
// Filters static options
|
||||
_filterOptions(search);
|
||||
|
||||
// Searches for date by pattern
|
||||
_searchDate(search);
|
||||
|
||||
// Searches for date by natural language prompt
|
||||
await _searchDateNLP(search);
|
||||
|
||||
return InlineActionsResult(
|
||||
title: LocaleKeys.inlineActions_date.tr(),
|
||||
results: options,
|
||||
);
|
||||
}
|
||||
|
||||
void _filterOptions(String? search) {
|
||||
if (search == null || search.isEmpty) {
|
||||
options = _allOptions;
|
||||
return;
|
||||
}
|
||||
|
||||
options = _allOptions
|
||||
.where(
|
||||
(option) =>
|
||||
option.keywords != null &&
|
||||
option.keywords!.isNotEmpty &&
|
||||
option.keywords!.any(
|
||||
(keyword) => keyword.contains(search.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (options.isEmpty && _keywords.any((k) => search.startsWith(k))) {
|
||||
options = _allOptions;
|
||||
}
|
||||
}
|
||||
|
||||
void _searchDate(String? search) {
|
||||
if (search == null || search.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final date = DateFormat.yMd(_locale).parse(search);
|
||||
options.insert(0, _itemFromDate(date));
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _searchDateNLP(String? search) async {
|
||||
if (search == null || search.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await DateService.queryDate(search);
|
||||
|
||||
result.fold(
|
||||
(l) {},
|
||||
(date) => options.insert(0, _itemFromDate(date)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _insertDateReference(
|
||||
EditorState editorState,
|
||||
DateTime date,
|
||||
int start,
|
||||
int end,
|
||||
) async {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
final delta = node?.delta;
|
||||
if (node == null || delta == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction
|
||||
..replaceText(
|
||||
node,
|
||||
start,
|
||||
end,
|
||||
'\$',
|
||||
attributes: {
|
||||
MentionBlockKeys.mention: {
|
||||
MentionBlockKeys.type: MentionType.date.name,
|
||||
MentionBlockKeys.date: date.toIso8601String(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
void _setOptions() {
|
||||
final today = DateTime.now();
|
||||
final tomorrow = today.add(const Duration(days: 1));
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
|
||||
_allOptions = [
|
||||
_itemFromDate(
|
||||
today,
|
||||
LocaleKeys.relativeDates_today.tr(),
|
||||
[DateFormat.yMd(_locale).format(today)],
|
||||
),
|
||||
_itemFromDate(
|
||||
tomorrow,
|
||||
LocaleKeys.relativeDates_tomorrow.tr(),
|
||||
[DateFormat.yMd(_locale).format(tomorrow)],
|
||||
),
|
||||
_itemFromDate(
|
||||
yesterday,
|
||||
LocaleKeys.relativeDates_yesterday.tr(),
|
||||
[DateFormat.yMd(_locale).format(yesterday)],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Sets Locale on each search to make sure
|
||||
/// keywords are localized
|
||||
void _setLocale() {
|
||||
final locale = context.locale.toLanguageTag();
|
||||
|
||||
if (locale != _locale) {
|
||||
_locale = locale;
|
||||
_setOptions();
|
||||
}
|
||||
}
|
||||
|
||||
InlineActionsMenuItem _itemFromDate(
|
||||
DateTime date, [
|
||||
String? label,
|
||||
List<String>? keywords,
|
||||
]) {
|
||||
final labelStr = label ?? DateFormat.yMd(_locale).format(date);
|
||||
|
||||
return InlineActionsMenuItem(
|
||||
label: labelStr.capitalize(),
|
||||
keywords: [labelStr.toLowerCase(), ...?keywords],
|
||||
onSelected: (context, editorState, menuService, replace) =>
|
||||
_insertDateReference(
|
||||
editorState,
|
||||
date,
|
||||
replace.$1,
|
||||
replace.$2,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
class InlinePageReferenceService {
|
||||
InlinePageReferenceService() {
|
||||
init();
|
||||
}
|
||||
|
||||
final Completer _initCompleter = Completer<void>();
|
||||
|
||||
late final ViewBackendService service;
|
||||
List<InlineActionsMenuItem> _items = [];
|
||||
List<InlineActionsMenuItem> _filtered = [];
|
||||
|
||||
Future<void> init() async {
|
||||
service = ViewBackendService();
|
||||
|
||||
_generatePageItems().then((value) {
|
||||
_items = value;
|
||||
_filtered = value;
|
||||
_initCompleter.complete();
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<InlineActionsMenuItem>> _filterItems(String? search) async {
|
||||
await _initCompleter.future;
|
||||
|
||||
if (search == null || search.isEmpty) {
|
||||
return _items;
|
||||
}
|
||||
|
||||
return _items
|
||||
.where(
|
||||
(item) =>
|
||||
item.keywords != null &&
|
||||
item.keywords!.isNotEmpty &&
|
||||
item.keywords!.any(
|
||||
(keyword) => keyword.contains(search.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<InlineActionsResult> inlinePageReferenceDelegate([
|
||||
String? search,
|
||||
]) async {
|
||||
_filtered = await _filterItems(search);
|
||||
|
||||
return InlineActionsResult(
|
||||
title: LocaleKeys.inlineActions_pageReference.tr(),
|
||||
results: _filtered,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<InlineActionsMenuItem>> _generatePageItems() async {
|
||||
final views = await service.fetchViews();
|
||||
if (views.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<InlineActionsMenuItem> pages = [];
|
||||
views.sort(((a, b) => b.createTime.compareTo(a.createTime)));
|
||||
|
||||
for (final view in views) {
|
||||
final pageSelectionMenuItem = InlineActionsMenuItem(
|
||||
keywords: [view.name.toLowerCase()],
|
||||
label: view.name,
|
||||
onSelected: (context, editorState, menuService, replace) async {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
final delta = node?.delta;
|
||||
if (node == null || delta == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @page name -> $
|
||||
// preload the page infos
|
||||
pageMemorizer[view.id] = view;
|
||||
final transaction = editorState.transaction
|
||||
..replaceText(
|
||||
node,
|
||||
replace.$1,
|
||||
replace.$2,
|
||||
'\$',
|
||||
attributes: {
|
||||
MentionBlockKeys.mention: {
|
||||
MentionBlockKeys.type: MentionType.page.name,
|
||||
MentionBlockKeys.pageId: view.id,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await editorState.apply(transaction);
|
||||
},
|
||||
);
|
||||
|
||||
pages.add(pageSelectionMenuItem);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import 'package:appflowy/date/date_service.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
final _keywords = [
|
||||
LocaleKeys.inlineActions_reminder_groupTitle.tr().toLowerCase(),
|
||||
LocaleKeys.inlineActions_reminder_shortKeyword.tr().toLowerCase(),
|
||||
];
|
||||
|
||||
class ReminderReferenceService {
|
||||
ReminderReferenceService(this.context) {
|
||||
// Initialize locale
|
||||
_locale = context.locale.toLanguageTag();
|
||||
|
||||
// Initializes options
|
||||
_setOptions();
|
||||
}
|
||||
|
||||
final BuildContext context;
|
||||
|
||||
late String _locale;
|
||||
late List<InlineActionsMenuItem> _allOptions;
|
||||
|
||||
List<InlineActionsMenuItem> options = [];
|
||||
|
||||
Future<InlineActionsResult> reminderReferenceDelegate([
|
||||
String? search,
|
||||
]) async {
|
||||
// Checks if Locale has changed since last
|
||||
_setLocale();
|
||||
|
||||
// Filters static options
|
||||
_filterOptions(search);
|
||||
|
||||
// Searches for date by pattern
|
||||
_searchDate(search);
|
||||
|
||||
// Searches for date by natural language prompt
|
||||
await _searchDateNLP(search);
|
||||
|
||||
return _groupFromResults(options);
|
||||
}
|
||||
|
||||
InlineActionsResult _groupFromResults([
|
||||
List<InlineActionsMenuItem>? options,
|
||||
]) =>
|
||||
InlineActionsResult(
|
||||
title: LocaleKeys.inlineActions_reminder_groupTitle.tr(),
|
||||
results: options ?? [],
|
||||
startsWithKeywords: [
|
||||
LocaleKeys.inlineActions_reminder_groupTitle.tr().toLowerCase(),
|
||||
LocaleKeys.inlineActions_reminder_shortKeyword.tr().toLowerCase(),
|
||||
],
|
||||
);
|
||||
|
||||
void _filterOptions(String? search) {
|
||||
if (search == null || search.isEmpty) {
|
||||
options = _allOptions;
|
||||
return;
|
||||
}
|
||||
|
||||
options = _allOptions
|
||||
.where(
|
||||
(option) =>
|
||||
option.keywords != null &&
|
||||
option.keywords!.isNotEmpty &&
|
||||
option.keywords!.any(
|
||||
(keyword) => keyword.contains(search.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (options.isEmpty && _keywords.any((k) => search.startsWith(k))) {
|
||||
options = _allOptions;
|
||||
}
|
||||
}
|
||||
|
||||
void _searchDate(String? search) {
|
||||
if (search == null || search.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final date = DateFormat.yMd(_locale).parse(search);
|
||||
options.insert(0, _itemFromDate(date));
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _searchDateNLP(String? search) async {
|
||||
if (search == null || search.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await DateService.queryDate(search);
|
||||
|
||||
result.fold(
|
||||
(l) {},
|
||||
(date) {
|
||||
// Only insert dates in the future
|
||||
if (DateTime.now().isBefore(date)) {
|
||||
options.insert(0, _itemFromDate(date));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _insertReminderReference(
|
||||
EditorState editorState,
|
||||
DateTime date,
|
||||
int start,
|
||||
int end,
|
||||
) async {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
final delta = node?.delta;
|
||||
if (node == null || delta == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final viewId = context.read<DocumentBloc>().view.id;
|
||||
final reminder = _reminderFromDate(date, viewId);
|
||||
|
||||
context.read<ReminderBloc>().add(ReminderEvent.add(reminder: reminder));
|
||||
|
||||
final transaction = editorState.transaction
|
||||
..replaceText(
|
||||
node,
|
||||
start,
|
||||
end,
|
||||
'\$',
|
||||
attributes: {
|
||||
MentionBlockKeys.mention: {
|
||||
MentionBlockKeys.type: MentionType.reminder.name,
|
||||
MentionBlockKeys.date: date.toIso8601String(),
|
||||
MentionBlockKeys.uid: reminder.id,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
void _setOptions() {
|
||||
final today = DateTime.now();
|
||||
final tomorrow = today.add(const Duration(days: 1));
|
||||
final oneWeek = today.add(const Duration(days: 7));
|
||||
|
||||
_allOptions = [
|
||||
_itemFromDate(
|
||||
tomorrow,
|
||||
LocaleKeys.relativeDates_tomorrow.tr(),
|
||||
[DateFormat.yMd(_locale).format(tomorrow)],
|
||||
),
|
||||
_itemFromDate(
|
||||
oneWeek,
|
||||
LocaleKeys.relativeDates_oneWeek.tr(),
|
||||
[DateFormat.yMd(_locale).format(oneWeek)],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Sets Locale on each search to make sure
|
||||
/// keywords are localized
|
||||
void _setLocale() {
|
||||
final locale = context.locale.toLanguageTag();
|
||||
|
||||
if (locale != _locale) {
|
||||
_locale = locale;
|
||||
_setOptions();
|
||||
}
|
||||
}
|
||||
|
||||
InlineActionsMenuItem _itemFromDate(
|
||||
DateTime date, [
|
||||
String? label,
|
||||
List<String>? keywords,
|
||||
]) {
|
||||
final labelStr = label ?? DateFormat.yMd(_locale).format(date);
|
||||
|
||||
return InlineActionsMenuItem(
|
||||
label: labelStr.capitalize(),
|
||||
keywords: [labelStr.toLowerCase(), ...?keywords],
|
||||
onSelected: (context, editorState, menuService, replace) =>
|
||||
_insertReminderReference(editorState, date, replace.$1, replace.$2),
|
||||
);
|
||||
}
|
||||
|
||||
ReminderPB _reminderFromDate(DateTime date, String viewId) {
|
||||
return ReminderPB(
|
||||
id: nanoid(),
|
||||
objectId: viewId,
|
||||
title: LocaleKeys.reminderNotification_title.tr(),
|
||||
message: LocaleKeys.reminderNotification_message.tr(),
|
||||
meta: {"document_id": viewId},
|
||||
scheduledAt: Int64(date.millisecondsSinceEpoch ~/ 1000),
|
||||
isAck: date.isBefore(DateTime.now()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
const inlineActionCharacter = '@';
|
||||
|
||||
CharacterShortcutEvent inlineActionsCommand(
|
||||
InlineActionsService inlineActionsService, {
|
||||
InlineActionsMenuStyle style = const InlineActionsMenuStyle.light(),
|
||||
}) =>
|
||||
CharacterShortcutEvent(
|
||||
key: 'Opens Inline Actions Menu',
|
||||
character: inlineActionCharacter,
|
||||
handler: (editorState) => inlineActionsCommandHandler(
|
||||
editorState,
|
||||
inlineActionsService,
|
||||
style,
|
||||
),
|
||||
);
|
||||
|
||||
InlineActionsMenuService? selectionMenuService;
|
||||
Future<bool> inlineActionsCommandHandler(
|
||||
EditorState editorState,
|
||||
InlineActionsService service,
|
||||
InlineActionsMenuStyle style,
|
||||
) async {
|
||||
final selection = editorState.selection;
|
||||
if (PlatformExtension.isMobile || selection == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selection.isCollapsed) {
|
||||
await editorState.deleteSelection(selection);
|
||||
}
|
||||
|
||||
await editorState.insertTextAtPosition(
|
||||
inlineActionCharacter,
|
||||
position: selection.start,
|
||||
);
|
||||
|
||||
final List<InlineActionsResult> initialResults = [];
|
||||
for (final handler in service.handlers) {
|
||||
final group = await handler();
|
||||
|
||||
if (group.results.isNotEmpty) {
|
||||
initialResults.add(group);
|
||||
}
|
||||
}
|
||||
|
||||
if (service.context != null) {
|
||||
selectionMenuService = InlineActionsMenu(
|
||||
context: service.context!,
|
||||
editorState: editorState,
|
||||
service: service,
|
||||
initialResults: initialResults,
|
||||
style: style,
|
||||
);
|
||||
|
||||
selectionMenuService?.show();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
abstract class InlineActionsMenuService {
|
||||
InlineActionsMenuStyle get style;
|
||||
|
||||
void show();
|
||||
void dismiss();
|
||||
}
|
||||
|
||||
class InlineActionsMenu extends InlineActionsMenuService {
|
||||
InlineActionsMenu({
|
||||
required this.context,
|
||||
required this.editorState,
|
||||
required this.service,
|
||||
required this.initialResults,
|
||||
required this.style,
|
||||
});
|
||||
|
||||
final BuildContext context;
|
||||
final EditorState editorState;
|
||||
final InlineActionsService service;
|
||||
final List<InlineActionsResult> initialResults;
|
||||
|
||||
@override
|
||||
final InlineActionsMenuStyle style;
|
||||
|
||||
OverlayEntry? _menuEntry;
|
||||
bool selectionChangedByMenu = false;
|
||||
|
||||
@override
|
||||
void dismiss() {
|
||||
if (_menuEntry != null) {
|
||||
editorState.service.keyboardService?.enable();
|
||||
editorState.service.scrollService?.enable();
|
||||
}
|
||||
|
||||
_menuEntry?.remove();
|
||||
_menuEntry = null;
|
||||
|
||||
// workaround: SelectionService has been released after hot reload.
|
||||
final isSelectionDisposed =
|
||||
editorState.service.selectionServiceKey.currentState == null;
|
||||
if (!isSelectionDisposed) {
|
||||
final selectionService = editorState.service.selectionService;
|
||||
selectionService.currentSelection.removeListener(_onSelectionChange);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelectionUpdate() => selectionChangedByMenu = true;
|
||||
|
||||
@override
|
||||
void show() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
|
||||
}
|
||||
|
||||
void _show() {
|
||||
dismiss();
|
||||
|
||||
final selectionService = editorState.service.selectionService;
|
||||
final selectionRects = selectionService.selectionRects;
|
||||
if (selectionRects.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const double menuHeight = 200.0;
|
||||
const Offset menuOffset = Offset(0, 10);
|
||||
final Offset editorOffset =
|
||||
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
final Size editorSize = editorState.renderBox!.size;
|
||||
|
||||
// Default to opening the overlay below
|
||||
Alignment alignment = Alignment.topLeft;
|
||||
|
||||
final firstRect = selectionRects.first;
|
||||
Offset offset = firstRect.bottomRight + menuOffset;
|
||||
|
||||
// Show above
|
||||
if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) {
|
||||
offset = firstRect.topRight - menuOffset;
|
||||
alignment = Alignment.bottomLeft;
|
||||
|
||||
offset = Offset(
|
||||
offset.dx,
|
||||
MediaQuery.of(context).size.height - offset.dy,
|
||||
);
|
||||
}
|
||||
|
||||
// Show on the left
|
||||
if (offset.dx > editorSize.width / 2) {
|
||||
alignment = alignment == Alignment.topLeft
|
||||
? Alignment.topRight
|
||||
: Alignment.bottomRight;
|
||||
|
||||
offset = Offset(
|
||||
editorSize.width - offset.dx,
|
||||
offset.dy,
|
||||
);
|
||||
}
|
||||
|
||||
final (left, top, right, bottom) = _getPosition(alignment, offset);
|
||||
|
||||
_menuEntry = OverlayEntry(
|
||||
builder: (context) => SizedBox(
|
||||
height: editorSize.height,
|
||||
width: editorSize.width,
|
||||
|
||||
// GestureDetector handles clicks outside of the context menu,
|
||||
// to dismiss the context menu.
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: dismiss,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
left: left,
|
||||
right: right,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: InlineActionsHandler(
|
||||
service: service,
|
||||
results: initialResults,
|
||||
editorState: editorState,
|
||||
menuService: this,
|
||||
onDismiss: dismiss,
|
||||
onSelectionUpdate: _onSelectionUpdate,
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_menuEntry!);
|
||||
|
||||
editorState.service.keyboardService?.disable(showCursor: true);
|
||||
editorState.service.scrollService?.disable();
|
||||
selectionService.currentSelection.addListener(_onSelectionChange);
|
||||
}
|
||||
|
||||
void _onSelectionChange() {
|
||||
// workaround: SelectionService has been released after hot reload.
|
||||
final isSelectionDisposed =
|
||||
editorState.service.selectionServiceKey.currentState == null;
|
||||
if (!isSelectionDisposed) {
|
||||
final selectionService = editorState.service.selectionService;
|
||||
if (selectionService.currentSelection.value == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectionChangedByMenu) {
|
||||
return dismiss();
|
||||
}
|
||||
|
||||
selectionChangedByMenu = false;
|
||||
}
|
||||
|
||||
(double? left, double? top, double? right, double? bottom) _getPosition(
|
||||
Alignment alignment,
|
||||
Offset offset,
|
||||
) {
|
||||
double? left, top, right, bottom;
|
||||
switch (alignment) {
|
||||
case Alignment.topLeft:
|
||||
left = offset.dx;
|
||||
top = offset.dy;
|
||||
break;
|
||||
case Alignment.bottomLeft:
|
||||
left = offset.dx;
|
||||
bottom = offset.dy;
|
||||
break;
|
||||
case Alignment.topRight:
|
||||
right = offset.dx;
|
||||
top = offset.dy;
|
||||
break;
|
||||
case Alignment.bottomRight:
|
||||
right = offset.dx;
|
||||
bottom = offset.dy;
|
||||
break;
|
||||
}
|
||||
|
||||
return (left, top, right, bottom);
|
||||
}
|
||||
}
|
||||
|
||||
class InlineActionsMenuStyle {
|
||||
InlineActionsMenuStyle({
|
||||
required this.backgroundColor,
|
||||
required this.groupTextColor,
|
||||
required this.menuItemTextColor,
|
||||
required this.menuItemSelectedColor,
|
||||
required this.menuItemSelectedTextColor,
|
||||
});
|
||||
|
||||
const InlineActionsMenuStyle.light()
|
||||
: backgroundColor = Colors.white,
|
||||
groupTextColor = const Color(0xFF555555),
|
||||
menuItemTextColor = const Color(0xFF333333),
|
||||
menuItemSelectedColor = const Color(0xFFE0F8FF),
|
||||
menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247);
|
||||
|
||||
const InlineActionsMenuStyle.dark()
|
||||
: backgroundColor = const Color(0xFF282E3A),
|
||||
groupTextColor = const Color(0xFFBBC3CD),
|
||||
menuItemTextColor = const Color(0xFFBBC3CD),
|
||||
menuItemSelectedColor = const Color(0xFF00BCF0),
|
||||
menuItemSelectedTextColor = const Color(0xFF131720);
|
||||
|
||||
/// The background color of the context menu itself
|
||||
///
|
||||
final Color backgroundColor;
|
||||
|
||||
/// The color of the [InlineActionsGroup]'s title text
|
||||
///
|
||||
final Color groupTextColor;
|
||||
|
||||
/// The text color of an [InlineActionsMenuItem]
|
||||
///
|
||||
final Color menuItemTextColor;
|
||||
|
||||
/// The background of the currently selected [InlineActionsMenuItem]
|
||||
///
|
||||
final Color menuItemSelectedColor;
|
||||
|
||||
/// The text color of the currently selected [InlineActionsMenuItem]
|
||||
///
|
||||
final Color menuItemSelectedTextColor;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef SelectItemHandler = void Function(
|
||||
BuildContext context,
|
||||
EditorState editorState,
|
||||
InlineActionsMenuService menuService,
|
||||
(int start, int end) replacement,
|
||||
);
|
||||
|
||||
class InlineActionsMenuItem {
|
||||
InlineActionsMenuItem({
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.keywords,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Widget Function(bool onSelected)? icon;
|
||||
final List<String>? keywords;
|
||||
final SelectItemHandler? onSelected;
|
||||
}
|
||||
|
||||
class InlineActionsResult {
|
||||
InlineActionsResult({
|
||||
required this.title,
|
||||
required this.results,
|
||||
this.startsWithKeywords,
|
||||
});
|
||||
|
||||
/// Localized title to be displayed above the results
|
||||
/// of the current group.
|
||||
///
|
||||
final String title;
|
||||
|
||||
/// List of results that will be displayed for this group
|
||||
/// made up of [SelectionMenuItem]s.
|
||||
///
|
||||
final List<InlineActionsMenuItem> results;
|
||||
|
||||
/// If the search term start with one of these keyword,
|
||||
/// the results will be reordered such that these results
|
||||
/// will be above.
|
||||
///
|
||||
final List<String>? startsWithKeywords;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef InlineActionsDelegate = Future<InlineActionsResult> Function([
|
||||
String? search,
|
||||
]);
|
||||
|
||||
abstract class _InlineActionsProvider {
|
||||
void dispose();
|
||||
}
|
||||
|
||||
class InlineActionsService extends _InlineActionsProvider {
|
||||
InlineActionsService({
|
||||
required this.context,
|
||||
required this.handlers,
|
||||
});
|
||||
|
||||
/// The [BuildContext] in which to show the [InlineActionsMenu]
|
||||
///
|
||||
BuildContext? context;
|
||||
|
||||
final List<InlineActionsDelegate> handlers;
|
||||
|
||||
/// This is a workaround for not having a mounted check.
|
||||
/// Thus when the widget that uses the service is disposed,
|
||||
/// we set the [BuildContext] to null.
|
||||
///
|
||||
@override
|
||||
void dispose() {
|
||||
context = null;
|
||||
}
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_menu_group.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:collection/collection.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/services.dart';
|
||||
|
||||
extension _StartWithsSort on List<InlineActionsResult> {
|
||||
void sortByStartsWithKeyword(String search) => sort(
|
||||
(a, b) {
|
||||
final aCount = a.startsWithKeywords
|
||||
?.where(
|
||||
(key) => search.toLowerCase().startsWith(key),
|
||||
)
|
||||
.length ??
|
||||
0;
|
||||
|
||||
final bCount = b.startsWithKeywords
|
||||
?.where(
|
||||
(key) => search.toLowerCase().startsWith(key),
|
||||
)
|
||||
.length ??
|
||||
0;
|
||||
|
||||
if (aCount > bCount) {
|
||||
return -1;
|
||||
} else if (bCount > aCount) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const _invalidSearchesAmount = 20;
|
||||
|
||||
class InlineActionsHandler extends StatefulWidget {
|
||||
const InlineActionsHandler({
|
||||
super.key,
|
||||
required this.service,
|
||||
required this.results,
|
||||
required this.editorState,
|
||||
required this.menuService,
|
||||
required this.onDismiss,
|
||||
required this.onSelectionUpdate,
|
||||
required this.style,
|
||||
});
|
||||
|
||||
final InlineActionsService service;
|
||||
final List<InlineActionsResult> results;
|
||||
final EditorState editorState;
|
||||
final InlineActionsMenuService menuService;
|
||||
final VoidCallback onDismiss;
|
||||
final VoidCallback onSelectionUpdate;
|
||||
final InlineActionsMenuStyle style;
|
||||
|
||||
@override
|
||||
State<InlineActionsHandler> createState() => _InlineActionsHandlerState();
|
||||
}
|
||||
|
||||
class _InlineActionsHandlerState extends State<InlineActionsHandler> {
|
||||
final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler');
|
||||
|
||||
late List<InlineActionsResult> results = widget.results;
|
||||
int invalidCounter = 0;
|
||||
late int startOffset;
|
||||
|
||||
String _search = '';
|
||||
set search(String search) {
|
||||
_search = search;
|
||||
_doSearch();
|
||||
}
|
||||
|
||||
Future<void> _doSearch() async {
|
||||
final List<InlineActionsResult> newResults = [];
|
||||
for (final handler in widget.service.handlers) {
|
||||
final group = await handler.call(_search);
|
||||
|
||||
if (group.results.isNotEmpty) {
|
||||
newResults.add(group);
|
||||
}
|
||||
}
|
||||
|
||||
invalidCounter = results.every((group) => group.results.isEmpty)
|
||||
? invalidCounter + 1
|
||||
: 0;
|
||||
|
||||
if (invalidCounter >= _invalidSearchesAmount) {
|
||||
return widget.onDismiss();
|
||||
}
|
||||
|
||||
_resetSelection();
|
||||
|
||||
newResults.sortByStartsWithKeyword(_search);
|
||||
|
||||
setState(() {
|
||||
results = newResults;
|
||||
});
|
||||
}
|
||||
|
||||
void _resetSelection() {
|
||||
_selectedGroup = 0;
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
|
||||
int _selectedGroup = 0;
|
||||
int _selectedIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
|
||||
startOffset = widget.editorState.selection?.endIndex ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
onKey: onKey,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.style.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: noResults
|
||||
? SizedBox(
|
||||
width: 150,
|
||||
child: FlowyText.regular(
|
||||
LocaleKeys.inlineActions_noResults.tr(),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: results
|
||||
.where((g) => g.results.isNotEmpty)
|
||||
.mapIndexed(
|
||||
(index, group) => InlineActionsGroup(
|
||||
result: group,
|
||||
editorState: widget.editorState,
|
||||
menuService: widget.menuService,
|
||||
style: widget.style,
|
||||
isGroupSelected: _selectedGroup == index,
|
||||
selectedIndex: _selectedIndex,
|
||||
onSelected: widget.onDismiss,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get noResults =>
|
||||
results.isEmpty || results.every((e) => e.results.isEmpty);
|
||||
|
||||
int get groupLength => results.length;
|
||||
|
||||
int lengthOfGroup(int index) => results[index].results.length;
|
||||
|
||||
InlineActionsMenuItem handlerOf(int groupIndex, int handlerIndex) =>
|
||||
results[groupIndex].results[handlerIndex];
|
||||
|
||||
KeyEventResult onKey(focus, event) {
|
||||
if (event is! RawKeyDownEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
const moveKeys = [
|
||||
LogicalKeyboardKey.arrowUp,
|
||||
LogicalKeyboardKey.arrowDown,
|
||||
LogicalKeyboardKey.tab,
|
||||
];
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
if (_selectedGroup <= groupLength &&
|
||||
_selectedIndex <= lengthOfGroup(_selectedGroup)) {
|
||||
handlerOf(_selectedGroup, _selectedIndex).onSelected?.call(
|
||||
context,
|
||||
widget.editorState,
|
||||
widget.menuService,
|
||||
(startOffset - 1, _search.length + 1),
|
||||
);
|
||||
|
||||
widget.onDismiss();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
widget.onDismiss();
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.backspace) {
|
||||
if (_search.isEmpty) {
|
||||
widget.onDismiss();
|
||||
widget.editorState.deleteBackward(); // Delete '@'
|
||||
} else {
|
||||
widget.onSelectionUpdate();
|
||||
widget.editorState.deleteBackward();
|
||||
_deleteCharacterAtSelection();
|
||||
}
|
||||
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.character != null &&
|
||||
![
|
||||
...moveKeys,
|
||||
LogicalKeyboardKey.arrowLeft,
|
||||
LogicalKeyboardKey.arrowRight
|
||||
].contains(event.logicalKey)) {
|
||||
/// Prevents dismissal of context menu by notifying the parent
|
||||
/// that the selection change occurred from the handler.
|
||||
widget.onSelectionUpdate();
|
||||
|
||||
// Interpolation to avoid having a getter for private variable
|
||||
_insertCharacter(event.character!);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (moveKeys.contains(event.logicalKey)) {
|
||||
_moveSelection(event.logicalKey);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if ([LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight]
|
||||
.contains(event.logicalKey)) {
|
||||
widget.onSelectionUpdate();
|
||||
|
||||
event.logicalKey == LogicalKeyboardKey.arrowLeft
|
||||
? widget.editorState.moveCursorForward(SelectionMoveRange.character)
|
||||
: widget.editorState.moveCursorBackward(SelectionMoveRange.character);
|
||||
|
||||
/// If cursor moves before @ then dismiss menu
|
||||
/// If cursor moves after @search.length then dismiss menu
|
||||
final selection = widget.editorState.selection;
|
||||
if (selection != null &&
|
||||
(selection.endIndex < startOffset ||
|
||||
selection.endIndex > (startOffset + _search.length))) {
|
||||
widget.onDismiss();
|
||||
}
|
||||
|
||||
/// Workaround: When using the move cursor methods, it seems the
|
||||
/// focus goes back to the editor, this makes sure this handler
|
||||
/// receives the next keypress.
|
||||
///
|
||||
_focusNode.requestFocus();
|
||||
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
void _insertCharacter(String character) {
|
||||
widget.editorState.insertTextAtCurrentSelection(character);
|
||||
|
||||
final selection = widget.editorState.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final delta = widget.editorState.getNodeAtPath(selection.end.path)?.delta;
|
||||
if (delta == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/// Grab index of the first character in command (right after @)
|
||||
final startIndex =
|
||||
delta.toPlainText().lastIndexOf(inlineActionCharacter) + 1;
|
||||
|
||||
search = widget.editorState
|
||||
.getTextInSelection(
|
||||
selection.copyWith(
|
||||
start: selection.start.copyWith(offset: startIndex),
|
||||
end: selection.start
|
||||
.copyWith(offset: startIndex + _search.length + 1),
|
||||
),
|
||||
)
|
||||
.join();
|
||||
}
|
||||
|
||||
void _moveSelection(LogicalKeyboardKey key) {
|
||||
if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab].contains(key)) {
|
||||
if (_selectedIndex < lengthOfGroup(_selectedGroup) - 1) {
|
||||
_selectedIndex += 1;
|
||||
} else if (_selectedGroup < groupLength - 1) {
|
||||
_selectedGroup += 1;
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
} else if (key == LogicalKeyboardKey.arrowUp) {
|
||||
if (_selectedIndex == 0 && _selectedGroup > 0) {
|
||||
_selectedGroup -= 1;
|
||||
_selectedIndex = lengthOfGroup(_selectedGroup) - 1;
|
||||
} else if (_selectedIndex > 0) {
|
||||
_selectedIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _deleteCharacterAtSelection() {
|
||||
final selection = widget.editorState.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final node = widget.editorState.getNodeAtPath(selection.end.path);
|
||||
final delta = node?.delta;
|
||||
if (node == null || delta == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
search = delta
|
||||
.toPlainText()
|
||||
.substring(startOffset, startOffset - 1 + _search.length);
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class InlineActionsGroup extends StatelessWidget {
|
||||
const InlineActionsGroup({
|
||||
super.key,
|
||||
required this.result,
|
||||
required this.editorState,
|
||||
required this.menuService,
|
||||
required this.style,
|
||||
required this.onSelected,
|
||||
this.isGroupSelected = false,
|
||||
this.selectedIndex = 0,
|
||||
});
|
||||
|
||||
final InlineActionsResult result;
|
||||
final EditorState editorState;
|
||||
final InlineActionsMenuService menuService;
|
||||
final InlineActionsMenuStyle style;
|
||||
final VoidCallback onSelected;
|
||||
|
||||
final bool isGroupSelected;
|
||||
final int selectedIndex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyText.medium(result.title, color: style.groupTextColor),
|
||||
const SizedBox(height: 4),
|
||||
...result.results.mapIndexed(
|
||||
(index, item) => InlineActionsWidget(
|
||||
item: item,
|
||||
editorState: editorState,
|
||||
menuService: menuService,
|
||||
isSelected: isGroupSelected && index == selectedIndex,
|
||||
style: style,
|
||||
onSelected: onSelected,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InlineActionsWidget extends StatefulWidget {
|
||||
const InlineActionsWidget({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.editorState,
|
||||
required this.menuService,
|
||||
required this.isSelected,
|
||||
required this.style,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final InlineActionsMenuItem item;
|
||||
final EditorState editorState;
|
||||
final InlineActionsMenuService menuService;
|
||||
final bool isSelected;
|
||||
final InlineActionsMenuStyle style;
|
||||
final VoidCallback onSelected;
|
||||
|
||||
@override
|
||||
State<InlineActionsWidget> createState() => _InlineActionsWidgetState();
|
||||
}
|
||||
|
||||
class _InlineActionsWidgetState extends State<InlineActionsWidget> {
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
child: widget.item.icon != null
|
||||
? TextButton.icon(
|
||||
onPressed: _onPressed,
|
||||
style: ButtonStyle(
|
||||
alignment: Alignment.centerLeft,
|
||||
backgroundColor: widget.isSelected
|
||||
? MaterialStateProperty.all(
|
||||
widget.style.menuItemSelectedColor,
|
||||
)
|
||||
: MaterialStateProperty.all(Colors.transparent),
|
||||
),
|
||||
icon: widget.item.icon!.call(widget.isSelected || isHovering),
|
||||
label: FlowyText.regular(
|
||||
widget.item.label,
|
||||
color: widget.isSelected
|
||||
? widget.style.menuItemSelectedTextColor
|
||||
: widget.style.menuItemTextColor,
|
||||
),
|
||||
)
|
||||
: TextButton(
|
||||
onPressed: _onPressed,
|
||||
style: ButtonStyle(
|
||||
alignment: Alignment.centerLeft,
|
||||
backgroundColor: widget.isSelected
|
||||
? MaterialStateProperty.all(
|
||||
widget.style.menuItemSelectedColor,
|
||||
)
|
||||
: MaterialStateProperty.all(Colors.transparent),
|
||||
),
|
||||
onHover: (value) => setState(() => isHovering = value),
|
||||
child: FlowyText.regular(
|
||||
widget.item.label,
|
||||
color: widget.isSelected
|
||||
? widget.style.menuItemSelectedTextColor
|
||||
: widget.style.menuItemTextColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPressed() {
|
||||
widget.onSelected();
|
||||
widget.item.onSelected?.call(
|
||||
context,
|
||||
widget.editorState,
|
||||
widget.menuService,
|
||||
(0, 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,18 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart';
|
||||
import 'package:appflowy/plugins/trash/application/prelude.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/supabase_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/supabase_auth_service.dart';
|
||||
import 'package:appflowy/user/application/prelude.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy/user/application/user_listener.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/user/presentation/router.dart';
|
||||
import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/local_notifications/notification_action_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
@@ -27,7 +30,7 @@ import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/workspace/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_impl.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
@@ -88,14 +91,24 @@ void _resolveCommonService(
|
||||
}
|
||||
|
||||
void _resolveUserDeps(GetIt getIt, IntegrationMode mode) {
|
||||
if (isSupabaseEnabled) {
|
||||
if (mode.isIntegrationTest) {
|
||||
getIt.registerFactory<AuthService>(() => MockAuthService());
|
||||
} else {
|
||||
getIt.registerFactory<AuthService>(() => SupabaseAuthService());
|
||||
}
|
||||
} else {
|
||||
getIt.registerFactory<AuthService>(() => AppFlowyAuthService());
|
||||
switch (currentCloudType()) {
|
||||
case CloudType.unknown:
|
||||
getIt.registerFactory<AuthService>(
|
||||
() => BackendAuthService(
|
||||
AuthTypePB.Local,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case CloudType.supabase:
|
||||
if (mode.isIntegrationTest) {
|
||||
getIt.registerFactory<AuthService>(() => MockAuthService());
|
||||
} else {
|
||||
getIt.registerFactory<AuthService>(() => SupabaseAuthService());
|
||||
}
|
||||
break;
|
||||
case CloudType.appflowyCloud:
|
||||
getIt.registerFactory<AuthService>(() => AFCloudAuthService());
|
||||
break;
|
||||
}
|
||||
|
||||
getIt.registerFactory<AuthRouter>(() => AuthRouter());
|
||||
@@ -133,7 +146,11 @@ void _resolveHomeDeps(GetIt getIt) {
|
||||
(view, _) => DocShareBloc(view: view),
|
||||
);
|
||||
|
||||
getIt.registerSingleton<NotificationActionBloc>(NotificationActionBloc());
|
||||
|
||||
getIt.registerLazySingleton<TabsBloc>(() => TabsBloc());
|
||||
|
||||
getIt.registerSingleton<ReminderBloc>(ReminderBloc());
|
||||
}
|
||||
|
||||
void _resolveFolderDeps(GetIt getIt) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
import 'package:appflowy/workspace/application/local_notifications/notification_service.dart';
|
||||
import 'package:appflowy/startup/tasks/prelude.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../user/application/user_settings_service.dart';
|
||||
import '../../workspace/application/appearance.dart';
|
||||
@@ -20,15 +23,22 @@ class InitAppWidgetTask extends LaunchTask {
|
||||
|
||||
@override
|
||||
Future<void> initialize(LaunchContext context) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await NotificationService.initialize();
|
||||
|
||||
final widget = context.getIt<EntryPoint>().create(context.config);
|
||||
final appearanceSetting =
|
||||
await UserSettingsBackendService().getAppearanceSetting();
|
||||
final dateTimeSettings =
|
||||
await UserSettingsBackendService().getDateTimeSettings();
|
||||
|
||||
// If the passed-in context is not the same as the context of the
|
||||
// application widget, the application widget will be rebuilt.
|
||||
final app = ApplicationWidget(
|
||||
key: ValueKey(context),
|
||||
appearanceSetting: appearanceSetting,
|
||||
dateTimeSettings: dateTimeSettings,
|
||||
appTheme: await appTheme(appearanceSetting.theme),
|
||||
child: widget,
|
||||
);
|
||||
@@ -69,36 +79,56 @@ class InitAppWidgetTask extends LaunchTask {
|
||||
),
|
||||
);
|
||||
|
||||
return Future(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
class ApplicationWidget extends StatelessWidget {
|
||||
final Widget child;
|
||||
final AppearanceSettingsPB appearanceSetting;
|
||||
final AppTheme appTheme;
|
||||
|
||||
class ApplicationWidget extends StatefulWidget {
|
||||
const ApplicationWidget({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.appTheme,
|
||||
required this.appearanceSetting,
|
||||
}) : super(key: key);
|
||||
required this.dateTimeSettings,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final AppearanceSettingsPB appearanceSetting;
|
||||
final AppTheme appTheme;
|
||||
final DateTimeSettingsPB dateTimeSettings;
|
||||
|
||||
@override
|
||||
State<ApplicationWidget> createState() => _ApplicationWidgetState();
|
||||
}
|
||||
|
||||
class _ApplicationWidgetState extends State<ApplicationWidget> {
|
||||
late final GoRouter routerConfig;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// avoid rebuild routerConfig when the appTheme is changed.
|
||||
routerConfig = generateRouter(widget.child);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = AppearanceSettingsCubit(appearanceSetting, appTheme)
|
||||
..readLocaleWhenAppLaunch(context);
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: cubit),
|
||||
BlocProvider<AppearanceSettingsCubit>(
|
||||
create: (_) => AppearanceSettingsCubit(
|
||||
widget.appearanceSetting,
|
||||
widget.dateTimeSettings,
|
||||
widget.appTheme,
|
||||
)..readLocaleWhenAppLaunch(context),
|
||||
),
|
||||
BlocProvider<DocumentAppearanceCubit>(
|
||||
create: (_) => DocumentAppearanceCubit()..fetch(),
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
|
||||
builder: (context, state) => MaterialApp(
|
||||
builder: (context, state) => MaterialApp.router(
|
||||
builder: overlayManagerBuilder(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: state.lightTheme,
|
||||
@@ -108,8 +138,7 @@ class ApplicationWidget extends StatelessWidget {
|
||||
[AppFlowyEditorLocalizations.delegate],
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: state.locale,
|
||||
navigatorKey: AppGlobals.rootNavKey,
|
||||
home: child,
|
||||
routerConfig: routerConfig,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/startup/tasks/app_widget.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/presentation/presentation.dart';
|
||||
import 'package:appflowy/util/platform_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/desktop_home_screen.dart';
|
||||
import 'package:flowy_infra/time/duration.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
GoRouter generateRouter(Widget child) {
|
||||
return GoRouter(
|
||||
navigatorKey: AppGlobals.rootNavKey,
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
// Root route is SplashScreen.
|
||||
// It needs LaunchConfiguration as a parameter, so we get it from ApplicationWidget's child.
|
||||
_rootRoute(child),
|
||||
// Routes in both desktop and mobile
|
||||
_signInScreenRoute(),
|
||||
_skipLogInScreenRoute(),
|
||||
_encryptSecretScreenRoute(),
|
||||
_workspaceErrorScreenRoute(),
|
||||
// Desktop only
|
||||
if (!PlatformExtension.isMobile) _desktopHomeScreenRoute(),
|
||||
// Mobile only
|
||||
if (PlatformExtension.isMobile) _mobileHomeScreenWithNavigationBarRoute(),
|
||||
|
||||
// Unused routes for now, it may need to be used in the future.
|
||||
// TODO(yijing): extract route method like other routes when it comes to be used.
|
||||
// Desktop and Mobile
|
||||
GoRoute(
|
||||
path: WorkspaceStartScreen.routeName,
|
||||
pageBuilder: (context, state) {
|
||||
final args = state.extra as Map<String, dynamic>;
|
||||
return CustomTransitionPage(
|
||||
child: WorkspaceStartScreen(
|
||||
userProfile: args[WorkspaceStartScreen.argUserProfile],
|
||||
),
|
||||
transitionsBuilder: _buildFadeTransition,
|
||||
transitionDuration: _slowDuration,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: SignUpScreen.routeName,
|
||||
pageBuilder: (context, state) {
|
||||
return CustomTransitionPage(
|
||||
child: SignUpScreen(
|
||||
router: getIt<AuthRouter>(),
|
||||
),
|
||||
transitionsBuilder: _buildFadeTransition,
|
||||
transitionDuration: _slowDuration,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// We use StatefulShellRoute to create a StatefulNavigationShell(ScaffoldWithNavBar) to access to multiple pages, and each page retains its own state.
|
||||
StatefulShellRoute _mobileHomeScreenWithNavigationBarRoute() {
|
||||
return StatefulShellRoute.indexedStack(
|
||||
builder: (
|
||||
BuildContext context,
|
||||
GoRouterState state,
|
||||
StatefulNavigationShell navigationShell,
|
||||
) {
|
||||
// Return the widget that implements the custom shell (in this case
|
||||
// using a BottomNavigationBar). The StatefulNavigationShell is passed
|
||||
// to be able access the state of the shell and to navigate to other
|
||||
// branches in a stateful way.
|
||||
return MobileBottomNavigationBar(navigationShell: navigationShell);
|
||||
},
|
||||
branches: <StatefulShellBranch>[
|
||||
StatefulShellBranch(
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
// The screen to display as the root in the first tab of the
|
||||
// bottom navigation bar.
|
||||
path: MobileHomeScreen.routeName,
|
||||
builder: (BuildContext context, GoRouterState state) {
|
||||
return const MobileHomeScreen();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// TODO(yijing): implement other tabs later
|
||||
// The following code comes from the example of StatefulShellRoute.indexedStack. I left there just for placeholder purpose. They will be updated in the future.
|
||||
// The route branch for the second tab of the bottom navigation bar.
|
||||
StatefulShellBranch(
|
||||
// It's not necessary to provide a navigatorKey if it isn't also
|
||||
// needed elsewhere. If not provided, a default key will be used.
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
// The screen to display as the root in the second tab of the
|
||||
// bottom navigation bar.
|
||||
path: '/b',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
const RootPlaceholderScreen(
|
||||
label: 'Favorite',
|
||||
detailsPath: '/b/details/1',
|
||||
secondDetailsPath: '/b/details/2',
|
||||
),
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: 'details/:param',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
DetailsPlaceholderScreen(
|
||||
label: 'Favorite details',
|
||||
param: state.pathParameters['param'],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// The route branch for the third tab of the bottom navigation bar.
|
||||
StatefulShellBranch(
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
// The screen to display as the root in the third tab of the
|
||||
// bottom navigation bar.
|
||||
path: '/c',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
const RootPlaceholderScreen(
|
||||
label: 'Add Document',
|
||||
detailsPath: '/c/details',
|
||||
),
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: 'details',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
DetailsPlaceholderScreen(
|
||||
label: 'Add Document details',
|
||||
extra: state.extra,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: '/d',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
const RootPlaceholderScreen(
|
||||
label: 'Search',
|
||||
detailsPath: '/d/details',
|
||||
),
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: 'details',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
const DetailsPlaceholderScreen(
|
||||
label: 'Search Page details',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: '/e',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
const RootPlaceholderScreen(
|
||||
label: 'Notification',
|
||||
detailsPath: '/e/details',
|
||||
),
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: 'details',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
const DetailsPlaceholderScreen(
|
||||
label: 'Notification Page details',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
GoRoute _desktopHomeScreenRoute() {
|
||||
return GoRoute(
|
||||
path: DesktopHomeScreen.routeName,
|
||||
pageBuilder: (context, state) {
|
||||
return CustomTransitionPage(
|
||||
child: const DesktopHomeScreen(),
|
||||
transitionsBuilder: _buildFadeTransition,
|
||||
transitionDuration: _slowDuration,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
GoRoute _workspaceErrorScreenRoute() {
|
||||
return GoRoute(
|
||||
path: WorkspaceErrorScreen.routeName,
|
||||
pageBuilder: (context, state) {
|
||||
final args = state.extra as Map<String, dynamic>;
|
||||
return CustomTransitionPage(
|
||||
child: WorkspaceErrorScreen(
|
||||
error: args[WorkspaceErrorScreen.argError],
|
||||
userFolder: args[WorkspaceErrorScreen.argUserFolder],
|
||||
),
|
||||
transitionsBuilder: _buildFadeTransition,
|
||||
transitionDuration: _slowDuration,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
GoRoute _encryptSecretScreenRoute() {
|
||||
return GoRoute(
|
||||
path: EncryptSecretScreen.routeName,
|
||||
pageBuilder: (context, state) {
|
||||
final args = state.extra as Map<String, dynamic>;
|
||||
return CustomTransitionPage(
|
||||
child: EncryptSecretScreen(
|
||||
user: args[EncryptSecretScreen.argUser],
|
||||
key: args[EncryptSecretScreen.argKey],
|
||||
),
|
||||
transitionsBuilder: _buildFadeTransition,
|
||||
transitionDuration: _slowDuration,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
GoRoute _skipLogInScreenRoute() {
|
||||
return GoRoute(
|
||||
path: SkipLogInScreen.routeName,
|
||||
pageBuilder: (context, state) {
|
||||
return CustomTransitionPage(
|
||||
child: const SkipLogInScreen(),
|
||||
transitionsBuilder: _buildFadeTransition,
|
||||
transitionDuration: _slowDuration,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
GoRoute _signInScreenRoute() {
|
||||
return GoRoute(
|
||||
path: SignInScreen.routeName,
|
||||
pageBuilder: (context, state) {
|
||||
return CustomTransitionPage(
|
||||
child: const SignInScreen(),
|
||||
transitionsBuilder: _buildFadeTransition,
|
||||
transitionDuration: _slowDuration,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
GoRoute _rootRoute(Widget child) {
|
||||
return GoRoute(
|
||||
path: '/',
|
||||
redirect: (context, state) async {
|
||||
// Every time before navigating to splash screen, we check if user is already logged in in desktop. It is used to skip showing splash screen when user just changes apperance settings like theme mode.
|
||||
final userResponse = await getIt<AuthService>().getUser();
|
||||
final routeName = userResponse.fold(
|
||||
(error) => null,
|
||||
(user) => DesktopHomeScreen.routeName,
|
||||
);
|
||||
if (routeName != null && !PlatformExtension.isMobile) return routeName;
|
||||
|
||||
return null;
|
||||
},
|
||||
// Root route is SplashScreen.
|
||||
// It needs LaunchConfiguration as a parameter, so we get it from ApplicationWidget's child.
|
||||
pageBuilder: (context, state) => MaterialPage(
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFadeTransition(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) =>
|
||||
FadeTransition(opacity: animation, child: child);
|
||||
|
||||
Duration _slowDuration = Duration(
|
||||
milliseconds: (RouteDurations.slow.inMilliseconds).round(),
|
||||
);
|
||||
@@ -7,3 +7,4 @@ export 'platform_error_catcher.dart';
|
||||
export 'windows.dart';
|
||||
export 'localization.dart';
|
||||
export 'supabase_task.dart';
|
||||
export 'generate_router.dart';
|
||||
@@ -36,8 +36,14 @@ AppFlowyEnv getAppFlowyEnv() {
|
||||
anon_key: Env.supabaseAnonKey,
|
||||
);
|
||||
|
||||
final appflowyCloudConfig = AppFlowyCloudConfiguration(
|
||||
base_url: Env.afCloudBaseUrl,
|
||||
base_ws_url: Env.afCloudBaseWSUrl,
|
||||
);
|
||||
|
||||
return AppFlowyEnv(
|
||||
supabase_config: supabaseConfig,
|
||||
appflowy_cloud_config: appflowyCloudConfig,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ SupbaseRealtimeService? realtimeService;
|
||||
class InitSupabaseTask extends LaunchTask {
|
||||
@override
|
||||
Future<void> initialize(LaunchContext context) async {
|
||||
if (!isSupabaseEnabled) {
|
||||
if (!isCloudEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/user/application/auth/backend_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
class AFCloudAuthService implements AuthService {
|
||||
AFCloudAuthService();
|
||||
|
||||
final BackendAuthService _backendAuthService = BackendAuthService(
|
||||
AuthTypePB.AFCloud,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signUp({
|
||||
required String name,
|
||||
required String email,
|
||||
required String password,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signIn({
|
||||
required String email,
|
||||
required String password,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signUpWithOAuth({
|
||||
required String platform,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
//
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
await _backendAuthService.signOut();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signUpAsGuest({
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
return _backendAuthService.signUpAsGuest();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signInWithMagicLink({
|
||||
required String email,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> getUser() async {
|
||||
return UserBackendService.getCurrentUserProfile();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/auth.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
@@ -21,7 +20,6 @@ abstract class AuthService {
|
||||
///
|
||||
/// - `email`: The email address of the user.
|
||||
/// - `password`: The password of the user.
|
||||
/// - `authType`: The type of authentication (optional).
|
||||
/// - `params`: Additional parameters for authentication (optional).
|
||||
///
|
||||
/// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError].
|
||||
@@ -29,7 +27,6 @@ abstract class AuthService {
|
||||
Future<Either<FlowyError, UserProfilePB>> signIn({
|
||||
required String email,
|
||||
required String password,
|
||||
AuthTypePB authType,
|
||||
Map<String, String> params,
|
||||
});
|
||||
|
||||
@@ -38,7 +35,6 @@ abstract class AuthService {
|
||||
/// - `name`: The name of the user.
|
||||
/// - `email`: The email address of the user.
|
||||
/// - `password`: The password of the user.
|
||||
/// - `authType`: The type of authentication (optional).
|
||||
/// - `params`: Additional parameters for registration (optional).
|
||||
///
|
||||
/// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError].
|
||||
@@ -46,31 +42,26 @@ abstract class AuthService {
|
||||
required String name,
|
||||
required String email,
|
||||
required String password,
|
||||
AuthTypePB authType,
|
||||
Map<String, String> params,
|
||||
});
|
||||
|
||||
/// Registers a new user with an OAuth platform.
|
||||
///
|
||||
/// - `platform`: The OAuth platform name.
|
||||
/// - `authType`: The type of authentication (optional).
|
||||
/// - `params`: Additional parameters for OAuth registration (optional).
|
||||
///
|
||||
/// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError].
|
||||
Future<Either<FlowyError, UserProfilePB>> signUpWithOAuth({
|
||||
required String platform,
|
||||
AuthTypePB authType,
|
||||
Map<String, String> params,
|
||||
});
|
||||
|
||||
/// Registers a user as a guest.
|
||||
///
|
||||
/// - `authType`: The type of authentication (optional).
|
||||
/// - `params`: Additional parameters for guest registration (optional).
|
||||
///
|
||||
/// Returns a default [UserProfilePB].
|
||||
Future<Either<FlowyError, UserProfilePB>> signUpAsGuest({
|
||||
AuthTypePB authType,
|
||||
Map<String, String> params,
|
||||
});
|
||||
|
||||
|
||||
+5
-5
@@ -13,12 +13,15 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
|
||||
import '../../../generated/locale_keys.g.dart';
|
||||
import 'device_id.dart';
|
||||
|
||||
class AppFlowyAuthService implements AuthService {
|
||||
class BackendAuthService implements AuthService {
|
||||
final AuthTypePB authType;
|
||||
|
||||
BackendAuthService(this.authType);
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signIn({
|
||||
required String email,
|
||||
required String password,
|
||||
AuthTypePB authType = AuthTypePB.Local,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
final request = SignInPayloadPB.create()
|
||||
@@ -35,7 +38,6 @@ class AppFlowyAuthService implements AuthService {
|
||||
required String name,
|
||||
required String email,
|
||||
required String password,
|
||||
AuthTypePB authType = AuthTypePB.Local,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
final request = SignUpPayloadPB.create()
|
||||
@@ -52,7 +54,6 @@ class AppFlowyAuthService implements AuthService {
|
||||
|
||||
@override
|
||||
Future<void> signOut({
|
||||
AuthTypePB authType = AuthTypePB.Local,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
await UserEventSignOut().send();
|
||||
@@ -61,7 +62,6 @@ class AppFlowyAuthService implements AuthService {
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signUpAsGuest({
|
||||
AuthTypePB authType = AuthTypePB.Local,
|
||||
Map<String, String> params = const {},
|
||||
}) {
|
||||
const password = "Guest!@123456";
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/startup/tasks/prelude.dart';
|
||||
import 'package:appflowy/user/application/auth/appflowy_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/backend_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/device_id.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
@@ -20,14 +20,15 @@ class SupabaseAuthService implements AuthService {
|
||||
SupabaseClient get _client => Supabase.instance.client;
|
||||
GoTrueClient get _auth => _client.auth;
|
||||
|
||||
final AppFlowyAuthService _appFlowyAuthService = AppFlowyAuthService();
|
||||
final BackendAuthService _backendAuthService = BackendAuthService(
|
||||
AuthTypePB.Supabase,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signUp({
|
||||
required String name,
|
||||
required String email,
|
||||
required String password,
|
||||
AuthTypePB authType = AuthTypePB.Supabase,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
// fetch the uuid from supabase.
|
||||
@@ -41,11 +42,10 @@ class SupabaseAuthService implements AuthService {
|
||||
}
|
||||
// assign the uuid to our backend service.
|
||||
// and will transfer this logic to backend later.
|
||||
return _appFlowyAuthService.signUp(
|
||||
return _backendAuthService.signUp(
|
||||
name: name,
|
||||
email: email,
|
||||
password: password,
|
||||
authType: authType,
|
||||
params: {
|
||||
AuthServiceMapKeys.uuid: uuid,
|
||||
},
|
||||
@@ -56,7 +56,6 @@ class SupabaseAuthService implements AuthService {
|
||||
Future<Either<FlowyError, UserProfilePB>> signIn({
|
||||
required String email,
|
||||
required String password,
|
||||
AuthTypePB authType = AuthTypePB.Supabase,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
try {
|
||||
@@ -68,10 +67,9 @@ class SupabaseAuthService implements AuthService {
|
||||
if (uuid == null) {
|
||||
return Left(AuthError.supabaseSignInError);
|
||||
}
|
||||
return _appFlowyAuthService.signIn(
|
||||
return _backendAuthService.signIn(
|
||||
email: email,
|
||||
password: password,
|
||||
authType: authType,
|
||||
params: {
|
||||
AuthServiceMapKeys.uuid: uuid,
|
||||
},
|
||||
@@ -85,7 +83,6 @@ class SupabaseAuthService implements AuthService {
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signUpWithOAuth({
|
||||
required String platform,
|
||||
AuthTypePB authType = AuthTypePB.Supabase,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
// Before signing in, sign out any existing users. Otherwise, the callback will be triggered even if the user doesn't click the 'Sign In' button on the website
|
||||
@@ -118,23 +115,18 @@ class SupabaseAuthService implements AuthService {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut({
|
||||
AuthTypePB authType = AuthTypePB.Supabase,
|
||||
}) async {
|
||||
Future<void> signOut() async {
|
||||
await _auth.signOut();
|
||||
await _appFlowyAuthService.signOut(
|
||||
authType: authType,
|
||||
);
|
||||
await _backendAuthService.signOut();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signUpAsGuest({
|
||||
AuthTypePB authType = AuthTypePB.Supabase,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
// supabase don't support guest login.
|
||||
// so, just forward to our backend.
|
||||
return _appFlowyAuthService.signUpAsGuest();
|
||||
return _backendAuthService.signUpAsGuest();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -177,13 +169,12 @@ class SupabaseAuthService implements AuthService {
|
||||
Future<Either<FlowyError, UserProfilePB>> _setupAuth({
|
||||
required Map<String, String> map,
|
||||
}) async {
|
||||
final payload = ThirdPartyAuthPB(
|
||||
final payload = OAuthPB(
|
||||
authType: AuthTypePB.Supabase,
|
||||
map: map,
|
||||
);
|
||||
return UserEventThirdPartyAuth(payload)
|
||||
.send()
|
||||
.then((value) => value.swap());
|
||||
|
||||
return UserEventOAuth(payload).send().then((value) => value.swap());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-16
@@ -1,6 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/user/application/auth/appflowy_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/backend_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
@@ -20,14 +20,14 @@ class MockAuthService implements AuthService {
|
||||
SupabaseClient get _client => Supabase.instance.client;
|
||||
GoTrueClient get _auth => _client.auth;
|
||||
|
||||
final AppFlowyAuthService _appFlowyAuthService = AppFlowyAuthService();
|
||||
final BackendAuthService _appFlowyAuthService =
|
||||
BackendAuthService(AuthTypePB.Supabase);
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signUp({
|
||||
required String name,
|
||||
required String email,
|
||||
required String password,
|
||||
AuthTypePB authType = AuthTypePB.Supabase,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
throw UnimplementedError();
|
||||
@@ -37,7 +37,6 @@ class MockAuthService implements AuthService {
|
||||
Future<Either<FlowyError, UserProfilePB>> signIn({
|
||||
required String email,
|
||||
required String password,
|
||||
AuthTypePB authType = AuthTypePB.Supabase,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
throw UnimplementedError();
|
||||
@@ -46,7 +45,6 @@ class MockAuthService implements AuthService {
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signUpWithOAuth({
|
||||
required String platform,
|
||||
AuthTypePB authType = AuthTypePB.Supabase,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
try {
|
||||
@@ -58,7 +56,7 @@ class MockAuthService implements AuthService {
|
||||
final uuid = response.user!.id;
|
||||
final email = response.user!.email!;
|
||||
|
||||
final payload = ThirdPartyAuthPB(
|
||||
final payload = OAuthPB(
|
||||
authType: AuthTypePB.Supabase,
|
||||
map: {
|
||||
AuthServiceMapKeys.uuid: uuid,
|
||||
@@ -66,9 +64,8 @@ class MockAuthService implements AuthService {
|
||||
AuthServiceMapKeys.deviceId: 'MockDeviceId'
|
||||
},
|
||||
);
|
||||
return UserEventThirdPartyAuth(payload)
|
||||
.send()
|
||||
.then((value) => value.swap());
|
||||
|
||||
return UserEventOAuth(payload).send().then((value) => value.swap());
|
||||
} on AuthException catch (e) {
|
||||
Log.error(e);
|
||||
return Left(AuthError.supabaseSignInError);
|
||||
@@ -76,18 +73,13 @@ class MockAuthService implements AuthService {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut({
|
||||
AuthTypePB authType = AuthTypePB.Supabase,
|
||||
}) async {
|
||||
Future<void> signOut() async {
|
||||
await _auth.signOut();
|
||||
await _appFlowyAuthService.signOut(
|
||||
authType: authType,
|
||||
);
|
||||
await _appFlowyAuthService.signOut();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, UserProfilePB>> signUpAsGuest({
|
||||
AuthTypePB authType = AuthTypePB.Supabase,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
// supabase don't support guest login.
|
||||
@@ -1,4 +1,4 @@
|
||||
export 'auth/appflowy_auth_service.dart';
|
||||
export 'auth/backend_auth_service.dart';
|
||||
export './sign_in_bloc.dart';
|
||||
export './sign_up_bloc.dart';
|
||||
export './splash_bloc.dart';
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_service.dart';
|
||||
import 'package:appflowy/workspace/application/local_notifications/notification_action.dart';
|
||||
import 'package:appflowy/workspace/application/local_notifications/notification_action_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/local_notifications/notification_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'reminder_bloc.freezed.dart';
|
||||
|
||||
class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
|
||||
late final NotificationActionBloc actionBloc;
|
||||
late final ReminderService reminderService;
|
||||
late final Timer timer;
|
||||
|
||||
ReminderBloc() : super(ReminderState()) {
|
||||
actionBloc = getIt<NotificationActionBloc>();
|
||||
reminderService = const ReminderService();
|
||||
timer = _periodicCheck();
|
||||
|
||||
on<ReminderEvent>((event, emit) async {
|
||||
await event.when(
|
||||
started: () async {
|
||||
final remindersOrFailure = await reminderService.fetchReminders();
|
||||
|
||||
remindersOrFailure.fold(
|
||||
(error) => Log.error(error),
|
||||
(reminders) => _updateState(emit, reminders),
|
||||
);
|
||||
},
|
||||
remove: (reminderId) async {
|
||||
final unitOrFailure =
|
||||
await reminderService.removeReminder(reminderId: reminderId);
|
||||
|
||||
unitOrFailure.fold(
|
||||
(error) => Log.error(error),
|
||||
(_) {
|
||||
final reminders = [...state.reminders];
|
||||
reminders.removeWhere((e) => e.id == reminderId);
|
||||
_updateState(emit, reminders);
|
||||
},
|
||||
);
|
||||
},
|
||||
add: (reminder) async {
|
||||
final unitOrFailure =
|
||||
await reminderService.addReminder(reminder: reminder);
|
||||
|
||||
return unitOrFailure.fold(
|
||||
(error) => Log.error(error),
|
||||
(_) {
|
||||
state.reminders.add(reminder);
|
||||
_updateState(emit, state.reminders);
|
||||
},
|
||||
);
|
||||
},
|
||||
update: (updateObject) async {
|
||||
final reminder =
|
||||
state.reminders.firstWhereOrNull((r) => r.id == updateObject.id);
|
||||
|
||||
if (reminder == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newReminder = updateObject.merge(a: reminder);
|
||||
final failureOrUnit = await reminderService.updateReminder(
|
||||
reminder: updateObject.merge(a: reminder),
|
||||
);
|
||||
|
||||
failureOrUnit.fold(
|
||||
(error) => Log.error(error),
|
||||
(_) {
|
||||
final index =
|
||||
state.reminders.indexWhere((r) => r.id == reminder.id);
|
||||
final reminders = [...state.reminders];
|
||||
reminders.replaceRange(index, index + 1, [newReminder]);
|
||||
_updateState(emit, reminders);
|
||||
},
|
||||
);
|
||||
},
|
||||
pressReminder: (reminderId) {
|
||||
final reminder =
|
||||
state.reminders.firstWhereOrNull((r) => r.id == reminderId);
|
||||
|
||||
if (reminder == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
add(
|
||||
ReminderEvent.update(ReminderUpdate(id: reminderId, isRead: true)),
|
||||
);
|
||||
|
||||
actionBloc.add(
|
||||
NotificationActionEvent.performAction(
|
||||
action: NotificationAction(objectId: reminder.objectId),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _updateState(Emitter emit, List<ReminderPB> reminders) {
|
||||
final now = DateTime.now();
|
||||
final hasUnreads = reminders.any(
|
||||
(r) =>
|
||||
DateTime.fromMillisecondsSinceEpoch(r.scheduledAt.toInt() * 1000)
|
||||
.isBefore(now) &&
|
||||
!r.isRead,
|
||||
);
|
||||
emit(state.copyWith(reminders: reminders, hasUnreads: hasUnreads));
|
||||
}
|
||||
|
||||
Timer _periodicCheck() {
|
||||
return Timer.periodic(
|
||||
const Duration(minutes: 1),
|
||||
(_) {
|
||||
final now = DateTime.now();
|
||||
for (final reminder in state.reminders) {
|
||||
if (reminder.isAck) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final scheduledAt = DateTime.fromMillisecondsSinceEpoch(
|
||||
reminder.scheduledAt.toInt() * 1000,
|
||||
);
|
||||
|
||||
if (scheduledAt.isBefore(now)) {
|
||||
NotificationMessage(
|
||||
identifier: reminder.id,
|
||||
title: LocaleKeys.reminderNotification_title.tr(),
|
||||
body: LocaleKeys.reminderNotification_message.tr(),
|
||||
onClick: () => actionBloc.add(
|
||||
NotificationActionEvent.performAction(
|
||||
action: NotificationAction(objectId: reminder.objectId),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
add(
|
||||
ReminderEvent.update(
|
||||
ReminderUpdate(id: reminder.id, isAck: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ReminderEvent with _$ReminderEvent {
|
||||
// On startup we fetch all reminders and upcoming ones
|
||||
const factory ReminderEvent.started() = _Started;
|
||||
|
||||
// Remove a reminder
|
||||
const factory ReminderEvent.remove({required String reminderId}) = _Remove;
|
||||
|
||||
// Add a reminder
|
||||
const factory ReminderEvent.add({required ReminderPB reminder}) = _Add;
|
||||
|
||||
// Update a reminder (eg. isAck, isRead, etc.)
|
||||
const factory ReminderEvent.update(ReminderUpdate update) = _Update;
|
||||
|
||||
const factory ReminderEvent.pressReminder({required String reminderId}) =
|
||||
_PressReminder;
|
||||
}
|
||||
|
||||
/// Object used to merge updates with
|
||||
/// a [ReminderPB]
|
||||
///
|
||||
class ReminderUpdate {
|
||||
final String id;
|
||||
final bool? isAck;
|
||||
final bool? isRead;
|
||||
final DateTime? scheduledAt;
|
||||
|
||||
ReminderUpdate({
|
||||
required this.id,
|
||||
this.isAck,
|
||||
this.isRead,
|
||||
this.scheduledAt,
|
||||
});
|
||||
|
||||
ReminderPB merge({required ReminderPB a}) {
|
||||
final isAcknowledged = isAck == null && scheduledAt != null
|
||||
? scheduledAt!.isBefore(DateTime.now())
|
||||
: a.isAck;
|
||||
|
||||
return ReminderPB(
|
||||
id: a.id,
|
||||
objectId: a.objectId,
|
||||
scheduledAt: scheduledAt != null
|
||||
? Int64(scheduledAt!.millisecondsSinceEpoch ~/ 1000)
|
||||
: a.scheduledAt,
|
||||
isAck: isAcknowledged,
|
||||
isRead: isRead ?? a.isRead,
|
||||
title: a.title,
|
||||
message: a.message,
|
||||
meta: a.meta,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReminderState {
|
||||
ReminderState({
|
||||
List<ReminderPB>? reminders,
|
||||
bool? hasUnreads,
|
||||
}) : reminders = reminders ?? [],
|
||||
hasUnreads = hasUnreads ?? false;
|
||||
|
||||
final List<ReminderPB> reminders;
|
||||
final bool hasUnreads;
|
||||
|
||||
ReminderState copyWith({
|
||||
List<ReminderPB>? reminders,
|
||||
bool? hasUnreads,
|
||||
}) =>
|
||||
ReminderState(
|
||||
reminders: reminders ?? this.reminders,
|
||||
hasUnreads: hasUnreads ?? this.hasUnreads,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
/// Interface for a Reminder Service that handles
|
||||
/// communication to the backend
|
||||
///
|
||||
abstract class IReminderService {
|
||||
Future<Either<FlowyError, List<ReminderPB>>> fetchReminders();
|
||||
|
||||
Future<Either<FlowyError, Unit>> removeReminder({required String reminderId});
|
||||
|
||||
Future<Either<FlowyError, Unit>> addReminder({required ReminderPB reminder});
|
||||
|
||||
Future<Either<FlowyError, Unit>> updateReminder({
|
||||
required ReminderPB reminder,
|
||||
});
|
||||
}
|
||||
|
||||
class ReminderService implements IReminderService {
|
||||
const ReminderService();
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, Unit>> addReminder({
|
||||
required ReminderPB reminder,
|
||||
}) async {
|
||||
final unitOrFailure = await UserEventCreateReminder(reminder).send();
|
||||
|
||||
return unitOrFailure.swap();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, Unit>> updateReminder({
|
||||
required ReminderPB reminder,
|
||||
}) async {
|
||||
final unitOrFailure = await UserEventUpdateReminder(reminder).send();
|
||||
|
||||
return unitOrFailure.swap();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, List<ReminderPB>>> fetchReminders() async {
|
||||
final resultOrFailure = await UserEventGetAllReminders().send();
|
||||
|
||||
return resultOrFailure.swap().fold((l) => left(l), (r) => right(r.items));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<FlowyError, Unit>> removeReminder({
|
||||
required String reminderId,
|
||||
}) async {
|
||||
final request = ReminderIdentifierPB(id: reminderId);
|
||||
final unitOrFailure = await UserEventRemoveReminder(request).send();
|
||||
|
||||
return unitOrFailure.swap();
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,9 @@ class UserSettingsBackendService {
|
||||
final result = await UserEventGetAppearanceSetting().send();
|
||||
|
||||
return result.fold(
|
||||
(AppearanceSettingsPB setting) {
|
||||
return setting;
|
||||
},
|
||||
(error) {
|
||||
throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty);
|
||||
},
|
||||
(AppearanceSettingsPB setting) => setting,
|
||||
(error) =>
|
||||
throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,4 +25,20 @@ class UserSettingsBackendService {
|
||||
) {
|
||||
return UserEventSetAppearanceSetting(setting).send();
|
||||
}
|
||||
|
||||
Future<DateTimeSettingsPB> getDateTimeSettings() async {
|
||||
final result = await UserEventGetDateTimeSettings().send();
|
||||
|
||||
return result.fold(
|
||||
(DateTimeSettingsPB setting) => setting,
|
||||
(error) =>
|
||||
throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Either<FlowyError, Unit>> setDateTimeSettings(
|
||||
DateTimeSettingsPB settings,
|
||||
) async {
|
||||
return (await UserEventSetDateTimeSettings(settings).send()).swap();
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -5,17 +5,17 @@ import 'package:dartz/dartz.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void handleSuccessOrFail(
|
||||
Either<UserProfilePB, FlowyError> result,
|
||||
void handleUserProfileResult(
|
||||
Either<UserProfilePB, FlowyError> userProfileResult,
|
||||
BuildContext context,
|
||||
AuthRouter router,
|
||||
AuthRouter authRouter,
|
||||
) {
|
||||
result.fold(
|
||||
(user) {
|
||||
if (user.encryptionType == EncryptionTypePB.Symmetric) {
|
||||
router.pushEncryptionScreen(context, user);
|
||||
userProfileResult.fold(
|
||||
(userProfile) {
|
||||
if (userProfile.encryptionType == EncryptionTypePB.Symmetric) {
|
||||
authRouter.pushEncryptionScreen(context, userProfile);
|
||||
} else {
|
||||
router.pushHomeScreen(context, user);
|
||||
authRouter.goHomeScreen(context, userProfile);
|
||||
}
|
||||
},
|
||||
(error) {
|
||||
@@ -1,2 +1,2 @@
|
||||
export 'handle_open_workspace_error.dart';
|
||||
export 'handle_success_or_fail.dart';
|
||||
export 'handle_user_profile_result.dart';
|
||||
@@ -1,18 +1,15 @@
|
||||
import 'package:appflowy/mobile/presentation/mobile_home_page.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/mobile_home_page.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/presentation/screens/screens.dart';
|
||||
import 'package:appflowy/user/presentation/screens/workspace_start_screen/workspace_start_screen.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_screen.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/desktop_home_screen.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:flowy_infra/time/duration.dart';
|
||||
import 'package:flowy_infra_ui/widget/route/animation.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
|
||||
show UserProfilePB;
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/util/platform_extension.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class AuthRouter {
|
||||
void pushForgetPasswordScreen(BuildContext context) {}
|
||||
@@ -25,12 +22,7 @@ class AuthRouter {
|
||||
}
|
||||
|
||||
void pushSignUpScreen(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
PageRoutes.fade(
|
||||
() => SignUpScreen(router: getIt<AuthRouter>()),
|
||||
const RouteSettings(name: SignUpScreen.routeName),
|
||||
),
|
||||
);
|
||||
context.push(SignUpScreen.routeName);
|
||||
}
|
||||
|
||||
/// Navigates to the home screen based on the current workspace and platform.
|
||||
@@ -46,39 +38,22 @@ class AuthRouter {
|
||||
/// @param [context] BuildContext for navigating to the appropriate screen.
|
||||
/// @param [userProfile] UserProfilePB object containing the details of the current user.
|
||||
///
|
||||
Future<void> pushHomeScreen(
|
||||
Future<void> goHomeScreen(
|
||||
BuildContext context,
|
||||
UserProfilePB userProfile,
|
||||
) async {
|
||||
final result = await FolderEventGetCurrentWorkspace().send();
|
||||
result.fold(
|
||||
(workspaceSetting) {
|
||||
// Replace SignInScreen or SkipLogInScreen as root page.
|
||||
// If user click back button, it will exit app rather than go back to SignInScreen or SkipLogInScreen
|
||||
if (PlatformExtension.isMobile) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (BuildContext context) => MobileHomeScreen(
|
||||
key: ValueKey(userProfile.id),
|
||||
userProfile: userProfile,
|
||||
workspaceSetting: workspaceSetting,
|
||||
),
|
||||
),
|
||||
// pop up all the pages until [SplashScreen]
|
||||
(route) => route.settings.name == SplashScreen.routeName,
|
||||
context.go(
|
||||
MobileHomeScreen.routeName,
|
||||
);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRoutes.fade(
|
||||
() => DesktopHomeScreen(
|
||||
key: ValueKey(userProfile.id),
|
||||
userProfile: userProfile,
|
||||
workspaceSetting: workspaceSetting,
|
||||
),
|
||||
const RouteSettings(
|
||||
name: DesktopHomeScreen.routeName,
|
||||
),
|
||||
RouteDurations.slow.inMilliseconds * .001,
|
||||
),
|
||||
context.go(
|
||||
DesktopHomeScreen.routeName,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -90,16 +65,13 @@ class AuthRouter {
|
||||
BuildContext context,
|
||||
UserProfilePB userProfile,
|
||||
) async {
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRoutes.fade(
|
||||
() => EncryptSecretScreen(
|
||||
user: userProfile,
|
||||
key: ValueKey(userProfile.id),
|
||||
),
|
||||
const RouteSettings(name: EncryptSecretScreen.routeName),
|
||||
RouteDurations.slow.inMilliseconds * .001,
|
||||
),
|
||||
// After log in,push EncryptionScreen on the top SignInScreen
|
||||
context.push(
|
||||
EncryptSecretScreen.routeName,
|
||||
extra: {
|
||||
EncryptSecretScreen.argUser: userProfile,
|
||||
EncryptSecretScreen.argKey: ValueKey(userProfile.id),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,38 +80,33 @@ class AuthRouter {
|
||||
UserFolderPB userFolder,
|
||||
FlowyError error,
|
||||
) async {
|
||||
final screen = WorkspaceErrorScreen(
|
||||
userFolder: userFolder,
|
||||
error: error,
|
||||
);
|
||||
await Navigator.of(context).push(
|
||||
PageRoutes.fade(
|
||||
() => screen,
|
||||
const RouteSettings(name: WorkspaceErrorScreen.routeName),
|
||||
RouteDurations.slow.inMilliseconds * .001,
|
||||
),
|
||||
await context.push(
|
||||
WorkspaceErrorScreen.routeName,
|
||||
extra: {
|
||||
WorkspaceErrorScreen.argUserFolder: userFolder,
|
||||
WorkspaceErrorScreen.argError: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SplashRouter {
|
||||
// Unused for now, it was planed to be used in SignUpScreen.
|
||||
// To let user choose workspace than navigate to corresponding home screen.
|
||||
Future<void> pushWorkspaceStartScreen(
|
||||
BuildContext context,
|
||||
UserProfilePB userProfile,
|
||||
) async {
|
||||
final screen = WorkspaceStartScreen(userProfile: userProfile);
|
||||
await Navigator.of(context).push(
|
||||
PageRoutes.fade(
|
||||
() => screen,
|
||||
const RouteSettings(name: WorkspaceStartScreen.routeName),
|
||||
RouteDurations.slow.inMilliseconds * .001,
|
||||
),
|
||||
await context.push(
|
||||
WorkspaceStartScreen.routeName,
|
||||
extra: {
|
||||
WorkspaceStartScreen.argUserProfile: userProfile,
|
||||
},
|
||||
);
|
||||
|
||||
FolderEventGetCurrentWorkspace().send().then((result) {
|
||||
result.fold(
|
||||
(workspaceSettingPB) =>
|
||||
pushHomeScreen(context, userProfile, workspaceSettingPB),
|
||||
(workspaceSettingPB) => pushHomeScreen(context),
|
||||
(r) => null,
|
||||
);
|
||||
});
|
||||
@@ -147,62 +114,29 @@ class SplashRouter {
|
||||
|
||||
void pushHomeScreen(
|
||||
BuildContext context,
|
||||
UserProfilePB userProfile,
|
||||
WorkspaceSettingPB workspaceSetting,
|
||||
) {
|
||||
if (PlatformExtension.isMobile) {
|
||||
Navigator.pushAndRemoveUntil<void>(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (BuildContext context) => MobileHomeScreen(
|
||||
key: ValueKey(userProfile.id),
|
||||
userProfile: userProfile,
|
||||
workspaceSetting: workspaceSetting,
|
||||
),
|
||||
),
|
||||
// pop up all the pages until [SplashScreen]
|
||||
(route) => route.settings.name == SplashScreen.routeName,
|
||||
context.push(
|
||||
MobileHomeScreen.routeName,
|
||||
);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRoutes.fade(
|
||||
() => DesktopHomeScreen(
|
||||
userProfile: userProfile,
|
||||
workspaceSetting: workspaceSetting,
|
||||
key: ValueKey(userProfile.id),
|
||||
),
|
||||
const RouteSettings(
|
||||
name: DesktopHomeScreen.routeName,
|
||||
),
|
||||
RouteDurations.slow.inMilliseconds * .001,
|
||||
),
|
||||
context.push(
|
||||
DesktopHomeScreen.routeName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void pushSignInScreen(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRoutes.fade(
|
||||
() => SignInScreen(router: getIt<AuthRouter>()),
|
||||
const RouteSettings(name: SignInScreen.routeName),
|
||||
RouteDurations.slow.inMilliseconds * .001,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void pushSkipLoginScreen(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRoutes.fade(
|
||||
() => SkipLogInScreen(
|
||||
router: getIt<AuthRouter>(),
|
||||
authService: getIt<AuthService>(),
|
||||
),
|
||||
const RouteSettings(name: SkipLogInScreen.routeName),
|
||||
RouteDurations.slow.inMilliseconds * .001,
|
||||
),
|
||||
);
|
||||
void goHomeScreen(
|
||||
BuildContext context,
|
||||
) {
|
||||
if (PlatformExtension.isMobile) {
|
||||
context.go(
|
||||
MobileHomeScreen.routeName,
|
||||
);
|
||||
} else {
|
||||
context.go(
|
||||
DesktopHomeScreen.routeName,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../application/encrypt_secret_bloc.dart';
|
||||
|
||||
class EncryptSecretScreen extends StatefulWidget {
|
||||
static const routeName = "/EncryptSecretScreen";
|
||||
static const routeName = '/EncryptSecretScreen';
|
||||
// arguments names to used in GoRouter
|
||||
static const argUser = 'user';
|
||||
static const argKey = 'key';
|
||||
|
||||
final UserProfilePB user;
|
||||
const EncryptSecretScreen({required this.user, super.key});
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ export 'splash_screen.dart';
|
||||
export 'sign_up_screen.dart';
|
||||
export 'encrypt_secret_screen.dart';
|
||||
export 'workspace_error_screen.dart';
|
||||
export 'workspace_start_screen/workspace_start_screen.dart';
|
||||
+5
-3
@@ -12,11 +12,9 @@ import '../../helpers/helpers.dart';
|
||||
class SignInScreen extends StatelessWidget {
|
||||
const SignInScreen({
|
||||
super.key,
|
||||
required this.router,
|
||||
});
|
||||
|
||||
static const routeName = '/SignInScreen';
|
||||
final AuthRouter router;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -26,7 +24,11 @@ class SignInScreen extends StatelessWidget {
|
||||
listener: (context, state) {
|
||||
state.successOrFail.fold(
|
||||
() => null,
|
||||
(result) => handleSuccessOrFail(result, context, router),
|
||||
(userProfileResult) => handleUserProfileResult(
|
||||
userProfileResult,
|
||||
context,
|
||||
getIt<AuthRouter>(),
|
||||
),
|
||||
);
|
||||
},
|
||||
builder: (context, state) {
|
||||
|
||||
@@ -21,14 +21,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class SkipLogInScreen extends StatefulWidget {
|
||||
final AuthRouter router;
|
||||
final AuthService authService;
|
||||
static const routeName = '/SkipLogInScreen';
|
||||
|
||||
const SkipLogInScreen({
|
||||
super.key,
|
||||
required this.router,
|
||||
required this.authService,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -86,13 +82,13 @@ class _SkipLogInScreenState extends State<SkipLogInScreen> {
|
||||
}
|
||||
|
||||
Future<void> _autoRegister(BuildContext context) async {
|
||||
final result = await widget.authService.signUpAsGuest();
|
||||
final result = await getIt<AuthService>().signUpAsGuest();
|
||||
result.fold(
|
||||
(error) {
|
||||
Log.error(error);
|
||||
},
|
||||
(user) {
|
||||
widget.router.pushHomeScreen(context, user);
|
||||
getIt<AuthRouter>().goHomeScreen(context, user);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ import 'package:appflowy/user/application/splash_bloc.dart';
|
||||
import 'package:appflowy/user/domain/auth_state.dart';
|
||||
import 'package:appflowy/user/presentation/helpers/helpers.dart';
|
||||
import 'package:appflowy/user/presentation/router.dart';
|
||||
import 'package:appflowy/user/presentation/screens/screens.dart';
|
||||
import 'package:appflowy/util/platform_extension.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
// [[diagram: splash screen]]
|
||||
// ┌────────────────┐1.get user ┌──────────┐ ┌────────────┐ 2.send UserEventCheckUser
|
||||
@@ -23,12 +25,12 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
// └───────────┘ └─────────────┘ └────────┘
|
||||
// 4. Show HomeScreen or SignIn 3.return AuthState
|
||||
class SplashScreen extends StatelessWidget {
|
||||
/// Root Page of the app.
|
||||
const SplashScreen({
|
||||
super.key,
|
||||
required this.autoRegister,
|
||||
});
|
||||
|
||||
static const routeName = '/SplashScreen';
|
||||
final bool autoRegister;
|
||||
|
||||
@override
|
||||
@@ -50,9 +52,8 @@ class SplashScreen extends StatelessWidget {
|
||||
|
||||
BlocProvider<SplashBloc> _buildChild(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) {
|
||||
return getIt<SplashBloc>()..add(const SplashEvent.getUser());
|
||||
},
|
||||
create: (context) =>
|
||||
getIt<SplashBloc>()..add(const SplashEvent.getUser()),
|
||||
child: Scaffold(
|
||||
body: BlocListener<SplashBloc, SplashState>(
|
||||
listener: (context, state) {
|
||||
@@ -87,10 +88,9 @@ class SplashScreen extends StatelessWidget {
|
||||
final result = await FolderEventGetCurrentWorkspace().send();
|
||||
result.fold(
|
||||
(workspaceSetting) {
|
||||
getIt<SplashRouter>().pushHomeScreen(
|
||||
// After login, replace Splash screen by corresponding home screen
|
||||
getIt<SplashRouter>().goHomeScreen(
|
||||
context,
|
||||
userProfile,
|
||||
workspaceSetting,
|
||||
);
|
||||
},
|
||||
(error) => handleOpenWorkspaceError(context, error),
|
||||
@@ -105,13 +105,14 @@ class SplashScreen extends StatelessWidget {
|
||||
|
||||
void _handleUnauthenticated(BuildContext context, Unauthenticated result) {
|
||||
Log.trace(
|
||||
'_handleUnauthenticated -> Supabase is enabled: $isSupabaseEnabled',
|
||||
'_handleUnauthenticated -> cloud is enabled: $isCloudEnabled',
|
||||
);
|
||||
// if the env is not configured, we will skip to the 'skip login screen'.
|
||||
if (isSupabaseEnabled) {
|
||||
getIt<SplashRouter>().pushSignInScreen(context);
|
||||
// replace Splash screen as root page
|
||||
if (isCloudEnabled) {
|
||||
context.go(SignInScreen.routeName);
|
||||
} else {
|
||||
getIt<SplashRouter>().pushSkipLoginScreen(context);
|
||||
// if the env is not configured, we will skip to the 'skip login screen'.
|
||||
context.go(SkipLogInScreen.routeName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ import '../../application/workspace_error_bloc.dart';
|
||||
|
||||
class WorkspaceErrorScreen extends StatelessWidget {
|
||||
static const routeName = "/WorkspaceErrorScreen";
|
||||
// arguments names to used in GoRouter
|
||||
static const argError = "error";
|
||||
static const argUserFolder = "userFolder";
|
||||
|
||||
final FlowyError error;
|
||||
final UserFolderPB userFolder;
|
||||
const WorkspaceErrorScreen({
|
||||
|
||||
+2
-2
@@ -7,6 +7,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class DesktopWorkspaceStartScreen extends StatelessWidget {
|
||||
const DesktopWorkspaceStartScreen({super.key, required this.workspaceState});
|
||||
@@ -101,6 +102,5 @@ Widget _renderCreateButton(BuildContext context) {
|
||||
// same method as in mobile
|
||||
void _popToWorkspace(BuildContext context, WorkspacePB workspace) {
|
||||
context.read<WorkspaceBloc>().add(WorkspaceEvent.openWorkspace(workspace));
|
||||
|
||||
Navigator.of(context).pop(workspace.id);
|
||||
context.pop(workspace.id);
|
||||
}
|
||||
+2
-2
@@ -7,6 +7,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
// TODO(yijing): needs refactor when multiple workspaces are supported
|
||||
class MobileWorkspaceStartScreen extends StatefulWidget {
|
||||
@@ -139,6 +140,5 @@ class _MobileWorkspaceStartScreenState
|
||||
// same method as in desktop
|
||||
void _popToWorkspace(BuildContext context, WorkspacePB workspace) {
|
||||
context.read<WorkspaceBloc>().add(WorkspaceEvent.openWorkspace(workspace));
|
||||
|
||||
Navigator.of(context).pop(workspace.id);
|
||||
context.pop(workspace.id);
|
||||
}
|
||||
+5
-1
@@ -9,8 +9,12 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
// For future use
|
||||
class WorkspaceStartScreen extends StatelessWidget {
|
||||
final UserProfilePB userProfile;
|
||||
static const routeName = "/WorkspaceStartScreen";
|
||||
static const argUserProfile = "userProfile";
|
||||
|
||||
final UserProfilePB userProfile;
|
||||
|
||||
/// To choose which screen is going to open
|
||||
const WorkspaceStartScreen({
|
||||
super.key,
|
||||
required this.userProfile,
|
||||
|
||||
@@ -3,8 +3,9 @@ import 'dart:async';
|
||||
import 'package:appflowy/user/application/user_settings_service.dart';
|
||||
import 'package:appflowy/util/platform_extension.dart';
|
||||
import 'package:appflowy/workspace/application/appearance_defaults.dart';
|
||||
import 'package:appflowy/workspace/application/mobile_theme_data.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_theme_data.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
@@ -23,11 +24,14 @@ const _white = Color(0xFFFFFFFF);
|
||||
/// It includes the [AppTheme], [ThemeMode], [TextStyles] and [Locale].
|
||||
class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
|
||||
final AppearanceSettingsPB _setting;
|
||||
final DateTimeSettingsPB _dateTimeSettings;
|
||||
|
||||
AppearanceSettingsCubit(
|
||||
AppearanceSettingsPB setting,
|
||||
DateTimeSettingsPB dateTimeSettings,
|
||||
AppTheme appTheme,
|
||||
) : _setting = setting,
|
||||
_dateTimeSettings = dateTimeSettings,
|
||||
super(
|
||||
AppearanceSettingsState.initial(
|
||||
appTheme,
|
||||
@@ -39,6 +43,9 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
|
||||
setting.locale,
|
||||
setting.isMenuCollapsed,
|
||||
setting.menuOffset,
|
||||
dateTimeSettings.dateFormat,
|
||||
dateTimeSettings.timeFormat,
|
||||
dateTimeSettings.timezoneId,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -173,6 +180,29 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
|
||||
setLocale(context, state.locale);
|
||||
}
|
||||
|
||||
void setDateFormat(UserDateFormatPB format) {
|
||||
_dateTimeSettings.dateFormat = format;
|
||||
_saveDateTimeSettings();
|
||||
emit(state.copyWith(dateFormat: format));
|
||||
}
|
||||
|
||||
void setTimeFormat(UserTimeFormatPB format) {
|
||||
_dateTimeSettings.timeFormat = format;
|
||||
_saveDateTimeSettings();
|
||||
emit(state.copyWith(timeFormat: format));
|
||||
}
|
||||
|
||||
Future<void> _saveDateTimeSettings() async {
|
||||
UserSettingsBackendService()
|
||||
.setDateTimeSettings(_dateTimeSettings)
|
||||
.then((result) {
|
||||
result.fold(
|
||||
(error) => Log.error(error),
|
||||
(_) => null,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveAppearanceSettings() async {
|
||||
UserSettingsBackendService().setAppearanceSetting(_setting).then((result) {
|
||||
result.fold(
|
||||
@@ -271,6 +301,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
|
||||
required Locale locale,
|
||||
required bool isMenuCollapsed,
|
||||
required double menuOffset,
|
||||
required UserDateFormatPB dateFormat,
|
||||
required UserTimeFormatPB timeFormat,
|
||||
required String timezoneId,
|
||||
}) = _AppearanceSettingsState;
|
||||
|
||||
factory AppearanceSettingsState.initial(
|
||||
@@ -283,6 +316,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
|
||||
LocaleSettingsPB localePB,
|
||||
bool isMenuCollapsed,
|
||||
double menuOffset,
|
||||
UserDateFormatPB dateFormat,
|
||||
UserTimeFormatPB timeFormat,
|
||||
String timezoneId,
|
||||
) {
|
||||
return AppearanceSettingsState(
|
||||
appTheme: appTheme,
|
||||
@@ -294,6 +330,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
|
||||
locale: Locale(localePB.languageCode, localePB.countryCode),
|
||||
isMenuCollapsed: isMenuCollapsed,
|
||||
menuOffset: menuOffset,
|
||||
dateFormat: dateFormat,
|
||||
timeFormat: timeFormat,
|
||||
timezoneId: timezoneId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
enum ActionType {
|
||||
openView,
|
||||
}
|
||||
|
||||
/// A [NotificationAction] is used to communicate with the
|
||||
/// [NotificationActionBloc] to perform actions based on an event
|
||||
/// triggered by pressing a notification, such as opening a specific
|
||||
/// view and jumping to a specific block.
|
||||
///
|
||||
class NotificationAction {
|
||||
const NotificationAction({
|
||||
this.type = ActionType.openView,
|
||||
required this.objectId,
|
||||
});
|
||||
|
||||
final ActionType type;
|
||||
|
||||
final String objectId;
|
||||
}
|
||||
Loaded 100 of 318 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user