mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-09-11 19:17:43 +03:00
feat: support slide actions on mobile (#5463)
* feat: support slide actions on mobile * fix: some ui issues * fix: scale down emoji size on windows * fix: flutter analyze * fix: force text height on macos
This commit is contained in:
parent
9f66dcdc8f
commit
e4eff7e632
@ -200,7 +200,7 @@ SPEC CHECKSUMS:
|
||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
|
||||
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
|
||||
image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb
|
||||
image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425
|
||||
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
|
||||
@ -227,4 +227,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.15.2
|
||||
|
@ -21,6 +21,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
|
||||
children: [
|
||||
FlowyOptionTile.text(
|
||||
text: LocaleKeys.document_menuName.tr(),
|
||||
height: 52.0,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.document_s,
|
||||
size: Size.square(18),
|
||||
@ -30,6 +31,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
|
||||
),
|
||||
FlowyOptionTile.text(
|
||||
text: LocaleKeys.grid_menuName.tr(),
|
||||
height: 52.0,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.grid_s,
|
||||
size: Size.square(18),
|
||||
@ -39,6 +41,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
|
||||
),
|
||||
FlowyOptionTile.text(
|
||||
text: LocaleKeys.board_menuName.tr(),
|
||||
height: 52.0,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.board_s,
|
||||
size: Size.square(18),
|
||||
@ -48,6 +51,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
|
||||
),
|
||||
FlowyOptionTile.text(
|
||||
text: LocaleKeys.calendar_menuName.tr(),
|
||||
height: 52.0,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.calendar_s,
|
||||
size: Size.square(18),
|
||||
@ -57,6 +61,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
|
||||
),
|
||||
FlowyOptionTile.text(
|
||||
text: LocaleKeys.chat_newChat.tr(),
|
||||
height: 52.0,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.chat_ai_page_s,
|
||||
size: Size.square(18),
|
||||
|
@ -44,6 +44,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
|
||||
case MobileViewItemBottomSheetBodyAction.rename:
|
||||
return FlowyOptionTile.text(
|
||||
text: LocaleKeys.button_rename.tr(),
|
||||
height: 52.0,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.view_item_rename_s,
|
||||
size: Size.square(18),
|
||||
@ -57,6 +58,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
|
||||
case MobileViewItemBottomSheetBodyAction.duplicate:
|
||||
return FlowyOptionTile.text(
|
||||
text: LocaleKeys.button_duplicate.tr(),
|
||||
height: 52.0,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.duplicate_s,
|
||||
size: Size.square(18),
|
||||
@ -71,6 +73,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
|
||||
case MobileViewItemBottomSheetBodyAction.share:
|
||||
return FlowyOptionTile.text(
|
||||
text: LocaleKeys.button_share.tr(),
|
||||
height: 52.0,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.share_s,
|
||||
size: Size.square(18),
|
||||
@ -84,9 +87,10 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
|
||||
case MobileViewItemBottomSheetBodyAction.delete:
|
||||
return FlowyOptionTile.text(
|
||||
text: LocaleKeys.button_delete.tr(),
|
||||
height: 52.0,
|
||||
textColor: Theme.of(context).colorScheme.error,
|
||||
leftIcon: FlowySvg(
|
||||
FlowySvgs.delete_s,
|
||||
FlowySvgs.trash_s,
|
||||
size: const Size.square(18),
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
@ -98,6 +102,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
|
||||
);
|
||||
case MobileViewItemBottomSheetBodyAction.addToFavorites:
|
||||
return FlowyOptionTile.text(
|
||||
height: 52.0,
|
||||
text: LocaleKeys.button_addToFavorites.tr(),
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.favorite_s,
|
||||
@ -111,6 +116,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
|
||||
);
|
||||
case MobileViewItemBottomSheetBodyAction.removeFromFavorites:
|
||||
return FlowyOptionTile.text(
|
||||
height: 52.0,
|
||||
text: LocaleKeys.button_removeFromFavorites.tr(),
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.favorite_section_remove_from_favorite_s,
|
||||
@ -124,6 +130,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
|
||||
);
|
||||
case MobileViewItemBottomSheetBodyAction.removeFromRecent:
|
||||
return FlowyOptionTile.text(
|
||||
height: 52.0,
|
||||
text: LocaleKeys.button_removeFromRecent.tr(),
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.remove_from_recent_s,
|
||||
@ -137,7 +144,10 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
|
||||
);
|
||||
|
||||
case MobileViewItemBottomSheetBodyAction.divider:
|
||||
return const Divider(height: 0.5);
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Divider(height: 0.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,15 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/shared/mobile_view_card.dart';
|
||||
import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flowy_infra/theme_extension.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:flutter_slidable/flutter_slidable.dart';
|
||||
@ -13,11 +18,14 @@ enum MobilePaneActionType {
|
||||
delete,
|
||||
addToFavorites,
|
||||
removeFromFavorites,
|
||||
more;
|
||||
more,
|
||||
add;
|
||||
|
||||
MobileSlideActionButton actionButton(
|
||||
BuildContext context,
|
||||
) {
|
||||
BuildContext context, {
|
||||
MobileViewCardType? cardType,
|
||||
FolderSpaceType? spaceType,
|
||||
}) {
|
||||
switch (this) {
|
||||
case MobilePaneActionType.delete:
|
||||
return MobileSlideActionButton(
|
||||
@ -29,59 +37,88 @@ enum MobilePaneActionType {
|
||||
);
|
||||
case MobilePaneActionType.removeFromFavorites:
|
||||
return MobileSlideActionButton(
|
||||
backgroundColor: Colors.orange,
|
||||
svg: FlowySvgs.favorite_s,
|
||||
backgroundColor: const Color(0xFFFA217F),
|
||||
svg: FlowySvgs.favorite_section_remove_from_favorite_s,
|
||||
size: 24.0,
|
||||
onPressed: (context) => context
|
||||
.read<FavoriteBloc>()
|
||||
.add(FavoriteEvent.toggle(context.read<ViewBloc>().view)),
|
||||
);
|
||||
case MobilePaneActionType.addToFavorites:
|
||||
return MobileSlideActionButton(
|
||||
backgroundColor: Colors.orange,
|
||||
svg: FlowySvgs.m_favorite_unselected_lg,
|
||||
size: 34.0,
|
||||
backgroundColor: const Color(0xFF00C8FF),
|
||||
svg: FlowySvgs.favorite_s,
|
||||
size: 24.0,
|
||||
onPressed: (context) => context
|
||||
.read<FavoriteBloc>()
|
||||
.add(FavoriteEvent.toggle(context.read<ViewBloc>().view)),
|
||||
);
|
||||
case MobilePaneActionType.more:
|
||||
case MobilePaneActionType.add:
|
||||
return MobileSlideActionButton(
|
||||
backgroundColor: Colors.grey,
|
||||
svg: FlowySvgs.three_dots_vertical_s,
|
||||
backgroundColor: const Color(0xFF00C8FF),
|
||||
svg: FlowySvgs.add_m,
|
||||
size: 28.0,
|
||||
onPressed: (context) {
|
||||
final viewBloc = context.read<ViewBloc>();
|
||||
final view = viewBloc.state.view;
|
||||
final title = view.name;
|
||||
showMobileBottomSheet(
|
||||
context,
|
||||
showHeader: true,
|
||||
title: title,
|
||||
showDragHandle: true,
|
||||
showCloseButton: true,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
builder: (sheetContext) {
|
||||
return AddNewPageWidgetBottomSheet(
|
||||
view: view,
|
||||
onAction: (layout) {
|
||||
context.read<ViewBloc>().add(
|
||||
ViewEvent.createView(
|
||||
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
layout,
|
||||
section: spaceType!.toViewSectionPB,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
case MobilePaneActionType.more:
|
||||
return MobileSlideActionButton(
|
||||
backgroundColor: const Color(0xE5515563),
|
||||
svg: FlowySvgs.three_dots_s,
|
||||
size: 24.0,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(10),
|
||||
bottomLeft: Radius.circular(10),
|
||||
),
|
||||
onPressed: (context) {
|
||||
final viewBloc = context.read<ViewBloc>();
|
||||
final favoriteBloc = context.read<FavoriteBloc>();
|
||||
final recentViewsBloc = context.read<RecentViewsBloc?>();
|
||||
showMobileBottomSheet(
|
||||
context,
|
||||
showDragHandle: true,
|
||||
showDivider: false,
|
||||
backgroundColor: AFThemeExtension.of(context).background,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
builder: (context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: viewBloc),
|
||||
BlocProvider.value(value: favoriteBloc),
|
||||
if (recentViewsBloc != null)
|
||||
BlocProvider.value(value: recentViewsBloc),
|
||||
],
|
||||
child: BlocBuilder<ViewBloc, ViewState>(
|
||||
builder: (context, state) {
|
||||
final isFavorite = state.view.isFavorite;
|
||||
return MobileViewItemBottomSheet(
|
||||
view: viewBloc.state.view,
|
||||
actions: [
|
||||
isFavorite
|
||||
? MobileViewItemBottomSheetBodyAction
|
||||
.removeFromFavorites
|
||||
: MobileViewItemBottomSheetBodyAction
|
||||
.addToFavorites,
|
||||
MobileViewItemBottomSheetBodyAction.divider,
|
||||
MobileViewItemBottomSheetBodyAction.rename,
|
||||
if (state.view.layout != ViewLayoutPB.Chat)
|
||||
MobileViewItemBottomSheetBodyAction.duplicate,
|
||||
MobileViewItemBottomSheetBodyAction.divider,
|
||||
MobileViewItemBottomSheetBodyAction.delete,
|
||||
],
|
||||
actions: _buildActions(state.view, cardType: cardType),
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -92,19 +129,71 @@ enum MobilePaneActionType {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<MobileViewItemBottomSheetBodyAction> _buildActions(
|
||||
ViewPB view, {
|
||||
MobileViewCardType? cardType,
|
||||
}) {
|
||||
final isFavorite = view.isFavorite;
|
||||
|
||||
if (cardType != null) {
|
||||
switch (cardType) {
|
||||
case MobileViewCardType.recent:
|
||||
return [
|
||||
isFavorite
|
||||
? MobileViewItemBottomSheetBodyAction.removeFromFavorites
|
||||
: MobileViewItemBottomSheetBodyAction.addToFavorites,
|
||||
MobileViewItemBottomSheetBodyAction.divider,
|
||||
if (view.layout != ViewLayoutPB.Chat)
|
||||
MobileViewItemBottomSheetBodyAction.duplicate,
|
||||
MobileViewItemBottomSheetBodyAction.divider,
|
||||
MobileViewItemBottomSheetBodyAction.removeFromRecent,
|
||||
];
|
||||
case MobileViewCardType.favorite:
|
||||
return [
|
||||
isFavorite
|
||||
? MobileViewItemBottomSheetBodyAction.removeFromFavorites
|
||||
: MobileViewItemBottomSheetBodyAction.addToFavorites,
|
||||
MobileViewItemBottomSheetBodyAction.divider,
|
||||
MobileViewItemBottomSheetBodyAction.duplicate,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
isFavorite
|
||||
? MobileViewItemBottomSheetBodyAction.removeFromFavorites
|
||||
: MobileViewItemBottomSheetBodyAction.addToFavorites,
|
||||
MobileViewItemBottomSheetBodyAction.divider,
|
||||
MobileViewItemBottomSheetBodyAction.rename,
|
||||
if (view.layout != ViewLayoutPB.Chat)
|
||||
MobileViewItemBottomSheetBodyAction.duplicate,
|
||||
MobileViewItemBottomSheetBodyAction.divider,
|
||||
MobileViewItemBottomSheetBodyAction.delete,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
ActionPane buildEndActionPane(
|
||||
BuildContext context,
|
||||
List<MobilePaneActionType> actions,
|
||||
) {
|
||||
List<MobilePaneActionType> actions, {
|
||||
bool needSpace = true,
|
||||
MobileViewCardType? cardType,
|
||||
FolderSpaceType? spaceType,
|
||||
}) {
|
||||
debugPrint('actions: $actions');
|
||||
return ActionPane(
|
||||
motion: const ScrollMotion(),
|
||||
extentRatio: actions.length / 5,
|
||||
children: actions
|
||||
.map(
|
||||
(action) => action.actionButton(context),
|
||||
)
|
||||
.toList(),
|
||||
children: [
|
||||
if (needSpace) const HSpace(20),
|
||||
...actions.map(
|
||||
(action) => action.actionButton(
|
||||
context,
|
||||
spaceType: spaceType,
|
||||
cardType: cardType,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -63,12 +63,16 @@ class MobileFavoriteFolder extends StatelessWidget {
|
||||
view: view,
|
||||
level: 0,
|
||||
onSelected: context.pushView,
|
||||
endActionPane: (context) => buildEndActionPane(context, [
|
||||
view.isFavorite
|
||||
? MobilePaneActionType.removeFromFavorites
|
||||
: MobilePaneActionType.addToFavorites,
|
||||
MobilePaneActionType.more,
|
||||
]),
|
||||
endActionPane: (context) => buildEndActionPane(
|
||||
context,
|
||||
[
|
||||
view.isFavorite
|
||||
? MobilePaneActionType.removeFromFavorites
|
||||
: MobilePaneActionType.addToFavorites,
|
||||
MobilePaneActionType.more,
|
||||
],
|
||||
spaceType: FolderSpaceType.favorite,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -111,7 +111,7 @@ class _TrashButton extends StatelessWidget {
|
||||
height: 52,
|
||||
child: FlowyButton(
|
||||
expand: true,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 2.0),
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.m_delete_s,
|
||||
),
|
||||
|
@ -123,12 +123,13 @@ class _MobileWorkspace extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 34.0,
|
||||
dimension: currentWorkspace.icon.isNotEmpty ? 34.0 : 26.0,
|
||||
child: WorkspaceIcon(
|
||||
workspace: currentWorkspace,
|
||||
iconSize: 26,
|
||||
fontSize: 16.0,
|
||||
enableEdit: false,
|
||||
alignment: Alignment.centerLeft,
|
||||
onSelected: (result) => context.read<UserWorkspaceBloc>().add(
|
||||
UserWorkspaceEvent.updateWorkspaceIcon(
|
||||
currentWorkspace.workspaceId,
|
||||
@ -137,32 +138,13 @@ class _MobileWorkspace extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const HSpace(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FlowyText.semibold(
|
||||
currentWorkspace.name,
|
||||
fontSize: 16.0,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const HSpace(4.0),
|
||||
const FlowySvg(FlowySvgs.list_dropdown_s),
|
||||
],
|
||||
),
|
||||
FlowyText.regular(
|
||||
userProfile.email.isNotEmpty
|
||||
? userProfile.email
|
||||
: userProfile.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
currentWorkspace.icon.isNotEmpty
|
||||
? const HSpace(2)
|
||||
: const HSpace(8),
|
||||
FlowyText.semibold(
|
||||
currentWorkspace.name,
|
||||
fontSize: 16.0,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -179,7 +161,9 @@ class _MobileWorkspace extends StatelessWidget {
|
||||
showDivider: false,
|
||||
showHeader: true,
|
||||
showDragHandle: true,
|
||||
showCloseButton: true,
|
||||
title: LocaleKeys.workspace_menuTitle.tr(),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
builder: (_) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<UserWorkspaceBloc>(),
|
||||
|
@ -6,6 +6,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
|
||||
class MobileRecentSpace extends StatefulWidget {
|
||||
const MobileRecentSpace({super.key});
|
||||
@ -62,34 +63,36 @@ class _RecentViews extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scrollbar(
|
||||
child: ListView.separated(
|
||||
key: const PageStorageKey('recent_views_page_storage_key'),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final sectionView = recentViews[index];
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.5,
|
||||
return SlidableAutoCloseBehavior(
|
||||
child: Scrollbar(
|
||||
child: ListView.separated(
|
||||
key: const PageStorageKey('recent_views_page_storage_key'),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final sectionView = recentViews[index];
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: MobileViewCard(
|
||||
key: ValueKey(sectionView.item.id),
|
||||
view: sectionView.item,
|
||||
timestamp: sectionView.timestamp,
|
||||
type: MobileViewCardType.recent,
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const HSpace(8),
|
||||
itemCount: recentViews.length,
|
||||
child: MobileViewCard(
|
||||
key: ValueKey(sectionView.item.id),
|
||||
view: sectionView.item,
|
||||
timestamp: sectionView.timestamp,
|
||||
type: MobileViewCardType.recent,
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const HSpace(8),
|
||||
itemCount: recentViews.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
|
||||
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
|
||||
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@ -69,6 +71,19 @@ class MobileSectionFolder extends StatelessWidget {
|
||||
leftPadding: HomeSpaceViewSizes.leftPadding,
|
||||
isFeedback: false,
|
||||
onSelected: context.pushView,
|
||||
endActionPane: (context) {
|
||||
final view = context.read<ViewBloc>().state.view;
|
||||
return buildEndActionPane(
|
||||
context,
|
||||
[
|
||||
MobilePaneActionType.more,
|
||||
if (view.layout == ViewLayoutPB.Document)
|
||||
MobilePaneActionType.add,
|
||||
],
|
||||
spaceType: spaceType,
|
||||
needSpace: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -38,7 +38,7 @@ class _MobileSectionFolderHeaderState extends State<MobileSectionFolderHeader> {
|
||||
widget.title,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 2.0),
|
||||
expandText: false,
|
||||
iconPadding: 2,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
|
@ -22,6 +22,7 @@ import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
@ -63,21 +64,33 @@ class MobileViewCard extends StatelessWidget {
|
||||
],
|
||||
child: BlocBuilder<RecentViewBloc, RecentViewState>(
|
||||
builder: (context, state) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapUp: (_) => context.pushView(view),
|
||||
onLongPressUp: () => _showActionSheet(context),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Expanded(child: _buildDescription(context, state)),
|
||||
const HSpace(20.0),
|
||||
SizedBox(
|
||||
width: 84,
|
||||
height: 60,
|
||||
child: _buildCover(context, state),
|
||||
),
|
||||
return Slidable(
|
||||
endActionPane: buildEndActionPane(
|
||||
context,
|
||||
[
|
||||
MobilePaneActionType.more,
|
||||
context.watch<ViewBloc>().state.view.isFavorite
|
||||
? MobilePaneActionType.removeFromFavorites
|
||||
: MobilePaneActionType.addToFavorites,
|
||||
],
|
||||
cardType: type,
|
||||
),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapUp: (_) => context.pushView(view),
|
||||
onLongPressUp: () => _showActionSheet(context),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Expanded(child: _buildDescription(context, state)),
|
||||
const HSpace(20.0),
|
||||
SizedBox(
|
||||
width: 84,
|
||||
height: 60,
|
||||
child: _buildCover(context, state),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
|
||||
@ -27,7 +28,13 @@ class MobileWorkspaceMenu extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<Widget> children = [];
|
||||
final List<Widget> children = [
|
||||
_WorkspaceUserItem(userProfile: userProfile),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Divider(height: 0.5),
|
||||
),
|
||||
];
|
||||
for (var i = 0; i < workspaces.length; i++) {
|
||||
final workspace = workspaces[i];
|
||||
children.add(
|
||||
@ -35,7 +42,7 @@ class MobileWorkspaceMenu extends StatelessWidget {
|
||||
key: ValueKey(workspace.workspaceId),
|
||||
userProfile: userProfile,
|
||||
workspace: workspace,
|
||||
showTopBorder: i == 0,
|
||||
showTopBorder: false,
|
||||
currentWorkspace: currentWorkspace,
|
||||
onWorkspaceSelected: onWorkspaceSelected,
|
||||
),
|
||||
@ -47,6 +54,34 @@ class MobileWorkspaceMenu extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkspaceUserItem extends StatelessWidget {
|
||||
const _WorkspaceUserItem({required this.userProfile});
|
||||
|
||||
final UserProfilePB userProfile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).isLightMode
|
||||
? const Color(0x99333333)
|
||||
: const Color(0x99CCCCCC);
|
||||
return FlowyOptionTile.text(
|
||||
height: 32,
|
||||
showTopBorder: false,
|
||||
showBottomBorder: false,
|
||||
content: Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(),
|
||||
child: FlowyText(
|
||||
userProfile.email,
|
||||
fontSize: 14,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkspaceMenuItem extends StatelessWidget {
|
||||
const _WorkspaceMenuItem({
|
||||
super.key,
|
||||
@ -102,6 +137,7 @@ class _WorkspaceMenuItem extends StatelessWidget {
|
||||
),
|
||||
height: 60,
|
||||
showTopBorder: showTopBorder,
|
||||
showBottomBorder: false,
|
||||
leftIcon: WorkspaceIcon(
|
||||
enableEdit: false,
|
||||
iconSize: 26,
|
||||
|
@ -9,6 +9,7 @@ class MobileSlideActionButton extends StatelessWidget {
|
||||
required this.svg,
|
||||
this.size = 32.0,
|
||||
this.backgroundColor = Colors.transparent,
|
||||
this.borderRadius = BorderRadius.zero,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@ -16,15 +17,18 @@ class MobileSlideActionButton extends StatelessWidget {
|
||||
final double size;
|
||||
final Color backgroundColor;
|
||||
final SlidableActionCallback onPressed;
|
||||
final BorderRadius borderRadius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomSlidableAction(
|
||||
borderRadius: borderRadius,
|
||||
backgroundColor: backgroundColor,
|
||||
onPressed: (context) {
|
||||
HapticFeedback.mediumImpact();
|
||||
onPressed(context);
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
child: FlowySvg(
|
||||
svg,
|
||||
size: Size.square(size),
|
||||
|
@ -1,16 +1,11 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item_add_button.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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';
|
||||
@ -270,17 +265,6 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
|
||||
),
|
||||
];
|
||||
|
||||
// hover action
|
||||
|
||||
// ··· more action button
|
||||
children.add(_buildViewMoreButton(context));
|
||||
// only support add button for document layout
|
||||
if (!widget.isFeedback && widget.view.layout == ViewLayoutPB.Document) {
|
||||
// + button
|
||||
|
||||
children.add(_buildViewAddButton(context));
|
||||
}
|
||||
|
||||
Widget child = InkWell(
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
onTap: () => widget.onSelected(widget.view),
|
||||
@ -345,86 +329,6 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// + button
|
||||
Widget _buildViewAddButton(BuildContext context) {
|
||||
return MobileViewAddButton(
|
||||
onPressed: () {
|
||||
final title = widget.view.name;
|
||||
showMobileBottomSheet(
|
||||
context,
|
||||
showHeader: true,
|
||||
title: title,
|
||||
showDragHandle: true,
|
||||
showCloseButton: true,
|
||||
useRootNavigator: true,
|
||||
builder: (sheetContext) {
|
||||
return AddNewPageWidgetBottomSheet(
|
||||
view: widget.view,
|
||||
onAction: (layout) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
context.read<ViewBloc>().add(
|
||||
ViewEvent.createView(
|
||||
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
layout,
|
||||
section: widget.spaceType != FolderSpaceType.favorite
|
||||
? widget.spaceType.toViewSectionPB
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// + button
|
||||
Widget _buildViewMoreButton(BuildContext context) {
|
||||
return MobileViewMoreButton(onPressed: () => _showMoreActions(context));
|
||||
}
|
||||
|
||||
Future<void> _showMoreActions(BuildContext context) async {
|
||||
final viewBloc = context.read<ViewBloc>();
|
||||
final favoriteBloc = context.read<FavoriteBloc>();
|
||||
await showMobileBottomSheet(
|
||||
context,
|
||||
showHeader: true,
|
||||
title: widget.view.name,
|
||||
showDragHandle: true,
|
||||
showCloseButton: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: viewBloc),
|
||||
BlocProvider.value(value: favoriteBloc),
|
||||
],
|
||||
child: BlocBuilder<ViewBloc, ViewState>(
|
||||
builder: (context, state) {
|
||||
final isFavorite = state.view.isFavorite;
|
||||
return MobileViewItemBottomSheet(
|
||||
view: viewBloc.state.view,
|
||||
actions: [
|
||||
isFavorite
|
||||
? MobileViewItemBottomSheetBodyAction.removeFromFavorites
|
||||
: MobileViewItemBottomSheetBodyAction.addToFavorites,
|
||||
MobileViewItemBottomSheetBodyAction.divider,
|
||||
MobileViewItemBottomSheetBodyAction.rename,
|
||||
MobileViewItemBottomSheetBodyAction.divider,
|
||||
if (state.view.layout != ViewLayoutPB.Chat)
|
||||
MobileViewItemBottomSheetBodyAction.duplicate,
|
||||
MobileViewItemBottomSheetBodyAction.divider,
|
||||
MobileViewItemBottomSheetBodyAction.delete,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// workaround: we should use view.isEndPoint or something to check if the view can contain child views. But currently, we don't have that field.
|
||||
|
@ -189,11 +189,13 @@ class FavoriteMoreButton extends StatelessWidget {
|
||||
margin: EdgeInsets.zero,
|
||||
child: FlowyButton(
|
||||
onTap: () {},
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 7.0),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 7.0),
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.workspace_three_dots_s,
|
||||
),
|
||||
text: FlowyText.regular(LocaleKeys.button_more.tr()),
|
||||
text: FlowyText.regular(
|
||||
LocaleKeys.button_more.tr(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -88,7 +88,7 @@ class SidebarTopMenu extends StatelessWidget {
|
||||
builder: (_, value, ___) => Opacity(
|
||||
opacity: value ? 1 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0, right: 4.0),
|
||||
padding: const EdgeInsets.only(top: 12.0, right: 6.0),
|
||||
child: FlowyTooltip(
|
||||
richMessage: textSpan,
|
||||
child: Listener(
|
||||
|
@ -191,6 +191,7 @@ class _SidebarState extends State<_Sidebar> {
|
||||
Timer? _scrollDebounce;
|
||||
bool _isScrolling = false;
|
||||
final _isHovered = ValueNotifier(false);
|
||||
final _scrollOffset = ValueNotifier<double>(0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -203,6 +204,7 @@ class _SidebarState extends State<_Sidebar> {
|
||||
_scrollDebounce?.cancel();
|
||||
_scrollController.removeListener(_onScrollChanged);
|
||||
_scrollController.dispose();
|
||||
_scrollOffset.dispose();
|
||||
_isHovered.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@ -255,11 +257,20 @@ class _SidebarState extends State<_Sidebar> {
|
||||
const SidebarNewPageButton(),
|
||||
// scrollable document list
|
||||
const VSpace(12.0),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Divider(
|
||||
color: Color(0x1E1F2329),
|
||||
height: 0.5,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _scrollOffset,
|
||||
builder: (_, offset, child) {
|
||||
return Opacity(
|
||||
opacity: offset > 0 ? 1 : 0,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: const Divider(
|
||||
color: Color(0x141F2329),
|
||||
height: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
@ -281,7 +292,7 @@ class _SidebarState extends State<_Sidebar> {
|
||||
Padding(
|
||||
padding: menuHorizontalInset +
|
||||
const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: const Divider(height: 1.0, color: Color(0x141F2329)),
|
||||
child: const Divider(height: 0.5, color: Color(0x141F2329)),
|
||||
),
|
||||
const VSpace(8),
|
||||
Padding(
|
||||
@ -302,6 +313,8 @@ class _SidebarState extends State<_Sidebar> {
|
||||
_scrollDebounce?.cancel();
|
||||
_scrollDebounce =
|
||||
Timer(const Duration(milliseconds: 300), _setScrollStopped);
|
||||
|
||||
_scrollOffset.value = _scrollController.offset;
|
||||
}
|
||||
|
||||
void _setScrollStopped() {
|
||||
|
@ -17,6 +17,7 @@ class WorkspaceIcon extends StatefulWidget {
|
||||
required this.onSelected,
|
||||
this.borderRadius = 4,
|
||||
this.emojiSize,
|
||||
this.alignment,
|
||||
});
|
||||
|
||||
final UserWorkspacePB workspace;
|
||||
@ -26,6 +27,7 @@ class WorkspaceIcon extends StatefulWidget {
|
||||
final double? emojiSize;
|
||||
final void Function(EmojiPickerResult) onSelected;
|
||||
final double borderRadius;
|
||||
final Alignment? alignment;
|
||||
|
||||
@override
|
||||
State<WorkspaceIcon> createState() => _WorkspaceIconState();
|
||||
@ -38,8 +40,8 @@ class _WorkspaceIconState extends State<WorkspaceIcon> {
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = widget.workspace.icon.isNotEmpty
|
||||
? Container(
|
||||
width: widget.iconSize,
|
||||
alignment: Alignment.center,
|
||||
width: widget.emojiSize ?? widget.iconSize,
|
||||
alignment: widget.alignment ?? Alignment.center,
|
||||
child: FlowyText.emoji(
|
||||
widget.workspace.icon,
|
||||
fontSize: widget.emojiSize ?? widget.iconSize,
|
||||
|
@ -50,7 +50,7 @@ class _SidebarWorkspaceState extends State<SidebarWorkspace> {
|
||||
UserSettingButton(userProfile: widget.userProfile),
|
||||
const HSpace(8.0),
|
||||
const NotificationButton(),
|
||||
const HSpace(10.0),
|
||||
const HSpace(12.0),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
@ -434,7 +434,6 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
|
||||
child: FlowyText.regular(
|
||||
widget.view.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
lineHeight: 1.1,
|
||||
),
|
||||
),
|
||||
];
|
||||
@ -466,7 +465,6 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: widget.level * widget.leftPadding),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
@ -499,7 +497,6 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
|
||||
? FlowyText.emoji(
|
||||
widget.view.icon.value,
|
||||
fontSize: 16.0,
|
||||
lineHeight: 1.4,
|
||||
)
|
||||
: Opacity(opacity: 0.6, child: widget.view.defaultIcon());
|
||||
|
||||
|
@ -3,8 +3,6 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
const String _emojiFontFamily = 'noto color emoji';
|
||||
|
||||
class FlowyText extends StatelessWidget {
|
||||
final String text;
|
||||
final TextOverflow? overflow;
|
||||
@ -138,16 +136,13 @@ class FlowyText extends StatelessWidget {
|
||||
|
||||
var fontFamily = this.fontFamily;
|
||||
var fallbackFontFamily = this.fallbackFontFamily;
|
||||
if (isEmoji && (Platform.isLinux || Platform.isAndroid)) {
|
||||
var fontSize =
|
||||
this.fontSize ?? Theme.of(context).textTheme.bodyMedium!.fontSize!;
|
||||
if (isEmoji && _useNotoColorEmoji) {
|
||||
fontFamily = _loadEmojiFontFamilyIfNeeded();
|
||||
if (fontFamily != null && fallbackFontFamily == null) {
|
||||
fallbackFontFamily = [fontFamily];
|
||||
}
|
||||
}
|
||||
|
||||
var fontSize =
|
||||
this.fontSize ?? Theme.of(context).textTheme.bodyMedium!.fontSize!;
|
||||
if (Platform.isLinux && fontFamily == _emojiFontFamily) {
|
||||
fontSize = fontSize * 0.8;
|
||||
}
|
||||
|
||||
@ -175,6 +170,14 @@ class FlowyText extends StatelessWidget {
|
||||
textAlign: textAlign,
|
||||
overflow: overflow ?? TextOverflow.clip,
|
||||
style: textStyle,
|
||||
strutStyle: Platform.isMacOS
|
||||
? StrutStyle.fromTextStyle(
|
||||
textStyle,
|
||||
forceStrutHeight: true,
|
||||
leadingDistribution: TextLeadingDistribution.even,
|
||||
height: 1.1,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -189,10 +192,13 @@ class FlowyText extends StatelessWidget {
|
||||
}
|
||||
|
||||
String? _loadEmojiFontFamilyIfNeeded() {
|
||||
if (Platform.isLinux || Platform.isAndroid) {
|
||||
if (_useNotoColorEmoji) {
|
||||
return GoogleFonts.notoColorEmoji().fontFamily;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get _useNotoColorEmoji =>
|
||||
Platform.isLinux || Platform.isAndroid || Platform.isWindows;
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ class FlowyTooltip extends StatelessWidget {
|
||||
final isLightMode = Theme.of(context).brightness == Brightness.light;
|
||||
return Tooltip(
|
||||
margin: margin,
|
||||
verticalOffset: 16.0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: isLightMode ? const Color(0xE5171717) : const Color(0xE5E5E5E5),
|
||||
|
Loading…
Reference in New Issue
Block a user