Compare commits

..
Author SHA1 Message Date
Nathan 89647ae683 chore: logs for windows 2025-03-31 00:15:59 +08:00
312 changed files with 3438 additions and 11658 deletions

No files matched your search

-11
View File
@@ -1,15 +1,4 @@
# Release Notes
## Version 0.8.8 - 01/04/2025
### New Features
- Added support for selecting AI models in AI writer
- Revamped link menu in toolbar
- Added support for using ":" to add emojis in documents
- Passed the history of past AI prompts and responses to AI writer
### Bug Fixes
- Improved AI writer scrolling user experience
- Fixed issue where checklist items would disappear during reordering
- Fixed numbered lists generated by AI to maintain the same index as the input
## Version 0.8.7 - 18/03/2025
### New Features
- Made local AI free and integrated with Ollama
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.8.9"
APPFLOWY_VERSION = "0.8.8"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -4,7 +4,6 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "packages/**/*.dart"
linter:
rules:
@@ -15,6 +15,7 @@ void main() {
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapContinousAnotherWay();
await tester.tapAnonymousSignInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
@@ -15,7 +15,6 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a database and add a linked database view
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
@@ -30,11 +29,6 @@ void main() {
await tester.tapHidePropertyButton();
tester.noFieldWithName('New field 1');
// create another field, New field 1 to be hidden still
await tester.tapNewPropertyButton();
await tester.dismissFieldEditor();
tester.noFieldWithName('New field 1');
// go back to inline database view, expect field to be shown
await tester.tapTabBarLinkedViewByViewName('Untitled');
tester.findFieldWithName('New field 1');
@@ -66,40 +60,5 @@ void main() {
await tester.tapDatabaseSortButton();
await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1");
});
testWidgets('field cell width', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a database and add a linked database view
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
// create a field
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
await tester.renameField('New field 1');
await tester.dismissFieldEditor();
// check the width of the field
expect(tester.getFieldWidth('New field 1'), 150);
// change the width of the field
await tester.changeFieldWidth('New field 1', 200);
expect(tester.getFieldWidth('New field 1'), 205);
// create another field, New field 1 to be same width
await tester.tapNewPropertyButton();
await tester.dismissFieldEditor();
expect(tester.getFieldWidth('New field 1'), 205);
// go back to inline database view, expect New field 1 to be 150px
await tester.tapTabBarLinkedViewByViewName('Untitled');
expect(tester.getFieldWidth('New field 1'), 150);
// go back to linked database view, expect New field 1 to be 205px
await tester.tapTabBarLinkedViewByViewName('Grid');
expect(tester.getFieldWidth('New field 1'), 205);
});
});
}
@@ -1,6 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -48,41 +47,5 @@ void main() {
expect(editorState.selection!.start.offset, 0);
});
testWidgets('select and delete text', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// create a new document
await tester.createNewPageWithNameUnderParent();
/// input text
final editor = tester.editor;
final editorState = editor.getCurrentEditorState();
const inputText = 'Test for text selection and deletion';
final texts = inputText.split(' ');
await editor.tapLineOfEditorAt(0);
await tester.ime.insertText(inputText);
/// selecte and delete
int index = 0;
while (texts.isNotEmpty) {
final text = texts.removeAt(0);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: index),
end: Position(path: [0], offset: index + text.length),
),
);
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
index++;
}
/// excpete the text value is correct
final node = editorState.getNodeAtPath([0])!;
final nodeText = node.delta?.toPlainText() ?? '';
expect(nodeText, ' ' * (index - 1));
});
});
}
@@ -184,45 +184,6 @@ void main() {
3,
);
});
testWidgets('toolbar will not rebuild after click item', (tester) async {
const text = 'Test rebuilding';
await prepareForToolbar(tester, text);
Finder toolbar = find.byType(DesktopFloatingToolbar);
Element toolbarElement = toolbar.evaluate().first;
final elementHashcode = toolbarElement.hashCode;
final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m),
underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m),
italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m);
/// tap format buttons
await tester.tapButton(boldButton);
await tester.tapButton(underlineButton);
await tester.tapButton(italicButton);
toolbar = find.byType(DesktopFloatingToolbar);
toolbarElement = toolbar.evaluate().first;
/// check if the toolbar is not rebuilt
expect(elementHashcode, toolbarElement.hashCode);
final editorState = tester.editor.getCurrentEditorState();
/// check text formats
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold),
true,
);
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic),
true,
);
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline),
true,
);
});
});
group('document toolbar: link', () {
@@ -267,7 +228,6 @@ void main() {
expect(toolbar, findsNothing);
/// show toolbar again
await tester.editor.tapLineOfEditorAt(0);
await selectText(tester, text);
await tester.tapButton(linkButton);
@@ -43,12 +43,12 @@ void main() {
});
group('insert emoji by colon', () {
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester, {String? search}) async {
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(':${search ?? 'a'}');
await tester.ime.insertText(':');
await tester.pumpAndSettle(Duration(seconds: 1));
}
@@ -106,10 +106,11 @@ void main() {
});
testWidgets('insert with searching', (tester) async {
await createNewDocumentAndShowEmojiList(tester, search: 's');
await createNewDocumentAndShowEmojiList(tester);
/// search for `smiling eyes`, IME is not working, use keyboard input
final searchText = [
LogicalKeyboardKey.keyS,
LogicalKeyboardKey.keyM,
LogicalKeyboardKey.keyI,
LogicalKeyboardKey.keyL,
@@ -137,10 +138,16 @@ void main() {
});
testWidgets('start searching with sapce', (tester) async {
await createNewDocumentAndShowEmojiList(tester, search: ' ');
await createNewDocumentAndShowEmojiList(tester);
/// emoji list is showing
final emojiHandler = find.byType(EmojiHandler);
expect(emojiHandler, findsOneWidget);
/// input space
await tester.simulateKeyEvent(LogicalKeyboardKey.space);
/// emoji list is dismissed
expect(emojiHandler, findsNothing);
});
});
@@ -942,31 +942,6 @@ extension AppFlowyDatabaseTest on WidgetTester {
await pumpAndSettle(const Duration(milliseconds: 200));
}
Future<void> changeFieldWidth(String fieldName, double width) async {
final field = find.byWidgetPredicate(
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
);
await hoverOnWidget(
field,
onHover: () async {
final dragHandle = find.descendant(
of: field,
matching: find.byType(DragToExpandLine),
);
await drag(dragHandle, Offset(width - getSize(field).width, 0));
await pumpAndSettle(const Duration(milliseconds: 200));
},
);
}
double getFieldWidth(String fieldName) {
final field = find.byWidgetPredicate(
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
);
return getSize(field).width;
}
Future<void> findDateEditor(dynamic matcher) async {
final finder = find.byType(DateCellEditor);
expect(finder, matcher);
@@ -307,11 +307,9 @@ class EditorOperations {
Future<void> openTurnIntoMenu(Path path) async {
await hoverAndClickOptionMenuButton(path);
await tester.tapButton(
find
.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
)
.first,
find.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
),
);
await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
}
+23 -23
View File
@@ -181,37 +181,37 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
@@ -23,7 +23,6 @@ class DesktopPromptInput extends StatefulWidget {
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
this.hideDecoration = false,
this.hideFormats = false,
this.extraBottomActionButton,
});
@@ -35,7 +34,6 @@ class DesktopPromptInput extends StatefulWidget {
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final bool hideDecoration;
final bool hideFormats;
final Widget? extraBottomActionButton;
@override
@@ -141,11 +139,11 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
children: [
ConstrainedBox(
constraints: getTextFieldConstraints(
state.showPredefinedFormats && !widget.hideFormats,
state.showPredefinedFormats,
),
child: inputTextField(),
),
if (state.showPredefinedFormats && !widget.hideFormats)
if (state.showPredefinedFormats)
Positioned.fill(
bottom: null,
child: TextFieldTapRegion(
@@ -170,9 +168,8 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
top: null,
child: TextFieldTapRegion(
child: _PromptBottomActions(
showPredefinedFormatBar:
showPredefinedFormats:
state.showPredefinedFormats,
showPredefinedFormatButton: !widget.hideFormats,
onTogglePredefinedFormatSection: () =>
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent
@@ -574,8 +571,7 @@ class PromptInputTextField extends StatelessWidget {
class _PromptBottomActions extends StatelessWidget {
const _PromptBottomActions({
required this.sendButtonState,
required this.showPredefinedFormatBar,
required this.showPredefinedFormatButton,
required this.showPredefinedFormats,
required this.onTogglePredefinedFormatSection,
required this.onStartMention,
required this.onSendPressed,
@@ -585,8 +581,7 @@ class _PromptBottomActions extends StatelessWidget {
this.extraBottomActionButton,
});
final bool showPredefinedFormatBar;
final bool showPredefinedFormatButton;
final bool showPredefinedFormats;
final void Function() onTogglePredefinedFormatSection;
final void Function() onStartMention;
final SendButtonState sendButtonState;
@@ -605,12 +600,10 @@ class _PromptBottomActions extends StatelessWidget {
builder: (context, state) {
return Row(
children: [
if (showPredefinedFormatButton) ...[
_predefinedFormatButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
_predefinedFormatButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
SelectModelMenu(
aiModelStateNotifier:
context.read<AIPromptInputBloc>().aiModelStateNotifier,
@@ -648,7 +641,7 @@ class _PromptBottomActions extends StatelessWidget {
Widget _predefinedFormatButton() {
return PromptInputDesktopToggleFormatButton(
showFormatBar: showPredefinedFormatBar,
showFormatBar: showPredefinedFormats,
onTap: onTogglePredefinedFormatSection,
);
}
@@ -173,6 +173,7 @@ class _ModelItem extends StatelessWidget {
model.i18n,
figmaLineHeight: 20,
overflow: TextOverflow.ellipsis,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
if (model.desc.isNotEmpty)
FlowyText(
@@ -16,8 +16,6 @@ const double _kMinimumWidth = 112.0;
const double _kDefaultHorizontalPadding = 12.0;
typedef CompareFunction<T> = bool Function(T? left, T? right);
// Navigation shortcuts to move the selected menu items up or down.
final Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts =
<ShortcutActivator, Intent>{
@@ -88,7 +86,6 @@ class AFDropdownMenu<T> extends StatefulWidget {
this.requestFocusOnTap,
this.expandedInsets,
this.searchCallback,
this.selectOptionCompare,
required this.dropdownMenuEntries,
});
@@ -270,11 +267,6 @@ class AFDropdownMenu<T> extends StatefulWidget {
/// which contains the contents of the text input field.
final SearchCallback<T>? searchCallback;
/// Defines the compare function for the menu items.
///
/// Defaults to null. If this is null, the menu items will be sorted by the label.
final CompareFunction<T>? selectOptionCompare;
@override
State<AFDropdownMenu<T>> createState() => _AFDropdownMenuState<T>();
}
@@ -309,16 +301,7 @@ class _AFDropdownMenuState<T> extends State<AFDropdownMenu<T>> {
filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
final int index = filteredEntries.indexWhere(
(DropdownMenuEntry<T> entry) {
if (widget.selectOptionCompare != null) {
return widget.selectOptionCompare!(
entry.value,
widget.initialSelection,
);
} else {
return entry.value == widget.initialSelection;
}
},
(DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection,
);
if (index != -1) {
_textEditingController.value = TextEditingValue(
@@ -16,7 +16,6 @@ enum _MobileSettingsPopupMenuItem {
members,
trash,
help,
helpAndDocumentation,
}
class HomePageSettingsPopupMenu extends StatelessWidget {
@@ -63,16 +62,10 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
text: LocaleKeys.settings_popupMenuItem_trash.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.helpAndDocumentation,
svg: FlowySvgs.help_and_documentation_s,
text: LocaleKeys.settings_popupMenuItem_helpAndDocumentation.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.help,
svg: FlowySvgs.message_support_s,
text: LocaleKeys.settings_popupMenuItem_getSupport.tr(),
text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(),
),
],
onSelected: (_MobileSettingsPopupMenuItem value) {
@@ -89,9 +82,6 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
case _MobileSettingsPopupMenuItem.help:
_openHelpPage(context);
break;
case _MobileSettingsPopupMenuItem.helpAndDocumentation:
_openHelpAndDocumentationPage(context);
break;
}
},
child: const Padding(
@@ -133,10 +123,6 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
void _openSettingsPage(BuildContext context) {
context.push(MobileHomeSettingPage.routeName);
}
void _openHelpAndDocumentationPage(BuildContext context) {
afLaunchUrlString('https://appflowy.com/guide');
}
}
class _PopupButton extends StatelessWidget {
@@ -25,14 +25,14 @@ class AboutSettingGroup extends StatelessWidget {
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.com/privacy'),
onTap: () => afLaunchUrlString('https://appflowy.io/privacy'),
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_termsAndConditions.tr(),
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.com/terms'),
onTap: () => afLaunchUrlString('https://appflowy.io/terms'),
),
if (kDebugMode)
MobileSettingItem(
@@ -282,12 +282,11 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
void _inviteMember(BuildContext context) {
final email = emailController.text;
if (!isEmail(email)) {
showToastNotification(
return showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
);
return;
}
context
.read<WorkspaceMemberBloc>()
@@ -411,28 +411,23 @@ class FieldController {
/// Listen for field setting changes in the backend.
void _listenOnFieldSettingsChanged() {
FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) {
final newFields = [...fieldInfos];
final List<FieldInfo> newFields = fieldInfos;
var updatedField = newFields.firstOrNull;
if (newFields.isEmpty) {
if (updatedField == null) {
return null;
}
final index = newFields
.indexWhere((field) => field.id == updatedFieldSettings.fieldId);
if (index != -1) {
newFields[index] =
newFields[index].copyWith(fieldSettings: updatedFieldSettings);
_fieldNotifier.fieldInfos = newFields;
_fieldSettings
..removeWhere(
(field) => field.fieldId == updatedFieldSettings.fieldId,
)
..add(updatedFieldSettings);
return newFields[index];
updatedField = newFields[index];
}
return null;
_fieldNotifier.fieldInfos = newFields;
return updatedField;
}
_fieldSettingsListener.start(
@@ -108,7 +108,7 @@ class _GridFieldCellState extends State<GridFieldCell> {
top: 0,
bottom: 0,
right: 0,
child: DragToExpandLine(),
child: _DragToExpandLine(),
);
return _GridHeaderCellContainer(
@@ -158,11 +158,8 @@ class _GridHeaderCellContainer extends StatelessWidget {
}
}
@visibleForTesting
class DragToExpandLine extends StatelessWidget {
const DragToExpandLine({
super.key,
});
class _DragToExpandLine extends StatelessWidget {
const _DragToExpandLine();
@override
Widget build(BuildContext context) {
@@ -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/application/cell/bloc/checklist_cell_bloc.dart';
@@ -14,7 +16,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
import '../editable_cell_skeleton/checklist.dart';
@@ -200,16 +201,19 @@ class _ChecklistItems extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(),
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: MouseRegion(
cursor: UniversalPlatform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: IgnorePointer(
child: BlocProvider.value(
child: Stack(
children: [
BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
buildDefaultDragHandles: false,
@@ -14,7 +14,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
import '../../application/cell/bloc/checklist_cell_bloc.dart';
import 'checklist_cell_textfield.dart';
@@ -126,16 +125,19 @@ class ChecklistItemList extends StatelessWidget {
shrinkWrap: true,
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: MouseRegion(
cursor: UniversalPlatform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: IgnorePointer(
child: BlocProvider.value(
child: Stack(
children: [
BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
buildDefaultDragHandles: false,
@@ -272,9 +272,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
if (options.inMemoryUpdate) {
if (enableDocumentInternalLog) {
Log.trace('skip transaction for in-memory update');
}
Log.trace('skip transaction for in-memory update');
return;
}
@@ -432,50 +432,44 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
);
}
return Center(
child: BlocProvider.value(
value: context.read<DocumentBloc>(),
child: FloatingToolbar(
floatingToolbarHeight: 40,
padding: EdgeInsets.symmetric(horizontal: 6),
style: FloatingToolbarStyle(
backgroundColor: Theme.of(context).cardColor,
toolbarActiveColor: Color(0xffe0f8fd),
),
items: toolbarItems,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: Offset(0, 4),
blurRadius: 24,
color: themeV2.shadow_medium,
),
],
),
toolbarBuilder: (_, child, onDismiss, isMetricsChanged) =>
BlocProvider.value(
value: context.read<DocumentBloc>(),
child: DesktopFloatingToolbar(
editorState: editorState,
onDismiss: onDismiss,
enableAnimation: !isMetricsChanged,
child: child,
),
),
placeHolderBuilder: (_) => customPlaceholderItem,
editorState: editorState,
editorScrollController: editorScrollController,
textDirection: textDirection,
tooltipBuilder: (context, id, message, child) =>
widget.styleCustomizer.buildToolbarItemTooltip(
context,
id,
message,
child,
),
child: editor,
child: FloatingToolbar(
floatingToolbarHeight: 40,
padding: EdgeInsets.symmetric(horizontal: 6),
style: FloatingToolbarStyle(
backgroundColor: Theme.of(context).cardColor,
toolbarActiveColor: Color(0xffe0f8fd),
),
items: toolbarItems,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: Offset(0, 4),
blurRadius: 24,
color: themeV2.shadow_medium,
),
],
),
toolbarBuilder: (context, child, onDismiss, isMetricsChanged) =>
DesktopFloatingToolbar(
editorState: editorState,
onDismiss: onDismiss,
enableAnimation: !isMetricsChanged,
child: child,
),
placeHolderBuilder: (_) => customPlaceholderItem,
editorState: editorState,
editorScrollController: editorScrollController,
textDirection: textDirection,
tooltipBuilder: (context, id, message, child) =>
widget.styleCustomizer.buildToolbarItemTooltip(
context,
id,
message,
child,
),
child: editor,
),
);
}
@@ -490,12 +490,6 @@ class MainContentArea extends StatelessWidget {
return DesktopPromptInput(
isStreaming: false,
hideDecoration: true,
hideFormats: [
AiWriterCommand.fixSpellingAndGrammar,
AiWriterCommand.improveWriting,
AiWriterCommand.makeLonger,
AiWriterCommand.makeShorter,
].contains(state.command),
textController: textController,
onSubmitted: (message, format, _) {
cubit.runCommand(state.command, message, format);
@@ -16,11 +16,6 @@ import 'ai_writer_block_operations.dart';
import 'ai_writer_entities.dart';
import 'ai_writer_node_extension.dart';
/// Enable the debug log for the AiWriterCubit.
///
/// This is useful for debugging the AI writer cubit.
const _aiWriterCubitDebugLog = false;
class AiWriterCubit extends Cubit<AiWriterState> {
AiWriterCubit({
required this.documentId,
@@ -101,10 +96,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
final command = node.aiWriterCommand;
final (run, prompt) = await _addSelectionTextToRecords(command);
_aiWriterCubitLog(
'command: $command, run: $run, prompt: $prompt',
);
if (!run) {
await exit();
return;
@@ -221,26 +212,20 @@ class AiWriterCubit extends Cubit<AiWriterState> {
return;
}
// Accept
//
// If the user clicks accept, we need to replace the selection with the AI's response
if (action case SuggestionAction.accept) {
// trim the markdown text to avoid extra new lines
final trimmedMarkdownText = _textRobot.markdownText.trim();
_aiWriterCubitLog(
'trigger accept action, markdown text: $trimmedMarkdownText',
await _textRobot.persist();
await formatSelection(
editorState,
selection,
ApplySuggestionFormatType.clear,
);
await _textRobot.deleteAINodes();
await _textRobot.replace(
selection: selection,
markdownText: trimmedMarkdownText,
final nodes = editorState.getNodesInSelection(selection);
final transaction = editorState.transaction..deleteNodes(nodes);
await editorState.apply(
transaction,
withUpdateSelection: false,
);
await exit(withDiscard: false, withUnformat: false);
return;
}
@@ -292,35 +277,30 @@ class AiWriterCubit extends Cubit<AiWriterState> {
AiWriterCommand command,
) async {
final node = aiWriterNode;
// check the node is registered
if (node == null) {
Log.warn('[AI writer] Node is null');
return (false, '');
}
// check the selection is valid
final selection = node.aiWriterSelection?.normalized;
if (selection == null) {
Log.warn('[AI writer]Selection is null');
return (false, '');
}
// if the command is continue writing, we don't need to get the selection text
if (command == AiWriterCommand.continueWriting) {
return (true, '');
}
// if the selection is collapsed, we don't need to get the selection text
if (selection.isCollapsed) {
return (true, '');
}
final selectionText = await editorState.getMarkdownInSelection(selection);
Log.warn('[AI writer] Selection is null');
if (command == AiWriterCommand.userQuestion) {
records.add(
AiWriterRecord.user(content: selectionText, format: null),
);
return (true, '');
} else {
return (true, selectionText);
@@ -564,10 +544,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
attributes: ApplySuggestionFormatType.replace.attributes,
);
onAppendToDocument?.call();
_aiWriterCubitLog(
'received message: $text',
);
},
processAssistMessage: (text) async {
if (state case final GeneratingAiWriterState generatingState) {
@@ -579,10 +555,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
),
);
}
_aiWriterCubitLog(
'received assist message: $text',
);
},
onEnd: () async {
if (state case final GeneratingAiWriterState generatingState) {
@@ -599,10 +571,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
records.add(
AiWriterRecord.ai(content: _textRobot.markdownText),
);
_aiWriterCubitLog(
'returned response: ${_textRobot.markdownText}',
);
}
},
onError: (error) async {
@@ -694,12 +662,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
);
}
}
void _aiWriterCubitLog(String message) {
if (_aiWriterCubitDebugLog) {
Log.debug('[AiWriterCubit] $message');
}
}
}
mixin RegisteredAiWriter {
@@ -57,30 +57,11 @@ extension AiWriterNodeExtension on EditorState {
slicedNodes.add(copiedNode);
}
for (final (i, node) in slicedNodes.indexed) {
final childNodesShouldBeDeleted = <Node>[];
for (final child in node.children) {
if (!child.path.inSelection(selection)) {
childNodesShouldBeDeleted.add(child);
}
}
for (final child in childNodesShouldBeDeleted) {
slicedNodes[i] = node.copyWith(
children: node.children.where((e) => e.id != child.id).toList(),
type: selection.startIndex != 0 ? ParagraphBlockKeys.type : node.type,
);
}
}
// use \n\n as line break to improve the ai response
// using \n will cause the ai response treat the text as a single line
final markdown = await customDocumentToMarkdown(
Document.blank()..insert([0], slicedNodes),
lineBreak: '\n\n',
);
// trim the last \n if it exists
return markdown.trimRight();
return markdown;
}
List<String> getPlainTextInSelection(Selection? selection) {
@@ -208,26 +208,11 @@ class _AiWriterScrollWrapperState extends State<AiWriterScrollWrapper> {
throttler.call(() {
if (aiWriterCubit.aiWriterNode != null) {
final path = aiWriterCubit.aiWriterNode!.path;
if (path.isEmpty) {
return;
if (path.isNotEmpty) {
widget.editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: path)),
);
}
if (path.previous.isNotEmpty) {
final node = widget.editorState.getNodeAtPath(path.previous);
if (node != null && node.delta != null && node.delta!.isNotEmpty) {
widget.editorState.updateSelectionWithReason(
Selection.collapsed(
Position(path: path, offset: node.delta!.length),
),
);
return;
}
}
widget.editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: path)),
);
}
});
}
@@ -110,13 +110,10 @@ class MarkdownTextRobot {
}
/// Persist the text into the document
Future<void> persist({
String? markdownText,
}) async {
Future<void> persist({String? markdownText}) async {
if (markdownText != null) {
_markdownText = markdownText;
}
await _lock.synchronized(() async {
await _refresh(inMemoryUpdate: false);
});
@@ -127,34 +124,6 @@ class MarkdownTextRobot {
}
}
/// Replace the selected content with the AI's response
Future<void> replace({
required Selection selection,
required String markdownText,
}) async {
if (selection.isSingle) {
await _replaceInSameLine(
selection: selection,
markdownText: markdownText,
);
} else {
await _replaceInMultiLines(
selection: selection,
markdownText: markdownText,
);
}
}
/// Delete the temporary inserted AI nodes
Future<void> deleteAINodes() async {
final nodes = getInsertedNodes();
final transaction = editorState.transaction..deleteNodes(nodes);
await editorState.apply(
transaction,
options: const ApplyOptions(recordUndo: false),
);
}
/// Discard the inserted content
Future<void> discard() async {
final start = _insertPosition;
@@ -313,194 +282,6 @@ class MarkdownTextRobot {
children: children,
);
}
/// If the selected content is in the same line,
/// keep the selected node and replace the delta.
Future<void> _replaceInSameLine({
required Selection selection,
required String markdownText,
}) async {
if (markdownText.isEmpty) {
assert(false, 'Expected non-empty markdown text');
Log.error('Expected non-empty markdown text');
return;
}
selection = selection.normalized;
// If the selection is not a single node, do nothing.
if (!selection.isSingle) {
assert(false, 'Expected single node selection');
Log.error('Expected single node selection');
return;
}
final startIndex = selection.startIndex;
final endIndex = selection.endIndex;
final length = endIndex - startIndex;
// Get the selected node.
final node = editorState.getNodeAtPath(selection.start.path);
final delta = node?.delta;
if (node == null || delta == null) {
assert(false, 'Expected non-null node and delta');
Log.error('Expected non-null node and delta');
return;
}
// Convert the markdown text to delta.
// Question: Why we need to convert the markdown to document first?
// Answer: Because the markdown text may contain the list item,
// if we convert the markdown to delta directly, the list item will be
// treated as a normal text node, and the delta will be incorrect.
// For example, the markdown text is:
// ```
// 1. item1
// ```
// if we convert the markdown to delta directly, the delta will be:
// ```
// [
// {
// "insert": "1. item1"
// }
// ]
// ```
// if we convert the markdown to document first, the document will be:
// ```
// [
// {
// "type": "numbered_list",
// "children": [
// {
// "insert": "item1"
// }
// ]
// }
// ]
final document = customMarkdownToDocument(markdownText);
final nodes = document.root.children;
final decoder = DeltaMarkdownDecoder();
final markdownDelta =
nodes.firstOrNull?.delta ?? decoder.convert(markdownText);
if (markdownDelta.isEmpty) {
assert(false, 'Expected non-empty markdown delta');
Log.error('Expected non-empty markdown delta');
return;
}
// Replace the delta of the selected node.
final transaction = editorState.transaction;
// it means the user selected the entire sentence, we just replace the node
if (startIndex == 0 && length == node.delta?.length) {
transaction
..insertNodes(node.path.next, nodes)
..deleteNode(node);
} else {
// it means the user selected a part of the sentence, we need to delete the
// selected part and insert the new delta.
transaction
..deleteText(node, startIndex, length)
..insertTextDelta(node, startIndex, markdownDelta);
// Add the remaining nodes to the document.
final remainingNodes = nodes.skip(1);
if (remainingNodes.isNotEmpty) {
transaction.insertNodes(
node.path.next,
remainingNodes,
);
}
}
await editorState.apply(transaction);
}
/// If the selected content is in multiple lines
Future<void> _replaceInMultiLines({
required Selection selection,
required String markdownText,
}) async {
selection = selection.normalized;
// If the selection is a single node, do nothing.
if (selection.isSingle) {
assert(false, 'Expected multi-line selection');
Log.error('Expected multi-line selection');
return;
}
final markdownNodes = customMarkdownToDocument(
markdownText,
tableWidth: 250.0,
).root.children;
// Get the selected nodes.
final nodes = editorState.getNodesInSelection(selection);
// Note: Don't change its order, otherwise the delta will be incorrect.
// step 1. merge the first selected node and the first node from the ai response
// step 2. merge the last selected node and the last node from the ai response
// step 3. insert the middle nodes from the ai response
// step 4. delete the middle nodes
final transaction = editorState.transaction;
// step 1
final firstNode = nodes.firstOrNull;
final delta = firstNode?.delta;
final firstMarkdownNode = markdownNodes.firstOrNull;
final firstMarkdownDelta = firstMarkdownNode?.delta;
if (firstNode != null &&
delta != null &&
firstMarkdownNode != null &&
firstMarkdownDelta != null) {
final startIndex = selection.startIndex;
final length = delta.length - startIndex;
transaction
..deleteText(firstNode, startIndex, length)
..insertTextDelta(firstNode, startIndex, firstMarkdownDelta);
}
// step 2
final lastNode = nodes.lastOrNull;
final lastDelta = lastNode?.delta;
final lastMarkdownNode = markdownNodes.lastOrNull;
final lastMarkdownDelta = lastMarkdownNode?.delta;
if (lastNode != null &&
lastDelta != null &&
lastMarkdownNode != null &&
lastMarkdownDelta != null) {
final endIndex = selection.endIndex;
transaction.deleteText(lastNode, 0, endIndex);
// if the last node is same as the first node, it means we have replaced the
// selected text in the first node.
if (lastMarkdownNode.id != firstMarkdownNode?.id) {
transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta);
}
}
// step 3
final insertedPath = selection.start.path.nextNPath(1);
if (markdownNodes.length > 2) {
transaction.insertNodes(
insertedPath,
markdownNodes.skip(1).take(markdownNodes.length - 2).toList(),
);
}
// step 4
final length = nodes.length - 2;
if (length > 0) {
final middleNodes = nodes.skip(1).take(length).toList();
transaction.deleteNodes(middleNodes);
}
await editorState.apply(transaction);
}
}
class AINodeExternalValues extends NodeExternalValues {
@@ -1,9 +1,10 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'toolbar_animation.dart';
import 'toolbar_cubit.dart';
class DesktopFloatingToolbar extends StatefulWidget {
const DesktopFloatingToolbar({
@@ -27,7 +28,6 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
EditorState get editorState => widget.editorState;
_Position? position;
final toolbarController = getIt<FloatingToolbarController>();
@override
void initState() {
@@ -39,32 +39,24 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
final selectionRect = editorState.selectionRects();
if (selectionRect.isEmpty) return;
position = calculateSelectionMenuOffset(selectionRect.first);
toolbarController._addCallback(dismiss);
}
@override
void dispose() {
toolbarController._removeCallback(dismiss);
super.dispose();
}
@override
Widget build(BuildContext context) {
if (position == null) return Container();
return Positioned(
left: position!.left,
top: position!.top,
right: position!.right,
child: widget.enableAnimation
? ToolbarAnimationWidget(child: widget.child)
: widget.child,
return BlocProvider<ToolbarCubit>(
create: (_) => ToolbarCubit(widget.onDismiss),
child: Positioned(
left: position!.left,
top: position!.top,
right: position!.right,
child: widget.enableAnimation
? ToolbarAnimationWidget(child: widget.child)
: widget.child,
),
);
}
void dismiss() {
widget.onDismiss.call();
}
_Position calculateSelectionMenuOffset(
Rect rect,
) {
@@ -100,33 +92,3 @@ class _Position {
final double? top;
final double? right;
}
class FloatingToolbarController {
final Set<VoidCallback> _dismissCallbacks = {};
final Set<VoidCallback> _displayListeners = {};
void _addCallback(VoidCallback callback) {
_dismissCallbacks.add(callback);
for (final listener in Set.of(_displayListeners)) {
listener.call();
}
}
void _removeCallback(VoidCallback callback) =>
_dismissCallbacks.remove(callback);
bool get isToolbarShowing => _dismissCallbacks.isNotEmpty;
void addDisplayListener(VoidCallback listener) =>
_displayListeners.add(listener);
void removeDisplayListener(VoidCallback listener) =>
_displayListeners.remove(listener);
void hideToolbar() {
if (_dismissCallbacks.isEmpty) return;
for (final callback in _dismissCallbacks) {
callback.call();
}
}
}
@@ -19,15 +19,11 @@ class LinkCreateMenu extends StatefulWidget {
required this.onSubmitted,
required this.onDismiss,
required this.alignment,
required this.currentViewId,
required this.initialText,
});
final EditorState editorState;
final void Function(String link, bool isPage) onSubmitted;
final VoidCallback onDismiss;
final String currentViewId;
final String initialText;
final LinkMenuAlignment alignment;
@override
@@ -36,8 +32,6 @@ class LinkCreateMenu extends StatefulWidget {
class _LinkCreateMenuState extends State<LinkCreateMenu> {
late LinkSearchTextField searchTextField = LinkSearchTextField(
currentViewId: widget.currentViewId,
initialSearchText: widget.initialText,
onEnter: () {
searchTextField.onSearchResult(
onLink: () => onSubmittedLink(),
@@ -54,28 +48,17 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
},
);
bool get isTextfieldEnable => searchTextField.isTextfieldEnable;
bool get isButtonEnable => searchText.isNotEmpty;
String get searchText => searchTextField.searchText;
bool get showAtTop => widget.alignment.isTop;
bool showErrorText = false;
@override
void initState() {
super.initState();
searchTextField.requestFocus();
searchTextField.searchRecentViews();
final focusNode = searchTextField.focusNode;
bool hasFocus = focusNode.hasFocus;
focusNode.addListener(() {
if (hasFocus != focusNode.hasFocus && mounted) {
setState(() {
hasFocus = focusNode.hasFocus;
});
}
});
}
@override
@@ -115,45 +98,32 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
Widget buildSearchContainer() {
return Container(
width: 320,
height: 48,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.all(8),
child: ValueListenableBuilder(
valueListenable: searchTextField.textEditingController,
builder: (context, _, __) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
return Row(
children: [
Row(
children: [
Expanded(
child: searchTextField.buildTextField(context: context),
),
HSpace(8),
FlowyTextButton(
LocaleKeys.document_toolbar_insert.tr(),
mainAxisAlignment: MainAxisAlignment.center,
padding: EdgeInsets.zero,
constraints: BoxConstraints(maxWidth: 72, minHeight: 32),
fontSize: 14,
fontColor: Colors.white,
fillColor: LinkStyle.fillThemeThick,
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
lineHeight: 20 / 14,
fontWeight: FontWeight.w600,
onPressed: onSubmittedLink,
),
],
Expanded(child: searchTextField.buildTextField()),
HSpace(8),
FlowyTextButton(
LocaleKeys.document_toolbar_insert.tr(),
mainAxisAlignment: MainAxisAlignment.center,
padding: EdgeInsets.zero,
constraints: BoxConstraints(maxWidth: 72, minHeight: 32),
fontSize: 14,
fontColor:
isButtonEnable ? Colors.white : LinkStyle.textTertiary,
fillColor: isButtonEnable
? LinkStyle.fillThemeThick
: LinkStyle.borderColor,
hoverColor: LinkStyle.fillThemeThick,
lineHeight: 20 / 14,
fontWeight: FontWeight.w600,
onPressed: isButtonEnable ? () => onSubmittedLink() : null,
),
if (showErrorText)
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText.regular(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: LinkStyle.textStatusError,
fontSize: 12,
figmaLineHeight: 16,
),
),
],
);
},
@@ -161,15 +131,7 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
);
}
void onSubmittedLink() {
if (!isTextfieldEnable) {
setState(() {
showErrorText = true;
});
return;
}
widget.onSubmitted(searchText, false);
}
void onSubmittedLink() => widget.onSubmitted(searchText, false);
void onSubmittedPageLink(ViewPB view) async {
final workspaceId = context
@@ -190,16 +152,13 @@ void showLinkCreateMenu(
BuildContext context,
EditorState editorState,
Selection selection,
String currentViewId,
) {
if (!context.mounted) return;
final (left, top, right, bottom, alignment) = _getPosition(editorState);
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final selectedText = editorState.getTextInSelection(selection).join();
OverlayEntry? overlay;
@@ -219,18 +178,12 @@ void showLinkCreateMenu(
builder: (context) {
return LinkCreateMenu(
alignment: alignment,
initialText: selectedText,
currentViewId: currentViewId,
editorState: editorState,
onSubmitted: (link, isPage) async {
await editorState.formatDelta(selection, {
BuiltInAttributeKey.href: link,
kIsPageLink: isPage,
});
await editorState.updateSelectionWithReason(
null,
reason: SelectionUpdateReason.uiEvent,
);
dismissOverlay();
},
onDismiss: dismissOverlay,
@@ -3,7 +3,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -11,8 +10,7 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
import 'link_create_menu.dart';
import 'link_search_text_field.dart';
import 'link_styles.dart';
@@ -24,14 +22,12 @@ class LinkEditMenu extends StatefulWidget {
required this.onDismiss,
required this.onApply,
required this.onRemoveLink,
required this.currentViewId,
});
final LinkInfo linkInfo;
final ValueChanged<LinkInfo> onApply;
final ValueChanged<LinkInfo> onRemoveLink;
final VoidCallback onRemoveLink;
final VoidCallback onDismiss;
final String currentViewId;
@override
State<LinkEditMenu> createState() => _LinkEditMenuState();
@@ -40,7 +36,7 @@ class LinkEditMenu extends StatefulWidget {
class _LinkEditMenuState extends State<LinkEditMenu> {
ValueChanged<LinkInfo> get onApply => widget.onApply;
ValueChanged<LinkInfo> get onRemoveLink => widget.onRemoveLink;
VoidCallback get onRemoveLink => widget.onRemoveLink;
VoidCallback get onDismiss => widget.onDismiss;
@@ -51,7 +47,9 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
late LinkSearchTextField searchTextField;
bool isShowingSearchResult = false;
ViewPB? currentView;
bool showErrorText = false;
bool get enableApply =>
linkInfo.link.isNotEmpty && linkNameController.text.isNotEmpty;
@override
void initState() {
@@ -60,9 +58,18 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
if (isPageLink) getPageView();
searchTextField = LinkSearchTextField(
initialSearchText: isPageLink ? '' : linkInfo.link,
initialViewId: linkInfo.viewId,
currentViewId: widget.currentViewId,
onEnter: onConfirm,
onEnter: () {
searchTextField.onSearchResult(
onLink: onLinkSelected,
onRecentViews: () =>
onPageSelected(searchTextField.currentRecentView),
onSearchViews: () =>
onPageSelected(searchTextField.currentSearchedView),
onEmpty: () {
searchTextField.unfocus();
},
);
},
onEscape: () {
if (isShowingSearchResult) {
hideSearchResult();
@@ -88,7 +95,6 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
Widget build(BuildContext context) {
final showingRecent =
searchTextField.showingRecent && isShowingSearchResult;
final errorHeight = showErrorText ? 20.0 : 0.0;
return GestureDetector(
onTap: onDismiss,
child: Container(
@@ -101,8 +107,9 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
onTap: hideSearchResult,
child: Container(
width: 400,
height: 192 + errorHeight,
height: 192,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.fromLTRB(20, 16, 20, 16),
),
),
Positioned(
@@ -116,7 +123,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
),
),
Positioned(
top: 80 + errorHeight,
top: 80,
left: 20,
child: FlowyText.semibold(
LocaleKeys.document_toolbar_linkName.tr(),
@@ -126,12 +133,12 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
),
),
Positioned(
top: 144 + errorHeight,
top: 152,
left: 20,
child: buildButtons(),
),
Positioned(
top: 100 + errorHeight,
top: 108,
left: 20,
child: buildNameTextField(),
),
@@ -148,53 +155,29 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
Widget buildLinkField() {
final showPageView = linkInfo.isPage && !isShowingSearchResult;
Widget child;
if (showPageView) {
child = buildPageView();
} else if (!isShowingSearchResult) {
child = buildLinkView();
} else {
return SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 360,
height: 32,
child: searchTextField.buildTextField(
autofocus: true,
context: context,
),
),
VSpace(6),
searchTextField.buildResultContainer(
context: context,
width: 360,
onPageLinkSelected: onPageSelected,
onLinkSelected: onLinkSelected,
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
child,
if (showErrorText)
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText.regular(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: LinkStyle.textStatusError,
fontSize: 12,
figmaLineHeight: 16,
if (showPageView) return buildPageView();
if (!isShowingSearchResult) return buildLinkView();
return SizedBox(
width: 360,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 360,
height: 32,
child: searchTextField.buildTextField(
autofocus: true,
),
),
],
VSpace(6),
searchTextField.buildResultContainer(
context: context,
width: 360,
onPageLinkSelected: onPageSelected,
onLinkSelected: onLinkSelected,
),
],
),
);
}
@@ -214,15 +197,15 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
preferBelow: false,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(color: LinkStyle.borderColor(context)),
border: Border.all(color: LinkStyle.borderColor),
),
onPressed: () => onRemoveLink.call(linkInfo),
onPressed: onRemoveLink,
),
Spacer(),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(color: LinkStyle.borderColor(context)),
border: Border.all(color: LinkStyle.borderColor),
),
child: FlowyTextButton(
LocaleKeys.button_cancel.tr(),
@@ -231,9 +214,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
fontSize: 14,
lineHeight: 20 / 14,
fontColor: Theme.of(context).isLightMode
? LinkStyle.textPrimary
: Theme.of(context).iconTheme.color,
fontColor: LinkStyle.textPrimary,
fillColor: Colors.transparent,
fontWeight: FontWeight.w400,
onPressed: onDismiss,
@@ -251,26 +232,14 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
fontSize: 14,
lineHeight: 20 / 14,
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
fontColor: Colors.white,
fillColor: LinkStyle.fillThemeThick,
fontColor:
enableApply ? Colors.white : LinkStyle.textTertiary,
fillColor: enableApply
? LinkStyle.fillThemeThick
: LinkStyle.borderColor,
fontWeight: FontWeight.w400,
onPressed: () {
if (isShowingSearchResult) {
onConfirm();
return;
}
if (linkInfo.link.isEmpty) {
widget.onRemoveLink(linkInfo);
return;
}
if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) {
setState(() {
showErrorText = true;
});
return;
}
widget.onApply.call(linkInfo);
},
onPressed:
enableApply ? () => widget.onApply.call(linkInfo) : null,
);
},
),
@@ -303,7 +272,6 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
},
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkNameHint.tr(),
context,
),
),
);
@@ -320,34 +288,24 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
),
);
} else {
final viewName = view.name;
final displayName = viewName.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: viewName;
child = GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: FlowyTooltip(
preferBelow: false,
message: displayName,
child: Container(
height: 32,
padding: EdgeInsets.fromLTRB(8, 0, 8, 0),
child: Row(
children: [
searchTextField.buildIcon(view),
HSpace(4),
Flexible(
child: FlowyText.regular(
displayName,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
fontSize: 14,
),
child: Container(
height: 32,
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
searchTextField.buildIcon(view),
HSpace(8),
Flexible(
child: FlowyText.regular(
view.name,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
@@ -366,28 +324,24 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
width: 360,
height: 32,
decoration: buildDecoration(),
child: FlowyTooltip(
preferBelow: false,
message: linkInfo.link,
child: GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
FlowySvg(FlowySvgs.toolbar_link_earth_m),
HSpace(8),
Flexible(
child: FlowyText.regular(
linkInfo.link,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
),
child: GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
FlowySvg(FlowySvgs.toolbar_link_earth_m),
HSpace(8),
Flexible(
child: FlowyText.regular(
linkInfo.link,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
),
],
),
),
],
),
),
),
@@ -395,21 +349,12 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
);
}
void onConfirm() {
searchTextField.onSearchResult(
onLink: onLinkSelected,
onRecentViews: () => onPageSelected(searchTextField.currentRecentView),
onSearchViews: () => onPageSelected(searchTextField.currentSearchedView),
onEmpty: () {
searchTextField.unfocus();
},
);
}
Future<void> getPageView() async {
if (!linkInfo.isPage) return;
final link = linkInfo.link;
final viewId = link.split('/').lastOrNull ?? '';
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(linkInfo.viewId);
await ViewBackendService.getMentionPageStatus(viewId);
if (mounted) {
setState(() {
currentView = view;
@@ -468,7 +413,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
BoxDecoration buildDecoration() => BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: LinkStyle.borderColor(context)),
border: Border.all(color: LinkStyle.borderColor),
);
}
@@ -481,6 +426,4 @@ class LinkInfo {
Attributes toAttribute() =>
{AppFlowyRichTextKeys.href: link, kIsPageLink: isPage};
String get viewId => isPage ? link.split('/').lastOrNull ?? '' : '';
}
@@ -3,25 +3,20 @@ import 'dart:math';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'link_create_menu.dart';
import 'link_edit_menu.dart';
import 'link_styles.dart';
class LinkHoverTrigger extends StatefulWidget {
const LinkHoverTrigger({
@@ -50,7 +45,6 @@ class LinkHoverTrigger extends StatefulWidget {
class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
final hoverMenuController = PopoverController();
final editMenuController = PopoverController();
final toolbarController = getIt<FloatingToolbarController>();
bool isHoverMenuShowing = false;
bool isHoverMenuHovering = false;
bool isHoverTriggerHovering = false;
@@ -63,13 +57,12 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
Attributes get attribute => widget.attribute;
late HoverTriggerKey triggerKey = HoverTriggerKey(widget.node.id, selection);
HoverTriggerKey get triggerKey => HoverTriggerKey(widget.node.id, selection);
@override
void initState() {
super.initState();
getIt<LinkHoverTriggers>()._add(triggerKey, showLinkHoverMenu);
toolbarController.addDisplayListener(onToolbarShow);
}
@override
@@ -77,7 +70,6 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
hoverMenuController.close();
editMenuController.close();
getIt<LinkHoverTriggers>()._remove(triggerKey, showLinkHoverMenu);
toolbarController.removeDisplayListener(onToolbarShow);
super.dispose();
}
@@ -140,9 +132,9 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
tryToDismissLinkHoverMenu();
},
onOpenLink: openLink,
onCopyLink: () => copyLink(context),
onCopyLink: copyLink,
onEditLink: showLinkEditMenu,
onRemoveLink: () => removeLink(editorState, selection),
onRemoveLink: () => removeLink(editorState, selection, true),
),
child: child,
);
@@ -152,7 +144,6 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
final href = attribute.href ?? '',
isPage = attribute.isPage,
title = editorState.getTextInSelection(selection).join();
final currentViewId = context.read<DocumentBloc?>()?.documentId ?? '';
return AppFlowyPopover(
controller: editMenuController,
direction: PopoverDirection.bottomWithLeftAligned,
@@ -168,7 +159,6 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
minHeight: 282,
),
popupBuilder: (context) => LinkEditMenu(
currentViewId: currentViewId,
linkInfo: LinkInfo(name: title, link: href, isPage: isPage),
onDismiss: () => editMenuController.close(),
onApply: (info) async {
@@ -183,19 +173,14 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
editMenuController.close();
await editorState.apply(transaction);
},
onRemoveLink: (linkinfo) =>
onRemoveAndReplaceLink(editorState, selection, linkinfo.name),
onRemoveLink: () => removeLink(editorState, selection, true),
),
child: child,
);
}
void onToolbarShow() => hoverMenuController.close();
void showLinkHoverMenu() {
if (isHoverMenuShowing || toolbarController.isToolbarShowing || !mounted) {
return;
}
if (isHoverMenuShowing) return;
keepEditorFocusNotifier.increase();
hoverMenuController.show();
}
@@ -221,7 +206,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
if (isPage) {
final viewId = href.split('/').lastOrNull ?? '';
if (viewId.isEmpty) {
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
await afLaunchUrlString(href);
} else {
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(viewId);
@@ -230,28 +215,24 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
}
}
} else {
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
await afLaunchUrlString(href);
}
}
Future<void> copyLink(BuildContext context) async {
Future<void> copyLink() async {
final href = widget.attribute.href ?? '';
if (href.isEmpty) return;
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: href));
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
hoverMenuController.close();
}
void removeLink(
EditorState editorState,
Selection selection,
bool isHref,
) {
if (!isHref) return;
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
@@ -270,34 +251,9 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
);
editorState.apply(transaction);
}
void onRemoveAndReplaceLink(
EditorState editorState,
Selection selection,
String text,
) {
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final index = selection.normalized.startIndex;
final length = selection.length;
final transaction = editorState.transaction
..replaceText(
node,
index,
length,
text,
attributes: {
BuiltInAttributeKey.href: null,
kIsPageLink: null,
},
);
editorState.apply(transaction);
}
}
class LinkHoverMenu extends StatefulWidget {
class LinkHoverMenu extends StatelessWidget {
const LinkHoverMenu({
super.key,
required this.attribute,
@@ -319,31 +275,17 @@ class LinkHoverMenu extends StatefulWidget {
final VoidCallback onEditLink;
final VoidCallback onRemoveLink;
@override
State<LinkHoverMenu> createState() => _LinkHoverMenuState();
}
class _LinkHoverMenuState extends State<LinkHoverMenu> {
ViewPB? currentView;
late bool isPage = widget.attribute.isPage;
late String href = widget.attribute.href ?? '';
@override
void initState() {
super.initState();
if (isPage) getPageView();
}
@override
Widget build(BuildContext context) {
final href = attribute.href ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MouseRegion(
onEnter: widget.onEnter,
onExit: widget.onExit,
onEnter: onEnter,
onExit: onExit,
child: SizedBox(
width: max(320, widget.triggerSize.width),
width: max(320, triggerSize.width),
height: 48,
child: Align(
alignment: Alignment.centerLeft,
@@ -351,15 +293,21 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
width: 320,
height: 48,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.fromLTRB(12, 8, 8, 8),
padding: EdgeInsets.all(8),
child: Row(
children: [
Expanded(child: buildLinkWidget()),
Expanded(
child: FlowyText.regular(
href,
fontSize: 14,
figmaLineHeight: 20,
overflow: TextOverflow.ellipsis,
),
),
Container(
height: 20,
width: 1,
color: Color(0xffE8ECF3)
.withAlpha(Theme.of(context).isLightMode ? 255 : 40),
color: LinkStyle.borderColor,
margin: EdgeInsets.symmetric(horizontal: 6),
),
FlowyIconButton(
@@ -367,21 +315,21 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
tooltipText: LocaleKeys.editor_copyLink.tr(),
width: 36,
height: 32,
onPressed: widget.onCopyLink,
onPressed: onCopyLink,
),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_edit_m),
tooltipText: LocaleKeys.editor_editLink.tr(),
width: 36,
height: 32,
onPressed: widget.onEditLink,
onPressed: onEditLink,
),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m),
tooltipText: LocaleKeys.editor_removeLink.tr(),
width: 36,
height: 32,
onPressed: widget.onRemoveLink,
onPressed: onRemoveLink,
),
],
),
@@ -391,13 +339,13 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
),
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: widget.onEnter,
onExit: widget.onExit,
onEnter: onEnter,
onExit: onExit,
child: GestureDetector(
onTap: widget.onOpenLink,
onTap: onOpenLink,
child: Container(
width: widget.triggerSize.width,
height: widget.triggerSize.height,
width: triggerSize.width,
height: triggerSize.height,
color: Colors.black.withAlpha(1),
),
),
@@ -405,46 +353,6 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
],
);
}
Future<void> getPageView() async {
final viewId = href.split('/').lastOrNull ?? '';
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(viewId);
if (mounted) {
setState(() {
currentView = view;
});
}
}
Widget buildLinkWidget() {
final view = currentView;
if (isPage && view == null) {
return SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(),
);
}
String text = '';
if (isPage && view != null) {
text = view.name;
if (text.isEmpty) {
text = LocaleKeys.document_title_placeholder.tr();
}
} else {
text = href;
}
return FlowyTooltip(
message: text,
preferBelow: false,
child: FlowyText.regular(
text,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
fontSize: 14,
),
);
}
}
class HoverTriggerKey {
@@ -459,11 +367,7 @@ class HoverTriggerKey {
other is HoverTriggerKey &&
runtimeType == other.runtimeType &&
nodeId == other.nodeId &&
isSelectionSame(other.selection);
bool isSelectionSame(Selection other) =>
(selection.start == other.start && selection.end == other.end) ||
(selection.start == other.end && selection.end == other.start);
selection == other.selection;
@override
int get hashCode => nodeId.hashCode ^ selection.hashCode;
@@ -486,7 +390,8 @@ class LinkHoverTriggers {
void call(HoverTriggerKey key) {
final callbacks = _map[key] ?? {};
if (callbacks.isEmpty) return;
callbacks.first.call();
for (final callback in callbacks) {
callback.call();
}
}
}
@@ -12,8 +12,6 @@ import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -27,16 +25,12 @@ class LinkSearchTextField {
this.onEscape,
this.onEnter,
this.onDataRefresh,
this.initialViewId = '',
required this.currentViewId,
String? initialSearchText,
}) : textEditingController = TextEditingController(
text: isUri(initialSearchText ?? '') ? initialSearchText : '',
text: initialSearchText ?? '',
);
final TextEditingController textEditingController;
final String initialViewId;
final String currentViewId;
final ItemScrollController searchController = ItemScrollController();
late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent);
final List<ViewPB> searchedViews = [];
@@ -49,7 +43,7 @@ class LinkSearchTextField {
String get searchText => textEditingController.text;
bool get isTextfieldEnable => searchText.isNotEmpty && isUri(searchText);
bool get isButtonEnable => searchText.isNotEmpty;
bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty;
@@ -64,11 +58,7 @@ class LinkSearchTextField {
recentViews.clear();
}
Widget buildTextField({
bool autofocus = false,
bool showError = false,
required BuildContext context,
}) {
Widget buildTextField({bool autofocus = false}) {
return TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
autofocus: autofocus,
@@ -91,8 +81,6 @@ class LinkSearchTextField {
},
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkInputHint.tr(),
context,
showErrorBorder: showError,
),
);
}
@@ -189,41 +177,29 @@ class LinkSearchTextField {
bool isSelected,
ValueChanged<ViewPB>? onSubmittedPageLink,
) {
final viewName = view.name;
final displayName = viewName.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: viewName;
final isCurrent = initialViewId == view.id;
return SizedBox(
height: 32,
child: FlowyButton(
isSelected: isSelected,
leftIcon: buildIcon(view, padding: EdgeInsets.zero),
leftIcon: buildIcon(view),
text: FlowyText.regular(
displayName,
view.name,
overflow: TextOverflow.ellipsis,
fontSize: 14,
figmaLineHeight: 20,
),
rightIcon: isCurrent ? FlowySvg(FlowySvgs.toolbar_check_m) : null,
onTap: () => onSubmittedPageLink?.call(view),
),
);
}
Widget buildIcon(
ViewPB view, {
EdgeInsetsGeometry padding = const EdgeInsets.only(top: 4),
}) {
Widget buildIcon(ViewPB view) {
if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20));
final iconData = view.icon.toEmojiIconData();
return Padding(
padding: padding,
child: RawEmojiIconWidget(
emoji: iconData,
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
lineHeight: 1,
),
return RawEmojiIconWidget(
emoji: iconData,
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
lineHeight: 1,
);
}
@@ -312,7 +288,6 @@ class LinkSearchTextField {
final views = sectionViews
.unique((e) => e.item.id)
.map((e) => e.item)
.where((e) => e.id != currentViewId)
.take(5)
.toList();
recentViews.clear();
@@ -328,14 +303,13 @@ class LinkSearchTextField {
?.items
.where(
(view) =>
(view.id != currentViewId) &&
(view.name.toLowerCase().contains(search.toLowerCase()) ||
(view.name.isEmpty && search.isEmpty) ||
(view.name.isEmpty &&
LocaleKeys.menuAppHeader_defaultNewPageName
.tr()
.toLowerCase()
.contains(search.toLowerCase()))),
view.name.toLowerCase().contains(search.toLowerCase()) ||
(view.name.isEmpty && search.isEmpty) ||
(view.name.isEmpty &&
LocaleKeys.menuAppHeader_defaultNewPageName
.tr()
.toLowerCase()
.contains(search.toLowerCase())),
)
.take(10)
.toList();
@@ -1,31 +1,19 @@
import 'package:appflowy/util/theme_extension.dart';
import 'package:flutter/material.dart';
class LinkStyle {
static const borderColor = Color(0xFFE8ECF3);
static const textTertiary = Color(0xFF99A1A8);
static const textStatusError = Color(0xffE71D32);
static const fillThemeThick = Color(0xFF00B5FF);
static const shadowMedium = Color(0x1F22251F);
static const textPrimary = Color(0xFF1F2329);
static Color borderColor(BuildContext context) =>
Theme.of(context).isLightMode ? Color(0xFFE8ECF3) : Color(0x64BDBDBD);
static InputDecoration buildLinkTextFieldInputDecoration(
String hintText,
BuildContext context, {
bool showErrorBorder = false,
}) {
final border = OutlineInputBorder(
static InputDecoration buildLinkTextFieldInputDecoration(String hintText) {
const border = OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
borderSide: BorderSide(color: borderColor(context)),
borderSide: BorderSide(color: LinkStyle.borderColor),
);
final enableBorder = border.copyWith(
borderSide: BorderSide(
color: showErrorBorder
? LinkStyle.textStatusError
: LinkStyle.fillThemeThick,
),
borderSide: BorderSide(color: LinkStyle.fillThemeThick),
);
const hintStyle = TextStyle(
fontSize: 14,
@@ -0,0 +1,17 @@
import 'dart:ui';
import 'package:bloc/bloc.dart';
class ToolbarCubit extends Cubit<ToolbarState> {
ToolbarCubit(this.onDismissCallback) : super(ToolbarState._());
final VoidCallback onDismissCallback;
void dismiss() {
onDismissCallback.call();
}
}
class ToolbarState {
const ToolbarState._();
}
@@ -104,11 +104,10 @@ Future<void> downloadMediaFile(
await afLaunchUrlString(file.url);
} else {
if (userProfile == null) {
showToastNotification(
return showToastNotification(
context,
message: LocaleKeys.grid_media_downloadFailedToken.tr(),
);
return;
}
final uri = Uri.parse(file.url);
@@ -2,7 +2,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart';
@@ -69,7 +68,7 @@ class _FormatToolbarItem extends ToolbarItem {
final hoverColor = isHighlight
? highlightColor
: EditorStyleCustomizer.toolbarHoverColor(context);
final isDark = !Theme.of(context).isLightMode;
final isDark = Theme.of(context).brightness == Brightness.dark;
final child = FlowyIconButton(
width: 36,
@@ -3,7 +3,6 @@ import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker;
import 'package:flowy_infra/theme_extension_v2.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -79,7 +78,7 @@ class _HighlightColorPickerWidgetState
}
Widget buildChild(BuildContext context) {
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
final iconColor = Theme.of(context).iconTheme.color;
final child = FlowyIconButton(
width: 36,
@@ -1,17 +1,15 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/theme_extension_v2.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'toolbar_id_enum.dart';
const kIsPageLink = 'is_page_link';
@@ -30,10 +28,10 @@ final customLinkItem = ToolbarItem(
);
});
final isDark = !Theme.of(context).isLightMode;
final hoverColor = isHref
? highlightColor
: EditorStyleCustomizer.toolbarHoverColor(context);
final toolbarCubit = context.read<ToolbarCubit?>();
final child = FlowyIconButton(
width: 36,
@@ -43,20 +41,16 @@ final customLinkItem = ToolbarItem(
icon: FlowySvg(
FlowySvgs.toolbar_link_m,
size: Size.square(20.0),
color: (isDark && isHref)
? Color(0xFF282E3A)
: AFThemeExtensionV2.of(context).icon_primary,
color: Theme.of(context).iconTheme.color,
),
onPressed: () {
getIt<FloatingToolbarController>().hideToolbar();
if (!isHref) {
final viewId = context.read<DocumentBloc?>()?.documentId ?? '';
showLinkCreateMenu(context, editorState, selection, viewId);
toolbarCubit?.dismiss();
if (isHref) {
getIt<LinkHoverTriggers>().call(
HoverTriggerKey(nodes.first.id, selection),
);
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
getIt<LinkHoverTriggers>()
.call(HoverTriggerKey(nodes.first.id, selection));
});
showLinkCreateMenu(context, editorState, selection);
}
},
);
@@ -5,7 +5,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_to
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension_v2.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'toolbar_id_enum.dart';
@@ -78,7 +77,7 @@ class _TextColorPickerWidgetState extends State<TextColorPickerWidget> {
}
Widget buildChild(BuildContext context) {
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
final iconColor = Theme.of(context).iconTheme.color;
final child = FlowyIconButton(
width: 36,
height: 32,
@@ -1,10 +1,9 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/startup/startup.dart';
@@ -289,7 +288,7 @@ class _MoreOptionActionListState extends State<MoreOptionActionList> {
popoverController: suggestionsPopoverController,
popoverDirection: PopoverDirection.leftWithTopAligned,
showOffset: Offset(-8, height),
onSelect: () => getIt<FloatingToolbarController>().hideToolbar(),
onSelect: () => context.read<ToolbarCubit?>()?.dismiss(),
child: buildCommandItem(
MoreOptionCommand.suggestions,
rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m),
@@ -309,7 +308,7 @@ class _MoreOptionActionListState extends State<MoreOptionActionList> {
popoverController: textAlignPopoverController,
popoverDirection: PopoverDirection.leftWithTopAligned,
showOffset: Offset(-8, 0),
onSelect: () => getIt<FloatingToolbarController>().hideToolbar(),
onSelect: () => context.read<ToolbarCubit?>()?.dismiss(),
highlightColor: highlightColor,
child: buildCommandItem(
MoreOptionCommand.textAlign,
@@ -380,14 +379,13 @@ enum MoreOptionCommand {
(attributes) => attributes[AppFlowyRichTextKeys.href] != null,
);
});
getIt<FloatingToolbarController>().hideToolbar();
context.read<ToolbarCubit?>()?.dismiss();
if (isHref) {
getIt<LinkHoverTriggers>().call(
HoverTriggerKey(nodes.first.id, selection),
);
} else {
final viewId = context.read<DocumentBloc?>()?.documentId ?? '';
showLinkCreateMenu(context, editorState, selection, viewId);
showLinkCreateMenu(context, editorState, selection);
}
} else if (this == strikethrough) {
await editorState.toggleAttribute(name);
@@ -84,14 +84,12 @@ class _SuggestionsActionListState extends State<SuggestionsActionList> {
void initState() {
super.initState();
refreshSuggestions();
editorState.selectionNotifier.addListener(refreshSuggestions);
}
@override
void dispose() {
editorState.selectionNotifier.removeListener(refreshSuggestions);
popoverController.close();
super.dispose();
popoverController.close();
}
@override
@@ -292,7 +290,6 @@ class _SuggestionsActionListState extends State<SuggestionsActionList> {
}
currentSuggestionItem =
suggestions.where((item) => item.type == suggestionType).first;
if (mounted) setState(() {});
}
}
@@ -568,6 +568,7 @@ class EditorStyleCustomizer {
if (style == null) {
return null;
}
final fontSize = style.fontSize ?? 14.0;
final isLight = Theme.of(context).isLightMode;
final textColor = isLight ? Color(0xFF007296) : Color(0xFF49CFF4);
final underlineColor = isLight ? Color(0x33005A7A) : Color(0x3349CFF4);
@@ -577,10 +578,17 @@ class EditorStyleCustomizer {
decoration: TextDecoration.lineThrough,
),
AiWriterBlockKeys.suggestionReplacement => style.copyWith(
color: textColor,
color: Colors.transparent,
decoration: TextDecoration.underline,
decorationColor: underlineColor,
decorationThickness: 1.0,
// hack: https://jtmuller5.medium.com/the-ultimate-guide-to-underlining-text-in-flutter-57936f5c79bb
shadows: [
Shadow(
color: textColor,
offset: Offset(0, -fontSize * 0.2),
),
],
),
_ => style,
};
@@ -1,27 +1,21 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:flutter/cupertino.dart';
import 'package:universal_platform/universal_platform.dart';
import 'emoji_menu.dart';
const _emojiCharacter = ':';
final _letterRegExp = RegExp(r'^[a-zA-Z]$');
const emojiCharacter = ':';
CharacterShortcutEvent emojiCommand(BuildContext context) =>
CharacterShortcutEvent(
key: 'Opens Emoji Menu',
character: '',
regExp: _letterRegExp,
handler: (editorState) async {
return false;
},
handlerWithCharacter: (editorState, character) {
emojiMenuService = EmojiMenu(
character: emojiCharacter,
handler: (editorState) {
emojiMenuService ??= EmojiMenu(
context: context,
editorState: editorState,
);
return emojiCommandHandler(editorState, context, character);
return emojiCommandHandler(editorState, context);
},
);
@@ -30,7 +24,6 @@ EmojiMenuService? emojiMenuService;
Future<bool> emojiCommandHandler(
EditorState editorState,
BuildContext context,
String character,
) async {
final selection = editorState.selection;
@@ -38,32 +31,14 @@ Future<bool> emojiCommandHandler(
return false;
}
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null ||
delta == null ||
delta.isEmpty ||
node.type == CodeBlockKeys.type) {
return false;
if (!selection.isCollapsed) {
await editorState.deleteSelection(selection);
}
if (selection.end.offset > 0) {
final plain = delta.toPlainText();
final previousCharacter = plain[selection.end.offset - 1];
if (previousCharacter != _emojiCharacter) return false;
if (!context.mounted) return false;
if (!selection.isCollapsed) return false;
await editorState.insertTextAtPosition(
character,
position: selection.start,
);
emojiMenuService?.show(character);
return true;
}
return false;
await editorState.insertTextAtPosition(
emojiCharacter,
position: selection.start,
);
emojiMenuService?.show();
return true;
}
@@ -1,16 +1,15 @@
import 'dart:async';
import 'dart:math';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'emoji_menu.dart';
@@ -24,7 +23,6 @@ class EmojiHandler extends StatefulWidget {
required this.onEmojiSelect,
this.startCharAmount = 1,
this.cancelBySpaceHandler,
this.initialSearchText = '',
});
final EditorState editorState;
@@ -33,7 +31,6 @@ class EmojiHandler extends StatefulWidget {
final VoidCallback onSelectionUpdate;
final SelectEmojiItemHandler onEmojiSelect;
final int startCharAmount;
final String initialSearchText;
final bool Function()? cancelBySpaceHandler;
@override
@@ -41,34 +38,30 @@ class EmojiHandler extends StatefulWidget {
}
class _EmojiHandlerState extends State<EmojiHandler> {
final focusNode = FocusNode(debugLabel: 'emoji_menu_handler');
final scrollController = ScrollController();
final _focusNode = FocusNode(debugLabel: 'emoji_menu_handler');
final ItemScrollController controller = ItemScrollController();
late EmojiData emojiData;
final List<Emoji> searchedEmojis = [];
bool loaded = false;
int invalidCounter = 0;
late int startOffset;
late String _search = widget.initialSearchText;
double emojiHeight = 36.0;
final configuration = EmojiPickerConfiguration(
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
);
String _search = '';
set search(String search) {
_search = search;
_doSearch();
}
final ValueNotifier<int> selectedIndexNotifier = ValueNotifier(0);
final ValueNotifier<int> _selectedIndexNotifier = ValueNotifier(0);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => focusNode.requestFocus(),
(_) => _focusNode.requestFocus(),
);
startOffset = (widget.editorState.selection?.endIndex ?? 0) - 1;
startOffset = widget.editorState.selection?.endIndex ?? 0;
if (kCachedEmojiData != null) {
loadEmojis(kCachedEmojiData!);
@@ -84,9 +77,8 @@ class _EmojiHandlerState extends State<EmojiHandler> {
@override
void dispose() {
focusNode.dispose();
selectedIndexNotifier.dispose();
scrollController.dispose();
_focusNode.dispose();
_selectedIndexNotifier.dispose();
super.dispose();
}
@@ -94,11 +86,10 @@ class _EmojiHandlerState extends State<EmojiHandler> {
Widget build(BuildContext context) {
final noEmojis = searchedEmojis.isEmpty;
return Focus(
focusNode: focusNode,
focusNode: _focusNode,
onKeyEvent: onKeyEvent,
child: Container(
constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360),
padding: const EdgeInsets.symmetric(vertical: 16),
constraints: const BoxConstraints(maxHeight: 400, maxWidth: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.0),
color: Theme.of(context).cardColor,
@@ -110,73 +101,52 @@ class _EmojiHandlerState extends State<EmojiHandler> {
),
],
),
child: noEmojis ? buildLoading() : buildEmojis(),
),
);
}
Widget buildLoading() {
return SizedBox(
width: 400,
height: 40,
child: Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(),
),
),
);
}
Widget buildEmojis() {
return SizedBox(
height:
(searchedEmojis.length / configuration.perLine).ceil() * emojiHeight,
child: GridView.builder(
controller: scrollController,
itemCount: searchedEmojis.length,
padding: const EdgeInsets.symmetric(horizontal: 16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: configuration.perLine,
),
itemBuilder: (context, index) {
final currentEmoji = searchedEmojis[index];
final emojiId = currentEmoji.id;
final emoji = emojiData.getEmojiById(
emojiId,
skinTone: configuration.defaultSkinTone,
);
return ValueListenableBuilder(
valueListenable: selectedIndexNotifier,
builder: (context, value, child) {
final isSelected = value == index;
return SizedBox.square(
dimension: emojiHeight,
child: FlowyButton(
isSelected: isSelected,
margin: EdgeInsets.zero,
radius: Corners.s8Border,
text: ManualTooltip(
key: ValueKey('$emojiId-$isSelected'),
message: currentEmoji.name,
showAutomaticlly: isSelected,
preferBelow: false,
child: FlowyText.emoji(
emoji,
fontSize: configuration.emojiSize,
),
child: noEmojis
? SizedBox(
width: 400,
height: 40,
child: Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(),
),
onTap: () => onSelect(index),
),
);
},
);
},
)
: ScrollablePositionedList.builder(
itemCount: searchedEmojis.length,
itemScrollController: controller,
padding: EdgeInsets.all(8),
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
itemBuilder: (ctx, index) {
return ValueListenableBuilder(
valueListenable: _selectedIndexNotifier,
builder: (context, value, __) {
final selectedEmoji = searchedEmojis[index];
final displayedEmoji =
emojiData.getEmojiById(selectedEmoji.id);
final isSelected = value == index;
return SizedBox(
height: 32,
child: FlowyButton(
text: FlowyText.medium(
'$displayedEmoji ${selectedEmoji.name}',
lineHeight: 1.0,
overflow: TextOverflow.ellipsis,
),
isSelected: isSelected,
onTap: () => onSelect(index),
),
);
},
);
},
),
),
);
}
void changeSelectedIndex(int index) => selectedIndexNotifier.value = index;
void changeSelectedIndex(int index) => _selectedIndexNotifier.value = index;
void loadEmojis(EmojiData data) {
emojiData = data;
@@ -187,14 +157,11 @@ class _EmojiHandlerState extends State<EmojiHandler> {
loaded = true;
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_doSearch();
});
}
void _doSearch() {
if (!loaded || !mounted) return;
if (_search.startsWith(' ') || _search.isEmpty) {
Future<void> _doSearch() async {
if (!loaded) return;
if (_search.startsWith(' ')) {
widget.onDismiss.call();
return;
}
@@ -203,7 +170,6 @@ class _EmojiHandlerState extends State<EmojiHandler> {
searchedEmojis.clear();
searchedEmojis.addAll(searchEmojiData.emojis.values);
changeSelectedIndex(0);
_scrollToItem();
});
if (searchedEmojis.isEmpty) {
widget.onDismiss.call();
@@ -211,19 +177,17 @@ class _EmojiHandlerState extends State<EmojiHandler> {
}
KeyEventResult onKeyEvent(focus, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (event is! KeyDownEvent) {
return KeyEventResult.ignored;
}
const moveKeys = [
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowRight,
];
if (event.logicalKey == LogicalKeyboardKey.enter) {
onSelect(selectedIndexNotifier.value);
onSelect(_selectedIndexNotifier.value);
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
// Workaround to bring focus back to editor
@@ -250,7 +214,11 @@ class _EmojiHandlerState extends State<EmojiHandler> {
return KeyEventResult.handled;
} else if (event.character != null &&
!moveKeys.contains(event.logicalKey)) {
![
...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();
@@ -270,13 +238,42 @@ class _EmojiHandlerState extends State<EmojiHandler> {
return KeyEventResult.handled;
}
if ([LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight]
.contains(event.logicalKey)) {
widget.onSelectionUpdate();
event.logicalKey == LogicalKeyboardKey.arrowLeft
? widget.editorState.moveCursorForward()
: 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.call();
}
/// 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 onSelect(int index) {
widget.onEmojiSelect.call(
context,
(startOffset - widget.startCharAmount, startOffset + _search.length),
(
startOffset - widget.startCharAmount,
_search.length + widget.startCharAmount
),
emojiData.getEmojiById(searchedEmojis[index].id),
);
widget.onDismiss.call();
@@ -299,70 +296,49 @@ class _EmojiHandlerState extends State<EmojiHandler> {
.getTextInSelection(
selection.copyWith(
start: selection.start.copyWith(offset: startOffset),
end: selection.start.copyWith(offset: startOffset + _search.length + 1),
end: selection.start
.copyWith(offset: startOffset + _search.length + 1),
),
)
.join();
}
void _moveSelection(LogicalKeyboardKey key) {
final index = selectedIndexNotifier.value,
perLine = configuration.perLine,
remainder = index % perLine,
length = searchedEmojis.length,
currentLine = index ~/ perLine,
maxLine = (length / perLine).ceil();
final heightBefore = currentLine * emojiHeight;
if (key == LogicalKeyboardKey.arrowUp) {
if (currentLine == 0) {
final exceptLine = max(0, maxLine - 1);
changeSelectedIndex(min(exceptLine * perLine + remainder, length - 1));
} else if (currentLine > 0) {
changeSelectedIndex(index - perLine);
}
} else if (key == LogicalKeyboardKey.arrowDown) {
if (currentLine == maxLine - 1) {
changeSelectedIndex(remainder);
} else if (currentLine < maxLine - 1) {
changeSelectedIndex(min(index + perLine, length - 1));
}
} else if (key == LogicalKeyboardKey.arrowLeft) {
bool didChange = false;
final index = _selectedIndexNotifier.value;
if (key == LogicalKeyboardKey.arrowUp ||
(key == LogicalKeyboardKey.tab &&
HardwareKeyboard.instance.isShiftPressed)) {
if (index == 0) {
changeSelectedIndex(length - 1);
changeSelectedIndex(max(0, searchedEmojis.length - 1));
didChange = true;
} else if (index > 0) {
changeSelectedIndex(index - 1);
didChange = true;
}
} else if (key == LogicalKeyboardKey.arrowRight) {
if (index == length - 1) {
changeSelectedIndex(0);
} else if (index < length - 1) {
} else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab]
.contains(key)) {
if (index < searchedEmojis.length - 1) {
changeSelectedIndex(index + 1);
didChange = true;
} else if (index == searchedEmojis.length - 1) {
changeSelectedIndex(0);
didChange = true;
}
}
final heightAfter =
(selectedIndexNotifier.value ~/ configuration.perLine) * emojiHeight;
if (mounted && (heightAfter != heightBefore)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToItem();
});
if (mounted && didChange) {
_scrollToItem();
}
}
void _scrollToItem() {
final noEmojis = searchedEmojis.isEmpty;
if (noEmojis || !mounted) return;
final currentItem = selectedIndexNotifier.value;
final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight;
final maxExtent = scrollController.position.maxScrollExtent;
final jumpTo = (exceptHeight - maxExtent > 10 * emojiHeight)
? exceptHeight
: min(exceptHeight, maxExtent);
scrollController.animateTo(
jumpTo,
duration: Duration(milliseconds: 300),
curve: Curves.linear,
if (noEmojis) return;
controller.scrollTo(
index: _selectedIndexNotifier.value,
duration: const Duration(milliseconds: 200),
alignment: 0.5,
);
}
@@ -380,7 +356,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
search = delta.toPlainText().substring(
startOffset,
startOffset + _search.length - 1,
startOffset - 1 + _search.length,
);
}
@@ -5,7 +5,7 @@ import 'emoji_actions_command.dart';
import 'emoji_handler.dart';
abstract class EmojiMenuService {
void show(String character);
void show();
void dismiss();
}
@@ -16,22 +16,16 @@ class EmojiMenu extends EmojiMenuService {
required this.editorState,
this.startCharAmount = 1,
this.cancelBySpaceHandler,
this.menuHeight = 400,
this.menuWidth = 300,
});
final BuildContext context;
final EditorState editorState;
final double menuHeight;
final double menuWidth;
final bool Function()? cancelBySpaceHandler;
final int startCharAmount;
Offset _offset = Offset.zero;
Alignment _alignment = Alignment.topLeft;
OverlayEntry? _menuEntry;
bool selectionChangedByMenu = false;
String initialCharacter = '';
@override
void dismiss() {
@@ -57,8 +51,7 @@ class EmojiMenu extends EmojiMenuService {
void _onSelectionUpdate() => selectionChangedByMenu = true;
@override
void show(String character) {
initialCharacter = character;
void show() {
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
}
@@ -69,11 +62,41 @@ class EmojiMenu extends EmojiMenuService {
return;
}
const menuHeight = 400.0, menuWidth = 300.0;
const Offset menuOffset = Offset(0, 10);
final Offset editorOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final Size editorSize = editorState.renderBox!.size;
final editorHeight = editorSize.height, editorWidth = editorSize.width;
// Default to opening the overlay below
Alignment alignment = Alignment.topLeft;
calculateSelectionMenuOffset(selectionRects.first);
final firstRect = selectionRects.first;
Offset offset = firstRect.bottomRight + menuOffset;
final (left, top, right, bottom) = _getPosition();
// Show above
if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) {
offset = firstRect.topRight - menuOffset;
alignment = Alignment.bottomLeft;
offset = Offset(
offset.dx,
editorHeight - offset.dy,
);
}
// Show on the left
if (offset.dx > (editorWidth - menuWidth)) {
alignment = alignment == Alignment.topLeft
? Alignment.topRight
: Alignment.bottomRight;
offset = Offset(
editorWidth - offset.dx,
offset.dy,
);
}
final (left, top, right, bottom) = _getPosition(alignment, offset);
_menuEntry = OverlayEntry(
builder: (context) => SizedBox(
@@ -99,7 +122,6 @@ class EmojiMenu extends EmojiMenuService {
onSelectionUpdate: _onSelectionUpdate,
startCharAmount: startCharAmount,
cancelBySpaceHandler: cancelBySpaceHandler,
initialSearchText: initialCharacter,
onEmojiSelect: (
BuildContext context,
(int, int) replacement,
@@ -112,14 +134,10 @@ class EmojiMenu extends EmojiMenuService {
editorState.document.nodeAtPath(selection.end.path);
if (node == null) return;
final transaction = editorState.transaction
..deleteText(
node,
replacement.$1,
replacement.$2 - replacement.$1,
)
..insertText(
..replaceText(
node,
replacement.$1,
replacement.$2,
emoji,
);
await editorState.apply(transaction);
@@ -157,77 +175,30 @@ class EmojiMenu extends EmojiMenuService {
selectionChangedByMenu = false;
}
(double? left, double? top, double? right, double? bottom) _getPosition() {
(double? left, double? top, double? right, double? bottom) _getPosition(
Alignment alignment,
Offset offset,
) {
double? left, top, right, bottom;
switch (_alignment) {
switch (alignment) {
case Alignment.topLeft:
left = _offset.dx;
top = _offset.dy;
left = offset.dx;
top = offset.dy;
break;
case Alignment.bottomLeft:
left = _offset.dx;
bottom = _offset.dy;
left = offset.dx;
bottom = offset.dy;
break;
case Alignment.topRight:
right = _offset.dx;
top = _offset.dy;
right = offset.dx;
top = offset.dy;
break;
case Alignment.bottomRight:
right = _offset.dx;
bottom = _offset.dy;
right = offset.dx;
bottom = offset.dy;
break;
}
return (left, top, right, bottom);
}
void calculateSelectionMenuOffset(Rect rect) {
// Workaround: We can customize the padding through the [EditorStyle],
// but the coordinates of overlay are not properly converted currently.
// Just subtract the padding here as a result.
const menuOffset = Offset(0, 10);
final editorOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final editorHeight = editorState.renderBox!.size.height;
final editorWidth = editorState.renderBox!.size.width;
// show below default
_alignment = Alignment.topLeft;
final bottomRight = rect.bottomRight;
final topRight = rect.topRight;
var offset = bottomRight + menuOffset;
_offset = Offset(
offset.dx,
offset.dy,
);
// show above
if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) {
offset = topRight - menuOffset;
_alignment = Alignment.bottomLeft;
_offset = Offset(
offset.dx,
editorHeight + editorOffset.dy - offset.dy,
);
}
// show on right
if (_offset.dx + menuWidth < editorOffset.dx + editorWidth) {
_offset = Offset(
_offset.dx,
_offset.dy,
);
} else if (offset.dx - editorOffset.dx > menuWidth) {
// show on left
_alignment = _alignment == Alignment.topLeft
? Alignment.topRight
: Alignment.bottomRight;
_offset = Offset(
editorWidth - _offset.dx + editorOffset.dx,
_offset.dy,
);
}
}
}
@@ -27,7 +27,6 @@ Future<String> customDocumentToMarkdown(
Document document, {
String path = '',
AsyncValueSetter<Archive>? onArchive,
String lineBreak = '',
}) async {
final List<Future<ArchiveFile>> fileFutures = [];
@@ -42,7 +41,6 @@ Future<String> customDocumentToMarkdown(
try {
markdown = documentToMarkdown(
document,
lineBreak: lineBreak,
customParsers: [
const MathEquationNodeParser(),
const CalloutNodeParser(),
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/util/expand_views.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
@@ -188,9 +187,6 @@ Future<void> initGetIt(
getIt.registerSingleton<PluginSandbox>(PluginSandbox());
getIt.registerSingleton<ViewExpanderRegistry>(ViewExpanderRegistry());
getIt.registerSingleton<LinkHoverTriggers>(LinkHoverTriggers());
getIt.registerSingleton<FloatingToolbarController>(
FloatingToolbarController(),
);
await DependencyResolver.resolve(getIt, mode);
}
@@ -19,7 +19,6 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -231,19 +230,14 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(state.textScaleFactor),
),
child: AppFlowyTheme(
data: Theme.of(context).brightness == Brightness.light
? AppFlowyThemeData.light()
: AppFlowyThemeData.dark(),
child: overlayManagerBuilder(
context,
!UniversalPlatform.isMobile && FeatureFlag.search.isOn
? CommandPalette(
notifier: _commandPaletteNotifier,
child: child,
)
: child,
),
child: overlayManagerBuilder(
context,
!UniversalPlatform.isMobile && FeatureFlag.search.isOn
? CommandPalette(
notifier: _commandPaletteNotifier,
child: child,
)
: child,
),
),
debugShowCheckedModeBanner: false,
@@ -8,15 +8,10 @@ import 'package:appflowy/startup/startup.dart';
class WindowSizeManager {
static const double minWindowHeight = 640.0;
static const double minWindowWidth = 640.0;
// Preventing failed assertion due to Texture Descriptor Validation
static const double maxWindowHeight = 8192.0;
static const double maxWindowWidth = 8192.0;
// Default windows size
static const double defaultWindowHeight = 960.0;
static const double defaultWindowWidth = 1280.0;
static const double maxScaleFactor = 2.0;
static const double minScaleFactor = 0.5;
@@ -41,8 +36,8 @@ class WindowSizeManager {
Future<Size> getSize() async {
final defaultWindowSize = jsonEncode(
{
WindowSizeManager.height: defaultWindowHeight,
WindowSizeManager.width: defaultWindowWidth,
WindowSizeManager.height: minWindowHeight,
WindowSizeManager.width: minWindowWidth,
},
);
final windowSize = await getIt<KeyValueStorage>().get(KVKeys.windowSize);
@@ -83,13 +83,6 @@ class AppFlowyCloudDeepLink {
void unsubscribeDeepLinkLoadingState(VoidCallback listener) =>
_stateNotifier?.removeListener(listener);
Future<void> passGotrueTokenResponse(
GotrueTokenResponsePB gotrueTokenResponse,
) async {
final uri = _buildDeepLinkUri(gotrueTokenResponse);
await _handleUri(uri);
}
Future<void> _handleUri(
Uri? uri,
) async {
@@ -180,57 +173,6 @@ class AppFlowyCloudDeepLink {
bool _isPaymentSuccessUri(Uri uri) {
return uri.host == 'payment-success';
}
Uri? _buildDeepLinkUri(GotrueTokenResponsePB gotrueTokenResponse) {
final params = <String, String>{};
if (gotrueTokenResponse.hasAccessToken() &&
gotrueTokenResponse.accessToken.isNotEmpty) {
params['access_token'] = gotrueTokenResponse.accessToken;
}
if (gotrueTokenResponse.hasExpiresAt()) {
params['expires_at'] = gotrueTokenResponse.expiresAt.toString();
}
if (gotrueTokenResponse.hasExpiresIn()) {
params['expires_in'] = gotrueTokenResponse.expiresIn.toString();
}
if (gotrueTokenResponse.hasProviderRefreshToken() &&
gotrueTokenResponse.providerRefreshToken.isNotEmpty) {
params['provider_refresh_token'] =
gotrueTokenResponse.providerRefreshToken;
}
if (gotrueTokenResponse.hasProviderAccessToken() &&
gotrueTokenResponse.providerAccessToken.isNotEmpty) {
params['provider_token'] = gotrueTokenResponse.providerAccessToken;
}
if (gotrueTokenResponse.hasRefreshToken() &&
gotrueTokenResponse.refreshToken.isNotEmpty) {
params['refresh_token'] = gotrueTokenResponse.refreshToken;
}
if (gotrueTokenResponse.hasTokenType() &&
gotrueTokenResponse.tokenType.isNotEmpty) {
params['token_type'] = gotrueTokenResponse.tokenType;
}
if (params.isEmpty) {
return null;
}
final fragment = params.entries
.map(
(e) =>
'${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}',
)
.join('&');
return Uri.parse('appflowy-flutter://login-callback#$fragment');
}
}
class InitAppFlowyCloudTask extends LaunchTask {
@@ -32,17 +32,12 @@ class AppFlowyCloudAuthService implements AuthService {
}
@override
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>>
signInWithEmailPassword({
Future<FlowyResult<UserProfilePB, FlowyError>> signInWithEmailPassword({
required String email,
required String password,
Map<String, String> params = const {},
}) async {
return _backendAuthService.signInWithEmailPassword(
email: email,
password: password,
params: params,
);
throw UnimplementedError();
}
@override
@@ -111,17 +106,6 @@ class AppFlowyCloudAuthService implements AuthService {
);
}
@override
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>> signInWithPasscode({
required String email,
required String passcode,
}) async {
return _backendAuthService.signInWithPasscode(
email: email,
passcode: passcode,
);
}
@override
Future<FlowyResult<UserProfilePB, FlowyError>> getUser() async {
return UserBackendService.getCurrentUserProfile();
@@ -33,8 +33,7 @@ class AppFlowyCloudMockAuthService implements AuthService {
}
@override
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>>
signInWithEmailPassword({
Future<FlowyResult<UserProfilePB, FlowyError>> signInWithEmailPassword({
required String email,
required String password,
Map<String, String> params = const {},
@@ -107,12 +106,4 @@ class AppFlowyCloudMockAuthService implements AuthService {
Future<FlowyResult<UserProfilePB, FlowyError>> getUser() async {
return UserBackendService.getCurrentUserProfile();
}
@override
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>> signInWithPasscode({
required String email,
required String passcode,
}) async {
throw UnimplementedError();
}
}
@@ -1,5 +1,5 @@
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart';
import 'package:appflowy_result/appflowy_result.dart';
class AuthServiceMapKeys {
@@ -23,8 +23,7 @@ abstract class AuthService {
///
/// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError].
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>>
signInWithEmailPassword({
Future<FlowyResult<UserProfilePB, FlowyError>> signInWithEmailPassword({
required String email,
required String password,
Map<String, String> params,
@@ -76,17 +75,6 @@ abstract class AuthService {
Map<String, String> params,
});
/// Authenticates a user with a passcode sent to their email.
///
/// - `email`: The email address of the user.
/// - `passcode`: The passcode of the user.
///
/// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError].
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>> signInWithPasscode({
required String email,
required String passcode,
});
/// Signs out the currently authenticated user.
Future<void> signOut();
@@ -19,8 +19,7 @@ class BackendAuthService implements AuthService {
final AuthenticatorPB authType;
@override
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>>
signInWithEmailPassword({
Future<FlowyResult<UserProfilePB, FlowyError>> signInWithEmailPassword({
required String email,
required String password,
Map<String, String> params = const {},
@@ -30,7 +29,8 @@ class BackendAuthService implements AuthService {
..password = password
..authType = authType
..deviceId = await getDeviceId();
return UserEventSignInWithEmailPassword(request).send();
final response = UserEventSignInWithEmailPassword(request).send();
return response.then((value) => value);
}
@override
@@ -107,12 +107,4 @@ class BackendAuthService implements AuthService {
// No need to pass the redirect URL.
return UserBackendService.signInWithMagicLink(email, '');
}
@override
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>> signInWithPasscode({
required String email,
required String passcode,
}) async {
return UserBackendService.signInWithPasscode(email, passcode);
}
}
@@ -30,26 +30,12 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
on<SignInEvent>(
(event, emit) async {
await event.when(
signInWithEmailAndPassword: (email, password) async =>
_onSignInWithEmailAndPassword(
emit,
email: email,
password: password,
),
signInWithOAuth: (platform) async => _onSignInWithOAuth(
emit,
platform: platform,
),
signInAsGuest: () async => _onSignInAsGuest(emit),
signInWithMagicLink: (email) async => _onSignInWithMagicLink(
emit,
email: email,
),
signInWithPasscode: (email, passcode) async => _onSignInWithPasscode(
emit,
email: email,
passcode: passcode,
),
signedInWithUserEmailAndPassword: () async => _onSignIn(emit),
signedInWithOAuth: (platform) async =>
_onSignInWithOAuth(emit, platform),
signedInAsGuest: () async => _onSignInAsGuest(emit),
signedWithMagicLink: (email) async =>
_onSignInWithMagicLink(emit, email),
deepLinkStateChange: (result) => _onDeepLinkStateChange(emit, result),
cancel: () {
emit(
@@ -133,34 +119,26 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
}
}
Future<void> _onSignInWithEmailAndPassword(
Emitter<SignInState> emit, {
required String email,
required String password,
}) async {
Future<void> _onSignIn(Emitter<SignInState> emit) async {
final result = await authService.signInWithEmailPassword(
email: email,
password: password,
email: state.email ?? '',
password: state.password ?? '',
);
emit(
result.fold(
(gotrueTokenResponse) {
getIt<AppFlowyCloudDeepLink>().passGotrueTokenResponse(
gotrueTokenResponse,
);
return state.copyWith(
isSubmitting: false,
);
},
(userProfile) => state.copyWith(
isSubmitting: false,
successOrFail: FlowyResult.success(userProfile),
),
(error) => _stateFromCode(error),
),
);
}
Future<void> _onSignInWithOAuth(
Emitter<SignInState> emit, {
required String platform,
}) async {
Emitter<SignInState> emit,
String platform,
) async {
emit(
state.copyWith(
isSubmitting: true,
@@ -183,9 +161,9 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
}
Future<void> _onSignInWithMagicLink(
Emitter<SignInState> emit, {
required String email,
}) async {
Emitter<SignInState> emit,
String email,
) async {
emit(
state.copyWith(
isSubmitting: true,
@@ -205,40 +183,6 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
);
}
Future<void> _onSignInWithPasscode(
Emitter<SignInState> emit, {
required String email,
required String passcode,
}) async {
emit(
state.copyWith(
isSubmitting: true,
emailError: null,
passwordError: null,
successOrFail: null,
),
);
final result = await authService.signInWithPasscode(
email: email,
passcode: passcode,
);
emit(
result.fold(
(gotrueTokenResponse) {
getIt<AppFlowyCloudDeepLink>().passGotrueTokenResponse(
gotrueTokenResponse,
);
return state.copyWith(
isSubmitting: false,
);
},
(error) => _stateFromCode(error),
),
);
}
Future<void> _onSignInAsGuest(
Emitter<SignInState> emit,
) async {
@@ -280,17 +224,10 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
emailError: null,
);
case ErrorCode.UserUnauthorized:
final errorMsg = error.msg;
String msg = LocaleKeys.signIn_generalError.tr();
if (errorMsg.contains('rate limit')) {
msg = LocaleKeys.signIn_limitRateError.tr();
} else if (errorMsg.contains('invalid')) {
msg = LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr();
}
return state.copyWith(
isSubmitting: false,
successOrFail: FlowyResult.failure(
FlowyError(msg: msg),
FlowyError(msg: LocaleKeys.signIn_limitRateError.tr()),
),
);
default:
@@ -306,35 +243,19 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
@freezed
class SignInEvent with _$SignInEvent {
// Sign in methods
const factory SignInEvent.signInWithEmailAndPassword({
required String email,
required String password,
}) = SignInWithEmailAndPassword;
const factory SignInEvent.signInWithOAuth({
required String platform,
}) = SignInWithOAuth;
const factory SignInEvent.signInAsGuest() = SignInAsGuest;
const factory SignInEvent.signInWithMagicLink({
required String email,
}) = SignInWithMagicLink;
const factory SignInEvent.signInWithPasscode({
required String email,
required String passcode,
}) = SignInWithPasscode;
// Event handlers
const factory SignInEvent.emailChanged({
required String email,
}) = EmailChanged;
const factory SignInEvent.passwordChanged({
required String password,
}) = PasswordChanged;
const factory SignInEvent.signedInWithUserEmailAndPassword() =
SignedInWithUserEmailAndPassword;
const factory SignInEvent.signedInWithOAuth(String platform) =
SignedInWithOAuth;
const factory SignInEvent.signedInAsGuest() = SignedInAsGuest;
const factory SignInEvent.signedWithMagicLink(String email) =
SignedWithMagicLink;
const factory SignInEvent.emailChanged(String email) = EmailChanged;
const factory SignInEvent.passwordChanged(String password) = PasswordChanged;
const factory SignInEvent.deepLinkStateChange(DeepLinkResult result) =
DeepLinkStateChange;
const factory SignInEvent.cancel() = Cancel;
const factory SignInEvent.switchLoginType(LoginType type) = SwitchLoginType;
const factory SignInEvent.cancel() = _Cancel;
const factory SignInEvent.switchLoginType(LoginType type) = _SwitchLoginType;
}
// we support sign in directly without sign up, but we want to allow the users to sign up if they want to
@@ -86,15 +86,6 @@ class UserBackendService implements IUserBackendService {
return UserEventMagicLinkSignIn(payload).send();
}
static Future<FlowyResult<GotrueTokenResponsePB, FlowyError>>
signInWithPasscode(
String email,
String passcode,
) async {
final payload = PasscodeSignInPB(email: email, passcode: passcode);
return UserEventPasscodeSignIn(payload).send();
}
static Future<FlowyResult<void, FlowyError>> signOut() {
return UserEventSignOut().send();
}
@@ -1,13 +1,11 @@
import 'package:appflowy/core/frameless_window.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/settings/show_settings.dart';
import 'package:appflowy/shared/window_title_bar.dart';
import 'package:appflowy/user/application/sign_in_bloc.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/user/presentation/widgets/widgets.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -21,8 +19,6 @@ class DesktopSignInScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
const indicatorMinHeight = 4.0;
return BlocBuilder<SignInBloc, SignInState>(
builder: (context, state) {
@@ -33,23 +29,25 @@ class DesktopSignInScreen extends StatelessWidget {
children: [
const Spacer(),
const VSpace(20),
// logo and title
FlowyLogoTitle(
title: LocaleKeys.welcomeText.tr(),
logoSize: Size.square(36),
logoSize: const Size(60, 60),
),
VSpace(theme.spacing.xxl),
const VSpace(20),
// continue with email and password
const ContinueWithEmailAndPassword(),
VSpace(theme.spacing.xxl),
// magic link sign in
const SignInWithMagicLinkButtons(),
const VSpace(20),
// third-party sign in.
if (isAuthEnabled) ...[
const _OrDivider(),
VSpace(theme.spacing.xxl),
const VSpace(20),
const ThirdPartySignInButtons(),
VSpace(theme.spacing.xxl),
const VSpace(20),
],
// sign in agreement
@@ -71,7 +69,7 @@ class DesktopSignInScreen extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
DesktopSignInSettingsButton(),
HSpace(20),
HSpace(42),
SignInAnonymousButtonV2(),
],
),
@@ -101,24 +99,18 @@ class DesktopSignInSettingsButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFGhostIconTextButton(
text: LocaleKeys.signIn_settings.tr(),
textColor: (context, isHovering, disabled) {
return theme.textColorScheme.secondary;
},
size: AFButtonSize.s,
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
vertical: theme.spacing.xs,
return FlowyButton(
useIntrinsicWidth: true,
text: FlowyText(
LocaleKeys.signIn_settings.tr(),
textAlign: TextAlign.center,
fontSize: 12.0,
// fontWeight: FontWeight.w500,
color: Colors.grey,
decoration: TextDecoration.underline,
),
onTap: () => showSimpleSettingsDialog(context),
iconBuilder: (context, isHovering, disabled) {
return FlowySvg(
FlowySvgs.settings_s,
size: Size.square(20),
color: theme.textColorScheme.secondary,
);
onTap: () {
showSimpleSettingsDialog(context);
},
);
}
@@ -129,30 +121,14 @@ class _OrDivider extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
children: [
Flexible(
child: Divider(
thickness: 1,
color: theme.borderColorScheme.greyTertiary,
),
),
const Flexible(child: Divider(thickness: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
LocaleKeys.signIn_or.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
),
Flexible(
child: Divider(
thickness: 1,
color: theme.borderColorScheme.greyTertiary,
),
child: FlowyText.regular(LocaleKeys.signIn_or.tr()),
),
const Flexible(child: Divider(thickness: 1)),
],
);
}
@@ -7,7 +7,6 @@ import 'package:appflowy/mobile/presentation/setting/launch_settings_page.dart';
import 'package:appflowy/user/application/sign_in_bloc.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -38,7 +37,7 @@ class MobileSignInScreen extends StatelessWidget {
const VSpace(spacing * 2),
isLocalAuthEnabled
? const SignInAnonymousButtonV3()
: const ContinueWithEmailAndPassword(),
: const SignInWithMagicLinkButtons(),
const VSpace(spacing),
if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme),
const VSpace(spacing * 1.5),
@@ -104,28 +103,21 @@ class MobileSignInScreen extends StatelessWidget {
}
Widget _buildSettingsButton(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
AFGhostIconTextButton(
text: LocaleKeys.signIn_settings.tr(),
textColor: (context, isHovering, disabled) {
return theme.textColorScheme.secondary;
},
size: AFButtonSize.s,
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
vertical: theme.spacing.xs,
FlowyButton(
useIntrinsicWidth: true,
text: FlowyText(
LocaleKeys.signIn_settings.tr(),
textAlign: TextAlign.center,
fontSize: 12.0,
// fontWeight: FontWeight.w500,
color: Colors.grey,
decoration: TextDecoration.underline,
),
onTap: () => context.push(MobileLaunchSettingsPage.routeName),
iconBuilder: (context, isHovering, disabled) {
return FlowySvg(
FlowySvgs.settings_s,
size: Size.square(20),
color: theme.textColorScheme.secondary,
);
onTap: () {
context.push(MobileLaunchSettingsPage.routeName);
},
),
const HSpace(24),
@@ -34,7 +34,7 @@ class SignInAnonymousButtonV3 extends StatelessWidget {
? () {
context
.read<SignInBloc>()
.add(const SignInEvent.signInAsGuest());
.add(const SignInEvent.signedInAsGuest());
}
: () {
final bloc = context.read<AnonUserBloc>();
@@ -1,16 +0,0 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
class AnonymousSignInButton extends StatelessWidget {
const AnonymousSignInButton({super.key});
@override
Widget build(BuildContext context) {
return AFGhostButton.normal(
onTap: () {},
builder: (context, isHovering, disabled) {
return const Placeholder();
},
);
}
}
@@ -1,23 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
class ContinueWithEmail extends StatelessWidget {
const ContinueWithEmail({
super.key,
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return AFFilledTextButton.primary(
text: LocaleKeys.signIn_continueWithEmail.tr(),
size: AFButtonSize.l,
alignment: Alignment.center,
onTap: onTap,
);
}
}
@@ -1,134 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/user/application/sign_in_bloc.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
import 'package:universal_platform/universal_platform.dart';
class ContinueWithEmailAndPassword extends StatefulWidget {
const ContinueWithEmailAndPassword({super.key});
@override
State<ContinueWithEmailAndPassword> createState() =>
_ContinueWithEmailAndPasswordState();
}
class _ContinueWithEmailAndPasswordState
extends State<ContinueWithEmailAndPassword> {
final controller = TextEditingController();
final focusNode = FocusNode();
@override
void dispose() {
controller.dispose();
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
children: [
SizedBox(
height: UniversalPlatform.isMobile ? 38.0 : 40.0,
child: AFTextField(
controller: controller,
hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(),
radius: 10,
onSubmitted: (value) => _pushContinueWithMagicLinkOrPasscodePage(
context,
value,
),
),
),
VSpace(theme.spacing.l),
ContinueWithEmail(
onTap: () => _pushContinueWithMagicLinkOrPasscodePage(
context,
controller.text,
),
),
// Hide password sign in until we implement the reset password / forgot password
// VSpace(theme.spacing.l),
// ContinueWithPassword(
// onTap: () => _pushContinueWithPasswordPage(
// context,
// controller.text,
// ),
// ),
],
);
}
void _pushContinueWithMagicLinkOrPasscodePage(
BuildContext context,
String email,
) {
if (!isEmail(email)) {
showToastNotification(
context,
message: LocaleKeys.signIn_invalidEmail.tr(),
type: ToastificationType.error,
);
return;
}
final signInBloc = context.read<SignInBloc>();
signInBloc.add(SignInEvent.signInWithMagicLink(email: email));
// push the a continue with magic link or passcode screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BlocProvider.value(
value: signInBloc,
child: ContinueWithMagicLinkOrPasscodePage(
email: email,
backToLogin: () => Navigator.pop(context),
onEnterPasscode: (passcode) => signInBloc.add(
SignInEvent.signInWithPasscode(
email: email,
passcode: passcode,
),
),
),
),
),
);
}
// void _pushContinueWithPasswordPage(
// BuildContext context,
// String email,
// ) {
// final signInBloc = context.read<SignInBloc>();
// Navigator.push(
// context,
// MaterialPageRoute(
// builder: (context) => ContinueWithPasswordPage(
// email: email,
// backToLogin: () => Navigator.pop(context),
// onEnterPassword: (password) => signInBloc.add(
// SignInEvent.signInWithEmailAndPassword(
// email: email,
// password: password,
// ),
// ),
// onForgotPassword: () {
// // todo: implement forgot password
// },
// ),
// ),
// );
// }
}
@@ -1,188 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/user/application/sign_in_bloc.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ContinueWithMagicLinkOrPasscodePage extends StatefulWidget {
const ContinueWithMagicLinkOrPasscodePage({
super.key,
required this.backToLogin,
required this.email,
required this.onEnterPasscode,
});
final String email;
final VoidCallback backToLogin;
final ValueChanged<String> onEnterPasscode;
@override
State<ContinueWithMagicLinkOrPasscodePage> createState() =>
_ContinueWithMagicLinkOrPasscodePageState();
}
class _ContinueWithMagicLinkOrPasscodePageState
extends State<ContinueWithMagicLinkOrPasscodePage> {
final passcodeController = TextEditingController();
bool isEnteringPasscode = false;
ToastificationItem? toastificationItem;
@override
void dispose() {
passcodeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<SignInBloc, SignInState>(
listener: (context, state) {
if (state.isSubmitting) {
_showLoadingDialog();
} else {
_dismissLoadingDialog();
}
},
child: Scaffold(
body: Center(
child: SizedBox(
width: 320,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo, title and description
..._buildLogoTitleAndDescription(),
// Enter code manually
..._buildEnterCodeManually(),
// Back to login
..._buildBackToLogin(),
],
),
),
),
),
);
}
List<Widget> _buildEnterCodeManually() {
// todo: ask designer to provide the spacing
final spacing = VSpace(20);
if (!isEnteringPasscode) {
return [
AFFilledTextButton.primary(
text: LocaleKeys.signIn_enterCodeManually.tr(),
onTap: () => setState(() => isEnteringPasscode = true),
size: AFButtonSize.l,
alignment: Alignment.center,
),
spacing,
];
}
return [
// Enter code manually
SizedBox(
height: 40,
child: AFTextField(
controller: passcodeController,
hintText: LocaleKeys.signIn_enterCode.tr(),
keyboardType: TextInputType.number,
radius: 10,
autoFocus: true,
onSubmitted: widget.onEnterPasscode,
),
),
// todo: ask designer to provide the spacing
VSpace(12),
// continue to login
AFFilledTextButton.primary(
text: 'Continue to sign in',
onTap: () => widget.onEnterPasscode(passcodeController.text),
size: AFButtonSize.l,
alignment: Alignment.center,
),
spacing,
];
}
List<Widget> _buildBackToLogin() {
return [
AFGhostTextButton(
text: LocaleKeys.signIn_backToLogin.tr(),
size: AFButtonSize.s,
onTap: widget.backToLogin,
textColor: (context, isHovering, disabled) {
final theme = AppFlowyTheme.of(context);
if (isHovering) {
return theme.fillColorScheme.themeThickHover;
}
return theme.textColorScheme.theme;
},
),
];
}
List<Widget> _buildLogoTitleAndDescription() {
final theme = AppFlowyTheme.of(context);
final spacing = VSpace(theme.spacing.xxl);
return [
// logo
const AFLogo(),
spacing,
// title
Text(
LocaleKeys.signIn_checkYourEmail.tr(),
style: theme.textStyle.heading.h3(
color: theme.textColorScheme.primary,
),
),
spacing,
// description
Text(
LocaleKeys.signIn_temporaryVerificationSent.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
textAlign: TextAlign.center,
),
Text(
widget.email,
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.primary,
),
textAlign: TextAlign.center,
),
spacing,
];
}
void _showLoadingDialog() {
_dismissLoadingDialog();
toastificationItem = showToastNotification(
context,
message: LocaleKeys.signIn_signingIn.tr(),
);
}
void _dismissLoadingDialog() {
final toastificationItem = this.toastificationItem;
if (toastificationItem != null) {
toastification.dismiss(toastificationItem);
}
}
}
@@ -1,21 +0,0 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
class ContinueWithPassword extends StatelessWidget {
const ContinueWithPassword({
super.key,
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return AFOutlinedTextButton.normal(
text: 'Continue with password',
size: AFButtonSize.l,
alignment: Alignment.center,
onTap: onTap,
);
}
}
@@ -1,147 +0,0 @@
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class ContinueWithPasswordPage extends StatefulWidget {
const ContinueWithPasswordPage({
super.key,
required this.backToLogin,
required this.email,
required this.onEnterPassword,
required this.onForgotPassword,
});
final String email;
final VoidCallback backToLogin;
final ValueChanged<String> onEnterPassword;
final VoidCallback onForgotPassword;
@override
State<ContinueWithPasswordPage> createState() =>
_ContinueWithPasswordPageState();
}
class _ContinueWithPasswordPageState extends State<ContinueWithPasswordPage> {
final passwordController = TextEditingController();
@override
void dispose() {
passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SizedBox(
width: 320,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo and title
..._buildLogoAndTitle(),
// Password input and buttons
..._buildPasswordSection(),
// Back to login
..._buildBackToLogin(),
],
),
),
),
);
}
List<Widget> _buildLogoAndTitle() {
final theme = AppFlowyTheme.of(context);
final spacing = VSpace(theme.spacing.xxl);
return [
// logo
const AFLogo(),
spacing,
// title
Text(
'Enter password',
style: theme.textStyle.heading.h3(
color: theme.textColorScheme.primary,
),
),
spacing,
// email display
RichText(
text: TextSpan(
children: [
TextSpan(
text: 'Login as ',
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
TextSpan(
text: widget.email,
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.primary,
),
),
],
),
),
spacing,
];
}
List<Widget> _buildPasswordSection() {
return [
// Password input
AFTextField(
controller: passwordController,
hintText: 'Enter password',
autoFocus: true,
onSubmitted: widget.onEnterPassword,
),
// todo: ask designer to provide the spacing
VSpace(12),
// todo: forgot password is not implemented yet
// Forgot password button
// AFGhostTextButton(
// text: 'Forget password?',
// size: AFButtonSize.s,
// onTap: widget.onForgotPassword,
// textColor: (context, isHovering, disabled) {
// return theme.textColorScheme.theme;
// },
// ),
VSpace(12),
// Continue button
AFFilledTextButton.primary(
text: 'Continue',
onTap: () => widget.onEnterPassword(passwordController.text),
size: AFButtonSize.l,
alignment: Alignment.center,
),
VSpace(20),
];
}
List<Widget> _buildBackToLogin() {
return [
AFGhostTextButton(
text: 'Back to Login',
size: AFButtonSize.s,
onTap: widget.backToLogin,
textColor: (context, isHovering, disabled) {
final theme = AppFlowyTheme.of(context);
return theme.textColorScheme.theme;
},
),
];
}
}
@@ -1,20 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:flutter/material.dart';
class AFLogo extends StatelessWidget {
const AFLogo({
super.key,
this.size = const Size.square(36),
});
final Size size;
@override
Widget build(BuildContext context) {
return FlowySvg(
FlowySvgs.flowy_logo_xl,
blendMode: null,
size: size,
);
}
}
@@ -64,17 +64,14 @@ class _SignInWithMagicLinkButtonsState
void _sendMagicLink(BuildContext context, String email) {
if (!isEmail(email)) {
showToastNotification(
return showToastNotification(
context,
message: LocaleKeys.signIn_invalidEmail.tr(),
type: ToastificationType.error,
);
return;
}
context
.read<SignInBloc>()
.add(SignInEvent.signInWithMagicLink(email: email));
context.read<SignInBloc>().add(SignInEvent.signedWithMagicLink(email));
showConfirmDialog(
context: context,
@@ -1,7 +1,6 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@@ -13,40 +12,41 @@ class SignInAgreement extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final textStyle = theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
);
final underlinedTextStyle = theme.textStyle.caption.underline(
color: theme.textColorScheme.secondary,
);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
children: [
TextSpan(
text: isLocalAuthEnabled
? '${LocaleKeys.web_signInLocalAgreement.tr()} \n'
: '${LocaleKeys.web_signInAgreement.tr()} \n',
style: textStyle,
? '${LocaleKeys.web_signInLocalAgreement.tr()} '
: '${LocaleKeys.web_signInAgreement.tr()} ',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
TextSpan(
text: '${LocaleKeys.web_termOfUse.tr()} ',
style: underlinedTextStyle,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
decoration: TextDecoration.underline,
),
mouseCursor: SystemMouseCursors.click,
recognizer: TapGestureRecognizer()
..onTap = () => afLaunchUrlString('https://appflowy.com/terms'),
..onTap = () => afLaunchUrlString('https://appflowy.io/terms'),
),
TextSpan(
text: '${LocaleKeys.web_and.tr()} ',
style: textStyle,
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
TextSpan(
text: LocaleKeys.web_privacyPolicy.tr(),
style: underlinedTextStyle,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
decoration: TextDecoration.underline,
),
mouseCursor: SystemMouseCursors.click,
recognizer: TapGestureRecognizer()
..onTap = () => afLaunchUrlString('https://appflowy.com/privacy'),
..onTap = () => afLaunchUrlString('https://appflowy.io/privacy'),
),
],
),
@@ -1,14 +1,91 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/anon_user_bloc.dart';
import 'package:appflowy/user/application/sign_in_bloc.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
/// Used in DesktopSignInScreen and MobileSignInScreen
class SignInAnonymousButton extends StatelessWidget {
const SignInAnonymousButton({
super.key,
});
@override
Widget build(BuildContext context) {
final isMobile = UniversalPlatform.isMobile;
return BlocBuilder<SignInBloc, SignInState>(
builder: (context, signInState) {
return BlocProvider(
create: (context) => AnonUserBloc()
..add(
const AnonUserEvent.initial(),
),
child: BlocListener<AnonUserBloc, AnonUserState>(
listener: (context, state) async {
if (state.openedAnonUser != null) {
await runAppFlowy();
}
},
child: BlocBuilder<AnonUserBloc, AnonUserState>(
builder: (context, state) {
final text = state.anonUsers.isEmpty
? LocaleKeys.signIn_loginStartWithAnonymous.tr()
: LocaleKeys.signIn_continueAnonymousUser.tr();
final onTap = state.anonUsers.isEmpty
? () {
context
.read<SignInBloc>()
.add(const SignInEvent.signedInAsGuest());
}
: () {
final bloc = context.read<AnonUserBloc>();
final user = bloc.state.anonUsers.first;
bloc.add(AnonUserEvent.openAnonUser(user));
};
// SignInAnonymousButton in mobile
if (isMobile) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
),
onPressed: onTap,
child: FlowyText(
LocaleKeys.signIn_loginStartWithAnonymous.tr(),
fontSize: 14,
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.w500,
),
);
}
// SignInAnonymousButton in desktop
return SizedBox(
height: 48,
child: FlowyButton(
isSelected: true,
disable: signInState.isSubmitting,
text: FlowyText.medium(
text,
textAlign: TextAlign.center,
),
radius: Corners.s6Border,
onTap: onTap,
),
);
},
),
),
);
},
);
}
}
class SignInAnonymousButtonV2 extends StatelessWidget {
const SignInAnonymousButtonV2({
@@ -32,35 +109,27 @@ class SignInAnonymousButtonV2 extends StatelessWidget {
},
child: BlocBuilder<AnonUserBloc, AnonUserState>(
builder: (context, state) {
final theme = AppFlowyTheme.of(context);
final text = LocaleKeys.signIn_anonymous.tr();
final onTap = state.anonUsers.isEmpty
? () {
context
.read<SignInBloc>()
.add(const SignInEvent.signInAsGuest());
.add(const SignInEvent.signedInAsGuest());
}
: () {
final bloc = context.read<AnonUserBloc>();
final user = bloc.state.anonUsers.first;
bloc.add(AnonUserEvent.openAnonUser(user));
};
return AFGhostIconTextButton(
text: LocaleKeys.signIn_anonymousMode.tr(),
textColor: (context, isHovering, disabled) {
return theme.textColorScheme.secondary;
},
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
vertical: theme.spacing.xs,
),
size: AFButtonSize.s,
return FlowyButton(
useIntrinsicWidth: true,
onTap: onTap,
iconBuilder: (context, isHovering, disabled) {
return FlowySvg(
FlowySvgs.anonymous_mode_m,
color: theme.textColorScheme.secondary,
);
},
text: FlowyText(
text,
color: Colors.grey,
decoration: TextDecoration.underline,
fontSize: 12,
),
);
},
),
@@ -1,5 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class MobileLogoutButton extends StatelessWidget {
@@ -18,19 +18,50 @@ class MobileLogoutButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AFOutlinedIconTextButton.normal(
text: text,
final style = Theme.of(context);
return GestureDetector(
onTap: onPressed,
size: AFButtonSize.l,
iconBuilder: (context, isHovering, disabled) {
if (icon == null) {
return const SizedBox.shrink();
}
return FlowySvg(
icon!,
size: Size.square(18),
);
},
child: Container(
height: 38,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(4),
),
border: Border.all(
color: textColor ?? style.colorScheme.outline,
width: 0.5,
),
),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
SizedBox(
// The icon could be in different height as original aspect ratio, we use a fixed sizebox to wrap it to make sure they all occupy the same space.
width: 30,
height: 30,
child: Center(
child: SizedBox(
width: 24,
child: FlowySvg(
icon!,
blendMode: null,
),
),
),
),
const HSpace(8),
],
FlowyText(
text,
fontSize: 14.0,
fontWeight: FontWeight.w400,
color: textColor,
),
],
),
),
);
}
}
@@ -1,7 +1,10 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
import 'package:appflowy/user/presentation/widgets/widgets.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
enum ThirdPartySignInButtonType {
@@ -99,55 +102,118 @@ class MobileThirdPartySignInButton extends StatelessWidget {
super.key,
this.height = 38,
this.fontSize = 14.0,
required this.onTap,
required this.onPressed,
required this.type,
});
final VoidCallback onTap;
final VoidCallback onPressed;
final double height;
final double fontSize;
final ThirdPartySignInButtonType type;
@override
Widget build(BuildContext context) {
return AFOutlinedIconTextButton.normal(
text: type.labelText,
onTap: onTap,
size: AFButtonSize.l,
iconBuilder: (context, isHovering, disabled) {
return FlowySvg(
type.icon,
size: Size.square(16),
blendMode: type.blendMode,
);
},
final style = Theme.of(context);
return AnimatedGestureDetector(
scaleFactor: 1.0,
onTapUp: onPressed,
child: Container(
height: height,
decoration: BoxDecoration(
color: type.backgroundColor(context),
borderRadius: const BorderRadius.all(
Radius.circular(4),
),
border: Border.all(
color: style.colorScheme.outline,
width: 0.5,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (type != ThirdPartySignInButtonType.anonymous)
FlowySvg(
type.icon,
size: Size.square(fontSize),
blendMode: type.blendMode,
color: type.textColor(context),
),
const HSpace(8.0),
FlowyText(
type.labelText,
fontSize: fontSize,
color: type.textColor(context),
),
],
),
),
);
}
}
class DesktopThirdPartySignInButton extends StatelessWidget {
const DesktopThirdPartySignInButton({
class DesktopSignInButton extends StatelessWidget {
const DesktopSignInButton({
super.key,
required this.type,
required this.onTap,
required this.onPressed,
});
final ThirdPartySignInButtonType type;
final VoidCallback onTap;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return AFOutlinedIconTextButton.normal(
text: type.labelText,
onTap: onTap,
size: AFButtonSize.l,
iconBuilder: (context, isHovering, disabled) {
return FlowySvg(
type.icon,
size: Size.square(18),
blendMode: type.blendMode,
);
},
final style = Theme.of(context);
// In desktop, the width of button is limited by [AuthFormContainer]
return SizedBox(
height: 48,
width: AuthFormContainer.width,
child: OutlinedButton.icon(
// In order to align all the labels vertically in a relatively centered position to the button, we use a fixed width container to wrap the icon(align to the right), then use another container to align the label to left.
icon: Container(
width: AuthFormContainer.width / 4,
alignment: Alignment.centerRight,
child: SizedBox(
// Some icons are not square, so we just use a fixed width here.
width: 24,
child: FlowySvg(
type.icon,
blendMode: type.blendMode,
),
),
),
label: Container(
padding: const EdgeInsets.only(left: 8),
alignment: Alignment.centerLeft,
child: FlowyText(
type.labelText,
fontSize: 14,
),
),
style: ButtonStyle(
overlayColor: WidgetStateProperty.resolveWith<Color?>(
(states) {
if (states.contains(WidgetState.hovered)) {
return style.colorScheme.onSecondaryContainer;
}
return null;
},
),
shape: WidgetStateProperty.all(
const RoundedRectangleBorder(
borderRadius: Corners.s6Border,
),
),
side: WidgetStateProperty.all(
BorderSide(
color: style.dividerColor,
),
),
),
onPressed: onPressed,
),
);
}
}
@@ -1,7 +1,8 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/user/application/sign_in_bloc.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -39,7 +40,7 @@ class ThirdPartySignInButtons extends StatelessWidget {
void _signIn(BuildContext context, String provider) {
context.read<SignInBloc>().add(
SignInEvent.signInWithOAuth(platform: provider),
SignInEvent.signedInWithOAuth(provider),
);
}
}
@@ -57,22 +58,23 @@ class _DesktopThirdPartySignIn extends StatefulWidget {
}
class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> {
static const padding = 12.0;
bool isExpanded = false;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
children: [
DesktopThirdPartySignInButton(
DesktopSignInButton(
key: signInWithGoogleButtonKey,
type: ThirdPartySignInButtonType.google,
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google),
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google),
),
VSpace(theme.spacing.l),
DesktopThirdPartySignInButton(
const VSpace(padding),
DesktopSignInButton(
type: ThirdPartySignInButtonType.apple,
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple),
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple),
),
...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(),
],
@@ -80,38 +82,38 @@ class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> {
}
List<Widget> _buildExpandedButtons() {
final theme = AppFlowyTheme.of(context);
return [
VSpace(theme.spacing.l),
DesktopThirdPartySignInButton(
const VSpace(padding * 1.5),
DesktopSignInButton(
type: ThirdPartySignInButtonType.github,
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github),
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github),
),
VSpace(theme.spacing.l),
DesktopThirdPartySignInButton(
const VSpace(padding),
DesktopSignInButton(
type: ThirdPartySignInButtonType.discord,
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord),
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord),
),
];
}
List<Widget> _buildCollapsedButtons() {
final theme = AppFlowyTheme.of(context);
return [
VSpace(theme.spacing.l),
AFGhostTextButton(
text: 'More options',
textColor: (context, isHovering, disabled) {
if (isHovering) {
return theme.fillColorScheme.themeThickHover;
}
return theme.textColorScheme.theme;
},
onTap: () {
setState(() {
isExpanded = !isExpanded;
});
},
const VSpace(padding),
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
setState(() {
isExpanded = !isExpanded;
});
},
child: FlowyText(
LocaleKeys.signIn_continueAnotherWay.tr(),
color: Theme.of(context).colorScheme.onSurface,
decoration: TextDecoration.underline,
fontSize: 14,
),
),
),
];
}
@@ -151,14 +153,14 @@ class _MobileThirdPartySignInState extends State<_MobileThirdPartySignIn> {
if (Platform.isIOS) ...[
MobileThirdPartySignInButton(
type: ThirdPartySignInButtonType.apple,
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple),
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple),
),
const VSpace(padding),
],
MobileThirdPartySignInButton(
key: signInWithGoogleButtonKey,
type: ThirdPartySignInButtonType.google,
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google),
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google),
),
...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(),
],
@@ -170,33 +172,31 @@ class _MobileThirdPartySignInState extends State<_MobileThirdPartySignIn> {
const VSpace(padding),
MobileThirdPartySignInButton(
type: ThirdPartySignInButtonType.github,
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github),
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github),
),
const VSpace(padding),
MobileThirdPartySignInButton(
type: ThirdPartySignInButtonType.discord,
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord),
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord),
),
];
}
List<Widget> _buildCollapsedButtons() {
final theme = AppFlowyTheme.of(context);
return [
const VSpace(padding * 2),
AFGhostTextButton(
text: 'More options',
textColor: (context, isHovering, disabled) {
if (isHovering) {
return theme.fillColorScheme.themeThickHover;
}
return theme.textColorScheme.theme;
},
GestureDetector(
onTap: () {
setState(() {
isExpanded = !isExpanded;
});
},
child: FlowyText(
LocaleKeys.signIn_continueAnotherWay.tr(),
color: Theme.of(context).colorScheme.onSurface,
decoration: TextDecoration.underline,
fontSize: 14,
),
),
];
}
@@ -1,7 +1,7 @@
export 'continue_with/continue_with_email_and_password.dart';
export 'sign_in_agreement.dart';
export 'magic_link_sign_in_buttons.dart';
export 'sign_in_anonymous_button.dart';
export 'sign_in_or_logout_button.dart';
export 'third_party_sign_in_button/third_party_sign_in_button.dart';
export 'third_party_sign_in_button.dart';
// export 'switch_sign_in_sign_up_button.dart';
export 'third_party_sign_in_button/third_party_sign_in_buttons.dart';
export 'third_party_sign_in_buttons.dart';
export 'sign_in_agreement.dart';
@@ -8,7 +8,7 @@ class AuthFormContainer extends StatelessWidget {
final List<Widget> children;
static const double width = 320;
static const double width = 340;
@override
Widget build(BuildContext context) {
@@ -1,7 +1,8 @@
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class FlowyLogoTitle extends StatelessWidget {
const FlowyLogoTitle({
@@ -15,20 +16,25 @@ class FlowyLogoTitle extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SizedBox(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AFLogo(size: logoSize),
const VSpace(20),
Text(
title,
style: theme.textStyle.heading.h3(
color: theme.textColorScheme.primary,
SizedBox.fromSize(
size: logoSize,
child: const FlowySvg(
FlowySvgs.flowy_logo_xl,
blendMode: null,
),
),
const VSpace(20),
FlowyText.regular(
title,
fontSize: FontSizes.s24,
fontFamily:
GoogleFonts.poppins(fontWeight: FontWeight.w500).fontFamily,
color: Theme.of(context).colorScheme.tertiary,
),
],
),
);
@@ -1,145 +1,94 @@
import 'dart:async';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'local_llm_listener.dart';
part 'local_ai_bloc.freezed.dart';
class LocalAiPluginBloc extends Bloc<LocalAiPluginEvent, LocalAiPluginState> {
LocalAiPluginBloc() : super(const LoadingLocalAiPluginState()) {
on<LocalAiPluginEvent>(_handleEvent);
_startListening();
_getLocalAiState();
}
final listener = LocalAIStateListener();
@override
Future<void> close() async {
await listener.stop();
return super.close();
class LocalAIToggleBloc extends Bloc<LocalAIToggleEvent, LocalAIToggleState> {
LocalAIToggleBloc() : super(const LocalAIToggleState()) {
on<LocalAIToggleEvent>(_handleEvent);
}
Future<void> _handleEvent(
LocalAiPluginEvent event,
Emitter<LocalAiPluginState> emit,
LocalAIToggleEvent event,
Emitter<LocalAIToggleState> emit,
) async {
await event.when(
didReceiveAiState: (aiState) {
started: () async {
final result = await AIEventGetLocalAIState().send();
_handleResult(emit, result);
},
toggle: () async {
emit(
LocalAiPluginState.ready(
isEnabled: aiState.enabled,
version: aiState.pluginVersion,
runningState: aiState.state,
lackOfResource:
aiState.hasLackOfResource() ? aiState.lackOfResource : null,
state.copyWith(
pageIndicator: const LocalAIToggleStateIndicator.loading(),
),
);
unawaited(
AIEventToggleLocalAI().send().then(
(result) {
if (!isClosed) {
add(LocalAIToggleEvent.handleResult(result));
}
},
),
);
},
didReceiveLackOfResources: (resources) {
state.maybeMap(
ready: (readyState) {
emit(readyState.copyWith(lackOfResource: resources));
},
orElse: () {},
);
},
toggle: () async {
emit(LocalAiPluginState.loading());
await AIEventToggleLocalAI().send().fold(
(aiState) {
add(LocalAiPluginEvent.didReceiveAiState(aiState));
},
Log.error,
);
},
restart: () async {
emit(LocalAiPluginState.loading());
await AIEventRestartLocalAI().send();
handleResult: (result) {
_handleResult(emit, result);
},
);
}
void _startListening() {
listener.start(
stateCallback: (pluginState) {
add(LocalAiPluginEvent.didReceiveAiState(pluginState));
void _handleResult(
Emitter<LocalAIToggleState> emit,
FlowyResult<LocalAIPB, FlowyError> result,
) {
result.fold(
(localAI) {
emit(
state.copyWith(
pageIndicator:
LocalAIToggleStateIndicator.isEnabled(localAI.enabled),
),
);
},
resourceCallback: (data) {
add(LocalAiPluginEvent.didReceiveLackOfResources(data));
(err) {
emit(
state.copyWith(
pageIndicator: LocalAIToggleStateIndicator.error(err),
),
);
},
);
}
void _getLocalAiState() {
AIEventGetLocalAIState().send().fold(
(aiState) {
add(LocalAiPluginEvent.didReceiveAiState(aiState));
},
Log.error,
);
}
}
@freezed
class LocalAiPluginEvent with _$LocalAiPluginEvent {
const factory LocalAiPluginEvent.didReceiveAiState(LocalAIPB aiState) =
_DidReceiveAiState;
const factory LocalAiPluginEvent.didReceiveLackOfResources(
LackOfAIResourcePB resources,
) = _DidReceiveLackOfResources;
const factory LocalAiPluginEvent.toggle() = _Toggle;
const factory LocalAiPluginEvent.restart() = _Restart;
class LocalAIToggleEvent with _$LocalAIToggleEvent {
const factory LocalAIToggleEvent.started() = _Started;
const factory LocalAIToggleEvent.toggle() = _Toggle;
const factory LocalAIToggleEvent.handleResult(
FlowyResult<LocalAIPB, FlowyError> result,
) = _HandleResult;
}
@freezed
class LocalAiPluginState with _$LocalAiPluginState {
const LocalAiPluginState._();
const factory LocalAiPluginState.ready({
required bool isEnabled,
required String version,
required RunningStatePB runningState,
required LackOfAIResourcePB? lackOfResource,
}) = ReadyLocalAiPluginState;
const factory LocalAiPluginState.loading() = LoadingLocalAiPluginState;
bool get isEnabled {
return maybeWhen(
ready: (isEnabled, _, __, ___) => isEnabled,
orElse: () => false,
);
}
bool get showIndicator {
return maybeWhen(
ready: (isEnabled, _, runningState, lackOfResource) =>
runningState != RunningStatePB.Running || lackOfResource != null,
orElse: () => false,
);
}
bool get showSettings {
return maybeWhen(
ready: (isEnabled, _, runningState, lackOfResource) {
final isConnecting = [
RunningStatePB.Connecting,
RunningStatePB.Connected,
].contains(runningState);
final resourcesReadyOrMissingModel = lackOfResource == null ||
lackOfResource.resourceType == LackOfAIResourceTypePB.MissingModel;
return !isConnecting && resourcesReadyOrMissingModel;
},
orElse: () => false,
);
}
class LocalAIToggleState with _$LocalAIToggleState {
const factory LocalAIToggleState({
@Default(LocalAIToggleStateIndicator.loading())
LocalAIToggleStateIndicator pageIndicator,
}) = _LocalAIToggleState;
}
@freezed
class LocalAIToggleStateIndicator with _$LocalAIToggleStateIndicator {
// when start downloading the model
const factory LocalAIToggleStateIndicator.error(FlowyError error) = _OnError;
const factory LocalAIToggleStateIndicator.isEnabled(bool isEnabled) = _Ready;
const factory LocalAIToggleStateIndicator.loading() = _Loading;
}
@@ -26,7 +26,7 @@ class LocalAIOnBoardingBloc
_dispatch();
}
void _onPaymentSuccessful() {
Future<void> _onPaymentSuccessful() async {
if (isClosed) {
return;
}
@@ -0,0 +1,92 @@
import 'dart:async';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'local_ai_setting_panel_bloc.freezed.dart';
class LocalAISettingPanelBloc
extends Bloc<LocalAISettingPanelEvent, LocalAISettingPanelState> {
LocalAISettingPanelBloc()
: listener = LocalAIStateListener(),
super(const LocalAISettingPanelState()) {
on<LocalAISettingPanelEvent>(_handleEvent);
listener.start(
stateCallback: (newState) {
if (!isClosed) {
add(LocalAISettingPanelEvent.updateAIState(newState));
}
},
);
AIEventGetLocalAIState().send().fold(
(localAIState) {
if (!isClosed) {
add(LocalAISettingPanelEvent.updateAIState(localAIState));
}
},
Log.error,
);
}
final LocalAIStateListener listener;
/// Handles incoming events and dispatches them to the appropriate handler.
Future<void> _handleEvent(
LocalAISettingPanelEvent event,
Emitter<LocalAISettingPanelState> emit,
) async {
event.when(
updateAIState: (LocalAIPB pluginState) {
if (pluginState.pluginDownloaded) {
emit(
state.copyWith(
runningState: pluginState.state,
progressIndicator: const LocalAIProgress.checkPluginState(),
),
);
} else {
emit(
state.copyWith(
progressIndicator: const LocalAIProgress.downloadLocalAIApp(),
),
);
}
},
);
}
@override
Future<void> close() async {
await listener.stop();
return super.close();
}
}
@freezed
class LocalAISettingPanelEvent with _$LocalAISettingPanelEvent {
const factory LocalAISettingPanelEvent.updateAIState(
LocalAIPB aiState,
) = _UpdateAIState;
}
@freezed
class LocalAISettingPanelState with _$LocalAISettingPanelState {
const factory LocalAISettingPanelState({
LocalAIProgress? progressIndicator,
@Default(RunningStatePB.Connecting) RunningStatePB runningState,
}) = _LocalAIChatSettingState;
}
@freezed
class LocalAIProgress with _$LocalAIProgress {
const factory LocalAIProgress.checkPluginState() = _CheckPluginStateProgress;
const factory LocalAIProgress.downloadLocalAIApp() =
_DownloadLocalAIAppProgress;
}
@@ -80,9 +80,11 @@ class OllamaSettingBloc extends Bloc<OllamaSettingEvent, OllamaSettingState> {
}
add(OllamaSettingEvent.updateSetting(setting));
AIEventUpdateLocalAISetting(setting).send().fold(
(_) => Log.info('AI setting updated successfully'),
(err) => Log.error("update ai setting failed: $err"),
);
(_) {
Log.info('AI setting updated successfully');
},
(err) => Log.error("update ai setting failed: $err"),
);
},
);
}
@@ -0,0 +1,150 @@
import 'dart:async';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'plugin_state_bloc.freezed.dart';
class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
PluginStateBloc()
: listener = LocalAIStateListener(),
super(
const PluginStateState(
action: PluginStateAction.unknown(),
),
) {
listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(PluginStateEvent.updateLocalAIState(pluginState));
}
},
resourceCallback: (data) {
if (!isClosed) {
add(PluginStateEvent.resourceStateChange(data));
}
},
);
on<PluginStateEvent>(_handleEvent);
}
final LocalAIStateListener listener;
@override
Future<void> close() async {
await listener.stop();
return super.close();
}
Future<void> _handleEvent(
PluginStateEvent event,
Emitter<PluginStateState> emit,
) async {
await event.when(
started: () async {
final result = await AIEventGetLocalAIState().send();
result.fold(
(pluginState) {
if (!isClosed) {
add(PluginStateEvent.updateLocalAIState(pluginState));
}
},
(err) => Log.error(err.toString()),
);
},
updateLocalAIState: (LocalAIPB aiState) {
// if the offline ai is not started, ask user to start it
if (aiState.hasLackOfResource()) {
emit(
PluginStateState(
action: PluginStateAction.lackOfResource(aiState.lackOfResource),
),
);
return;
}
// Chech state of the plugin
switch (aiState.state) {
case RunningStatePB.ReadyToRun:
emit(
const PluginStateState(
action: PluginStateAction.readToRun(),
),
);
case RunningStatePB.Connecting:
emit(
const PluginStateState(
action: PluginStateAction.initializingPlugin(),
),
);
case RunningStatePB.Connected:
emit(
const PluginStateState(
action: PluginStateAction.initializingPlugin(),
),
);
break;
case RunningStatePB.Running:
emit(
PluginStateState(
action: PluginStateAction.running(aiState.pluginVersion),
),
);
break;
case RunningStatePB.Stopped:
emit(
state.copyWith(action: const PluginStateAction.restartPlugin()),
);
default:
break;
}
},
restartLocalAI: () async {
emit(
const PluginStateState(action: PluginStateAction.restartPlugin()),
);
unawaited(AIEventRestartLocalAI().send());
},
resourceStateChange: (data) {
emit(
PluginStateState(
action: PluginStateAction.lackOfResource(data.resourceDesc),
),
);
},
);
}
}
@freezed
class PluginStateEvent with _$PluginStateEvent {
const factory PluginStateEvent.started() = _Started;
const factory PluginStateEvent.updateLocalAIState(LocalAIPB aiState) =
_UpdateLocalAIState;
const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI;
const factory PluginStateEvent.resourceStateChange(LackOfAIResourcePB data) =
_ResourceStateChange;
}
@freezed
class PluginStateState with _$PluginStateState {
const factory PluginStateState({
required PluginStateAction action,
}) = _PluginStateState;
}
@freezed
class PluginStateAction with _$PluginStateAction {
const factory PluginStateAction.unknown() = _Unknown;
const factory PluginStateAction.readToRun() = _ReadyToRun;
const factory PluginStateAction.initializingPlugin() = _InitializingPlugin;
const factory PluginStateAction.running(String version) = _PluginRunning;
const factory PluginStateAction.restartPlugin() = _RestartPlugin;
const factory PluginStateAction.lackOfResource(String desc) = _LackOfResource;
}
@@ -4,7 +4,6 @@ import 'package:appflowy/workspace/application/settings/appearance/base_appearan
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra/theme_extension_v2.dart';
import 'package:flutter/material.dart';
class MobileAppearance extends BaseAppearance {
@@ -30,7 +29,6 @@ class MobileAppearance extends BaseAppearance {
);
final codeFontStyle = getFontStyle(fontFamily: codeFontFamily);
final isLight = brightness == Brightness.light;
final theme = brightness == Brightness.light
? appTheme.lightTheme
@@ -285,11 +283,6 @@ class MobileAppearance extends BaseAppearance {
toolbarHoverColor: theme.toolbarHoverColor,
),
ToolbarColorExtension.fromBrightness(brightness),
isLight
? lightAFThemeV2
: darkAFThemeV2.copyWith(
icon_primary: theme.icon,
),
],
);
}
@@ -188,9 +188,6 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
UserEventGetWorkspaceUsage(payload).send().then((result) {
result.onSuccess(
(usage) {
if (isClosed) {
return;
}
add(SidebarPlanEvent.updateWorkspaceUsage(usage));
},
);
@@ -14,6 +14,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
const _confirmText = 'DELETE MY ACCOUNT';
const _acceptableConfirmTexts = [
'delete my account',
'deletemyaccount',
@@ -134,8 +135,7 @@ class _AccountDeletionDialog extends StatelessWidget {
),
const VSpace(12.0),
FlowyTextField(
hintText:
LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(),
hintText: _confirmText,
controller: controller,
),
const VSpace(16),
@@ -176,8 +176,7 @@ class _AccountDeletionDialog extends StatelessWidget {
bool _isConfirmTextValid(String text) {
// don't convert the text to lower case or upper case,
// just check if the text is in the list
return _acceptableConfirmTexts.contains(text) ||
text == LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr();
return _acceptableConfirmTexts.contains(text);
}
Future<void> deleteMyAccount(
@@ -4,7 +4,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/prelude.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart';
import 'package:appflowy/util/navigator_context_extension.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
@@ -111,7 +111,7 @@ class _SignInDialogContent extends StatelessWidget {
const _DialogHeader(),
const _DialogTitle(),
const VSpace(16),
const ContinueWithEmailAndPassword(),
const SignInWithMagicLinkButtons(),
if (isAuthEnabled) ...[
const VSpace(20),
const _OrDivider(),
@@ -0,0 +1,82 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class InitLocalAIIndicator extends StatelessWidget {
const InitLocalAIIndicator({super.key});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
color: Color(0xFFEDF7ED),
borderRadius: BorderRadius.all(
Radius.circular(4),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: BlocBuilder<LocalAISettingPanelBloc, LocalAISettingPanelState>(
builder: (context, state) {
switch (state.runningState) {
case RunningStatePB.Connecting:
case RunningStatePB.Connected:
return Row(
children: [
const HSpace(8),
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIInitializing
.tr(),
fontSize: 11,
color: const Color(0xFF1E4620),
maxLines: 3,
),
),
],
);
case RunningStatePB.Running:
return SizedBox(
height: 30,
child: Row(
children: [
const HSpace(8),
const FlowySvg(
FlowySvgs.download_success_s,
color: Color(0xFF2E7D32),
),
const HSpace(6),
FlowyText(
LocaleKeys.settings_aiPage_keys_localAILoaded.tr(),
fontSize: 11,
color: const Color(0xFF1E4620),
),
],
),
);
case RunningStatePB.Stopped:
return Row(
children: [
const HSpace(8),
FlowyText(
LocaleKeys.settings_aiPage_keys_localAIStopped.tr(),
fontSize: 11,
color: const Color(0xFFC62828),
),
],
);
default:
return const SizedBox.shrink();
}
},
),
),
);
}
}
@@ -1,148 +1,128 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:expandable/expandable.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ollama_setting.dart';
import 'plugin_status_indicator.dart';
class LocalAISetting extends StatefulWidget {
class LocalAISetting extends StatelessWidget {
const LocalAISetting({super.key});
@override
State<LocalAISetting> createState() => _LocalAISettingState();
}
class _LocalAISettingState extends State<LocalAISetting> {
final expandableController = ExpandableController(initialExpanded: false);
@override
void dispose() {
expandableController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => LocalAiPluginBloc(),
child: BlocConsumer<LocalAiPluginBloc, LocalAiPluginState>(
listener: (context, state) {
expandableController.value = state.isEnabled;
},
builder: (context, state) {
return ExpandablePanel(
controller: expandableController,
theme: ExpandableThemeData(
tapBodyToCollapse: false,
hasIcon: false,
tapBodyToExpand: false,
tapHeaderToExpand: false,
create: (context) =>
LocalAIToggleBloc()..add(const LocalAIToggleEvent.started()),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: ExpandableNotifier(
child: BlocListener<LocalAIToggleBloc, LocalAIToggleState>(
listener: (context, state) {
final controller =
ExpandableController.of(context, required: true)!;
state.pageIndicator.when(
error: (_) => controller.expanded = true,
isEnabled: (enabled) => controller.expanded = enabled,
loading: () => controller.expanded = true,
);
},
child: ExpandablePanel(
theme: const ExpandableThemeData(
headerAlignment: ExpandablePanelHeaderAlignment.center,
tapBodyToCollapse: false,
hasIcon: false,
tapBodyToExpand: false,
tapHeaderToExpand: false,
),
header: const LocalAISettingHeader(),
collapsed: const SizedBox.shrink(),
expanded: Column(
children: [
const VSpace(12),
DecoratedBox(
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
child: const Padding(
padding:
EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: LocalAISettingPanel(),
),
),
],
),
),
header: LocalAiSettingHeader(
isEnabled: state.isEnabled,
isToggleable: state is ReadyLocalAiPluginState,
),
collapsed: const SizedBox.shrink(),
expanded: Padding(
padding: EdgeInsets.only(top: 12),
child: LocalAISettingPanel(),
),
);
},
),
),
),
);
}
}
class LocalAiSettingHeader extends StatelessWidget {
const LocalAiSettingHeader({
super.key,
required this.isEnabled,
required this.isToggleable,
});
final bool isEnabled;
final bool isToggleable;
class LocalAISettingHeader extends StatelessWidget {
const LocalAISettingHeader({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.medium(
LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(),
),
const VSpace(4),
FlowyText(
LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(),
maxLines: 3,
fontSize: 12,
),
],
),
),
IgnorePointer(
ignoring: !isToggleable,
child: Opacity(
opacity: isToggleable ? 1 : 0.5,
child: Toggle(
value: isEnabled,
onChanged: (_) => _onToggleChanged(context),
),
),
),
],
);
}
void _onToggleChanged(BuildContext context) {
if (isEnabled) {
showConfirmDialog(
context: context,
title: LocaleKeys.settings_aiPage_keys_disableLocalAITitle.tr(),
description:
LocaleKeys.settings_aiPage_keys_disableLocalAIDescription.tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () {
context
.read<LocalAiPluginBloc>()
.add(const LocalAiPluginEvent.toggle());
},
);
} else {
context.read<LocalAiPluginBloc>().add(const LocalAiPluginEvent.toggle());
}
}
}
class LocalAISettingPanel extends StatelessWidget {
const LocalAISettingPanel({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<LocalAiPluginBloc, LocalAiPluginState>(
return BlocBuilder<LocalAIToggleBloc, LocalAIToggleState>(
builder: (context, state) {
if (state is! ReadyLocalAiPluginState) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const LocalAIStatusIndicator(),
const VSpace(10),
OllamaSettingPage(),
],
return state.pageIndicator.when(
error: (error) => SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
isEnabled: (isEnabled) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
FlowyText.medium(
LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(),
),
const Spacer(),
Toggle(
value: isEnabled,
onChanged: (_) {
if (isEnabled) {
showConfirmDialog(
context: context,
title: LocaleKeys
.settings_aiPage_keys_disableLocalAITitle
.tr(),
description: LocaleKeys
.settings_aiPage_keys_disableLocalAIDescription
.tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () => context
.read<LocalAIToggleBloc>()
.add(const LocalAIToggleEvent.toggle()),
);
} else {
context
.read<LocalAIToggleBloc>()
.add(const LocalAIToggleEvent.toggle());
}
},
),
],
),
const VSpace(4),
FlowyText(
LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(),
maxLines: 3,
fontSize: 12,
),
],
);
},
);
},
);
@@ -0,0 +1,32 @@
import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'plugin_state.dart';
class LocalAISettingPanel extends StatelessWidget {
const LocalAISettingPanel({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => LocalAISettingPanelBloc(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: BlocBuilder<LocalAISettingPanelBloc, LocalAISettingPanelState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OllamaSettingPage(),
VSpace(6),
PluginStateIndicator(),
],
);
},
),
),
);
}
}
@@ -20,44 +20,38 @@ class AIModelSelection extends StatelessWidget {
buildWhen: (previous, current) =>
previous.availableModels != current.availableModels,
builder: (context, state) {
final models = state.availableModels?.models;
if (models == null) {
if (state.availableModels == null) {
return const SizedBox(
// Using same height as SettingsDropdown to avoid layout shift
height: height,
);
}
final localModels = models.where((model) => model.isLocal).toList();
final cloudModels = models.where((model) => !model.isLocal).toList();
final selectedModel = state.availableModels!.selectedModel;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
Flexible(
child: FlowyText.medium(
LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
overflow: TextOverflow.ellipsis,
fontSize: 14,
),
),
const Spacer(),
Flexible(
child: SettingsDropdown<AIModelPB>(
key: const Key('_AIModelSelection'),
onChanged: (model) => context
.read<SettingsAIBloc>()
.add(SettingsAIEvent.selectModel(model)),
selectedOption: selectedModel,
selectOptionCompare: (left, right) =>
left?.name == right?.name,
options: [...localModels, ...cloudModels]
selectedOption: state.availableModels!.selectedModel,
options: state.availableModels!.models
.map(
(model) => buildDropdownMenuEntry<AIModelPB>(
context,
value: model,
label:
model.isLocal ? "${model.i18n} 🔐" : model.i18n,
label: model.i18n,
subLabel: model.desc,
maximumHeight: height,
),
@@ -1,114 +0,0 @@
import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class OllamaSettingPage extends StatelessWidget {
const OllamaSettingPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
OllamaSettingBloc()..add(const OllamaSettingEvent.started()),
child: BlocBuilder<OllamaSettingBloc, OllamaSettingState>(
buildWhen: (previous, current) =>
previous.inputItems != current.inputItems ||
previous.isEdited != current.isEdited,
builder: (context, state) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
),
padding: EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 10,
children: [
for (final item in state.inputItems)
_SettingItemWidget(item: item),
_SaveButton(isEdited: state.isEdited),
],
),
);
},
),
);
}
}
class _SettingItemWidget extends StatelessWidget {
const _SettingItemWidget({required this.item});
final SettingItem item;
@override
Widget build(BuildContext context) {
return Column(
key: ValueKey(item.content + item.settingType.title),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText(
item.settingType.title,
fontSize: 12,
figmaLineHeight: 16,
),
const VSpace(4),
SizedBox(
height: 32,
child: FlowyTextField(
hintText: item.hintText,
text: item.content,
onChanged: (content) {
context.read<OllamaSettingBloc>().add(
OllamaSettingEvent.onEdit(content, item.settingType),
);
},
),
),
],
);
}
}
class _SaveButton extends StatelessWidget {
const _SaveButton({required this.isEdited});
final bool isEdited;
@override
Widget build(BuildContext context) {
return Align(
alignment: AlignmentDirectional.centerEnd,
child: FlowyTooltip(
message: isEdited ? null : 'No changes',
child: SizedBox(
child: FlowyButton(
text: FlowyText(
'Apply',
figmaLineHeight: 20,
color: Theme.of(context).colorScheme.onPrimary,
),
disable: !isEdited,
expandText: false,
margin: EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
backgroundColor: Theme.of(context).colorScheme.primary,
hoverColor: Theme.of(context).colorScheme.primary.withAlpha(200),
onTap: () {
if (isEdited) {
context
.read<OllamaSettingBloc>()
.add(const OllamaSettingEvent.submit());
}
},
),
),
),
);
}
}
@@ -0,0 +1,168 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class OllamaSettingPage extends StatelessWidget {
const OllamaSettingPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
OllamaSettingBloc()..add(const OllamaSettingEvent.started()),
child: BlocBuilder<OllamaSettingBloc, OllamaSettingState>(
buildWhen: (previous, current) =>
previous.inputItems != current.inputItems ||
previous.isEdited != current.isEdited,
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListView.separated(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: state.inputItems.length,
separatorBuilder: (_, __) => const VSpace(10),
itemBuilder: (context, index) {
final item = state.inputItems[index];
return _SettingItemWidget(item: item);
},
),
const VSpace(6),
Opacity(
opacity: 0.6,
child: _InstallOllamaInstruction(),
),
_SaveButton(isEdited: state.isEdited),
],
);
},
),
);
}
}
class _SettingItemWidget extends StatelessWidget {
const _SettingItemWidget({required this.item});
final SettingItem item;
@override
Widget build(BuildContext context) {
return Column(
key: ValueKey(item.content + item.settingType.title),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText(item.settingType.title),
const VSpace(8),
FlowyTextField(
hintText: item.hintText,
text: item.content,
onChanged: (content) {
context.read<OllamaSettingBloc>().add(
OllamaSettingEvent.onEdit(content, item.settingType),
);
},
),
],
);
}
}
class _SaveButton extends StatelessWidget {
const _SaveButton({required this.isEdited});
final bool isEdited;
@override
Widget build(BuildContext context) {
final tooltipMessage = isEdited ? 'Click to apply changes' : 'No changes';
return SizedBox(
height: 50,
child: Row(
children: [
const Spacer(),
SizedBox(
width: 120,
child: FlowyTooltip(
message: tooltipMessage,
child: Opacity(
opacity: isEdited ? 1 : 0.5,
child: FlowyTextButton(
'Apply',
mainAxisAlignment: MainAxisAlignment.center,
onPressed: isEdited
? () {
context
.read<OllamaSettingBloc>()
.add(const OllamaSettingEvent.submit());
}
: null,
),
),
),
),
],
),
);
}
}
class _InstallOllamaInstruction extends StatelessWidget {
const _InstallOllamaInstruction();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
maxLines: 3,
textAlign: TextAlign.left,
text: TextSpan(
children: <TextSpan>[
TextSpan(
text:
"${LocaleKeys.settings_aiPage_keys_localAISetupInstruction1.tr()} ",
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(height: 1.5),
),
TextSpan(
text:
" ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction2.tr()} ",
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: FontSizes.s14,
color: Theme.of(context).colorScheme.primary,
height: 1.5,
),
recognizer: TapGestureRecognizer()
..onTap = () => afLaunchUrlString(
"https://appflowy.com/guide/appflowy-local-ai-ollama",
),
),
TextSpan(
text:
" ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction3.tr()} ",
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(height: 1.5),
),
],
),
),
],
);
}
}
@@ -0,0 +1,168 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PluginStateIndicator extends StatelessWidget {
const PluginStateIndicator({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
PluginStateBloc()..add(const PluginStateEvent.started()),
child: BlocBuilder<PluginStateBloc, PluginStateState>(
builder: (context, state) {
return state.action.when(
unknown: () => const SizedBox.shrink(),
readToRun: () => const _PrepareRunning(),
initializingPlugin: () => const InitLocalAIIndicator(),
running: (version) => _LocalAIRunning(
key: ValueKey(version),
version: version,
),
restartPlugin: () => const _RestartPluginButton(),
lackOfResource: (desc) => _LackOfResource(desc: desc),
);
},
),
);
}
}
class _PrepareRunning extends StatelessWidget {
const _PrepareRunning();
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIStart.tr(),
maxLines: 3,
),
),
],
);
}
}
class _RestartPluginButton extends StatelessWidget {
const _RestartPluginButton();
@override
Widget build(BuildContext context) {
return Row(
children: [
const FlowySvg(
FlowySvgs.download_warn_s,
color: Color(0xFFC62828),
),
const HSpace(6),
FlowyText(LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr()),
const Spacer(),
SizedBox(
height: 30,
child: FlowyButton(
useIntrinsicWidth: true,
text:
FlowyText(LocaleKeys.settings_aiPage_keys_restartLocalAI.tr()),
onTap: () {
context.read<PluginStateBloc>().add(
const PluginStateEvent.restartLocalAI(),
);
},
),
),
],
);
}
}
class _LocalAIRunning extends StatelessWidget {
const _LocalAIRunning({required this.version, super.key});
final String version;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
color: Color(0xFFEDF7ED),
borderRadius: BorderRadius.all(
Radius.circular(4),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
children: [
const HSpace(8),
const FlowySvg(
FlowySvgs.download_success_s,
color: Color(0xFF2E7D32),
),
const HSpace(6),
if (version.isNotEmpty)
Flexible(
child: FlowyText(
"($version) ",
fontSize: 11,
color: const Color(0xFF1E4620),
),
),
Flexible(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIRunning.tr(),
fontSize: 11,
color: const Color(0xFF1E4620),
maxLines: 3,
),
),
],
),
),
],
),
),
);
}
}
class _LackOfResource extends StatelessWidget {
const _LackOfResource({required this.desc});
final String desc;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowySvg(
FlowySvgs.toast_warning_filled_s,
size: const Size.square(20.0),
blendMode: null,
),
const HSpace(6),
Expanded(
child: FlowyText(
desc,
maxLines: 3,
),
),
],
);
}
}
@@ -1,363 +0,0 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class LocalAIStatusIndicator extends StatelessWidget {
const LocalAIStatusIndicator({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<LocalAiPluginBloc, LocalAiPluginState>(
builder: (context, state) {
return state.maybeWhen(
ready: (_, version, runningState, lackOfResource) {
if (lackOfResource != null) {
return _LackOfResource(resource: lackOfResource);
}
return switch (runningState) {
RunningStatePB.ReadyToRun => const _ReadyToRun(),
RunningStatePB.Connecting ||
RunningStatePB.Connected =>
_Initializing(),
RunningStatePB.Running => _LocalAIRunning(version: version),
RunningStatePB.Stopped => const _RestartPluginButton(),
_ => const SizedBox.shrink(),
};
},
orElse: () => const SizedBox.shrink(),
);
},
);
}
}
class _ReadyToRun extends StatelessWidget {
const _ReadyToRun();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
const SizedBox.square(
dimension: 20.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
strokeAlign: BorderSide.strokeAlignInside,
),
),
const HSpace(8.0),
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIStart.tr(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _Initializing extends StatelessWidget {
const _Initializing();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
const SizedBox.square(
dimension: 20.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
strokeAlign: BorderSide.strokeAlignInside,
),
),
HSpace(8),
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _RestartPluginButton extends StatelessWidget {
const _RestartPluginButton();
@override
Widget build(BuildContext context) {
final textStyle =
Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5);
return Container(
decoration: BoxDecoration(
color: Theme.of(context).isLightMode
? const Color(0x80FFE7EE)
: const Color(0x80591734),
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
const FlowySvg(
FlowySvgs.toast_error_filled_s,
size: Size.square(20.0),
blendMode: null,
),
const HSpace(8),
Expanded(
child: RichText(
maxLines: 3,
text: TextSpan(
children: [
TextSpan(
text:
LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr(),
style: textStyle,
),
TextSpan(
text: ' ',
style: textStyle,
),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_restartLocalAI.tr(),
style: textStyle?.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () {
context
.read<LocalAiPluginBloc>()
.add(const LocalAiPluginEvent.restart());
},
),
],
),
),
),
],
),
);
}
}
class _LocalAIRunning extends StatelessWidget {
const _LocalAIRunning({
required this.version,
});
final String version;
@override
Widget build(BuildContext context) {
final runningText = LocaleKeys.settings_aiPage_keys_localAIRunning.tr();
final text = version.isEmpty ? runningText : "$runningText ($version)";
return Container(
decoration: const BoxDecoration(
color: Color(0xFFEDF7ED),
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
const FlowySvg(
FlowySvgs.download_success_s,
color: Color(0xFF2E7D32),
),
const HSpace(6),
Expanded(
child: FlowyText(
text,
color: const Color(0xFF1E4620),
maxLines: 3,
),
),
],
),
);
}
}
class _LackOfResource extends StatelessWidget {
const _LackOfResource({required this.resource});
final LackOfAIResourcePB resource;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).isLightMode
? const Color(0x80FFE7EE)
: const Color(0x80591734),
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
FlowySvg(
FlowySvgs.toast_error_filled_s,
size: const Size.square(20.0),
blendMode: null,
),
const HSpace(8),
Expanded(
child: switch (resource.resourceType) {
LackOfAIResourceTypePB.PluginExecutableNotReady =>
_buildNoLAI(context),
LackOfAIResourceTypePB.OllamaServerNotReady =>
_buildNoOllama(context),
LackOfAIResourceTypePB.MissingModel =>
_buildNoModel(context, resource.missingModelNames),
_ => const SizedBox.shrink(),
},
),
],
),
);
}
TextStyle? _textStyle(BuildContext context) {
return Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5);
}
Widget _buildNoLAI(BuildContext context) {
final textStyle = _textStyle(context);
return RichText(
maxLines: 3,
text: TextSpan(
children: [
TextSpan(
text: LocaleKeys.settings_aiPage_keys_laiNotReady.tr(),
style: textStyle,
),
TextSpan(text: ' ', style: _textStyle(context)),
..._downloadInstructions(textStyle),
],
),
);
}
Widget _buildNoOllama(BuildContext context) {
final textStyle = _textStyle(context);
return RichText(
maxLines: 3,
text: TextSpan(
children: [
TextSpan(
text: LocaleKeys.settings_aiPage_keys_ollamaNotReady.tr(),
style: textStyle,
),
TextSpan(text: ' ', style: textStyle),
..._downloadInstructions(textStyle),
],
),
);
}
Widget _buildNoModel(BuildContext context, List<String> modelNames) {
final textStyle = _textStyle(context);
return RichText(
maxLines: 3,
text: TextSpan(
children: [
TextSpan(
text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(),
style: textStyle,
),
TextSpan(
text: modelNames.join(', '),
style: textStyle,
),
TextSpan(
text: ' ',
style: textStyle,
),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(),
style: textStyle,
),
TextSpan(
text: ' ',
style: textStyle,
),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_instructions.tr(),
style: textStyle?.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () {
afLaunchUrlString(
"https://appflowy.com/guide/appflowy-local-ai-ollama",
);
},
),
TextSpan(
text: ' ',
style: textStyle,
),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_downloadModel.tr(),
style: textStyle,
),
],
),
);
}
List<TextSpan> _downloadInstructions(TextStyle? textStyle) {
return [
TextSpan(
text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(),
style: textStyle,
),
TextSpan(
text: ' ',
style: textStyle,
),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_instructions.tr(),
style: textStyle?.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () {
afLaunchUrlString(
"https://appflowy.com/guide/appflowy-local-ai-ollama",
);
},
),
TextSpan(text: ' ', style: textStyle),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_installOllamaLai.tr(),
style: textStyle,
),
];
}
}
@@ -16,11 +16,9 @@ class SettingsDropdown<T> extends StatefulWidget {
this.onChanged,
this.actions,
this.expandWidth = true,
this.selectOptionCompare,
});
final T selectedOption;
final CompareFunction<T>? selectOptionCompare;
final List<DropdownMenuEntry<T>> options;
final void Function(T)? onChanged;
final List<Widget>? actions;
@@ -54,7 +52,6 @@ class _SettingsDropdownState<T> extends State<SettingsDropdown<T>> {
expandedInsets: widget.expandWidth ? EdgeInsets.zero : null,
initialSelection: widget.selectedOption,
dropdownMenuEntries: widget.options,
selectOptionCompare: widget.selectOptionCompare,
textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontFamily: fontFamilyUsed,
fontWeight: FontWeight.w400,
@@ -71,7 +71,7 @@ class AppFlowyCloudViewSetting extends StatelessWidget {
const VSpace(8),
const AppFlowyCloudEnableSync(),
const VSpace(6),
// const AppFlowyCloudSyncLogEnabled(),
const AppFlowyCloudSyncLogEnabled(),
const VSpace(12),
RestartButton(
onClick: () {
@@ -130,7 +130,7 @@ class CustomAppFlowyCloudView extends StatelessWidget {
final List<Widget> children = [];
children.addAll([
const AppFlowyCloudEnableSync(),
// const AppFlowyCloudSyncLogEnabled(),
const AppFlowyCloudSyncLogEnabled(),
const VSpace(40),
]);
@@ -363,7 +363,7 @@ class OkCancelButton extends StatelessWidget {
}
}
ToastificationItem showToastNotification(
void showToastNotification(
BuildContext context, {
String? message,
TextSpan? richMessage,
@@ -376,7 +376,7 @@ ToastificationItem showToastNotification(
(message == null) != (richMessage == null),
"Exactly one of message or richMessage must be non-null.",
);
return toastification.showCustom(
toastification.showCustom(
alignment: Alignment.bottomCenter,
autoCloseDuration: const Duration(milliseconds: 3000),
callbacks: callbacks ?? const ToastificationCallbacks(),
@@ -86,7 +86,7 @@ class _BubbleActionListState extends State<BubbleActionList> {
),
buildChild: (controller) {
return FlowyTooltip(
message: LocaleKeys.questionBubble_getSupport.tr(),
message: LocaleKeys.questionBubble_help.tr(),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
@@ -121,22 +121,22 @@ class _BubbleActionListState extends State<BubbleActionList> {
if (action is BubbleActionWrapper) {
switch (action.inner) {
case BubbleAction.whatsNews:
afLaunchUrlString('https://www.appflowy.io/what-is-new');
afLaunchUrlString("https://www.appflowy.io/what-is-new");
break;
case BubbleAction.getSupport:
afLaunchUrlString('https://discord.gg/9Q2xaN37tV');
case BubbleAction.help:
afLaunchUrlString("https://discord.gg/9Q2xaN37tV");
break;
case BubbleAction.debug:
_DebugToast().show();
break;
case BubbleAction.shortcuts:
afLaunchUrlString(
'https://docs.appflowy.io/docs/appflowy/product/shortcuts',
"https://docs.appflowy.io/docs/appflowy/product/shortcuts",
);
break;
case BubbleAction.markdown:
afLaunchUrlString(
'https://docs.appflowy.io/docs/appflowy/product/markdown',
"https://docs.appflowy.io/docs/appflowy/product/markdown",
);
break;
case BubbleAction.github:
@@ -144,11 +144,6 @@ class _BubbleActionListState extends State<BubbleActionList> {
'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose',
);
break;
case BubbleAction.helpAndDocumentation:
afLaunchUrlString(
'https://appflowy.com/guide',
);
break;
}
}
@@ -160,7 +155,7 @@ class _BubbleActionListState extends State<BubbleActionList> {
class _DebugToast {
void show() async {
String debugInfo = '';
String debugInfo = "";
debugInfo += await _getDeviceInfo();
debugInfo += await _getDocumentPath();
await Clipboard.setData(ClipboardData(text: debugInfo));
@@ -173,21 +168,20 @@ class _DebugToast {
final deviceInfo = await deviceInfoPlugin.deviceInfo;
return deviceInfo.data.entries
.fold('', (prev, el) => '$prev${el.key}: ${el.value}\n');
.fold('', (prev, el) => "$prev${el.key}: ${el.value}\n");
}
Future<String> _getDocumentPath() async {
return appFlowyApplicationDataDirectory().then((directory) {
final path = directory.path.toString();
return 'Document: $path\n';
return "Document: $path\n";
});
}
}
enum BubbleAction {
whatsNews,
helpAndDocumentation,
getSupport,
help,
debug,
shortcuts,
markdown,
@@ -210,10 +204,8 @@ extension QuestionBubbleExtension on BubbleAction {
switch (this) {
case BubbleAction.whatsNews:
return LocaleKeys.questionBubble_whatsNew.tr();
case BubbleAction.helpAndDocumentation:
return LocaleKeys.questionBubble_helpAndDocumentation.tr();
case BubbleAction.getSupport:
return LocaleKeys.questionBubble_getSupport.tr();
case BubbleAction.help:
return LocaleKeys.questionBubble_help.tr();
case BubbleAction.debug:
return LocaleKeys.questionBubble_debug_name.tr();
case BubbleAction.shortcuts:
@@ -229,12 +221,7 @@ extension QuestionBubbleExtension on BubbleAction {
switch (this) {
case BubbleAction.whatsNews:
return const FlowySvg(FlowySvgs.star_s);
case BubbleAction.helpAndDocumentation:
return const FlowySvg(
FlowySvgs.help_and_documentation_s,
size: Size.square(16.0),
);
case BubbleAction.getSupport:
case BubbleAction.help:
return const FlowySvg(FlowySvgs.message_support_s);
case BubbleAction.debug:
return const FlowySvg(FlowySvgs.debug_s);
@@ -25,13 +25,18 @@ class SocialMediaSection extends CustomActionCell {
action: SocialMediaWrapper(social),
itemHeight: ActionListSizes.itemHeight,
onSelected: (action) {
final url = switch (action.inner) {
SocialMedia.reddit => 'https://www.reddit.com/r/AppFlowy/',
SocialMedia.twitter => 'https://x.com/appflowy',
SocialMedia.forum => 'https://forum.appflowy.com/',
};
afLaunchUrlString(url);
switch (action.inner) {
case SocialMedia.reddit:
afLaunchUrlString(
'https://www.reddit.com/r/AppFlowy/',
);
case SocialMedia.twitter:
afLaunchUrlString(
'https://x.com/appflowy',
);
case SocialMedia.forum:
afLaunchUrlString('https://forum.appflowy.io/');
}
},
);
},
@@ -80,11 +85,11 @@ extension QuestionBubbleExtension on SocialMedia {
String get name {
switch (this) {
case SocialMedia.forum:
return 'Community Forum';
return "Community Forum";
case SocialMedia.twitter:
return 'Twitter – @appflowy';
return "Twitter – @appflowy";
case SocialMedia.reddit:
return 'Reddit – r/appflowy';
return "Reddit – r/appflowy";
}
}
+23 -23
View File
@@ -144,34 +144,34 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468
appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118
bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9
connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041
file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31
flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e
app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a
appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9
auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d
bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00
connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823
@@ -1,31 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
build/
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff. Show more