diff --git a/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart index e01e02c6e1..51df6d6b14 100644 --- a/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart +++ b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart @@ -12,7 +12,7 @@ import 'util.dart'; extension AppFlowyAuthTest on WidgetTester { Future tapGoogleLoginInButton() async { await tapButton( - find.byKey(const Key('signInWithGoogleButton')), + find.byKey(signInWithGoogleButtonKey), ); } @@ -36,7 +36,7 @@ extension AppFlowyAuthTest on WidgetTester { } void expectToSeeGoogleLoginButton() { - expect(find.byKey(const Key('signInWithGoogleButton')), findsOneWidget); + expect(find.byKey(signInWithGoogleButtonKey), findsOneWidget); } void assertSwitchValue(Finder finder, bool value) { diff --git a/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj index aa53cf9b88..804ad052be 100644 --- a/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj @@ -372,6 +372,7 @@ ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -383,6 +384,8 @@ STRIP_STYLE = "non-global"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -511,6 +514,7 @@ ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -522,6 +526,8 @@ STRIP_STYLE = "non-global"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -545,6 +551,7 @@ ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -556,6 +563,8 @@ STRIP_STYLE = "non-global"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; diff --git a/frontend/appflowy_flutter/ios/Runner/Info.plist b/frontend/appflowy_flutter/ios/Runner/Info.plist index 5ec528b05e..cca5b3716c 100644 --- a/frontend/appflowy_flutter/ios/Runner/Info.plist +++ b/frontend/appflowy_flutter/ios/Runner/Info.plist @@ -1,75 +1,73 @@ - - NSCameraUsageDescription - AppFlowy requires access to the camera. - NSPhotoLibraryUsageDescription - AppFlowy requires access to the photo library. - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleLocalizations - - en - - FLTEnableImpeller - - CFBundleName - AppFlowy - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLName - - CFBundleURLSchemes - - appflowy-flutter - - - - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - NSAppTransportSecurity + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + + CFBundleName + AppFlowy + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + - NSAllowsArbitraryLoads - + CFBundleURLName + + CFBundleURLSchemes + + appflowy-flutter + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FLTEnableImpeller + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + - \ No newline at end of file + NSCameraUsageDescription + AppFlowy requires access to the camera. + NSPhotoLibraryUsageDescription + AppFlowy requires access to the photo library. + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/frontend/appflowy_flutter/ios/Runner/Runner.entitlements b/frontend/appflowy_flutter/ios/Runner/Runner.entitlements index 903def2af5..80b5221de7 100644 --- a/frontend/appflowy_flutter/ios/Runner/Runner.entitlements +++ b/frontend/appflowy_flutter/ios/Runner/Runner.entitlements @@ -4,5 +4,9 @@ aps-environment development + com.apple.developer.applesignin + + Default + diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart index cabc234fec..8ac4d9b20e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart @@ -65,7 +65,7 @@ class _MobileBottomSheetRenameWidgetState padding: const EdgeInsets.symmetric( horizontal: 16.0, ), - textColor: Colors.white, + fontColor: Colors.white, fillColor: Theme.of(context).primaryColor, onPressed: () { widget.onRename(controller.text); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart index c26cf759de..c1129af79d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart @@ -64,6 +64,10 @@ class _MobileViewItemBottomSheetState extends State { case MobileViewItemBottomSheetBodyAction.duplicate: Navigator.pop(context); context.read().add(const ViewEvent.duplicate()); + showToastNotification( + context, + message: LocaleKeys.button_duplicateSuccessfully.tr(), + ); break; case MobileViewItemBottomSheetBodyAction.share: // unimplemented @@ -79,6 +83,12 @@ class _MobileViewItemBottomSheetState extends State { context .read() .add(FavoriteEvent.toggle(widget.view)); + showToastNotification( + context, + message: !widget.view.isFavorite + ? LocaleKeys.button_favoriteSuccessfully.tr() + : LocaleKeys.button_unfavoriteSuccessfully.tr(), + ); break; case MobileViewItemBottomSheetBodyAction.removeFromRecent: _removeFromRecent(context); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart index c3f0340953..1078d12b1f 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart @@ -177,7 +177,13 @@ class _HomePageState extends State<_HomePage> { getIt().reset(); mCurrentWorkspace.value = state.currentWorkspace; - _showResultDialog(context, state); + Debounce.debounce( + 'workspace_action_result', + const Duration(milliseconds: 150), + () { + _showResultDialog(context, state); + }, + ); }, builder: (context, state) { if (state.currentWorkspace == null) { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart index 24c0b5432f..28e3812b93 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart @@ -47,15 +47,6 @@ class MobileHomePageHeader extends StatelessWidget { : _MobileUser(userProfile: userProfile), ), const HomePageSettingsPopupMenu(), - // GestureDetector( - // onTap: () => context.push( - // MobileHomeSettingPage.routeName, - // ), - // child: const Padding( - // padding: EdgeInsets.all(8.0), - // child: FlowySvg(FlowySvgs.m_notification_settings_s), - // ), - // ), const HSpace(8.0), ], ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart index ae7842d08f..a13f7b3c75 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart @@ -3,9 +3,11 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart'; +import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.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/material.dart' + hide PopupMenuButton, PopupMenuDivider, PopupMenuItem, PopupMenuEntry; import 'package:go_router/go_router.dart'; enum _MobileSettingsPopupMenuItem { @@ -28,9 +30,9 @@ class HomePageSettingsPopupMenu extends StatelessWidget { Radius.circular(12.0), ), ), - // todo: replace it with shadows shadowColor: const Color(0x68000000), elevation: 10, + color: Theme.of(context).colorScheme.surface, child: const Padding( padding: EdgeInsets.all(8.0), child: FlowySvg( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart index 341acb8099..1b99be9a42 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart @@ -36,6 +36,7 @@ class EmptySpacePlaceholder extends StatelessWidget { lineHeight: 1.3, color: Theme.of(context).hintColor, ), + const VSpace(kBottomNavigationBarHeight + 36.0), ], ), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart index e378658887..b414158aa5 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart @@ -1,12 +1,14 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' + hide PopupMenuButton, PopupMenuDivider, PopupMenuItem, PopupMenuEntry; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart index 3ce8e57b36..24c50f7ae6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart @@ -16,13 +16,10 @@ class AppFlowyCloudPage extends StatelessWidget { appBar: FlowyAppBar( titleText: LocaleKeys.settings_menu_cloudSettings.tr(), ), - body: Padding( - padding: const EdgeInsets.all(20.0), - child: SettingCloud( - restartAppFlowy: () async { - await runAppFlowy(); - }, - ), + body: SettingCloud( + restartAppFlowy: () async { + await runAppFlowy(); + }, ), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart index 1145d08048..1d91c3b9f6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart @@ -33,7 +33,9 @@ class UserSessionSettingGroup extends StatelessWidget { ); }, builder: (context, state) { - return const ThirdPartySignInButtons(); + return const ThirdPartySignInButtons( + expanded: true, + ); }, ), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart index b9658d670c..2e394c95e0 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart @@ -34,6 +34,7 @@ class InviteMembersScreen extends StatelessWidget { titleText: LocaleKeys.settings_appearance_members_label.tr(), ), body: const _InviteMemberPage(), + resizeToAvoidBottomInset: false, ); } } @@ -193,6 +194,9 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { final actionType = actionResult.actionType; final result = actionResult.result; + // get keyboard height + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + // only show the result dialog when the action is WorkspaceMemberActionType.add if (actionType == WorkspaceMemberActionType.add) { result.fold( @@ -201,6 +205,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { context, message: LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), + bottomPadding: keyboardHeight, ); }, (f) { @@ -216,6 +221,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { showToastNotification( context, type: ToastificationType.error, + bottomPadding: keyboardHeight, message: message, ); }, @@ -227,6 +233,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { context, message: LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), + bottomPadding: keyboardHeight, ); }, (f) { @@ -244,6 +251,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { context, type: ToastificationType.error, message: message, + bottomPadding: keyboardHeight, ); }, ); @@ -255,6 +263,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { message: LocaleKeys .settings_appearance_members_removeFromWorkspaceSuccess .tr(), + bottomPadding: keyboardHeight, ); }, (f) { @@ -264,6 +273,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { message: LocaleKeys .settings_appearance_members_removeFromWorkspaceFailed .tr(), + bottomPadding: keyboardHeight, ); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart index 858acadc5a..51c372fb6e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart @@ -89,6 +89,15 @@ class CellController { fieldId: _cellContext.fieldId, ); + _rowCache.addListener( + rowId: rowId, + onRowChanged: (context, reason) { + if (reason == const ChangedReason.didFetchRow()) { + _onRowMetaChanged?.call(); + } + }, + ); + // 1. Listen on user edit event and load the new cell data if needed. // For example: // user input: 12 diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart index 6f4d886f80..1d11185a54 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart @@ -44,7 +44,8 @@ class RowCache { for (final fieldInfo in fieldInfos) { _cellMemCache.removeCellWithFieldId(fieldInfo.id); } - _changedNotifier.receive(const ChangedReason.fieldDidChange()); + + _changedNotifier?.receive(const ChangedReason.fieldDidChange()); }); } @@ -53,7 +54,7 @@ class RowCache { final CellMemCache _cellMemCache; final RowLifeCycle _rowLifeCycle; final RowFieldsDelegate _fieldDelegate; - final RowChangesetNotifier _changedNotifier; + RowChangesetNotifier? _changedNotifier; /// Returns a unmodifiable list of RowInfo UnmodifiableListView get rowInfos { @@ -67,7 +68,8 @@ class RowCache { } CellMemCache get cellCache => _cellMemCache; - ChangedReason get changeReason => _changedNotifier.reason; + ChangedReason get changeReason => + _changedNotifier?.reason ?? const InitialListState(); RowInfo? getRow(RowId rowId) { return _rowList.get(rowId); @@ -78,18 +80,19 @@ class RowCache { final rowInfo = buildGridRow(row); _rowList.add(rowInfo); } - _changedNotifier.receive(const ChangedReason.setInitialRows()); + _changedNotifier?.receive(const ChangedReason.setInitialRows()); } void setRowMeta(RowMetaPB rowMeta) { final rowInfo = buildGridRow(rowMeta); _rowList.add(rowInfo); - _changedNotifier.receive(const ChangedReason.didFetchRow()); + _changedNotifier?.receive(const ChangedReason.didFetchRow()); } void dispose() { _rowLifeCycle.onRowDisposed(); - _changedNotifier.dispose(); + _changedNotifier?.dispose(); + _changedNotifier = null; _cellMemCache.dispose(); } @@ -106,7 +109,7 @@ class RowCache { void reorderAllRows(List rowIds) { _rowList.reorderWithRowIds(rowIds); - _changedNotifier.receive(const ChangedReason.reorderRows()); + _changedNotifier?.receive(const ChangedReason.reorderRows()); } void reorderSingleRow(ReorderSingleRowPB reorderRow) { @@ -117,7 +120,7 @@ class RowCache { reorderRow.oldIndex, reorderRow.newIndex, ); - _changedNotifier.receive( + _changedNotifier?.receive( ChangedReason.reorderSingleRow( reorderRow, rowInfo, @@ -130,7 +133,7 @@ class RowCache { for (final rowId in deletedRowIds) { final deletedRow = _rowList.remove(rowId); if (deletedRow != null) { - _changedNotifier.receive(ChangedReason.delete(deletedRow)); + _changedNotifier?.receive(ChangedReason.delete(deletedRow)); } } } @@ -140,7 +143,7 @@ class RowCache { final insertedIndex = _rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta)); if (insertedIndex != null) { - _changedNotifier.receive(ChangedReason.insert(insertedIndex)); + _changedNotifier?.receive(ChangedReason.insert(insertedIndex)); } } } @@ -165,7 +168,7 @@ class RowCache { _rowList.updateRows(updatedList, (rowId) => buildGridRow(rowId)); if (updatedIndexs.isNotEmpty) { - _changedNotifier.receive(ChangedReason.update(updatedIndexs)); + _changedNotifier?.receive(ChangedReason.update(updatedIndexs)); } } @@ -173,7 +176,7 @@ class RowCache { for (final rowId in invisibleRows) { final deletedRow = _rowList.remove(rowId); if (deletedRow != null) { - _changedNotifier.receive(ChangedReason.delete(deletedRow)); + _changedNotifier?.receive(ChangedReason.delete(deletedRow)); } } } @@ -183,14 +186,16 @@ class RowCache { final insertedIndex = _rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta)); if (insertedIndex != null) { - _changedNotifier.receive(ChangedReason.insert(insertedIndex)); + _changedNotifier?.receive(ChangedReason.insert(insertedIndex)); } } } void onRowsChanged(void Function(ChangedReason) onRowChanged) { - _changedNotifier.addListener(() { - onRowChanged(_changedNotifier.reason); + _changedNotifier?.addListener(() { + if (_changedNotifier != null) { + onRowChanged(_changedNotifier!.reason); + } }); } @@ -203,17 +208,19 @@ class RowCache { final rowInfo = _rowList.get(rowId); if (rowInfo != null) { final cellDataMap = _makeCells(rowInfo.rowMeta); - onRowChanged(cellDataMap, _changedNotifier.reason); + if (_changedNotifier != null) { + onRowChanged(cellDataMap, _changedNotifier!.reason); + } } } } - _changedNotifier.addListener(listenerHandler); + _changedNotifier?.addListener(listenerHandler); return listenerHandler; } void removeRowListener(VoidCallback callback) { - _changedNotifier.removeListener(callback); + _changedNotifier?.removeListener(callback); } List loadCells(RowMetaPB rowMeta) { @@ -242,7 +249,7 @@ class RowCache { rowId: rowMetaPB.id, ); - _changedNotifier.receive(ChangedReason.update(updatedIndexs)); + _changedNotifier?.receive(ChangedReason.update(updatedIndexs)); } }, (err) => Log.error(err), diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart index a52bd66199..1ba336b321 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart @@ -1,6 +1,8 @@ +import 'dart:async'; + import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/domain/row_listener.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/material.dart'; import '../cell/cell_cache.dart'; @@ -18,21 +20,11 @@ class RowController { }) : _rowMeta = rowMeta, _rowCache = rowCache, _rowBackendSvc = RowBackendService(viewId: viewId), - _rowListener = RowListener(rowMeta.id) { - _rowBackendSvc.initRow(rowMeta.id); - _rowListener.start( - onMetaChanged: (newRowMeta) { - if (_isDisposed) { - return; - } - _rowMeta = newRowMeta; - _rowCache.setRowMeta(newRowMeta); - }, - ); - } + _rowListener = RowListener(rowMeta.id); RowMetaPB _rowMeta; final String? groupId; + VoidCallback? _onRowMetaChanged; final String viewId; final List _onRowChangedListeners = []; final RowCache _rowCache; @@ -40,14 +32,52 @@ class RowController { final RowBackendService _rowBackendSvc; bool _isDisposed = false; - CellMemCache get cellCache => _rowCache.cellCache; - String get rowId => rowMeta.id; RowMetaPB get rowMeta => _rowMeta; + CellMemCache get cellCache => _rowCache.cellCache; List loadCells() => _rowCache.loadCells(rowMeta); - void addListener({OnRowChanged? onRowChanged}) { + Future initialize() async { + await _rowBackendSvc.initRow(rowMeta.id); + unawaited( + _rowBackendSvc.getRowMeta(rowId).then( + (result) { + if (_isDisposed) { + return; + } + + result.fold( + (rowMeta) { + _rowMeta = rowMeta; + _rowCache.setRowMeta(rowMeta); + _onRowMetaChanged?.call(); + }, + (error) => debugPrint(error.toString()), + ); + }, + ), + ); + + _rowListener.start( + onRowFetched: (DidFetchRowPB row) { + _rowCache.setRowMeta(row.meta); + }, + onMetaChanged: (newRowMeta) { + if (_isDisposed) { + return; + } + _rowMeta = newRowMeta; + _rowCache.setRowMeta(newRowMeta); + _onRowMetaChanged?.call(); + }, + ); + } + + void addListener({ + OnRowChanged? onRowChanged, + VoidCallback? onMetaChanged, + }) { final fn = _rowCache.addListener( rowId: rowMeta.id, onRowChanged: (context, reasons) { @@ -60,6 +90,7 @@ class RowController { // Add the listener to the list so that we can remove it later. _onRowChangedListeners.add(fn); + _onRowMetaChanged = onMetaChanged; } Future dispose() async { diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart index 7ddd3faf11..77670fb0bb 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart @@ -2,9 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import '../defines.dart'; import '../field/field_controller.dart'; @@ -93,17 +91,6 @@ class DatabaseViewCache { (reorderRow) => _rowCache.reorderSingleRow(reorderRow), (err) => Log.error(err), ), - onReloadRows: () { - final payload = DatabaseViewIdPB(value: viewId); - DatabaseEventGetAllRows(payload).send().then((result) { - result.fold( - (rows) { - _rowCache.setInitialRows(rows.items); - }, - (err) => Log.error(err), - ); - }); - }, ); _rowCache.onRowsChanged( diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart index 1aecbb2767..3f97304296 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart @@ -32,7 +32,6 @@ class DatabaseViewListener { required ReorderAllRowsCallback onReorderAllRows, required SingleRowCallback onReorderSingleRow, required RowsVisibilityCallback onRowsVisibilityChanged, - required void Function() onReloadRows, }) { // Stop any existing listener _listener?.stop(); @@ -47,7 +46,6 @@ class DatabaseViewListener { onReorderAllRows, onReorderSingleRow, onRowsVisibilityChanged, - onReloadRows, ), ); } @@ -59,7 +57,6 @@ class DatabaseViewListener { ReorderAllRowsCallback onReorderAllRows, SingleRowCallback onReorderSingleRow, RowsVisibilityCallback onRowsVisibilityChanged, - void Function() onReloadRows, ) { switch (ty) { case DatabaseNotification.DidUpdateViewRowsVisibility: @@ -94,9 +91,6 @@ class DatabaseViewListener { (error) => onReorderSingleRow(FlowyResult.failure(error)), ); break; - case DatabaseNotification.ReloadRows: - onReloadRows(); - break; default: break; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart index 7550076726..fc258acd11 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart @@ -653,7 +653,7 @@ class _BoardCardState extends State<_BoardCard> { onTap: (context) => _openCard( context: context, databaseController: databaseController, - rowMeta: context.read().state.rowMeta, + rowMeta: context.read().rowController.rowMeta, ), onShiftTap: (_) { Focus.of(context).requestFocus(); diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_bloc.dart index 69feda410c..2267870b02 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_bloc.dart @@ -26,6 +26,7 @@ class RowBloc extends Bloc { _dispatch(); _startListening(); _init(); + rowController.initialize(); } final FieldController fieldController; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart index b2e873e67e..19c4e11483 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart'; @@ -305,22 +307,26 @@ class _GridRowsState extends State<_GridRows> { buildWhen: (previous, current) => previous.fields != current.fields, builder: (context, state) { return Flexible( - child: _WrapScrollView( - scrollController: widget.scrollController, - contentWidth: GridLayout.headerWidth(state.fields), - child: BlocConsumer( - listenWhen: (previous, current) => - previous.rowCount != current.rowCount, - listener: (context, state) => _evaluateFloatingCalculations(), - builder: (context, state) { - return ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - scrollbars: false, - ), - child: _renderList(context, state), - ); - }, - ), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints layoutConstraits) { + return _WrapScrollView( + scrollController: widget.scrollController, + contentWidth: GridLayout.headerWidth(state.fields), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.rowCount != current.rowCount, + listener: (context, state) => _evaluateFloatingCalculations(), + builder: (context, state) { + return ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + ), + child: _renderList(context, state, layoutConstraits), + ); + }, + ), + ); + }, ), ); }, @@ -330,19 +336,19 @@ class _GridRowsState extends State<_GridRows> { Widget _renderList( BuildContext context, GridState state, + BoxConstraints layoutConstraints, ) { // 1. GridRowBottomBar // 2. GridCalculationsRow // 3. Footer Padding final itemCount = state.rowInfos.length + 3; - return Stack( children: [ Positioned.fill( child: ReorderableListView.builder( /// This is a workaround related to /// https://github.com/flutter/flutter/issues/25652 - cacheExtent: 5000, + cacheExtent: max(layoutConstraints.maxHeight * 2, 500), scrollController: widget.scrollController.verticalController, physics: const ClampingScrollPhysics(), buildDefaultDragHandles: false, @@ -421,7 +427,7 @@ class _GridRowsState extends State<_GridRows> { ); final child = GridRow( - key: ValueKey(rowMeta.id), + key: ValueKey(rowId), fieldController: databaseController.fieldController, rowId: rowId, viewId: viewId, diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart index ad08d6b8e2..5b66c3a149 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart @@ -57,7 +57,7 @@ class _DatabaseViewSettingContent extends StatelessWidget { builder: (context, state) { return Padding( padding: EdgeInsets.symmetric( - horizontal: GridSize.horizontalHeaderPadding + 40, + horizontal: GridSize.horizontalHeaderPadding, ), child: DecoratedBox( decoration: BoxDecoration( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart index d5c73a1179..bd6bce8dd4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart @@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/row/action.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -73,13 +74,18 @@ class _RowCardState extends State { @override void initState() { super.initState(); + final rowController = RowController( + viewId: widget.viewId, + rowMeta: widget.rowMeta, + rowCache: widget.rowCache, + ); + _cardBloc = CardBloc( fieldController: widget.fieldController, viewId: widget.viewId, groupFieldId: widget.groupingFieldId, isEditing: widget.isEditing, - rowMeta: widget.rowMeta, - rowCache: widget.rowCache, + rowController: rowController, )..add(const CardEvent.initial()); } @@ -143,7 +149,7 @@ class _RowCardState extends State { popupBuilder: (_) { return RowActionMenu.board( viewId: _cardBloc.viewId, - rowId: _cardBloc.rowId, + rowId: _cardBloc.rowController.rowId, groupId: widget.groupId, ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart index 5bd4d6f505..fa066db319 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/foundation.dart'; @@ -7,7 +8,6 @@ import 'package:flutter/foundation.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; -import 'package:appflowy/plugins/database/domain/row_listener.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -19,42 +19,35 @@ class CardBloc extends Bloc { required this.fieldController, required this.groupFieldId, required this.viewId, - required RowMetaPB rowMeta, - required RowCache rowCache, required bool isEditing, - }) : rowId = rowMeta.id, - _rowListener = RowListener(rowMeta.id), - _rowCache = rowCache, - super( + required this.rowController, + }) : super( CardState.initial( - rowMeta, _makeCells( fieldController, groupFieldId, - rowCache.loadCells(rowMeta), + rowController, ), isEditing, + rowController.rowMeta, ), ) { _dispatch(); } final FieldController fieldController; - final String rowId; final String? groupFieldId; - final RowCache _rowCache; final String viewId; - final RowListener _rowListener; + final RowController rowController; VoidCallback? _rowCallback; @override Future close() async { if (_rowCallback != null) { - _rowCache.removeRowListener(_rowCallback!); _rowCallback = null; } - await _rowListener.stop(); + await rowController.dispose(); return super.close(); } @@ -85,20 +78,17 @@ class CardBloc extends Bloc { } Future _startListening() async { - _rowCallback = _rowCache.addListener( - rowId: rowId, + rowController.addListener( onRowChanged: (cellMap, reason) { if (!isClosed) { - final cells = _makeCells(fieldController, groupFieldId, cellMap); + final cells = + _makeCells(fieldController, groupFieldId, rowController); add(CardEvent.didReceiveCells(cells, reason)); } }, - ); - - _rowListener.start( - onMetaChanged: (rowMeta) { + onMetaChanged: () { if (!isClosed) { - add(CardEvent.didUpdateRowMeta(rowMeta)); + add(CardEvent.didUpdateRowMeta(rowController.rowMeta)); } }, ); @@ -108,16 +98,18 @@ class CardBloc extends Bloc { List _makeCells( FieldController fieldController, String? groupFieldId, - List cellContexts, + RowController rowController, ) { // Only show the non-hidden cells and cells that aren't of the grouping field - cellContexts.removeWhere((cellContext) { + final cellContext = rowController.loadCells(); + + cellContext.removeWhere((cellContext) { final fieldInfo = fieldController.getField(cellContext.fieldId); return fieldInfo == null || !(fieldInfo.visibility?.isVisibleState() ?? false) || (groupFieldId != null && cellContext.fieldId == groupFieldId); }); - return cellContexts + return cellContext .map( (cellCtx) => CellMeta( fieldId: cellCtx.fieldId, @@ -157,19 +149,19 @@ class CellMeta with _$CellMeta { class CardState with _$CardState { const factory CardState({ required List cells, - required RowMetaPB rowMeta, required bool isEditing, + required RowMetaPB rowMeta, ChangedReason? changeReason, }) = _RowCardState; factory CardState.initial( - RowMetaPB rowMeta, List cells, bool isEditing, + RowMetaPB rowMeta, ) => CardState( cells: cells, - rowMeta: rowMeta, isEditing: isEditing, + rowMeta: rowMeta, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart index 8ccda95391..17a3519d3d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart @@ -8,6 +8,7 @@ import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -202,8 +203,14 @@ class _URLAccessoryIconContainer extends StatelessWidget { ), borderRadius: Corners.s6Border, ), - child: Center( - child: child, + child: FlowyHover( + style: HoverStyle( + backgroundColor: AFThemeExtension.of(context).background, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + child: Center( + child: child, + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart index ba2dc28702..15fe9b59ff 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart @@ -372,7 +372,7 @@ class _NewTaskItemState extends State { ? Theme.of(context).disabledColor : Theme.of(context).colorScheme.primaryContainer, fontColor: Theme.of(context).colorScheme.onPrimary, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), onPressed: _textEditingController.text.isEmpty ? null : () { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 731a0b6d6d..53dbf57c6d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -499,11 +499,12 @@ class _AppFlowyEditorPageState extends State { } void _customizeBlockComponentBackgroundColorDecorator() { - if (!context.mounted) { - return; - } - blockComponentBackgroundColorDecorator = (Node node, String colorString) => - buildEditorCustomizedColor(context, node, colorString); + blockComponentBackgroundColorDecorator = (Node node, String colorString) { + if (context.mounted) { + return buildEditorCustomizedColor(context, node, colorString); + } + return null; + }; } void _initEditorL10n() => AppFlowyEditorL10n.current = EditorI18n(); diff --git a/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart b/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart new file mode 100644 index 0000000000..550c8609e5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart @@ -0,0 +1,1607 @@ +// This file is copied from Flutter source code, +// and modified to fit AppFlowy's needs. + +// changes: +// 1. remove the default ink effect +// 2. remove the tooltip +// 3. support customize transition animation + +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; + +// Examples can assume: +// enum Commands { heroAndScholar, hurricaneCame } +// late bool _heroAndScholar; +// late dynamic _selection; +// late BuildContext context; +// void setState(VoidCallback fn) { } +// enum Menu { itemOne, itemTwo, itemThree, itemFour } + +const Duration _kMenuDuration = Duration(milliseconds: 300); +const double _kMenuCloseIntervalEnd = 2.0 / 3.0; +const double _kMenuDividerHeight = 16.0; +const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep; +const double _kMenuMinWidth = 2.0 * _kMenuWidthStep; +const double _kMenuVerticalPadding = 8.0; +const double _kMenuWidthStep = 56.0; +const double _kMenuScreenPadding = 8.0; + +/// A base class for entries in a Material Design popup menu. +/// +/// The popup menu widget uses this interface to interact with the menu items. +/// To show a popup menu, use the [showMenu] function. To create a button that +/// shows a popup menu, consider using [PopupMenuButton]. +/// +/// The type `T` is the type of the value(s) the entry represents. All the +/// entries in a given menu must represent values with consistent types. +/// +/// A [PopupMenuEntry] may represent multiple values, for example a row with +/// several icons, or a single entry, for example a menu item with an icon (see +/// [PopupMenuItem]), or no value at all (for example, [PopupMenuDivider]). +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for a single value. +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [CheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +abstract class PopupMenuEntry extends StatefulWidget { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const PopupMenuEntry({super.key}); + + /// The amount of vertical space occupied by this entry. + /// + /// This value is used at the time the [showMenu] method is called, if the + /// `initialValue` argument is provided, to determine the position of this + /// entry when aligning the selected entry over the given `position`. It is + /// otherwise ignored. + double get height; + + /// Whether this entry represents a particular value. + /// + /// This method is used by [showMenu], when it is called, to align the entry + /// representing the `initialValue`, if any, to the given `position`, and then + /// later is called on each entry to determine if it should be highlighted (if + /// the method returns true, the entry will have its background color set to + /// the ambient [ThemeData.highlightColor]). If `initialValue` is null, then + /// this method is not called. + /// + /// If the [PopupMenuEntry] represents a single value, this should return true + /// if the argument matches that value. If it represents multiple values, it + /// should return true if the argument matches any of them. + bool represents(T? value); +} + +/// A horizontal divider in a Material Design popup menu. +/// +/// This widget adapts the [Divider] for use in popup menus. +/// +/// See also: +/// +/// * [PopupMenuItem], for the kinds of items that this widget divides. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class PopupMenuDivider extends PopupMenuEntry { + /// Creates a horizontal divider for a popup menu. + /// + /// By default, the divider has a height of 16 logical pixels. + const PopupMenuDivider({super.key, this.height = _kMenuDividerHeight}); + + /// The height of the divider entry. + /// + /// Defaults to 16 pixels. + @override + final double height; + + @override + bool represents(void value) => false; + + @override + State createState() => _PopupMenuDividerState(); +} + +class _PopupMenuDividerState extends State { + @override + Widget build(BuildContext context) => Divider(height: widget.height); +} + +// This widget only exists to enable _PopupMenuRoute to save the sizes of +// each menu item. The sizes are used by _PopupMenuRouteLayout to compute the +// y coordinate of the menu's origin so that the center of selected menu +// item lines up with the center of its PopupMenuButton. +class _MenuItem extends SingleChildRenderObjectWidget { + const _MenuItem({ + required this.onLayout, + required super.child, + }); + + final ValueChanged onLayout; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderMenuItem(onLayout); + } + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderMenuItem renderObject, + ) { + renderObject.onLayout = onLayout; + } +} + +class _RenderMenuItem extends RenderShiftedBox { + _RenderMenuItem(this.onLayout, [RenderBox? child]) : super(child); + + ValueChanged onLayout; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return child?.getDryLayout(constraints) ?? Size.zero; + } + + @override + void performLayout() { + if (child == null) { + size = Size.zero; + } else { + child!.layout(constraints, parentUsesSize: true); + size = constraints.constrain(child!.size); + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Offset.zero; + } + onLayout(size); + } +} + +/// An item in a Material Design popup menu. +/// +/// To show a popup menu, use the [showMenu] function. To create a button that +/// shows a popup menu, consider using [PopupMenuButton]. +/// +/// To show a checkmark next to a popup menu item, consider using +/// [CheckedPopupMenuItem]. +/// +/// Typically the [child] of a [PopupMenuItem] is a [Text] widget. More +/// elaborate menus with icons can use a [ListTile]. By default, a +/// [PopupMenuItem] is [kMinInteractiveDimension] pixels high. If you use a widget +/// with a different height, it must be specified in the [height] property. +/// +/// {@tool snippet} +/// +/// Here, a [Text] widget is used with a popup menu item. The `Menu` type +/// is an enum, not shown here. +/// +/// ```dart +/// const PopupMenuItem( +/// value: Menu.itemOne, +/// child: Text('Item 1'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See the example at [PopupMenuButton] for how this example could be used in a +/// complete menu, and see the example at [CheckedPopupMenuItem] for one way to +/// keep the text of [PopupMenuItem]s that use [Text] widgets in their [child] +/// slot aligned with the text of [CheckedPopupMenuItem]s or of [PopupMenuItem] +/// that use a [ListTile] in their [child] slot. +/// +/// See also: +/// +/// * [PopupMenuDivider], which can be used to divide items from each other. +/// * [CheckedPopupMenuItem], a variant of [PopupMenuItem] with a checkmark. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class PopupMenuItem extends PopupMenuEntry { + /// Creates an item for a popup menu. + /// + /// By default, the item is [enabled]. + const PopupMenuItem({ + super.key, + this.value, + this.onTap, + this.enabled = true, + this.height = kMinInteractiveDimension, + this.padding, + this.textStyle, + this.labelTextStyle, + this.mouseCursor, + required this.child, + }); + + /// The value that will be returned by [showMenu] if this entry is selected. + final T? value; + + /// Called when the menu item is tapped. + final VoidCallback? onTap; + + /// Whether the user is permitted to select this item. + /// + /// Defaults to true. If this is false, then the item will not react to + /// touches. + final bool enabled; + + /// The minimum height of the menu item. + /// + /// Defaults to [kMinInteractiveDimension] pixels. + @override + final double height; + + /// The padding of the menu item. + /// + /// The [height] property may interact with the applied padding. For example, + /// If a [height] greater than the height of the sum of the padding and [child] + /// is provided, then the padding's effect will not be visible. + /// + /// If this is null and [ThemeData.useMaterial3] is true, the horizontal padding + /// defaults to 12.0 on both sides. + /// + /// If this is null and [ThemeData.useMaterial3] is false, the horizontal padding + /// defaults to 16.0 on both sides. + final EdgeInsets? padding; + + /// The text style of the popup menu item. + /// + /// If this property is null, then [PopupMenuThemeData.textStyle] is used. + /// If [PopupMenuThemeData.textStyle] is also null, then [TextTheme.titleMedium] + /// of [ThemeData.textTheme] is used. + final TextStyle? textStyle; + + /// The label style of the popup menu item. + /// + /// When [ThemeData.useMaterial3] is true, this styles the text of the popup menu item. + /// + /// If this property is null, then [PopupMenuThemeData.labelTextStyle] is used. + /// If [PopupMenuThemeData.labelTextStyle] is also null, then [TextTheme.labelLarge] + /// is used with the [ColorScheme.onSurface] color when popup menu item is enabled and + /// the [ColorScheme.onSurface] color with 0.38 opacity when the popup menu item is disabled. + final WidgetStateProperty? labelTextStyle; + + /// {@template flutter.material.popupmenu.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [MaterialStateProperty], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If null, then the value of [PopupMenuThemeData.mouseCursor] is used. If + /// that is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// The widget below this widget in the tree. + /// + /// Typically a single-line [ListTile] (for menus with icons) or a [Text]. An + /// appropriate [DefaultTextStyle] is put in scope for the child. In either + /// case, the text should be short enough that it won't wrap. + final Widget? child; + + @override + bool represents(T? value) => value == this.value; + + @override + PopupMenuItemState> createState() => + PopupMenuItemState>(); +} + +/// The [State] for [PopupMenuItem] subclasses. +/// +/// By default this implements the basic styling and layout of Material Design +/// popup menu items. +/// +/// The [buildChild] method can be overridden to adjust exactly what gets placed +/// in the menu. By default it returns [PopupMenuItem.child]. +/// +/// The [handleTap] method can be overridden to adjust exactly what happens when +/// the item is tapped. By default, it uses [Navigator.pop] to return the +/// [PopupMenuItem.value] from the menu route. +/// +/// This class takes two type arguments. The second, `W`, is the exact type of +/// the [Widget] that is using this [State]. It must be a subclass of +/// [PopupMenuItem]. The first, `T`, must match the type argument of that widget +/// class, and is the type of values returned from this menu. +class PopupMenuItemState> extends State { + /// The menu item contents. + /// + /// Used by the [build] method. + /// + /// By default, this returns [PopupMenuItem.child]. Override this to put + /// something else in the menu entry. + @protected + Widget? buildChild() => widget.child; + + /// The handler for when the user selects the menu item. + /// + /// Used by the [InkWell] inserted by the [build] method. + /// + /// By default, uses [Navigator.pop] to return the [PopupMenuItem.value] from + /// the menu route. + @protected + void handleTap() { + // Need to pop the navigator first in case onTap may push new route onto navigator. + Navigator.pop(context, widget.value); + + widget.onTap?.call(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 + ? _PopupMenuDefaultsM3(context) + : _PopupMenuDefaultsM2(context); + final Set states = { + if (!widget.enabled) WidgetState.disabled, + }; + + TextStyle style = theme.useMaterial3 + ? (widget.labelTextStyle?.resolve(states) ?? + popupMenuTheme.labelTextStyle?.resolve(states)! ?? + defaults.labelTextStyle!.resolve(states)!) + : (widget.textStyle ?? popupMenuTheme.textStyle ?? defaults.textStyle!); + + if (!widget.enabled && !theme.useMaterial3) { + style = style.copyWith(color: theme.disabledColor); + } + + Widget item = AnimatedDefaultTextStyle( + style: style, + duration: kThemeChangeDuration, + child: Container( + alignment: AlignmentDirectional.centerStart, + constraints: BoxConstraints(minHeight: widget.height), + padding: widget.padding ?? + (theme.useMaterial3 + ? _PopupMenuDefaultsM3.menuHorizontalPadding + : _PopupMenuDefaultsM2.menuHorizontalPadding), + child: buildChild(), + ), + ); + + if (!widget.enabled) { + final bool isDark = theme.brightness == Brightness.dark; + item = IconTheme.merge( + data: IconThemeData(opacity: isDark ? 0.5 : 0.38), + child: item, + ); + } + + return MergeSemantics( + child: Semantics( + enabled: widget.enabled, + button: true, + child: GestureDetector( + onTap: widget.enabled ? handleTap : null, + behavior: HitTestBehavior.opaque, + child: ListTileTheme.merge( + contentPadding: EdgeInsets.zero, + titleTextStyle: style, + child: item, + ), + ), + ), + ); + } +} + +/// An item with a checkmark in a Material Design popup menu. +/// +/// To show a popup menu, use the [showMenu] function. To create a button that +/// shows a popup menu, consider using [PopupMenuButton]. +/// +/// A [CheckedPopupMenuItem] is kMinInteractiveDimension pixels high, which +/// matches the default minimum height of a [PopupMenuItem]. The horizontal +/// layout uses [ListTile]; the checkmark is an [Icons.done] icon, shown in the +/// [ListTile.leading] position. +/// +/// {@tool snippet} +/// +/// Suppose a `Commands` enum exists that lists the possible commands from a +/// particular popup menu, including `Commands.heroAndScholar` and +/// `Commands.hurricaneCame`, and further suppose that there is a +/// `_heroAndScholar` member field which is a boolean. The example below shows a +/// menu with one menu item with a checkmark that can toggle the boolean, and +/// one menu item without a checkmark for selecting the second option. (It also +/// shows a divider placed between the two menu items.) +/// +/// ```dart +/// PopupMenuButton( +/// onSelected: (Commands result) { +/// switch (result) { +/// case Commands.heroAndScholar: +/// setState(() { _heroAndScholar = !_heroAndScholar; }); +/// case Commands.hurricaneCame: +/// // ...handle hurricane option +/// break; +/// // ...other items handled here +/// } +/// }, +/// itemBuilder: (BuildContext context) => >[ +/// CheckedPopupMenuItem( +/// checked: _heroAndScholar, +/// value: Commands.heroAndScholar, +/// child: const Text('Hero and scholar'), +/// ), +/// const PopupMenuDivider(), +/// const PopupMenuItem( +/// value: Commands.hurricaneCame, +/// child: ListTile(leading: Icon(null), title: Text('Bring hurricane')), +/// ), +/// // ...other items listed here +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// In particular, observe how the second menu item uses a [ListTile] with a +/// blank [Icon] in the [ListTile.leading] position to get the same alignment as +/// the item with the checkmark. +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for picking a command (as opposed to +/// toggling a value). +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class CheckedPopupMenuItem extends PopupMenuItem { + /// Creates a popup menu item with a checkmark. + /// + /// By default, the menu item is [enabled] but unchecked. To mark the item as + /// checked, set [checked] to true. + const CheckedPopupMenuItem({ + super.key, + super.value, + this.checked = false, + super.enabled, + super.padding, + super.height, + super.labelTextStyle, + super.mouseCursor, + super.child, + super.onTap, + }); + + /// Whether to display a checkmark next to the menu item. + /// + /// Defaults to false. + /// + /// When true, an [Icons.done] checkmark is displayed. + /// + /// When this popup menu item is selected, the checkmark will fade in or out + /// as appropriate to represent the implied new state. + final bool checked; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text]. An appropriate [DefaultTextStyle] is put in scope for + /// the child. The text should be short enough that it won't wrap. + /// + /// This widget is placed in the [ListTile.title] slot of a [ListTile] whose + /// [ListTile.leading] slot is an [Icons.done] icon. + @override + Widget? get child => super.child; + + @override + PopupMenuItemState> createState() => + _CheckedPopupMenuItemState(); +} + +class _CheckedPopupMenuItemState + extends PopupMenuItemState> + with SingleTickerProviderStateMixin { + static const Duration _fadeDuration = Duration(milliseconds: 150); + late AnimationController _controller; + Animation get _opacity => _controller.view; + + @override + void initState() { + super.initState(); + _controller = AnimationController(duration: _fadeDuration, vsync: this) + ..value = widget.checked ? 1.0 : 0.0 + ..addListener(() => setState(() {/* animation changed */})); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void handleTap() { + // This fades the checkmark in or out when tapped. + if (widget.checked) { + _controller.reverse(); + } else { + _controller.forward(); + } + super.handleTap(); + } + + @override + Widget buildChild() { + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 + ? _PopupMenuDefaultsM3(context) + : _PopupMenuDefaultsM2(context); + final Set states = { + if (widget.checked) WidgetState.selected, + }; + final WidgetStateProperty? effectiveLabelTextStyle = + widget.labelTextStyle ?? + popupMenuTheme.labelTextStyle ?? + defaults.labelTextStyle; + return IgnorePointer( + child: ListTileTheme.merge( + contentPadding: EdgeInsets.zero, + child: ListTile( + enabled: widget.enabled, + titleTextStyle: effectiveLabelTextStyle?.resolve(states), + leading: FadeTransition( + opacity: _opacity, + child: Icon(_controller.isDismissed ? null : Icons.done), + ), + title: widget.child, + ), + ), + ); + } +} + +class _PopupMenu extends StatelessWidget { + const _PopupMenu({ + super.key, + required this.itemKeys, + required this.route, + required this.semanticLabel, + this.constraints, + required this.clipBehavior, + }); + + final List itemKeys; + final _PopupMenuRoute route; + final String? semanticLabel; + final BoxConstraints? constraints; + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + final double unit = 1.0 / + (route.items.length + + 1.5); // 1.0 for the width and 0.5 for the last item's fade. + final List children = []; + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 + ? _PopupMenuDefaultsM3(context) + : _PopupMenuDefaultsM2(context); + + for (int i = 0; i < route.items.length; i += 1) { + final double start = (i + 1) * unit; + final double end = clampDouble(start + 1.5 * unit, 0.0, 1.0); + final CurvedAnimation opacity = CurvedAnimation( + parent: route.animation!, + curve: Interval(start, end), + ); + Widget item = route.items[i]; + if (route.initialValue != null && + route.items[i].represents(route.initialValue)) { + item = ColoredBox( + color: Theme.of(context).highlightColor, + child: item, + ); + } + children.add( + _MenuItem( + onLayout: (Size size) { + route.itemSizes[i] = size; + }, + child: FadeTransition( + key: itemKeys[i], + opacity: opacity, + child: item, + ), + ), + ); + } + + final CurveTween opacity = + CurveTween(curve: const Interval(0.0, 1.0 / 3.0)); + final CurveTween width = CurveTween(curve: Interval(0.0, unit)); + final CurveTween height = + CurveTween(curve: Interval(0.0, unit * route.items.length)); + + final Widget child = ConstrainedBox( + constraints: constraints ?? + const BoxConstraints( + minWidth: _kMenuMinWidth, + maxWidth: _kMenuMaxWidth, + ), + child: IntrinsicWidth( + stepWidth: _kMenuWidthStep, + child: Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: semanticLabel, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + vertical: _kMenuVerticalPadding, + ), + child: ListBody(children: children), + ), + ), + ), + ); + + return AnimatedBuilder( + animation: route.animation!, + builder: (BuildContext context, Widget? child) { + return FadeTransition( + opacity: opacity.animate(route.animation!), + child: Material( + shape: route.shape ?? popupMenuTheme.shape ?? defaults.shape, + color: route.color ?? popupMenuTheme.color ?? defaults.color, + clipBehavior: clipBehavior, + type: MaterialType.card, + elevation: route.elevation ?? + popupMenuTheme.elevation ?? + defaults.elevation!, + shadowColor: route.shadowColor ?? + popupMenuTheme.shadowColor ?? + defaults.shadowColor, + surfaceTintColor: route.surfaceTintColor ?? + popupMenuTheme.surfaceTintColor ?? + defaults.surfaceTintColor, + child: Align( + alignment: AlignmentDirectional.topEnd, + widthFactor: width.evaluate(route.animation!), + heightFactor: height.evaluate(route.animation!), + child: child, + ), + ), + ); + }, + child: child, + ); + } +} + +// Positioning of the menu on the screen. +class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { + _PopupMenuRouteLayout( + this.position, + this.itemSizes, + this.selectedItemIndex, + this.textDirection, + this.padding, + this.avoidBounds, + ); + + // Rectangle of underlying button, relative to the overlay's dimensions. + final RelativeRect position; + + // The sizes of each item are computed when the menu is laid out, and before + // the route is laid out. + List itemSizes; + + // The index of the selected item, or null if PopupMenuButton.initialValue + // was not specified. + final int? selectedItemIndex; + + // Whether to prefer going to the left or to the right. + final TextDirection textDirection; + + // The padding of unsafe area. + EdgeInsets padding; + + // List of rectangles that we should avoid overlapping. Unusable screen area. + final Set avoidBounds; + + // We put the child wherever position specifies, so long as it will fit within + // the specified parent size padded (inset) by 8. If necessary, we adjust the + // child's position so that it fits. + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + // The menu can be at most the size of the overlay minus 8.0 pixels in each + // direction. + return BoxConstraints.loose(constraints.biggest).deflate( + const EdgeInsets.all(_kMenuScreenPadding) + padding, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final double y = position.top; + + // Find the ideal horizontal position. + // size: The size of the overlay. + // childSize: The size of the menu, when fully open, as determined by + // getConstraintsForChild. + double x; + if (position.left > position.right) { + // Menu button is closer to the right edge, so grow to the left, aligned to the right edge. + x = size.width - position.right - childSize.width; + } else if (position.left < position.right) { + // Menu button is closer to the left edge, so grow to the right, aligned to the left edge. + x = position.left; + } else { + // Menu button is equidistant from both edges, so grow in reading direction. + x = switch (textDirection) { + TextDirection.rtl => size.width - position.right - childSize.width, + TextDirection.ltr => position.left, + }; + } + final Offset wantedPosition = Offset(x, y); + final Offset originCenter = position.toRect(Offset.zero & size).center; + final Iterable subScreens = + DisplayFeatureSubScreen.subScreensInBounds( + Offset.zero & size, + avoidBounds, + ); + final Rect subScreen = _closestScreen(subScreens, originCenter); + return _fitInsideScreen(subScreen, childSize, wantedPosition); + } + + Rect _closestScreen(Iterable screens, Offset point) { + Rect closest = screens.first; + for (final Rect screen in screens) { + if ((screen.center - point).distance < + (closest.center - point).distance) { + closest = screen; + } + } + return closest; + } + + Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition) { + double x = wantedPosition.dx; + double y = wantedPosition.dy; + // Avoid going outside an area defined as the rectangle 8.0 pixels from the + // edge of the screen in every direction. + if (x < screen.left + _kMenuScreenPadding + padding.left) { + x = screen.left + _kMenuScreenPadding + padding.left; + } else if (x + childSize.width > + screen.right - _kMenuScreenPadding - padding.right) { + x = screen.right - childSize.width - _kMenuScreenPadding - padding.right; + } + if (y < screen.top + _kMenuScreenPadding + padding.top) { + y = _kMenuScreenPadding + padding.top; + } else if (y + childSize.height > + screen.bottom - _kMenuScreenPadding - padding.bottom) { + y = screen.bottom - + childSize.height - + _kMenuScreenPadding - + padding.bottom; + } + + return Offset(x, y); + } + + @override + bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) { + // If called when the old and new itemSizes have been initialized then + // we expect them to have the same length because there's no practical + // way to change length of the items list once the menu has been shown. + assert(itemSizes.length == oldDelegate.itemSizes.length); + + return position != oldDelegate.position || + selectedItemIndex != oldDelegate.selectedItemIndex || + textDirection != oldDelegate.textDirection || + !listEquals(itemSizes, oldDelegate.itemSizes) || + padding != oldDelegate.padding || + !setEquals(avoidBounds, oldDelegate.avoidBounds); + } +} + +class _PopupMenuRoute extends PopupRoute { + _PopupMenuRoute({ + required this.position, + required this.items, + required this.itemKeys, + this.initialValue, + this.elevation, + this.surfaceTintColor, + this.shadowColor, + required this.barrierLabel, + this.semanticLabel, + this.shape, + this.color, + required this.capturedThemes, + this.constraints, + required this.clipBehavior, + super.settings, + this.popUpAnimationStyle, + }) : itemSizes = List.filled(items.length, null), + // Menus always cycle focus through their items irrespective of the + // focus traversal edge behavior set in the Navigator. + super(traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop); + + final RelativeRect position; + final List> items; + final List itemKeys; + final List itemSizes; + final T? initialValue; + final double? elevation; + final Color? surfaceTintColor; + final Color? shadowColor; + final String? semanticLabel; + final ShapeBorder? shape; + final Color? color; + final CapturedThemes capturedThemes; + final BoxConstraints? constraints; + final Clip clipBehavior; + final AnimationStyle? popUpAnimationStyle; + + @override + Animation createAnimation() { + if (popUpAnimationStyle != AnimationStyle.noAnimation) { + return CurvedAnimation( + parent: super.createAnimation(), + curve: popUpAnimationStyle?.curve ?? Curves.easeInBack, + reverseCurve: popUpAnimationStyle?.reverseCurve ?? + const Interval(0.0, _kMenuCloseIntervalEnd), + ); + } + return super.createAnimation(); + } + + void scrollTo(int selectedItemIndex) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (itemKeys[selectedItemIndex].currentContext != null) { + Scrollable.ensureVisible(itemKeys[selectedItemIndex].currentContext!); + } + }); + } + + @override + Duration get transitionDuration => + popUpAnimationStyle?.duration ?? _kMenuDuration; + + @override + bool get barrierDismissible => true; + + @override + Color? get barrierColor => null; + + @override + final String barrierLabel; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (!animation.isCompleted) { + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + final size = position.toSize(Size(screenWidth, screenHeight)); + final center = size.width / 2.0; + final alignment = FractionalOffset( + (screenWidth - position.right - center) / screenWidth, + (screenHeight - position.bottom - center) / screenHeight, + ); + child = FadeTransition( + opacity: animation, + child: ScaleTransition( + alignment: alignment, + scale: animation, + child: child, + ), + ); + } + return child; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + int? selectedItemIndex; + if (initialValue != null) { + for (int index = 0; + selectedItemIndex == null && index < items.length; + index += 1) { + if (items[index].represents(initialValue)) { + selectedItemIndex = index; + } + } + } + if (selectedItemIndex != null) { + scrollTo(selectedItemIndex); + } + + final Widget menu = _PopupMenu( + route: this, + itemKeys: itemKeys, + semanticLabel: semanticLabel, + constraints: constraints, + clipBehavior: clipBehavior, + ); + final MediaQueryData mediaQuery = MediaQuery.of(context); + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + removeLeft: true, + removeRight: true, + child: Builder( + builder: (BuildContext context) { + return CustomSingleChildLayout( + delegate: _PopupMenuRouteLayout( + position, + itemSizes, + selectedItemIndex, + Directionality.of(context), + mediaQuery.padding, + _avoidBounds(mediaQuery), + ), + child: capturedThemes.wrap(menu), + ); + }, + ), + ); + } + + Set _avoidBounds(MediaQueryData mediaQuery) { + return DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(); + } +} + +/// Show a popup menu that contains the `items` at `position`. +/// +/// The `items` parameter must not be empty. +/// +/// If `initialValue` is specified then the first item with a matching value +/// will be highlighted and the value of `position` gives the rectangle whose +/// vertical center will be aligned with the vertical center of the highlighted +/// item (when possible). +/// +/// If `initialValue` is not specified then the top of the menu will be aligned +/// with the top of the `position` rectangle. +/// +/// In both cases, the menu position will be adjusted if necessary to fit on the +/// screen. +/// +/// Horizontally, the menu is positioned so that it grows in the direction that +/// has the most room. For example, if the `position` describes a rectangle on +/// the left edge of the screen, then the left edge of the menu is aligned with +/// the left edge of the `position`, and the menu grows to the right. If both +/// edges of the `position` are equidistant from the opposite edge of the +/// screen, then the ambient [Directionality] is used as a tie-breaker, +/// preferring to grow in the reading direction. +/// +/// The positioning of the `initialValue` at the `position` is implemented by +/// iterating over the `items` to find the first whose +/// [PopupMenuEntry.represents] method returns true for `initialValue`, and then +/// summing the values of [PopupMenuEntry.height] for all the preceding widgets +/// in the list. +/// +/// The `elevation` argument specifies the z-coordinate at which to place the +/// menu. The elevation defaults to 8, the appropriate elevation for popup +/// menus. +/// +/// The `context` argument is used to look up the [Navigator] and [Theme] for +/// the menu. It is only used when the method is called. Its corresponding +/// widget can be safely removed from the tree before the popup menu is closed. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// menu to the [Navigator] furthest from or nearest to the given `context`. It +/// is `false` by default. +/// +/// The `semanticLabel` argument is used by accessibility frameworks to +/// announce screen transitions when the menu is opened and closed. If this +/// label is not provided, it will default to +/// [MaterialLocalizations.popupMenuLabel]. +/// +/// The `clipBehavior` argument is used to clip the shape of the menu. Defaults to +/// [Clip.none]. +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for a single value. +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [CheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [PopupMenuButton], which provides an [IconButton] that shows a menu by +/// calling this method automatically. +/// * [SemanticsConfiguration.namesRoute], for a description of edge triggered +/// semantics. +Future showMenu({ + required BuildContext context, + required RelativeRect position, + required List> items, + T? initialValue, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + String? semanticLabel, + ShapeBorder? shape, + Color? color, + bool useRootNavigator = false, + BoxConstraints? constraints, + Clip clipBehavior = Clip.none, + RouteSettings? routeSettings, + AnimationStyle? popUpAnimationStyle, +}) { + assert(items.isNotEmpty); + assert(debugCheckHasMaterialLocalizations(context)); + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + semanticLabel ??= MaterialLocalizations.of(context).popupMenuLabel; + } + + final List menuItemKeys = + List.generate(items.length, (int index) => GlobalKey()); + final NavigatorState navigator = + Navigator.of(context, rootNavigator: useRootNavigator); + return navigator.push( + _PopupMenuRoute( + position: position, + items: items, + itemKeys: menuItemKeys, + initialValue: initialValue, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + semanticLabel: semanticLabel, + barrierLabel: MaterialLocalizations.of(context).menuDismissLabel, + shape: shape, + color: color, + capturedThemes: + InheritedTheme.capture(from: context, to: navigator.context), + constraints: constraints, + clipBehavior: clipBehavior, + settings: routeSettings, + popUpAnimationStyle: popUpAnimationStyle, + ), + ); +} + +/// Signature for the callback invoked when a menu item is selected. The +/// argument is the value of the [PopupMenuItem] that caused its menu to be +/// dismissed. +/// +/// Used by [PopupMenuButton.onSelected]. +typedef PopupMenuItemSelected = void Function(T value); + +/// Signature for the callback invoked when a [PopupMenuButton] is dismissed +/// without selecting an item. +/// +/// Used by [PopupMenuButton.onCanceled]. +typedef PopupMenuCanceled = void Function(); + +/// Signature used by [PopupMenuButton] to lazily construct the items shown when +/// the button is pressed. +/// +/// Used by [PopupMenuButton.itemBuilder]. +typedef PopupMenuItemBuilder = List> Function( + BuildContext context, +); + +/// Displays a menu when pressed and calls [onSelected] when the menu is dismissed +/// because an item was selected. The value passed to [onSelected] is the value of +/// the selected menu item. +/// +/// One of [child] or [icon] may be provided, but not both. If [icon] is provided, +/// then [PopupMenuButton] behaves like an [IconButton]. +/// +/// If both are null, then a standard overflow icon is created (depending on the +/// platform). +/// +/// ## Updating to [MenuAnchor] +/// +/// There is a Material 3 component, +/// [MenuAnchor] that is preferred for applications that are configured +/// for Material 3 (see [ThemeData.useMaterial3]). +/// The [MenuAnchor] widget's visuals +/// are a little bit different, see the Material 3 spec at +/// for +/// more details. +/// +/// The [MenuAnchor] widget's API is also slightly different. +/// [MenuAnchor]'s were built to be lower level interface for +/// creating menus that are displayed from an anchor. +/// +/// There are a few steps you would take to migrate from +/// [PopupMenuButton] to [MenuAnchor]: +/// +/// 1. Instead of using the [PopupMenuButton.itemBuilder] to build +/// a list of [PopupMenuEntry]s, you would use the [MenuAnchor.menuChildren] +/// which takes a list of [Widget]s. Usually, you would use a list of +/// [MenuItemButton]s as shown in the example below. +/// +/// 2. Instead of using the [PopupMenuButton.onSelected] callback, you would +/// set individual callbacks for each of the [MenuItemButton]s using the +/// [MenuItemButton.onPressed] property. +/// +/// 3. To anchor the [MenuAnchor] to a widget, you would use the [MenuAnchor.builder] +/// to return the widget of choice - usually a [TextButton] or an [IconButton]. +/// +/// 4. You may want to style the [MenuItemButton]s, see the [MenuItemButton] +/// documentation for details. +/// +/// Use the sample below for an example of migrating from [PopupMenuButton] to +/// [MenuAnchor]. +/// +/// {@tool dartpad} +/// This example shows a menu with three items, selecting between an enum's +/// values and setting a `selectedMenu` field based on the selection. +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to migrate the above to a [MenuAnchor]. +/// +/// ** See code in examples/api/lib/material/menu_anchor/menu_anchor.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of a popup menu, as described in: +/// https://m3.material.io/components/menus/overview +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample showcases how to override the [PopupMenuButton] animation +/// curves and duration using [AnimationStyle]. +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for a single value. +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [CheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +class PopupMenuButton extends StatefulWidget { + /// Creates a button that shows a popup menu. + const PopupMenuButton({ + super.key, + required this.itemBuilder, + this.initialValue, + this.onOpened, + this.onSelected, + this.onCanceled, + this.tooltip, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.padding = const EdgeInsets.all(8.0), + this.child, + this.splashRadius, + this.icon, + this.iconSize, + this.offset = Offset.zero, + this.enabled = true, + this.shape, + this.color, + this.iconColor, + this.enableFeedback, + this.constraints, + this.position, + this.clipBehavior = Clip.none, + this.useRootNavigator = false, + this.popUpAnimationStyle, + this.routeSettings, + this.style, + }) : assert( + !(child != null && icon != null), + 'You can only pass [child] or [icon], not both.', + ); + + /// Called when the button is pressed to create the items to show in the menu. + final PopupMenuItemBuilder itemBuilder; + + /// The value of the menu item, if any, that should be highlighted when the menu opens. + final T? initialValue; + + /// Called when the popup menu is shown. + final VoidCallback? onOpened; + + /// Called when the user selects a value from the popup menu created by this button. + /// + /// If the popup menu is dismissed without selecting a value, [onCanceled] is + /// called instead. + final PopupMenuItemSelected? onSelected; + + /// Called when the user dismisses the popup menu without selecting an item. + /// + /// If the user selects a value, [onSelected] is called instead. + final PopupMenuCanceled? onCanceled; + + /// Text that describes the action that will occur when the button is pressed. + /// + /// This text is displayed when the user long-presses on the button and is + /// used for accessibility. + final String? tooltip; + + /// The z-coordinate at which to place the menu when open. This controls the + /// size of the shadow below the menu. + /// + /// Defaults to 8, the appropriate elevation for popup menus. + final double? elevation; + + /// The color used to paint the shadow below the menu. + /// + /// If null then the ambient [PopupMenuThemeData.shadowColor] is used. + /// If that is null too, then the overall theme's [ThemeData.shadowColor] + /// (default black) is used. + final Color? shadowColor; + + /// The color used as an overlay on [color] to indicate elevation. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// If null, [PopupMenuThemeData.surfaceTintColor] is used. If that + /// is also null, the default value is [Colors.transparent]. + /// + /// See [Material.surfaceTintColor] for more details on how this + /// overlay is applied. + final Color? surfaceTintColor; + + /// Matches IconButton's 8 dps padding by default. In some cases, notably where + /// this button appears as the trailing element of a list item, it's useful to be able + /// to set the padding to zero. + final EdgeInsetsGeometry padding; + + /// The splash radius. + /// + /// If null, default splash radius of [InkWell] or [IconButton] is used. + final double? splashRadius; + + /// If provided, [child] is the widget used for this button + /// and the button will utilize an [InkWell] for taps. + final Widget? child; + + /// If provided, the [icon] is used for this button + /// and the button will behave like an [IconButton]. + final Widget? icon; + + /// The offset is applied relative to the initial position + /// set by the [position]. + /// + /// When not set, the offset defaults to [Offset.zero]. + final Offset offset; + + /// Whether this popup menu button is interactive. + /// + /// Defaults to true. + /// + /// If true, the button will respond to presses by displaying the menu. + /// + /// If false, the button is styled with the disabled color from the + /// current [Theme] and will not respond to presses or show the popup + /// menu and [onSelected], [onCanceled] and [itemBuilder] will not be called. + /// + /// This can be useful in situations where the app needs to show the button, + /// but doesn't currently have anything to show in the menu. + final bool enabled; + + /// If provided, the shape used for the menu. + /// + /// If this property is null, then [PopupMenuThemeData.shape] is used. + /// If [PopupMenuThemeData.shape] is also null, then the default shape for + /// [MaterialType.card] is used. This default shape is a rectangle with + /// rounded edges of BorderRadius.circular(2.0). + final ShapeBorder? shape; + + /// If provided, the background color used for the menu. + /// + /// If this property is null, then [PopupMenuThemeData.color] is used. + /// If [PopupMenuThemeData.color] is also null, then + /// [ThemeData.cardColor] is used in Material 2. In Material3, defaults to + /// [ColorScheme.surfaceContainer]. + final Color? color; + + /// If provided, this color is used for the button icon. + /// + /// If this property is null, then [PopupMenuThemeData.iconColor] is used. + /// If [PopupMenuThemeData.iconColor] is also null then defaults to + /// [IconThemeData.color]. + final Color? iconColor; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// If provided, the size of the [Icon]. + /// + /// If this property is null, then [IconThemeData.size] is used. + /// If [IconThemeData.size] is also null, then + /// default size is 24.0 pixels. + final double? iconSize; + + /// Optional size constraints for the menu. + /// + /// When unspecified, defaults to: + /// ```dart + /// const BoxConstraints( + /// minWidth: 2.0 * 56.0, + /// maxWidth: 5.0 * 56.0, + /// ) + /// ``` + /// + /// The default constraints ensure that the menu width matches maximum width + /// recommended by the Material Design guidelines. + /// Specifying this parameter enables creation of menu wider than + /// the default maximum width. + final BoxConstraints? constraints; + + /// Whether the popup menu is positioned over or under the popup menu button. + /// + /// [offset] is used to change the position of the popup menu relative to the + /// position set by this parameter. + /// + /// If this property is `null`, then [PopupMenuThemeData.position] is used. If + /// [PopupMenuThemeData.position] is also `null`, then the position defaults + /// to [PopupMenuPosition.over] which makes the popup menu appear directly + /// over the button that was used to create it. + final PopupMenuPosition? position; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// The [clipBehavior] argument is used the clip shape of the menu. + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// Used to determine whether to push the menu to the [Navigator] furthest + /// from or nearest to the given `context`. + /// + /// Defaults to false. + final bool useRootNavigator; + + /// Used to override the default animation curves and durations of the popup + /// menu's open and close transitions. + /// + /// If [AnimationStyle.curve] is provided, it will be used to override + /// the default popup animation curve. Otherwise, defaults to [Curves.linear]. + /// + /// If [AnimationStyle.reverseCurve] is provided, it will be used to + /// override the default popup animation reverse curve. Otherwise, defaults to + /// `Interval(0.0, 2.0 / 3.0)`. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the default popup animation duration. Otherwise, defaults to 300ms. + /// + /// To disable the theme animation, use [AnimationStyle.noAnimation]. + /// + /// If this is null, then the default animation will be used. + final AnimationStyle? popUpAnimationStyle; + + /// Optional route settings for the menu. + /// + /// See [RouteSettings] for details. + final RouteSettings? routeSettings; + + /// Customizes this icon button's appearance. + /// + /// The [style] is only used for Material 3 [IconButton]s. If [ThemeData.useMaterial3] + /// is set to true, [style] is preferred for icon button customization, and any + /// parameters defined in [style] will override the same parameters in [IconButton]. + /// + /// Null by default. + final ButtonStyle? style; + + @override + PopupMenuButtonState createState() => PopupMenuButtonState(); +} + +/// The [State] for a [PopupMenuButton]. +/// +/// See [showButtonMenu] for a way to programmatically open the popup menu +/// of your button state. +class PopupMenuButtonState extends State> { + /// A method to show a popup menu with the items supplied to + /// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton]. + /// + /// By default, it is called when the user taps the button and [PopupMenuButton.enabled] + /// is set to `true`. Moreover, you can open the button by calling the method manually. + /// + /// You would access your [PopupMenuButtonState] using a [GlobalKey] and + /// show the menu of the button with `globalKey.currentState.showButtonMenu`. + void showButtonMenu() { + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final RenderBox button = context.findRenderObject()! as RenderBox; + final RenderBox overlay = + Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox; + final PopupMenuPosition popupMenuPosition = + widget.position ?? popupMenuTheme.position ?? PopupMenuPosition.over; + late Offset offset; + switch (popupMenuPosition) { + case PopupMenuPosition.over: + offset = widget.offset; + case PopupMenuPosition.under: + offset = Offset(0.0, button.size.height) + widget.offset; + if (widget.child == null) { + // Remove the padding of the icon button. + offset -= Offset(0.0, widget.padding.vertical / 2); + } + } + final RelativeRect position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(offset, ancestor: overlay), + button.localToGlobal( + button.size.bottomRight(Offset.zero) + offset, + ancestor: overlay, + ), + ), + Offset.zero & overlay.size, + ); + final List> items = widget.itemBuilder(context); + // Only show the menu if there is something to show + if (items.isNotEmpty) { + var popUpAnimationStyle = widget.popUpAnimationStyle; + if (popUpAnimationStyle == null && + Theme.of(context).platform == TargetPlatform.iOS) { + popUpAnimationStyle = AnimationStyle( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 300), + ); + } + widget.onOpened?.call(); + showMenu( + context: context, + elevation: widget.elevation ?? popupMenuTheme.elevation, + shadowColor: widget.shadowColor ?? popupMenuTheme.shadowColor, + surfaceTintColor: + widget.surfaceTintColor ?? popupMenuTheme.surfaceTintColor, + items: items, + initialValue: widget.initialValue, + position: position, + shape: widget.shape ?? popupMenuTheme.shape, + color: widget.color ?? popupMenuTheme.color, + constraints: widget.constraints, + clipBehavior: widget.clipBehavior, + useRootNavigator: widget.useRootNavigator, + popUpAnimationStyle: popUpAnimationStyle, + routeSettings: widget.routeSettings, + ).then((T? newValue) { + if (!mounted) { + return null; + } + if (newValue == null) { + widget.onCanceled?.call(); + return null; + } + widget.onSelected?.call(newValue); + }); + } + } + + @override + Widget build(BuildContext context) { + final IconThemeData iconTheme = IconTheme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final bool enableFeedback = widget.enableFeedback ?? + PopupMenuTheme.of(context).enableFeedback ?? + true; + + assert(debugCheckHasMaterialLocalizations(context)); + + if (widget.child != null) { + return GestureDetector( + onTap: widget.enabled ? showButtonMenu : null, + child: widget.child, + ); + } + + return IconButton( + icon: widget.icon ?? Icon(Icons.adaptive.more), + padding: widget.padding, + splashRadius: widget.splashRadius, + iconSize: widget.iconSize ?? popupMenuTheme.iconSize ?? iconTheme.size, + color: widget.iconColor ?? popupMenuTheme.iconColor ?? iconTheme.color, + tooltip: + widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, + onPressed: widget.enabled ? showButtonMenu : null, + enableFeedback: enableFeedback, + style: widget.style, + ); + } +} + +class _PopupMenuDefaultsM2 extends PopupMenuThemeData { + _PopupMenuDefaultsM2(this.context) : super(elevation: 8.0); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final TextTheme _textTheme = _theme.textTheme; + + @override + TextStyle? get textStyle => _textTheme.titleMedium; + + static EdgeInsets menuHorizontalPadding = + const EdgeInsets.symmetric(horizontal: 16.0); +} + +// BEGIN GENERATED TOKEN PROPERTIES - PopupMenu + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +class _PopupMenuDefaultsM3 extends PopupMenuThemeData { + _PopupMenuDefaultsM3(this.context) : super(elevation: 3.0); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + @override + WidgetStateProperty? get labelTextStyle { + return WidgetStateProperty.resolveWith((Set states) { + final TextStyle style = _textTheme.labelLarge!; + if (states.contains(WidgetState.disabled)) { + return style.apply(color: _colors.onSurface.withOpacity(0.38)); + } + return style.apply(color: _colors.onSurface); + }); + } + + @override + Color? get color => _colors.surfaceContainer; + + @override + Color? get shadowColor => _colors.shadow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + ShapeBorder? get shape => const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0)), + ); + + // TODO(tahatesser): This is taken from https://m3.material.io/components/menus/specs + // Update this when the token is available. + static EdgeInsets menuHorizontalPadding = + const EdgeInsets.symmetric(horizontal: 12.0); +} +// END GENERATED TOKEN PROPERTIES - PopupMenu diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart index 81dd8ed9cf..ae7fe37982 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart @@ -121,6 +121,8 @@ extension ProviderTypePBExtension on ProviderTypePB { return ProviderTypePB.Google; case 'discord': return ProviderTypePB.Discord; + case 'apple': + return ProviderTypePB.Apple; default: throw UnimplementedError(); } diff --git a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart index 52a103899c..6fda156567 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -1,16 +1,16 @@ -import 'package:flutter/foundation.dart'; - import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/appflowy_cloud_task.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -208,6 +208,8 @@ class SignInBloc extends Bloc { } SignInState _stateFromCode(FlowyError error) { + Log.error('SignInState _stateFromCode: ${error.msg}'); + switch (error.code) { case ErrorCode.EmailFormatInvalid: return state.copyWith( diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart index 3ecacf0961..a6375e0c4e 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart @@ -1,11 +1,12 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/presentation/router.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flutter/material.dart'; +import 'package:toastification/toastification.dart'; void handleOpenWorkspaceError(BuildContext context, FlowyError error) { Log.error(error); @@ -15,24 +16,24 @@ void handleOpenWorkspaceError(BuildContext context, FlowyError error) { getIt().pushWorkspaceErrorScreen(context, userFolder, error); break; case ErrorCode.InvalidEncryptSecret: - showSnapBar( + case ErrorCode.HttpError: + showToastNotification( context, - error.msg, + message: error.msg, + type: ToastificationType.error, ); break; - case ErrorCode.HttpError: - showSnapBar( - context, - error.msg, - ); default: - showSnapBar( + showToastNotification( context, - error.msg, - onClosed: () { - getIt().signOut(); - runAppFlowy(); - }, + message: error.msg, + type: ToastificationType.error, + callbacks: ToastificationCallbacks( + onDismissed: (_) { + getIt().signOut(); + runAppFlowy(); + }, + ), ); } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart index 89b782d231..7c023befea 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -10,6 +8,7 @@ import 'package:appflowy/user/presentation/widgets/widgets.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_bloc/flutter_bloc.dart'; class DesktopSignInScreen extends StatelessWidget { @@ -45,14 +44,14 @@ class DesktopSignInScreen extends StatelessWidget { if (isAuthEnabled) ...[ const _OrDivider(), - const VSpace(10), + const VSpace(20), const ThirdPartySignInButtons(), ], const VSpace(20), // anonymous sign in const SignInAnonymousButtonV2(), - const VSpace(10), + const VSpace(16), // sign in agreement const SignInAgreement(), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart index ce0e959aca..5526cb6c70 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'dart:io'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; @@ -8,6 +8,7 @@ import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -23,26 +24,26 @@ class MobileSignInScreen extends StatelessWidget { return BlocBuilder( builder: (context, state) { return Scaffold( + resizeToAvoidBottomInset: false, body: Padding( - padding: const EdgeInsets.symmetric(vertical: 50, horizontal: 40), + padding: const EdgeInsets.symmetric(vertical: 38, horizontal: 40), child: Column( children: [ const Spacer(flex: 4), _buildLogo(), - const VSpace(spacing * 2), - _buildWelcomeText(), + const VSpace(spacing), _buildAppNameText(colorScheme), const VSpace(spacing * 2), const SignInWithMagicLinkButtons(), const VSpace(spacing), if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme), - const VSpace(spacing), - const SignInAnonymousButtonV2(), - const VSpace(spacing), + const VSpace(spacing * 1.5), const SignInAgreement(), const VSpace(spacing), - _buildSettingsButton(context), if (!isAuthEnabled) const Spacer(flex: 2), + const Spacer(flex: 2), + const Spacer(), + Expanded(child: _buildSettingsButton(context)), ], ), ), @@ -51,19 +52,10 @@ class MobileSignInScreen extends StatelessWidget { ); } - Widget _buildWelcomeText() { - return FlowyText( - LocaleKeys.welcomeTo.tr(), - textAlign: TextAlign.center, - fontSize: 32, - fontWeight: FontWeight.w700, - ); - } - Widget _buildLogo() { return const FlowySvg( FlowySvgs.flowy_logo_xl, - size: Size.square(64), + size: Size.square(56), blendMode: null, ); } @@ -72,7 +64,7 @@ class MobileSignInScreen extends StatelessWidget { return FlowyText( LocaleKeys.appName.tr(), textAlign: TextAlign.center, - fontSize: 32, + fontSize: 28, color: const Color(0xFF00BCF0), fontWeight: FontWeight.w700, ); @@ -89,6 +81,7 @@ class MobileSignInScreen extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8), child: FlowyText( LocaleKeys.signIn_or.tr(), + fontSize: 12, color: colorScheme.onSecondary, ), ), @@ -96,23 +89,45 @@ class MobileSignInScreen extends StatelessWidget { ], ), const VSpace(16), - const ThirdPartySignInButtons(), + // expand third-party sign in buttons on Android by default. + // on iOS, the github and discord buttons are collapsed by default. + ThirdPartySignInButtons( + expanded: Platform.isAndroid, + ), ], ); } Widget _buildSettingsButton(BuildContext context) { - return FlowyButton( - text: FlowyText( - LocaleKeys.signIn_settings.tr(), - textAlign: TextAlign.center, - fontSize: 12.0, - fontWeight: FontWeight.w500, - decoration: TextDecoration.underline, - ), - onTap: () { - context.push(MobileLaunchSettingsPage.routeName); - }, + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + 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); + }, + ), + const HSpace(24), + SignInAnonymousButtonV2( + child: FlowyText( + LocaleKeys.signIn_anonymous.tr(), + textAlign: TextAlign.center, + fontSize: 12.0, + // fontWeight: FontWeight.w500, + color: Colors.grey, + decoration: TextDecoration.underline, + ), + ), + ], ); } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart index 731faed73e..8935001624 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/router.dart'; @@ -7,6 +5,7 @@ import 'package:appflowy/user/presentation/screens/sign_in_screen/desktop_sign_i import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../helpers/helpers.dart'; @@ -21,16 +20,7 @@ class SignInScreen extends StatelessWidget { return BlocProvider( create: (context) => getIt(), child: BlocConsumer( - listener: (context, state) { - final successOrFail = state.successOrFail; - if (successOrFail != null) { - handleUserProfileResult( - successOrFail, - context, - getIt(), - ); - } - }, + listener: _showSignInError, builder: (context, state) { final isLoading = context.read().state.isSubmitting; if (PlatformExtension.isMobile) { @@ -43,4 +33,15 @@ class SignInScreen extends StatelessWidget { ), ); } + + void _showSignInError(BuildContext context, SignInState state) { + final successOrFail = state.successOrFail; + if (successOrFail != null) { + handleUserProfileResult( + successOrFail, + context, + getIt(), + ); + } + } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart index e32a9e2908..d95f3ecbcf 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart @@ -34,11 +34,19 @@ class _SignInWithMagicLinkButtonsState crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - height: 48.0, + height: PlatformExtension.isMobile ? 38.0 : 48.0, child: FlowyTextField( autoFocus: false, controller: controller, + borderRadius: BorderRadius.circular(4.0), hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(), + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14.0, + color: Theme.of(context).hintColor, + ), + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14.0, + ), keyboardType: TextInputType.emailAddress, onSubmitted: (_) => _sendMagicLink(context, controller.text), ), @@ -88,14 +96,14 @@ class _ConfirmButton extends StatelessWidget { if (PlatformExtension.isMobile) { return ElevatedButton( style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 56), + minimumSize: const Size(double.infinity, 32), + maximumSize: const Size(double.infinity, 38), ), onPressed: onTap, child: FlowyText( name, fontSize: 14, color: Theme.of(context).colorScheme.onPrimary, - fontWeight: FontWeight.w500, ), ); } else { @@ -108,6 +116,7 @@ class _ConfirmButton extends StatelessWidget { text: FlowyText.medium( name, textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.onPrimary, ), radius: Corners.s6Border, ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart index 7976e63667..7351871b6a 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart @@ -21,7 +21,11 @@ class SignInAgreement extends StatelessWidget { ), TextSpan( text: '${LocaleKeys.web_termOfUse.tr()} ', - style: const TextStyle(color: Colors.blue, fontSize: 12), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + decoration: TextDecoration.underline, + ), mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() ..onTap = () => afLaunchUrlString('https://appflowy.io/terms'), @@ -32,7 +36,11 @@ class SignInAgreement extends StatelessWidget { ), TextSpan( text: LocaleKeys.web_privacyPolicy.tr(), - style: const TextStyle(color: Colors.blue, fontSize: 12), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + decoration: TextDecoration.underline, + ), mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() ..onTap = () => afLaunchUrlString('https://appflowy.io/privacy'), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart index e8d6bac536..e12582b9cd 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart @@ -89,8 +89,11 @@ class SignInAnonymousButton extends StatelessWidget { class SignInAnonymousButtonV2 extends StatelessWidget { const SignInAnonymousButtonV2({ super.key, + this.child, }); + final Widget? child; + @override Widget build(BuildContext context) { return BlocBuilder( @@ -126,11 +129,12 @@ class SignInAnonymousButtonV2 extends StatelessWidget { cursor: SystemMouseCursors.click, child: GestureDetector( onTap: onTap, - child: FlowyText( - text, - color: Colors.blue, - fontSize: 12, - ), + child: child ?? + FlowyText( + text, + color: Colors.blue, + fontSize: 12, + ), ), ); }, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart index e25fcf3a35..58509aee5a 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart @@ -20,7 +20,7 @@ class MobileSignInOrLogoutButton extends StatelessWidget { return GestureDetector( onTap: onPressed, child: Container( - height: 48, + height: 38, decoration: BoxDecoration( borderRadius: const BorderRadius.all( Radius.circular(4), @@ -54,7 +54,7 @@ class MobileSignInOrLogoutButton extends StatelessWidget { FlowyText( labelText, fontSize: 14.0, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w400, ), ], ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart new file mode 100644 index 0000000000..47c6ea515f --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart @@ -0,0 +1,137 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/gesture.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +enum ThirdPartySignInButtonType { + apple, + google, + github, + discord, + anonymous; + + FlowySvgData get icon { + switch (this) { + case ThirdPartySignInButtonType.apple: + return FlowySvgs.m_apple_icon_xl; + case ThirdPartySignInButtonType.google: + return FlowySvgs.m_google_icon_xl; + case ThirdPartySignInButtonType.github: + return FlowySvgs.m_github_icon_xl; + case ThirdPartySignInButtonType.discord: + return FlowySvgs.m_discord_icon_xl; + case ThirdPartySignInButtonType.anonymous: + return FlowySvgs.m_discord_icon_xl; + } + } + + String get labelText { + switch (this) { + case ThirdPartySignInButtonType.apple: + return LocaleKeys.signIn_signInWithApple.tr(); + case ThirdPartySignInButtonType.google: + return LocaleKeys.signIn_signInWithGoogle.tr(); + case ThirdPartySignInButtonType.github: + return LocaleKeys.signIn_signInWithGithub.tr(); + case ThirdPartySignInButtonType.discord: + return LocaleKeys.signIn_signInWithDiscord.tr(); + case ThirdPartySignInButtonType.anonymous: + return 'Anonymous session'; + } + } + + // https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple + Color backgroundColor(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + switch (this) { + case ThirdPartySignInButtonType.apple: + return isDarkMode ? Colors.white : Colors.black; + case ThirdPartySignInButtonType.google: + case ThirdPartySignInButtonType.github: + case ThirdPartySignInButtonType.discord: + case ThirdPartySignInButtonType.anonymous: + return isDarkMode ? Colors.black : Colors.grey.shade100; + } + } + + Color textColor(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + switch (this) { + case ThirdPartySignInButtonType.apple: + return isDarkMode ? Colors.black : Colors.white; + case ThirdPartySignInButtonType.google: + case ThirdPartySignInButtonType.github: + case ThirdPartySignInButtonType.discord: + case ThirdPartySignInButtonType.anonymous: + return isDarkMode ? Colors.white : Colors.black; + } + } + + BlendMode? get blendMode { + switch (this) { + case ThirdPartySignInButtonType.apple: + case ThirdPartySignInButtonType.github: + return BlendMode.srcIn; + default: + return null; + } + } +} + +class MobileThirdPartySignInButton extends StatelessWidget { + const MobileThirdPartySignInButton({ + super.key, + this.height = 38, + this.fontSize = 14.0, + required this.onPressed, + required this.type, + }); + + final VoidCallback onPressed; + final double height; + final double fontSize; + final ThirdPartySignInButtonType type; + + @override + Widget build(BuildContext context) { + 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), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart index b7fe53a8ea..6972eb2105 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart @@ -1,126 +1,167 @@ -import 'package:flutter/material.dart'; +import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/presentation.dart'; -import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy_editor/appflowy_editor.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'; -class ThirdPartySignInButtons extends StatelessWidget { +import 'third_party_sign_in_button.dart'; + +@visibleForTesting +const Key signInWithGoogleButtonKey = Key('signInWithGoogleButton'); + +class ThirdPartySignInButtons extends StatefulWidget { /// Used in DesktopSignInScreen, MobileSignInScreen and SettingThirdPartyLogin - const ThirdPartySignInButtons({super.key}); - - @override - Widget build(BuildContext context) { - // Get themeMode from AppearanceSettingsCubit - // When user changes themeMode, it changes the state in AppearanceSettingsCubit, but the themeMode for the MaterialApp won't change, it only got updated(get value from AppearanceSettingsCubit) when user open the app again. Thus, we should get themeMode from AppearanceSettingsCubit rather than MediaQuery. - - final themeModeFromCubit = - context.watch().state.themeMode; - - final isDarkMode = themeModeFromCubit == ThemeMode.system - ? MediaQuery.of(context).platformBrightness == Brightness.dark - : themeModeFromCubit == ThemeMode.dark; - - return BlocBuilder( - builder: (context, state) { - final (googleText, githubText, discordText) = switch (state.loginType) { - LoginType.signIn => ( - LocaleKeys.signIn_signInWithGoogle.tr(), - LocaleKeys.signIn_signInWithGithub.tr(), - LocaleKeys.signIn_signInWithDiscord.tr() - ), - LoginType.signUp => ( - LocaleKeys.signIn_signUpWithGoogle.tr(), - LocaleKeys.signIn_signUpWithGithub.tr(), - LocaleKeys.signIn_signUpWithDiscord.tr() - ), - }; - return Column( - children: [ - _ThirdPartySignInButton( - key: const Key('signInWithGoogleButton'), - icon: FlowySvgs.google_mark_xl, - labelText: googleText, - onPressed: () { - _signInWithGoogle(context); - }, - ), - const VSpace(8), - _ThirdPartySignInButton( - icon: isDarkMode - ? FlowySvgs.github_mark_white_xl - : FlowySvgs.github_mark_black_xl, - labelText: githubText, - onPressed: () { - _signInWithGithub(context); - }, - ), - const VSpace(8), - _ThirdPartySignInButton( - icon: isDarkMode - ? FlowySvgs.discord_mark_white_xl - : FlowySvgs.discord_mark_blurple_xl, - labelText: discordText, - onPressed: () { - _signInWithDiscord(context); - }, - ), - ], - ); - }, - ); - } -} - -class _ThirdPartySignInButton extends StatelessWidget { - /// Build button based on current Platform(mobile or desktop). - const _ThirdPartySignInButton({ + const ThirdPartySignInButtons({ super.key, - required this.icon, - required this.labelText, - required this.onPressed, + this.expanded = false, }); - final FlowySvgData icon; - final String labelText; + final bool expanded; - final VoidCallback onPressed; + @override + State createState() => + _ThirdPartySignInButtonsState(); +} + +class _ThirdPartySignInButtonsState extends State { + bool expanded = false; + + @override + void initState() { + super.initState(); + + expanded = widget.expanded; + } @override Widget build(BuildContext context) { - if (PlatformExtension.isMobile) { - return MobileSignInOrLogoutButton( - icon: icon, - labelText: labelText, - onPressed: onPressed, + if (PlatformExtension.isDesktopOrWeb) { + const padding = 16.0; + return Column( + children: [ + _DesktopSignInButton( + key: signInWithGoogleButtonKey, + type: ThirdPartySignInButtonType.google, + onPressed: () { + _signInWithGoogle(context); + }, + ), + const VSpace(padding), + _DesktopSignInButton( + type: ThirdPartySignInButtonType.github, + onPressed: () { + _signInWithGithub(context); + }, + ), + const VSpace(padding), + _DesktopSignInButton( + type: ThirdPartySignInButtonType.discord, + onPressed: () { + _signInWithDiscord(context); + }, + ), + ], ); } else { - return _DesktopSignInButton( - icon: icon, - labelText: labelText, - onPressed: onPressed, + const padding = 8.0; + return BlocBuilder( + builder: (context, state) { + return Column( + children: [ + if (Platform.isIOS) ...[ + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.apple, + onPressed: () { + _signInWithApple(context); + }, + ), + const VSpace(padding), + ], + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.google, + onPressed: () { + _signInWithGoogle(context); + }, + ), + if (expanded) ...[ + const VSpace(padding), + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.github, + onPressed: () { + _signInWithGithub(context); + }, + ), + const VSpace(padding), + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.discord, + onPressed: () { + _signInWithDiscord(context); + }, + ), + ], + if (!expanded) ...[ + const VSpace(padding * 2), + GestureDetector( + onTap: () { + setState(() { + expanded = !expanded; + }); + }, + child: FlowyText( + LocaleKeys.signIn_continueAnotherWay.tr(), + color: Theme.of(context).colorScheme.onSurface, + decoration: TextDecoration.underline, + fontSize: 14, + ), + ), + ], + ], + ); + }, ); } } + + void _signInWithApple(BuildContext context) { + context.read().add( + const SignInEvent.signedInWithOAuth('apple'), + ); + } + + void _signInWithGoogle(BuildContext context) { + context.read().add( + const SignInEvent.signedInWithOAuth('google'), + ); + } + + void _signInWithGithub(BuildContext context) { + context + .read() + .add(const SignInEvent.signedInWithOAuth('github')); + } + + void _signInWithDiscord(BuildContext context) { + context + .read() + .add(const SignInEvent.signedInWithOAuth('discord')); + } } class _DesktopSignInButton extends StatelessWidget { const _DesktopSignInButton({ - required this.icon, - required this.labelText, + super.key, + required this.type, required this.onPressed, }); - final FlowySvgData icon; - final String labelText; - + final ThirdPartySignInButtonType type; final VoidCallback onPressed; @override @@ -139,8 +180,8 @@ class _DesktopSignInButton extends StatelessWidget { // Some icons are not square, so we just use a fixed width here. width: 24, child: FlowySvg( - icon, - blendMode: null, + type.icon, + blendMode: type.blendMode, ), ), ), @@ -148,7 +189,7 @@ class _DesktopSignInButton extends StatelessWidget { padding: const EdgeInsets.only(left: 8), alignment: Alignment.centerLeft, child: FlowyText( - labelText, + type.labelText, fontSize: 14, ), ), @@ -177,19 +218,3 @@ class _DesktopSignInButton extends StatelessWidget { ); } } - -void _signInWithGoogle(BuildContext context) { - context.read().add( - const SignInEvent.signedInWithOAuth('google'), - ); -} - -void _signInWithGithub(BuildContext context) { - context.read().add(const SignInEvent.signedInWithOAuth('github')); -} - -void _signInWithDiscord(BuildContext context) { - context - .read() - .add(const SignInEvent.signedInWithOAuth('discord')); -} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart index c1c2bfecf9..18e260a472 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart @@ -1,7 +1,7 @@ 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.dart'; // export 'switch_sign_in_sign_up_button.dart'; export 'third_party_sign_in_buttons.dart'; export 'sign_in_agreement.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart index feef136206..41ef345755 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -559,7 +559,10 @@ class SpaceBloc extends Bloc { return true; } - final viewId = fixedUuid(user.id.toInt(), UuidType.publicSpace); + final viewId = fixedUuid( + user.id.toInt() + (_workspaceId?.hashCode ?? 0), + UuidType.publicSpace, + ); final publicSpace = await _createSpace( name: 'Shared', icon: builtInSpaceIcons.first, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart index e7ca76e533..288f42c064 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart @@ -3,6 +3,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart'; @@ -31,7 +32,6 @@ class SidebarSpace extends StatelessWidget { @override Widget build(BuildContext context) { - // const sectionPadding = 16.0; return ValueListenableBuilder( valueListenable: getIt().notifier, builder: (context, value, child) { @@ -89,6 +89,8 @@ class _SpaceState extends State<_Space> { @override Widget build(BuildContext context) { + final currentWorkspace = + context.watch().state.currentWorkspace; return BlocBuilder( builder: (context, state) { if (state.spaces.isEmpty) { @@ -115,7 +117,12 @@ class _SpaceState extends State<_Space> { onEnter: (_) => isHovered.value = true, onExit: (_) => isHovered.value = false, child: SpacePages( - key: ValueKey(currentSpace.id), + key: ValueKey( + Object.hashAll([ + currentWorkspace?.workspaceId ?? '', + currentSpace.id, + ]), + ), isExpandedNotifier: isExpandedNotifier, space: currentSpace, isHovered: isHovered, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart index bc48db3e9f..a356e3fd50 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart @@ -107,7 +107,6 @@ class SingleSettingAction extends StatelessWidget { radius: Corners.s8Border, hoverColor: hoverColor(context), fontColor: fontColor(context), - textColor: fontColor(context), fontHoverColor: fontHoverColor(context), borderColor: borderColor(context), fontSize: 12, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart index cdeec95e88..5047b73e63 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart @@ -447,7 +447,7 @@ class _IncludeTimePickerState extends State<_IncludeTimePicker> { LocaleKeys.button_confirm.tr(), constraints: const BoxConstraints.tightFor(height: 42), mainAxisAlignment: MainAxisAlignment.center, - textColor: Theme.of(context).colorScheme.onPrimary, + fontColor: Theme.of(context).colorScheme.onPrimary, fillColor: Theme.of(context).primaryColor, onPressed: () { if (isStartDay) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 80beca1200..76a9421968 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -303,14 +303,18 @@ void showToastNotification( required String message, String? description, ToastificationType type = ToastificationType.success, + ToastificationCallbacks? callbacks, + double bottomPadding = 100, }) { if (PlatformExtension.isMobile) { toastification.showCustom( alignment: Alignment.bottomCenter, autoCloseDuration: const Duration(milliseconds: 3000), + callbacks: callbacks ?? const ToastificationCallbacks(), builder: (_, __) => _MToast( message: message, type: type, + bottomPadding: bottomPadding, ), ); return; @@ -346,10 +350,12 @@ class _MToast extends StatelessWidget { const _MToast({ required this.message, this.type = ToastificationType.success, + this.bottomPadding = 100, }); final String message; final ToastificationType type; + final double bottomPadding; @override Widget build(BuildContext context) { @@ -362,7 +368,7 @@ class _MToast extends StatelessWidget { ); return Container( alignment: Alignment.bottomCenter, - padding: const EdgeInsets.only(bottom: 100, left: 16, right: 16), + padding: EdgeInsets.only(bottom: bottomPadding, left: 16, right: 16), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 13.0), decoration: BoxDecoration( diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart index 3b9ba09430..742fb9baff 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart @@ -297,7 +297,6 @@ class FlowyTextButton extends StatelessWidget { this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), this.hoverColor, this.fillColor, - this.textColor, this.heading, this.radius, this.mainAxisAlignment = MainAxisAlignment.start, @@ -353,7 +352,6 @@ class FlowyTextButton extends StatelessWidget { final Widget? heading; final Color? hoverColor; final Color? fillColor; - final Color? textColor; final BorderRadius? radius; final MainAxisAlignment mainAxisAlignment; final String? tooltip; @@ -376,9 +374,10 @@ class FlowyTextButton extends StatelessWidget { children.add(FlowyText( text, overflow: overflow, - color: textColor, + color: fontColor ?? Theme.of(context).colorScheme.onPrimary, textAlign: TextAlign.center, lineHeight: lineHeight, + fontSize: fontSize, )); Widget child = Row( diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart index 32cc2fa1fc..d0780b1c89 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart @@ -38,6 +38,7 @@ class FlowyTextField extends StatefulWidget { final bool isDense; final bool readOnly; final Color? enableBorderColor; + final BorderRadius? borderRadius; const FlowyTextField({ super.key, @@ -74,6 +75,7 @@ class FlowyTextField extends StatefulWidget { this.isDense = true, this.readOnly = false, this.enableBorderColor, + this.borderRadius, }); @override @@ -180,7 +182,7 @@ class FlowyTextFieldState extends State { (widget.maxLines == null || widget.maxLines! > 1) ? 12 : 0, ), enabledBorder: OutlineInputBorder( - borderRadius: Corners.s8Border, + borderRadius: widget.borderRadius ?? Corners.s8Border, borderSide: BorderSide( color: widget.enableBorderColor ?? Theme.of(context).colorScheme.outline, @@ -202,7 +204,7 @@ class FlowyTextFieldState extends State { suffixText: widget.showCounter ? _suffixText() : "", counterText: "", focusedBorder: OutlineInputBorder( - borderRadius: Corners.s8Border, + borderRadius: widget.borderRadius ?? Corners.s8Border, borderSide: BorderSide( color: widget.readOnly ? widget.enableBorderColor ?? @@ -214,13 +216,13 @@ class FlowyTextFieldState extends State { borderSide: BorderSide( color: Theme.of(context).colorScheme.error, ), - borderRadius: Corners.s8Border, + borderRadius: widget.borderRadius ?? Corners.s8Border, ), focusedErrorBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.error, ), - borderRadius: Corners.s8Border, + borderRadius: widget.borderRadius ?? Corners.s8Border, ), prefixIcon: widget.prefixIcon, suffixIcon: widget.suffixIcon, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart index a49b70c4d0..0fa181a1dd 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart @@ -51,11 +51,9 @@ class RoundedTextButton extends StatelessWidget { radius: borderRadius ?? Corners.s6Border, fontColor: textColor ?? Theme.of(context).colorScheme.onPrimary, fillColor: fillColor ?? Theme.of(context).colorScheme.primary, - textColor: textColor, hoverColor: hoverColor ?? Theme.of(context).colorScheme.primaryContainer, padding: padding, - ), ), ); diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 267fb80c21..04e83c0bcc 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -53,8 +53,8 @@ packages: dependency: "direct main" description: path: "." - ref: "8e17d14" - resolved-ref: "8e17d1447eea0b57ff92e31dbe88796ce759fb37" + ref: a64a516 + resolved-ref: a64a5165e79bd2424e594b793843a7158e7d4fb4 url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "3.2.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 278b5bd187..a02263011a 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -190,7 +190,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "8e17d14" + ref: "a64a516" appflowy_editor_plugins: git: diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index 2ce43d14f2..4b8421d9c3 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -172,7 +172,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "bincode", @@ -192,7 +192,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "bytes", @@ -291,6 +291,17 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -826,7 +837,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "again", "anyhow", @@ -877,7 +888,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "collab-entity", "collab-rt-entity", @@ -890,7 +901,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "futures-channel", "futures-util", @@ -964,7 +975,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "arc-swap", @@ -989,7 +1000,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "async-trait", @@ -1018,7 +1029,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "arc-swap", @@ -1038,7 +1049,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "bytes", @@ -1057,7 +1068,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "arc-swap", @@ -1100,7 +1111,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "async-stream", @@ -1138,7 +1149,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "bincode", @@ -1163,7 +1174,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "async-trait", @@ -1180,7 +1191,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "collab", @@ -1209,6 +1220,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.14.1" @@ -1426,7 +1446,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa 1.0.6", - "phf 0.8.0", + "phf 0.11.2", "smallvec", ] @@ -1551,7 +1571,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "app-error", @@ -1864,6 +1884,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "faccess" version = "0.2.4" @@ -3076,7 +3117,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "futures-util", @@ -3093,7 +3134,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "app-error", @@ -3525,7 +3566,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "bytes", @@ -4615,6 +4656,12 @@ dependencies = [ "system-deps 6.1.1", ] +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + [[package]] name = "parking_lot" version = "0.11.2" @@ -6122,7 +6169,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "app-error", @@ -8400,12 +8447,14 @@ dependencies = [ [[package]] name = "yrs" -version = "0.19.2" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8ca5126331b9a5ef5bb10f3f1c3d01b05f298d348c66f8fb15497d83ee73176" +checksum = "a8fc56b25e3aaf4b81a73f2a9a68ceae1e02d9005552e24058cfb9f96db73f33" dependencies = [ "arc-swap", - "atomic_refcell", + "async-lock", + "async-trait", + "dashmap 6.0.1", "fastrand", "serde", "serde_json", diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index c8488232b5..32cf0aa188 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -53,7 +53,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ae3833ea91c238a66ca7bda63763d1d654740fb4" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "5badffc97b17984d5f25a178c0c5a477338039c4" } [dependencies] serde_json.workspace = true @@ -116,13 +116,13 @@ custom-protocol = ["tauri/custom-protocol"] # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } # Working directory: frontend # To update the commit ID, run: diff --git a/frontend/appflowy_web_app/deploy/server.ts b/frontend/appflowy_web_app/deploy/server.ts index 325268a6b2..43b696139f 100644 --- a/frontend/appflowy_web_app/deploy/server.ts +++ b/frontend/appflowy_web_app/deploy/server.ts @@ -68,7 +68,7 @@ const createServer = async (req: Request) => { logger.info(`Request URL: ${hostname}${reqUrl.pathname}`); - if (['/after-payment', '/login'].includes(reqUrl.pathname)) { + if (['/after-payment', '/login', '/as-template'].includes(reqUrl.pathname)) { timer(); const htmlData = fs.readFileSync(indexPath, 'utf8'); const $ = load(htmlData); diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index f397b04388..4e0c87a259 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -73,6 +73,7 @@ "react-datepicker": "^4.23.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.13", + "react-hook-form": "^7.52.2", "react-hot-toast": "^2.4.1", "react-i18next": "^14.1.0", "react-katex": "^3.0.1", @@ -137,6 +138,7 @@ "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.13", + "axios-mock-adapter": "^2.0.0", "babel-jest": "^29.6.2", "chalk": "^4.1.2", "cheerio": "1.0.0-rc.12", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index 432a5676f0..321468236c 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -152,6 +152,9 @@ dependencies: react-error-boundary: specifier: ^4.0.13 version: 4.0.13(react@18.2.0) + react-hook-form: + specifier: ^7.52.2 + version: 7.52.2(react@18.2.0) react-hot-toast: specifier: ^2.4.1 version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0) @@ -340,6 +343,9 @@ devDependencies: autoprefixer: specifier: ^10.4.13 version: 10.4.13(postcss@8.4.21) + axios-mock-adapter: + specifier: ^2.0.0 + version: 2.0.0(axios@1.7.2) babel-jest: specifier: ^29.6.2 version: 29.6.2(@babel/core@7.24.3) @@ -4945,6 +4951,16 @@ packages: /aws4@1.12.0: resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} + /axios-mock-adapter@2.0.0(axios@1.7.2): + resolution: {integrity: sha512-D/K0J5Zm6KvaMTnsWrBQZWLzKN9GxUFZEa0mx2qeEHXDeTugCoplWehy8y36dj5vuSjhe1u/Dol8cZ8lzzmDew==} + peerDependencies: + axios: '>= 0.17.0' + dependencies: + axios: 1.7.2 + fast-deep-equal: 3.1.3 + is-buffer: 2.0.5 + dev: true + /axios@1.7.2: resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==} dependencies: @@ -4953,7 +4969,6 @@ packages: proxy-from-env: 1.1.0 transitivePeerDependencies: - debug - dev: false /b4a@1.6.6: resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} @@ -6709,7 +6724,6 @@ packages: peerDependenciesMeta: debug: optional: true - dev: false /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -7289,6 +7303,11 @@ packages: resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} dev: false + /is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + dev: true + /is-callable@1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} @@ -9258,7 +9277,6 @@ packages: /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: false /psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} @@ -9512,6 +9530,15 @@ packages: /react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + /react-hook-form@7.52.2(react@18.2.0): + resolution: {integrity: sha512-pqfPEbERnxxiNMPd0bzmt1tuaPcVccywFDpyk2uV5xCIBphHV5T8SVnX9/o3kplPE1zzKt77+YIoq+EMwJp56A==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + dependencies: + react: 18.2.0 + dev: false + /react-hot-toast@2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==} engines: {node: '>=10'} diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.lock b/frontend/appflowy_web_app/src-tauri/Cargo.lock index cbf0e3167b..f176748d1e 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.lock +++ b/frontend/appflowy_web_app/src-tauri/Cargo.lock @@ -163,7 +163,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "bincode", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "bytes", @@ -301,6 +301,17 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -800,7 +811,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "again", "anyhow", @@ -851,7 +862,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "collab-entity", "collab-rt-entity", @@ -864,7 +875,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "futures-channel", "futures-util", @@ -947,7 +958,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "arc-swap", @@ -972,7 +983,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "async-trait", @@ -1001,7 +1012,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "arc-swap", @@ -1021,7 +1032,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "bytes", @@ -1040,7 +1051,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "arc-swap", @@ -1083,7 +1094,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "async-stream", @@ -1121,7 +1132,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "bincode", @@ -1146,7 +1157,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "async-trait", @@ -1163,7 +1174,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=97163517303bb0ed468992a4511aac6eb8775a4d#97163517303bb0ed468992a4511aac6eb8775a4d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=49cf2206d7494bb3006402b807e7f171905213e3#49cf2206d7494bb3006402b807e7f171905213e3" dependencies = [ "anyhow", "collab", @@ -1192,6 +1203,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.14.1" @@ -1416,7 +1436,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa 1.0.10", - "phf 0.8.0", + "phf 0.11.2", "smallvec", ] @@ -1541,7 +1561,7 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "app-error", @@ -1894,6 +1914,27 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "faccess" version = "0.2.4" @@ -3143,7 +3184,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "futures-util", @@ -3160,7 +3201,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "app-error", @@ -3597,7 +3638,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "bytes", @@ -4677,6 +4718,12 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + [[package]] name = "parking_lot" version = "0.11.2" @@ -6186,7 +6233,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ae3833ea91c238a66ca7bda63763d1d654740fb4#ae3833ea91c238a66ca7bda63763d1d654740fb4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=5badffc97b17984d5f25a178c0c5a477338039c4#5badffc97b17984d5f25a178c0c5a477338039c4" dependencies = [ "anyhow", "app-error", @@ -8658,12 +8705,14 @@ dependencies = [ [[package]] name = "yrs" -version = "0.19.2" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8ca5126331b9a5ef5bb10f3f1c3d01b05f298d348c66f8fb15497d83ee73176" +checksum = "a8fc56b25e3aaf4b81a73f2a9a68ceae1e02d9005552e24058cfb9f96db73f33" dependencies = [ "arc-swap", - "atomic_refcell", + "async-lock", + "async-trait", + "dashmap 6.0.1", "fastrand", "serde", "serde_json", diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.toml b/frontend/appflowy_web_app/src-tauri/Cargo.toml index 71a373a38a..588364dbfa 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.toml +++ b/frontend/appflowy_web_app/src-tauri/Cargo.toml @@ -52,7 +52,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ae3833ea91c238a66ca7bda63763d1d654740fb4" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "5badffc97b17984d5f25a178c0c5a477338039c4" } [dependencies] serde_json.workspace = true @@ -116,13 +116,13 @@ custom-protocol = ["tauri/custom-protocol"] # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "97163517303bb0ed468992a4511aac6eb8775a4d" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "49cf2206d7494bb3006402b807e7f171905213e3" } # Working directory: frontend # To update the commit ID, run: diff --git a/frontend/appflowy_web_app/src/application/collab.type.ts b/frontend/appflowy_web_app/src/application/collab.type.ts index 0040c49ffc..81378c8099 100644 --- a/frontend/appflowy_web_app/src/application/collab.type.ts +++ b/frontend/appflowy_web_app/src/application/collab.type.ts @@ -34,6 +34,7 @@ export enum BlockType { TableBlock = 'table', TableCell = 'table/cell', LinkPreview = 'link_preview', + FileBlock = 'file', } export enum InlineBlockType { @@ -85,6 +86,18 @@ export interface LinkPreviewBlockData extends BlockData { url?: string; } +export enum FieldURLType { + Upload = 2, + Link = 1, +} + +export interface FileBlockData extends BlockData { + name: string; + uploaded_at: number; + url: string; + url_type: FieldURLType; +} + export enum ImageType { Local = 0, Internal = 1, @@ -271,151 +284,151 @@ export enum YjsDatabaseKey { export interface YDoc extends Y.Doc { // eslint-disable-next-line @typescript-eslint/no-explicit-any - getMap(key: YjsEditorKey.data_section): YSharedRoot | any; + getMap (key: YjsEditorKey.data_section): YSharedRoot | any; } export interface YDatabaseRow extends Y.Map { - get(key: YjsDatabaseKey.id): RowId; + get (key: YjsDatabaseKey.id): RowId; - get(key: YjsDatabaseKey.height): string; + get (key: YjsDatabaseKey.height): string; - get(key: YjsDatabaseKey.visibility): boolean; + get (key: YjsDatabaseKey.visibility): boolean; - get(key: YjsDatabaseKey.created_at): CreatedAt; + get (key: YjsDatabaseKey.created_at): CreatedAt; - get(key: YjsDatabaseKey.last_modified): LastModified; + get (key: YjsDatabaseKey.last_modified): LastModified; - get(key: YjsDatabaseKey.cells): YDatabaseCells; + get (key: YjsDatabaseKey.cells): YDatabaseCells; } export interface YDatabaseCells extends Y.Map { - get(key: FieldId): YDatabaseCell; + get (key: FieldId): YDatabaseCell; } export type EndTimestamp = string; export type ReminderId = string; export interface YDatabaseCell extends Y.Map { - get(key: YjsDatabaseKey.created_at): CreatedAt; + get (key: YjsDatabaseKey.created_at): CreatedAt; - get(key: YjsDatabaseKey.last_modified): LastModified; + get (key: YjsDatabaseKey.last_modified): LastModified; - get(key: YjsDatabaseKey.field_type): string; + get (key: YjsDatabaseKey.field_type): string; - get(key: YjsDatabaseKey.data): object | string | boolean | number; + get (key: YjsDatabaseKey.data): object | string | boolean | number; - get(key: YjsDatabaseKey.end_timestamp): EndTimestamp; + get (key: YjsDatabaseKey.end_timestamp): EndTimestamp; - get(key: YjsDatabaseKey.include_time): boolean; + get (key: YjsDatabaseKey.include_time): boolean; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.is_range): boolean; + get (key: YjsDatabaseKey.is_range): boolean; - get(key: YjsDatabaseKey.reminder_id): ReminderId; + get (key: YjsDatabaseKey.reminder_id): ReminderId; } export interface YSharedRoot extends Y.Map { - get(key: YjsEditorKey.document): YDocument; + get (key: YjsEditorKey.document): YDocument; - get(key: YjsEditorKey.folder): YFolder; + get (key: YjsEditorKey.folder): YFolder; - get(key: YjsEditorKey.database): YDatabase; + get (key: YjsEditorKey.database): YDatabase; - get(key: YjsEditorKey.database_row): YDatabaseRow; + get (key: YjsEditorKey.database_row): YDatabaseRow; } export interface YFolder extends Y.Map { - get(key: YjsFolderKey.views): YViews; + get (key: YjsFolderKey.views): YViews; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.meta): YFolderMeta; + get (key: YjsFolderKey.meta): YFolderMeta; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.relation): YFolderRelation; + get (key: YjsFolderKey.relation): YFolderRelation; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.section): YFolderSection; + get (key: YjsFolderKey.section): YFolderSection; } export interface YViews extends Y.Map { - get(key: ViewId): YView; + get (key: ViewId): YView; } export interface YView extends Y.Map { - get(key: YjsFolderKey.id): ViewId; + get (key: YjsFolderKey.id): ViewId; - get(key: YjsFolderKey.bid): string; + get (key: YjsFolderKey.bid): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.name): string; + get (key: YjsFolderKey.name): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.icon | YjsFolderKey.extra): string; + get (key: YjsFolderKey.icon | YjsFolderKey.extra): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.layout): string; + get (key: YjsFolderKey.layout): string; } export interface YFolderRelation extends Y.Map { - get(key: ViewId): Y.Array; + get (key: ViewId): Y.Array; } export interface YFolderMeta extends Y.Map { - get(key: YjsFolderKey.current_view | YjsFolderKey.current_workspace): string; + get (key: YjsFolderKey.current_view | YjsFolderKey.current_workspace): string; } export interface YFolderSection extends Y.Map { - get(key: YjsFolderKey.favorite | YjsFolderKey.private | YjsFolderKey.recent | YjsFolderKey.trash): YFolderSectionItem; + get (key: YjsFolderKey.favorite | YjsFolderKey.private | YjsFolderKey.recent | YjsFolderKey.trash): YFolderSectionItem; } export interface YFolderSectionItem extends Y.Map { - get(key: string): Y.Array; + get (key: string): Y.Array; } export interface YDocument extends Y.Map { - get(key: YjsEditorKey.blocks | YjsEditorKey.page_id | YjsEditorKey.meta): YBlocks | YMeta | string; + get (key: YjsEditorKey.blocks | YjsEditorKey.page_id | YjsEditorKey.meta): YBlocks | YMeta | string; } export interface YBlocks extends Y.Map { - get(key: BlockId): YBlock; + get (key: BlockId): YBlock; } export interface YBlock extends Y.Map { - get(key: YjsEditorKey.block_id | YjsEditorKey.block_parent): BlockId; + get (key: YjsEditorKey.block_id | YjsEditorKey.block_parent): BlockId; - get(key: YjsEditorKey.block_type): BlockType; + get (key: YjsEditorKey.block_type): BlockType; - get(key: YjsEditorKey.block_data): string; + get (key: YjsEditorKey.block_data): string; - get(key: YjsEditorKey.block_children): ChildrenId; + get (key: YjsEditorKey.block_children): ChildrenId; - get(key: YjsEditorKey.block_external_id): ExternalId; + get (key: YjsEditorKey.block_external_id): ExternalId; } export interface YMeta extends Y.Map { - get(key: YjsEditorKey.children_map | YjsEditorKey.text_map): YChildrenMap | YTextMap; + get (key: YjsEditorKey.children_map | YjsEditorKey.text_map): YChildrenMap | YTextMap; } export interface YChildrenMap extends Y.Map { - get(key: ChildrenId): Y.Array; + get (key: ChildrenId): Y.Array; } export interface YTextMap extends Y.Map { - get(key: ExternalId): Y.Text; + get (key: ExternalId): Y.Text; } export interface YDatabase extends Y.Map { - get(key: YjsDatabaseKey.views): YDatabaseViews; + get (key: YjsDatabaseKey.views): YDatabaseViews; - get(key: YjsDatabaseKey.metas): YDatabaseMetas; + get (key: YjsDatabaseKey.metas): YDatabaseMetas; - get(key: YjsDatabaseKey.fields): YDatabaseFields; + get (key: YjsDatabaseKey.fields): YDatabaseFields; - get(key: YjsDatabaseKey.id): string; + get (key: YjsDatabaseKey.id): string; } export interface YDatabaseViews extends Y.Map { - get(key: ViewId): YDatabaseView; + get (key: ViewId): YDatabaseView; } export type DatabaseId = string; @@ -431,32 +444,32 @@ export enum DatabaseViewLayout { } export interface YDatabaseView extends Y.Map { - get(key: YjsDatabaseKey.database_id): DatabaseId; + get (key: YjsDatabaseKey.database_id): DatabaseId; - get(key: YjsDatabaseKey.name): string; + get (key: YjsDatabaseKey.name): string; - get(key: YjsDatabaseKey.created_at): CreatedAt; + get (key: YjsDatabaseKey.created_at): CreatedAt; - get(key: YjsDatabaseKey.modified_at): ModifiedAt; + get (key: YjsDatabaseKey.modified_at): ModifiedAt; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.layout): string; + get (key: YjsDatabaseKey.layout): string; - get(key: YjsDatabaseKey.layout_settings): YDatabaseLayoutSettings; + get (key: YjsDatabaseKey.layout_settings): YDatabaseLayoutSettings; - get(key: YjsDatabaseKey.filters): YDatabaseFilters; + get (key: YjsDatabaseKey.filters): YDatabaseFilters; - get(key: YjsDatabaseKey.groups): YDatabaseGroups; + get (key: YjsDatabaseKey.groups): YDatabaseGroups; - get(key: YjsDatabaseKey.sorts): YDatabaseSorts; + get (key: YjsDatabaseKey.sorts): YDatabaseSorts; - get(key: YjsDatabaseKey.field_settings): YDatabaseFieldSettings; + get (key: YjsDatabaseKey.field_settings): YDatabaseFieldSettings; - get(key: YjsDatabaseKey.field_orders): YDatabaseFieldOrders; + get (key: YjsDatabaseKey.field_orders): YDatabaseFieldOrders; - get(key: YjsDatabaseKey.row_orders): YDatabaseRowOrders; + get (key: YjsDatabaseKey.row_orders): YDatabaseRowOrders; - get(key: YjsDatabaseKey.calculations): YDatabaseCalculations; + get (key: YjsDatabaseKey.calculations): YDatabaseCalculations; } export type YDatabaseFieldOrders = Y.Array; // [ { id: FieldId } ] @@ -477,128 +490,128 @@ export type GroupId = string; export interface YDatabaseLayoutSettings extends Y.Map { // DatabaseViewLayout.Board - get(key: '1'): YDatabaseBoardLayoutSetting; + get (key: '1'): YDatabaseBoardLayoutSetting; // DatabaseViewLayout.Calendar - get(key: '2'): YDatabaseCalendarLayoutSetting; + get (key: '2'): YDatabaseCalendarLayoutSetting; } export interface YDatabaseBoardLayoutSetting extends Y.Map { - get(key: YjsDatabaseKey.hide_ungrouped_column | YjsDatabaseKey.collapse_hidden_groups): boolean; + get (key: YjsDatabaseKey.hide_ungrouped_column | YjsDatabaseKey.collapse_hidden_groups): boolean; } export interface YDatabaseCalendarLayoutSetting extends Y.Map { - get(key: YjsDatabaseKey.first_day_of_week | YjsDatabaseKey.field_id | YjsDatabaseKey.layout_ty): string; + get (key: YjsDatabaseKey.first_day_of_week | YjsDatabaseKey.field_id | YjsDatabaseKey.layout_ty): string; - get(key: YjsDatabaseKey.show_week_numbers | YjsDatabaseKey.show_weekends): boolean; + get (key: YjsDatabaseKey.show_week_numbers | YjsDatabaseKey.show_weekends): boolean; } export interface YDatabaseGroup extends Y.Map { - get(key: YjsDatabaseKey.id): GroupId; + get (key: YjsDatabaseKey.id): GroupId; - get(key: YjsDatabaseKey.field_id): FieldId; + get (key: YjsDatabaseKey.field_id): FieldId; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.content): string; + get (key: YjsDatabaseKey.content): string; - get(key: YjsDatabaseKey.groups): YDatabaseGroupColumns; + get (key: YjsDatabaseKey.groups): YDatabaseGroupColumns; } export type YDatabaseGroupColumns = Y.Array; export interface YDatabaseGroupColumn extends Y.Map { - get(key: YjsDatabaseKey.id): string; + get (key: YjsDatabaseKey.id): string; - get(key: YjsDatabaseKey.visible): boolean; + get (key: YjsDatabaseKey.visible): boolean; } export interface YDatabaseRowOrder extends Y.Map { - get(key: YjsDatabaseKey.id): SortId; + get (key: YjsDatabaseKey.id): SortId; - get(key: YjsDatabaseKey.height): number; + get (key: YjsDatabaseKey.height): number; } export interface YDatabaseSort extends Y.Map { - get(key: YjsDatabaseKey.id): SortId; + get (key: YjsDatabaseKey.id): SortId; - get(key: YjsDatabaseKey.field_id): FieldId; + get (key: YjsDatabaseKey.field_id): FieldId; - get(key: YjsDatabaseKey.condition): string; + get (key: YjsDatabaseKey.condition): string; } export type FilterId = string; export interface YDatabaseFilter extends Y.Map { - get(key: YjsDatabaseKey.id): FilterId; + get (key: YjsDatabaseKey.id): FilterId; - get(key: YjsDatabaseKey.field_id): FieldId; + get (key: YjsDatabaseKey.field_id): FieldId; - get(key: YjsDatabaseKey.type | YjsDatabaseKey.condition | YjsDatabaseKey.content | YjsDatabaseKey.filter_type): string; + get (key: YjsDatabaseKey.type | YjsDatabaseKey.condition | YjsDatabaseKey.content | YjsDatabaseKey.filter_type): string; } export interface YDatabaseCalculation extends Y.Map { - get(key: YjsDatabaseKey.field_id): FieldId; + get (key: YjsDatabaseKey.field_id): FieldId; - get(key: YjsDatabaseKey.id | YjsDatabaseKey.type | YjsDatabaseKey.calculation_value): string; + get (key: YjsDatabaseKey.id | YjsDatabaseKey.type | YjsDatabaseKey.calculation_value): string; } export interface YDatabaseFieldSettings extends Y.Map { - get(key: FieldId): YDatabaseFieldSetting; + get (key: FieldId): YDatabaseFieldSetting; } export interface YDatabaseFieldSetting extends Y.Map { - get(key: YjsDatabaseKey.visibility): string; + get (key: YjsDatabaseKey.visibility): string; - get(key: YjsDatabaseKey.wrap): boolean; + get (key: YjsDatabaseKey.wrap): boolean; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.width): string; + get (key: YjsDatabaseKey.width): string; } export interface YDatabaseMetas extends Y.Map { - get(key: YjsDatabaseKey.iid): string; + get (key: YjsDatabaseKey.iid): string; } export interface YDatabaseFields extends Y.Map { - get(key: FieldId): YDatabaseField; + get (key: FieldId): YDatabaseField; } export interface YDatabaseField extends Y.Map { - get(key: YjsDatabaseKey.name): string; + get (key: YjsDatabaseKey.name): string; - get(key: YjsDatabaseKey.id): FieldId; + get (key: YjsDatabaseKey.id): FieldId; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.type): string; + get (key: YjsDatabaseKey.type): string; - get(key: YjsDatabaseKey.type_option): YDatabaseFieldTypeOption; + get (key: YjsDatabaseKey.type_option): YDatabaseFieldTypeOption; - get(key: YjsDatabaseKey.is_primary): boolean; + get (key: YjsDatabaseKey.is_primary): boolean; - get(key: YjsDatabaseKey.last_modified): LastModified; + get (key: YjsDatabaseKey.last_modified): LastModified; } export interface YDatabaseFieldTypeOption extends Y.Map { // key is the field type - get(key: string): YMapFieldTypeOption; + get (key: string): YMapFieldTypeOption; } export interface YMapFieldTypeOption extends Y.Map { - get(key: YjsDatabaseKey.content): string; + get (key: YjsDatabaseKey.content): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.data): string; + get (key: YjsDatabaseKey.data): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.time_format): string; + get (key: YjsDatabaseKey.time_format): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.date_format): string; + get (key: YjsDatabaseKey.date_format): string; - get(key: YjsDatabaseKey.database_id): DatabaseId; + get (key: YjsDatabaseKey.database_id): DatabaseId; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.format): string; + get (key: YjsDatabaseKey.format): string; } export enum CollabType { diff --git a/frontend/appflowy_web_app/src/application/publish/context.tsx b/frontend/appflowy_web_app/src/application/publish/context.tsx index 6463f00b25..9e7773624b 100644 --- a/frontend/appflowy_web_app/src/application/publish/context.tsx +++ b/frontend/appflowy_web_app/src/application/publish/context.tsx @@ -1,7 +1,7 @@ import { GetViewRowsMap, LoadView, LoadViewMeta } from '@/application/collab.type'; import { db } from '@/application/db'; import { ViewMeta } from '@/application/db/tables/view_metas'; -import { AFConfigContext } from '@/components/app/AppConfig'; +import { AFConfigContext } from '@/components/app/app.hooks'; import { useLiveQuery } from 'dexie-react-hooks'; import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -9,6 +9,7 @@ import { useNavigate } from 'react-router-dom'; export interface PublishContextType { namespace: string; publishName: string; + isTemplateThumb?: boolean; viewMeta?: ViewMeta; toView: (viewId: string) => Promise; loadViewMeta: LoadViewMeta; @@ -23,10 +24,12 @@ export const PublishProvider = ({ children, namespace, publishName, + isTemplateThumb, }: { children: React.ReactNode; namespace: string; publishName: string; + isTemplateThumb?: boolean; }) => { const viewMeta = useLiveQuery(async () => { const name = `${namespace}_${publishName}`; @@ -87,7 +90,7 @@ export const PublishProvider = ({ return Promise.reject(e); } }, - [navigate, service] + [navigate, service], ); const loadViewMeta = useCallback( @@ -124,7 +127,7 @@ export const PublishProvider = ({ return Promise.reject(e); } }, - [service] + [service], ); const getViewRowsMap = useCallback( @@ -148,7 +151,7 @@ export const PublishProvider = ({ return Promise.reject(e); } }, - [service] + [service], ); const loadView = useCallback( @@ -173,7 +176,7 @@ export const PublishProvider = ({ return Promise.reject(e); } }, - [service] + [service], ); useEffect(() => { @@ -195,6 +198,7 @@ export const PublishProvider = ({ toView, namespace, publishName, + isTemplateThumb, }} > {children} @@ -202,6 +206,6 @@ export const PublishProvider = ({ ); }; -export function usePublishContext() { +export function usePublishContext () { return useContext(PublishContext); } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts index 2ab02a750f..2c8c39f57a 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts @@ -15,7 +15,7 @@ import { Fetcher, StrategyType } from '@/application/services/js-services/cache/ // import { IndexeddbPersistence } from 'y-indexeddb'; import * as Y from 'yjs'; -export function collabTypeToDBType(type: CollabType) { +export function collabTypeToDBType (type: CollabType) { switch (type) { case CollabType.Folder: return 'folder'; @@ -44,7 +44,7 @@ const collabSharedRootKeyMap = { [CollabType.Empty]: YjsEditorKey.empty, }; -export function hasCollabCache(doc: YDoc) { +export function hasCollabCache (doc: YDoc) { const data = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; return Object.values(collabSharedRootKeyMap).some((key) => { @@ -52,7 +52,7 @@ export function hasCollabCache(doc: YDoc) { }); } -export async function hasViewMetaCache(name: string) { +export async function hasViewMetaCache (name: string) { const data = await db.view_metas.get(name); return !!data; @@ -64,7 +64,7 @@ export async function getPublishViewMeta< child_views: PublishViewInfo[]; ancestor_views: PublishViewInfo[]; } ->( +> ( fetcher: Fetcher, { namespace, @@ -73,7 +73,7 @@ export async function getPublishViewMeta< namespace: string; publishName: string; }, - strategy: StrategyType = StrategyType.CACHE_AND_NETWORK + strategy: StrategyType = StrategyType.CACHE_AND_NETWORK, ) { const name = `${namespace}_${publishName}`; const exist = await hasViewMetaCache(name); @@ -124,7 +124,7 @@ export async function getPublishView< ancestor_views: PublishViewInfo[]; }; } ->( +> ( fetcher: Fetcher, { namespace, @@ -133,7 +133,7 @@ export async function getPublishView< namespace: string; publishName: string; }, - strategy: StrategyType = StrategyType.CACHE_AND_NETWORK + strategy: StrategyType = StrategyType.CACHE_AND_NETWORK, ) { const name = `${namespace}_${publishName}`; const doc = await openCollabDB(name); @@ -197,7 +197,7 @@ export async function revalidatePublishViewMeta< child_views: PublishViewInfo[]; ancestor_views: PublishViewInfo[]; } ->(name: string, fetcher: Fetcher) { +> (name: string, fetcher: Fetcher) { const { view, child_views, ancestor_views } = await fetcher(); const dbView = await db.view_metas.get(name); @@ -211,7 +211,7 @@ export async function revalidatePublishViewMeta< visible_view_ids: dbView?.visible_view_ids ?? [], database_relations: dbView?.database_relations ?? {}, }, - name + name, ); return db.view_metas.get(name); @@ -225,7 +225,7 @@ export async function revalidatePublishView< relations?: Record; meta: PublishViewMetaData; } ->(name: string, fetcher: Fetcher, collab: YDoc, rowMapDoc: Y.Doc) { +> (name: string, fetcher: Fetcher, collab: YDoc, rowMapDoc: Y.Doc) { const { data, meta, rows, visibleViewIds = [], relations = {} } = await fetcher(); await db.view_metas.put( @@ -237,7 +237,7 @@ export async function revalidatePublishView< visible_view_ids: visibleViewIds, database_relations: relations, }, - name + name, ); if (rows) { @@ -260,16 +260,14 @@ export async function revalidatePublishView< } } - console.log('====', data); - applyYDoc(collab, data); } -export async function deleteViewMeta(name: string) { +export async function deleteViewMeta (name: string) { await db.view_metas.delete(name); } -export async function deleteView(name: string) { +export async function deleteView (name: string) { console.log('deleteView', name); await deleteViewMeta(name); await closeCollabDB(name); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts index 6905d8fd02..b95a382578 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts @@ -4,6 +4,13 @@ import { initGrantService, refreshToken } from '@/application/services/js-servic import { blobToBytes } from '@/application/services/js-services/http/utils'; import { AFCloudConfig } from '@/application/services/services.type'; import { getTokenParsed, invalidToken } from '@/application/session/token'; +import { + Template, + TemplateCategory, + TemplateCategoryFormValues, + TemplateCreator, TemplateCreatorFormValues, TemplateSummary, + UploadTemplatePayload, +} from '@/application/template.type'; import { FolderView, User, Workspace } from '@/application/types'; import axios, { AxiosInstance } from 'axios'; import dayjs from 'dayjs'; @@ -12,13 +19,16 @@ export * from './gotrue'; let axiosInstance: AxiosInstance | null = null; -export function initAPIService(config: AFCloudConfig) { +export function initAPIService (config: AFCloudConfig) { if (axiosInstance) { return; } axiosInstance = axios.create({ baseURL: config.baseURL, + headers: { + 'Content-Type': 'application/json', + }, }); initGrantService(config.gotrueURL); @@ -27,10 +37,6 @@ export function initAPIService(config: AFCloudConfig) { async (config) => { const token = getTokenParsed(); - Object.assign(config.headers, { - 'Content-Type': 'application/json', - }); - if (!token) { return config; } @@ -56,7 +62,7 @@ export function initAPIService(config: AFCloudConfig) { }, (error) => { return Promise.reject(error); - } + }, ); axiosInstance.interceptors.response.use(async (response) => { @@ -83,7 +89,7 @@ export function initAPIService(config: AFCloudConfig) { }); } -export async function signInWithUrl(url: string) { +export async function signInWithUrl (url: string) { const hash = new URL(url).hash; if (!hash) { @@ -100,7 +106,7 @@ export async function signInWithUrl(url: string) { await refreshToken(refresh_token); } -export async function verifyToken(accessToken: string) { +export async function verifyToken (accessToken: string) { const url = `/api/user/verify/${accessToken}`; const response = await axiosInstance?.get<{ code: number; @@ -119,7 +125,7 @@ export async function verifyToken(accessToken: string) { return Promise.reject(data); } -export async function getCurrentUser(): Promise { +export async function getCurrentUser (): Promise { const url = '/api/user/profile'; const response = await axiosInstance?.get<{ code: number; @@ -155,14 +161,14 @@ export async function getCurrentUser(): Promise { return Promise.reject(data); } -export async function getPublishViewMeta(namespace: string, publishName: string) { +export async function getPublishViewMeta (namespace: string, publishName: string) { const url = `/api/workspace/published/${namespace}/${publishName}`; const response = await axiosInstance?.get(url); return response?.data; } -export async function getPublishViewBlob(namespace: string, publishName: string) { +export async function getPublishViewBlob (namespace: string, publishName: string) { const url = `/api/workspace/published/${namespace}/${publishName}/blob`; const response = await axiosInstance?.get(url, { responseType: 'blob', @@ -171,7 +177,7 @@ export async function getPublishViewBlob(namespace: string, publishName: string) return blobToBytes(response?.data); } -export async function getPublishView(publishNamespace: string, publishName: string) { +export async function getPublishView (publishNamespace: string, publishName: string) { const meta = await getPublishViewMeta(publishNamespace, publishName); const blob = await getPublishViewBlob(publishNamespace, publishName); @@ -207,7 +213,7 @@ export async function getPublishView(publishNamespace: string, publishName: stri } } -export async function getPublishInfoWithViewId(viewId: string) { +export async function getPublishInfoWithViewId (viewId: string) { const url = `/api/workspace/published-info/${viewId}`; const response = await axiosInstance?.get<{ code: number; @@ -227,7 +233,7 @@ export async function getPublishInfoWithViewId(viewId: string) { return Promise.reject(data); } -export async function getPublishViewComments(viewId: string): Promise { +export async function getPublishViewComments (viewId: string): Promise { const url = `/api/workspace/published-info/${viewId}/comment`; const response = await axiosInstance?.get<{ code: number; @@ -276,7 +282,7 @@ export async function getPublishViewComments(viewId: string): Promise> { +export async function getReactions (viewId: string, commentId?: string): Promise> { let url = `/api/workspace/published-info/${viewId}/reaction`; if (commentId) { @@ -327,7 +333,7 @@ export async function getReactions(viewId: string, commentId?: string): Promise< return Promise.reject(data); } -export async function createGlobalCommentOnPublishView(viewId: string, content: string, replyCommentId?: string) { +export async function createGlobalCommentOnPublishView (viewId: string, content: string, replyCommentId?: string) { const url = `/api/workspace/published-info/${viewId}/comment`; const response = await axiosInstance?.post<{ code: number; message: string }>(url, { content, @@ -341,7 +347,7 @@ export async function createGlobalCommentOnPublishView(viewId: string, content: return Promise.reject(response?.data.message); } -export async function deleteGlobalCommentOnPublishView(viewId: string, commentId: string) { +export async function deleteGlobalCommentOnPublishView (viewId: string, commentId: string) { const url = `/api/workspace/published-info/${viewId}/comment`; const response = await axiosInstance?.delete<{ code: number; message: string }>(url, { data: { @@ -356,7 +362,7 @@ export async function deleteGlobalCommentOnPublishView(viewId: string, commentId return Promise.reject(response?.data.message); } -export async function addReaction(viewId: string, commentId: string, reactionType: string) { +export async function addReaction (viewId: string, commentId: string, reactionType: string) { const url = `/api/workspace/published-info/${viewId}/reaction`; const response = await axiosInstance?.post<{ code: number; message: string }>(url, { comment_id: commentId, @@ -370,7 +376,7 @@ export async function addReaction(viewId: string, commentId: string, reactionTyp return Promise.reject(response?.data.message); } -export async function removeReaction(viewId: string, commentId: string, reactionType: string) { +export async function removeReaction (viewId: string, commentId: string, reactionType: string) { const url = `/api/workspace/published-info/${viewId}/reaction`; const response = await axiosInstance?.delete<{ code: number; message: string }>(url, { data: { @@ -386,7 +392,7 @@ export async function removeReaction(viewId: string, commentId: string, reaction return Promise.reject(response?.data.message); } -export async function getWorkspaces(): Promise { +export async function getWorkspaces (): Promise { const query = new URLSearchParams({ include_member_count: 'true', }); @@ -436,7 +442,7 @@ export interface WorkspaceFolder { children: WorkspaceFolder[]; } -function iterateFolder(folder: WorkspaceFolder): FolderView { +function iterateFolder (folder: WorkspaceFolder): FolderView { return { id: folder.view_id, name: folder.name, @@ -450,7 +456,7 @@ function iterateFolder(folder: WorkspaceFolder): FolderView { }; } -export async function getWorkspaceFolder(workspaceId: string): Promise { +export async function getWorkspaceFolder (workspaceId: string): Promise { const url = `/api/workspace/${workspaceId}/folder`; const response = await axiosInstance?.get<{ code: number; @@ -473,7 +479,7 @@ export interface DuplicatePublishViewPayload { dest_view_id: string; } -export async function duplicatePublishView(workspaceId: string, payload: DuplicatePublishViewPayload) { +export async function duplicatePublishView (workspaceId: string, payload: DuplicatePublishViewPayload) { const url = `/api/workspace/${workspaceId}/published-duplicate`; const res = await axiosInstance?.post<{ @@ -487,3 +493,247 @@ export async function duplicatePublishView(workspaceId: string, payload: Duplica return Promise.reject(res?.data.message); } + +export async function createTemplate (template: UploadTemplatePayload) { + const url = '/api/template-center/template'; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, template); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function updateTemplate (viewId: string, template: UploadTemplatePayload) { + const url = `/api/template-center/template/${viewId}`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, template); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getTemplates ({ + categoryId, + nameContains, +}: { + categoryId?: string; + nameContains?: string; +}) { + const url = `/api/template-center/template`; + + const response = await axiosInstance?.get<{ + code: number; + data?: { + templates: TemplateSummary[]; + }; + message: string; + }>(url, { + params: { + category_id: categoryId, + name_contains: nameContains, + }, + }); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.templates; + } + + return Promise.reject(data); +} + +export async function getTemplateById (viewId: string) { + const url = `/api/template-center/template/${viewId}`; + const response = await axiosInstance?.get<{ + code: number; + data?: Template; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function deleteTemplate (viewId: string) { + const url = `/api/template-center/template/${viewId}`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getTemplateCategories () { + const url = '/api/template-center/category'; + const response = await axiosInstance?.get<{ + code: number; + data?: { + categories: TemplateCategory[] + + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.categories; + } + + return Promise.reject(data); +} + +export async function addTemplateCategory (category: TemplateCategoryFormValues) { + const url = '/api/template-center/category'; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, category); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function updateTemplateCategory (id: string, category: TemplateCategoryFormValues) { + const url = `/api/template-center/category/${id}`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, category); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function deleteTemplateCategory (categoryId: string) { + const url = `/api/template-center/category/${categoryId}`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getTemplateCreators () { + const url = '/api/template-center/creator'; + const response = await axiosInstance?.get<{ + code: number; + data?: { + creators: TemplateCreator[]; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.creators; + } + + return Promise.reject(data); +} + +export async function createTemplateCreator (creator: TemplateCreatorFormValues) { + const url = '/api/template-center/creator'; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, creator); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function updateTemplateCreator (creatorId: string, creator: TemplateCreatorFormValues) { + const url = `/api/template-center/creator/${creatorId}`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, creator); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function deleteTemplateCreator (creatorId: string) { + const url = `/api/template-center/creator/${creatorId}`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function uploadFileToCDN (file: File) { + const url = '/api/template-center/avatar'; + const formData = new FormData(); + + console.log(file); + formData.append('avatar', file); + + const response = await axiosInstance?.request<{ + code: number; + data?: { + file_id: string; + }; + message: string; + }>({ + method: 'PUT', + url, + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return axiosInstance?.defaults.baseURL + '/api/template-center/avatar/' + data.data.file_id; + } + + return Promise.reject(data); +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts index 172f233d2f..aa197a7516 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts @@ -1,4 +1,4 @@ -export function blobToBytes(blob: Blob): Promise { +export function blobToBytes (blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts index 04f9778edf..91fb1bf4e3 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -13,6 +13,11 @@ import { APIService } from '@/application/services/js-services/http'; import { AFService, AFServiceConfig } from '@/application/services/services.type'; import { emit, EventType } from '@/application/session'; import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in'; +import { + TemplateCategoryFormValues, + TemplateCreatorFormValues, + UploadTemplatePayload, +} from '@/application/template.type'; import { nanoid } from 'nanoid'; import * as Y from 'yjs'; import { DuplicatePublishView } from '@/application/types'; @@ -36,15 +41,15 @@ export class AFClientService implements AFService { private cacheDatabaseRowFolder: Map> = new Map(); - constructor(config: AFServiceConfig) { + constructor (config: AFServiceConfig) { APIService.initAPIService(config.cloudConfig); } - getClientId() { + getClientId () { return this.clientId; } - async getPublishViewMeta(namespace: string, publishName: string) { + async getPublishViewMeta (namespace: string, publishName: string) { const name = `${namespace}_${publishName}`; const isLoaded = this.publishViewLoaded.has(name); @@ -56,7 +61,7 @@ export class AFClientService implements AFService { namespace, publishName, }, - isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK + isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK, ); if (!viewMeta) { @@ -66,7 +71,7 @@ export class AFClientService implements AFService { return viewMeta; } - async getPublishView(namespace: string, publishName: string) { + async getPublishView (namespace: string, publishName: string) { const name = `${namespace}_${publishName}`; const isLoaded = this.publishViewLoaded.has(name); @@ -91,7 +96,7 @@ export class AFClientService implements AFService { namespace, publishName, }, - isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK + isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK, ); if (!isLoaded) { @@ -103,7 +108,7 @@ export class AFClientService implements AFService { return doc; } - async getPublishDatabaseViewRows(namespace: string, publishName: string) { + async getPublishDatabaseViewRows (namespace: string, publishName: string) { const name = `${namespace}_${publishName}`; if (!this.publishViewLoaded.has(name) || !this.cacheDatabaseRowDocMap.has(name)) { @@ -133,7 +138,7 @@ export class AFClientService implements AFService { }; } - async getPublishInfo(viewId: string) { + async getPublishInfo (viewId: string) { if (this.publishViewInfo.has(viewId)) { return this.publishViewInfo.get(viewId) as { namespace: string; @@ -159,7 +164,7 @@ export class AFClientService implements AFService { return data; } - async loginAuth(url: string) { + async loginAuth (url: string) { try { console.log('loginAuth', url); await APIService.signInWithUrl(url); @@ -173,45 +178,45 @@ export class AFClientService implements AFService { } @withSignIn() - async signInMagicLink({ email }: { email: string; redirectTo: string }) { + async signInMagicLink ({ email }: { email: string; redirectTo: string }) { return await APIService.signInWithMagicLink(email, AUTH_CALLBACK_URL); } @withSignIn() - async signInGoogle(_: { redirectTo: string }) { + async signInGoogle (_: { redirectTo: string }) { return APIService.signInGoogle(AUTH_CALLBACK_URL); } @withSignIn() - async signInGithub(_: { redirectTo: string }) { + async signInGithub (_: { redirectTo: string }) { return APIService.signInGithub(AUTH_CALLBACK_URL); } @withSignIn() - async signInDiscord(_: { redirectTo: string }) { + async signInDiscord (_: { redirectTo: string }) { return APIService.signInDiscord(AUTH_CALLBACK_URL); } - async getWorkspaces() { + async getWorkspaces () { const data = APIService.getWorkspaces(); return data; } - async getWorkspaceFolder(workspaceId: string) { + async getWorkspaceFolder (workspaceId: string) { const data = await APIService.getWorkspaceFolder(workspaceId); return data; } - async getCurrentUser() { + async getCurrentUser () { const data = await APIService.getCurrentUser(); await APIService.getWorkspaces(); return data; } - async duplicatePublishView(params: DuplicatePublishView) { + async duplicatePublishView (params: DuplicatePublishView) { return APIService.duplicatePublishView(params.workspaceId, { dest_view_id: params.spaceViewId, published_view_id: params.viewId, @@ -219,27 +224,87 @@ export class AFClientService implements AFService { }); } - createCommentOnPublishView(viewId: string, content: string, replyCommentId: string | undefined): Promise { + createCommentOnPublishView (viewId: string, content: string, replyCommentId: string | undefined): Promise { return APIService.createGlobalCommentOnPublishView(viewId, content, replyCommentId); } - deleteCommentOnPublishView(viewId: string, commentId: string): Promise { + deleteCommentOnPublishView (viewId: string, commentId: string): Promise { return APIService.deleteGlobalCommentOnPublishView(viewId, commentId); } - getPublishViewGlobalComments(viewId: string): Promise { + getPublishViewGlobalComments (viewId: string): Promise { return APIService.getPublishViewComments(viewId); } - getPublishViewReactions(viewId: string, commentId?: string): Promise> { + getPublishViewReactions (viewId: string, commentId?: string): Promise> { return APIService.getReactions(viewId, commentId); } - addPublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise { + addPublishViewReaction (viewId: string, commentId: string, reactionType: string): Promise { return APIService.addReaction(viewId, commentId, reactionType); } - removePublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise { + removePublishViewReaction (viewId: string, commentId: string, reactionType: string): Promise { return APIService.removeReaction(viewId, commentId, reactionType); } + + async getTemplateCategories () { + return APIService.getTemplateCategories(); + } + + async getTemplateCreators () { + return APIService.getTemplateCreators(); + } + + async createTemplate (template: UploadTemplatePayload) { + return APIService.createTemplate(template); + } + + async updateTemplate (id: string, template: UploadTemplatePayload) { + return APIService.updateTemplate(id, template); + } + + async getTemplateById (id: string) { + return APIService.getTemplateById(id); + } + + async getTemplates (params: { + categoryId?: string; + nameContains?: string; + }) { + return APIService.getTemplates(params); + } + + async deleteTemplate (id: string) { + return APIService.deleteTemplate(id); + } + + async addTemplateCategory (category: TemplateCategoryFormValues) { + return APIService.addTemplateCategory(category); + } + + async updateTemplateCategory (categoryId: string, category: TemplateCategoryFormValues) { + return APIService.updateTemplateCategory(categoryId, category); + } + + async deleteTemplateCategory (categoryId: string) { + return APIService.deleteTemplateCategory(categoryId); + } + + async updateTemplateCreator (creatorId: string, creator: TemplateCreatorFormValues) { + return APIService.updateTemplateCreator(creatorId, creator); + } + + async createTemplateCreator (creator: TemplateCreatorFormValues) { + return APIService.createTemplateCreator(creator); + } + + async deleteTemplateCreator (creatorId: string) { + return APIService.deleteTemplateCreator(creatorId); + } + + async uploadFileToCDN (file: File) { + return APIService.uploadFileToCDN(file); + } + } diff --git a/frontend/appflowy_web_app/src/application/services/services.type.ts b/frontend/appflowy_web_app/src/application/services/services.type.ts index 77b7086fa3..a346800058 100644 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -1,6 +1,13 @@ import { YDoc } from '@/application/collab.type'; import { GlobalComment, Reaction } from '@/application/comment.type'; import { ViewMeta } from '@/application/db/tables/view_metas'; +import { + Template, + TemplateCategory, + TemplateCategoryFormValues, + TemplateCreator, TemplateCreatorFormValues, TemplateSummary, + UploadTemplatePayload, +} from '@/application/template.type'; import * as Y from 'yjs'; import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types'; @@ -24,11 +31,12 @@ export interface PublishService { getPublishDatabaseViewRows: ( namespace: string, publishName: string, - rowIds?: string[] + rowIds?: string[], ) => Promise<{ rows: Y.Map; destroy: () => void; }>; + getPublishViewGlobalComments: (viewId: string) => Promise; createCommentOnPublishView: (viewId: string, content: string, replyCommentId?: string) => Promise; deleteCommentOnPublishView: (viewId: string, commentId: string) => Promise; @@ -46,4 +54,22 @@ export interface PublishService { getWorkspaceFolder: (workspaceId: string) => Promise; getCurrentUser: () => Promise; duplicatePublishView: (params: DuplicatePublishView) => Promise; + + getTemplateCategories: () => Promise; + addTemplateCategory: (category: TemplateCategoryFormValues) => Promise; + deleteTemplateCategory: (categoryId: string) => Promise; + getTemplateCreators: () => Promise; + createTemplateCreator: (creator: TemplateCreatorFormValues) => Promise; + deleteTemplateCreator: (creatorId: string) => Promise; + getTemplateById: (id: string) => Promise