mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-07-23 17:11:23 +00:00

* chore: refactor the publish code * feat: integrate publish into database page * feat: add publish database structure * feat: add database row collab * feat: publish the database row collabs * chore: update collab * chore: improve question bubble * feat: publish the database relations * fix: rust ci * feat: select grid view to publish * feat: unable to deselect the primary database * feat: optimize the read recent views speed (#5726) * feat: optimize the read recent views speed * fix: order of recent views should be from the latest to the oldest * chore: update translations * fix: replace the unable to be selected icon * feat: remove left padding of inline database * fix: code review * chore: remove publish api err log * chore: read the database collab and document collab from disk instead of memory * chore: code cleanup * chore: revert beta.appflowy.com * chore: code cleanup * test: add database encode test * test: add publish database test * chore: refresh sidebar layout * chore: update comments
560 lines
16 KiB
Dart
560 lines
16 KiB
Dart
import 'package:appflowy/core/helpers/url_launcher.dart';
|
|
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
|
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
|
|
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
|
import 'package:appflowy/plugins/shared/share/publish_color_extension.dart';
|
|
import 'package:appflowy/plugins/shared/share/publish_name_generator.dart';
|
|
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
|
|
import 'package:appflowy/startup/startup.dart';
|
|
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
|
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
|
import 'package:appflowy_backend/log.dart';
|
|
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
|
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
|
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
|
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
|
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
|
|
class PublishTab extends StatelessWidget {
|
|
const PublishTab({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocConsumer<ShareBloc, ShareState>(
|
|
listener: (context, state) {
|
|
_showToast(context, state);
|
|
},
|
|
builder: (context, state) {
|
|
if (state.isPublished) {
|
|
return _PublishedWidget(
|
|
url: state.url,
|
|
onVisitSite: (url) => afLaunchUrlString(url),
|
|
onUnPublish: () {
|
|
context.read<ShareBloc>().add(const ShareEvent.unPublish());
|
|
},
|
|
);
|
|
} else {
|
|
return _PublishWidget(
|
|
onPublish: (selectedViews) async {
|
|
final id = context.read<ShareBloc>().view.id;
|
|
final publishName = await generatePublishName(
|
|
id,
|
|
state.viewName,
|
|
);
|
|
|
|
if (selectedViews.isNotEmpty) {
|
|
Log.info(
|
|
'Publishing views: ${selectedViews.map((e) => e.name)}',
|
|
);
|
|
}
|
|
|
|
if (context.mounted) {
|
|
context.read<ShareBloc>().add(
|
|
ShareEvent.publish(
|
|
'',
|
|
publishName,
|
|
selectedViews.map((e) => e.id).toList(),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showToast(BuildContext context, ShareState state) {
|
|
if (state.publishResult != null) {
|
|
state.publishResult!.fold(
|
|
(value) => showToastNotification(
|
|
context,
|
|
message: LocaleKeys.publish_publishSuccessfully.tr(),
|
|
),
|
|
(error) => showToastNotification(
|
|
context,
|
|
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
|
|
),
|
|
);
|
|
} else if (state.unpublishResult != null) {
|
|
state.unpublishResult!.fold(
|
|
(value) => showToastNotification(
|
|
context,
|
|
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
|
|
),
|
|
(error) => showToastNotification(
|
|
context,
|
|
message: LocaleKeys.publish_unpublishFailed.tr(),
|
|
description: error.msg,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _PublishedWidget extends StatefulWidget {
|
|
const _PublishedWidget({
|
|
required this.url,
|
|
required this.onVisitSite,
|
|
required this.onUnPublish,
|
|
});
|
|
|
|
final String url;
|
|
final void Function(String url) onVisitSite;
|
|
final VoidCallback onUnPublish;
|
|
|
|
@override
|
|
State<_PublishedWidget> createState() => _PublishedWidgetState();
|
|
}
|
|
|
|
class _PublishedWidgetState extends State<_PublishedWidget> {
|
|
final controller = TextEditingController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
controller.text = widget.url;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const VSpace(16),
|
|
const _PublishTabHeader(),
|
|
const VSpace(16),
|
|
_PublishUrl(
|
|
controller: controller,
|
|
onCopy: (url) {
|
|
getIt<ClipboardService>().setData(
|
|
ClipboardServiceData(plainText: url),
|
|
);
|
|
|
|
showToastNotification(
|
|
context,
|
|
message: LocaleKeys.grid_url_copy.tr(),
|
|
);
|
|
},
|
|
onSubmitted: (url) {},
|
|
),
|
|
const VSpace(16),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildUnpublishButton(),
|
|
const Spacer(),
|
|
_buildVisitSiteButton(),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildUnpublishButton() {
|
|
return SizedBox(
|
|
width: 184,
|
|
height: 36,
|
|
child: FlowyButton(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: ShareMenuColors.borderColor(context)),
|
|
),
|
|
radius: BorderRadius.circular(10),
|
|
text: FlowyText.regular(
|
|
LocaleKeys.shareAction_unPublish.tr(),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
onTap: widget.onUnPublish,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVisitSiteButton() {
|
|
return RoundedTextButton(
|
|
width: 184,
|
|
height: 36,
|
|
onPressed: () => widget.onVisitSite(controller.text),
|
|
title: LocaleKeys.shareAction_visitSite.tr(),
|
|
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
|
fillColor: Theme.of(context).colorScheme.primary,
|
|
textColor: Theme.of(context).colorScheme.onPrimary,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PublishWidget extends StatefulWidget {
|
|
const _PublishWidget({
|
|
required this.onPublish,
|
|
});
|
|
|
|
final void Function(List<ViewPB> selectedViews) onPublish;
|
|
|
|
@override
|
|
State<_PublishWidget> createState() => _PublishWidgetState();
|
|
}
|
|
|
|
class _PublishWidgetState extends State<_PublishWidget> {
|
|
List<ViewPB> _selectedViews = [];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const VSpace(16),
|
|
const _PublishTabHeader(),
|
|
const VSpace(16),
|
|
// if current view is a database, show the database selector
|
|
if (context.read<ShareBloc>().view.layout.isDatabaseView) ...[
|
|
_PublishDatabaseSelector(
|
|
view: context.read<ShareBloc>().view,
|
|
onSelected: (selectedDatabases) {
|
|
_selectedViews = selectedDatabases;
|
|
},
|
|
),
|
|
const VSpace(16),
|
|
],
|
|
_PublishButton(
|
|
onPublish: () {
|
|
if (context.read<ShareBloc>().view.layout.isDatabaseView) {
|
|
// check if any database is selected
|
|
if (_selectedViews.isEmpty) {
|
|
showToastNotification(
|
|
context,
|
|
message: LocaleKeys.publish_noDatabaseSelected.tr(),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
widget.onPublish(_selectedViews);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PublishButton extends StatelessWidget {
|
|
const _PublishButton({
|
|
required this.onPublish,
|
|
});
|
|
|
|
final VoidCallback onPublish;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return RoundedTextButton(
|
|
height: 36,
|
|
title: LocaleKeys.shareAction_publish.tr(),
|
|
padding: const EdgeInsets.symmetric(vertical: 9.0),
|
|
fontSize: 14.0,
|
|
textColor: Theme.of(context).colorScheme.onPrimary,
|
|
onPressed: onPublish,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PublishTabHeader extends StatelessWidget {
|
|
const _PublishTabHeader();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const FlowySvg(FlowySvgs.share_publish_s),
|
|
const HSpace(6),
|
|
FlowyText(LocaleKeys.shareAction_publishToTheWeb.tr()),
|
|
],
|
|
),
|
|
const VSpace(4),
|
|
FlowyText.regular(
|
|
LocaleKeys.shareAction_publishToTheWebHint.tr(),
|
|
fontSize: 12,
|
|
maxLines: 3,
|
|
color: Theme.of(context).hintColor,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PublishUrl extends StatelessWidget {
|
|
const _PublishUrl({
|
|
required this.controller,
|
|
required this.onCopy,
|
|
required this.onSubmitted,
|
|
});
|
|
|
|
final TextEditingController controller;
|
|
final void Function(String url) onCopy;
|
|
final void Function(String url) onSubmitted;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
height: 36,
|
|
child: FlowyTextField(
|
|
readOnly: true,
|
|
autoFocus: false,
|
|
controller: controller,
|
|
enableBorderColor: ShareMenuColors.borderColor(context),
|
|
suffixIcon: _buildCopyLinkIcon(context),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCopyLinkIcon(BuildContext context) {
|
|
return FlowyHover(
|
|
child: GestureDetector(
|
|
onTap: () => onCopy(controller.text),
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
alignment: Alignment.center,
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: const BoxDecoration(
|
|
border: Border(left: BorderSide(color: Color(0x141F2329))),
|
|
),
|
|
child: const FlowySvg(
|
|
FlowySvgs.m_toolbar_link_m,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// used to select which database view should be published
|
|
class _PublishDatabaseSelector extends StatefulWidget {
|
|
const _PublishDatabaseSelector({
|
|
required this.view,
|
|
required this.onSelected,
|
|
});
|
|
|
|
final ViewPB view;
|
|
final void Function(List<ViewPB> selectedDatabases) onSelected;
|
|
|
|
@override
|
|
State<_PublishDatabaseSelector> createState() =>
|
|
_PublishDatabaseSelectorState();
|
|
}
|
|
|
|
class _PublishDatabaseSelectorState extends State<_PublishDatabaseSelector> {
|
|
final PropertyValueNotifier<List<(ViewPB, bool)>> _databaseStatus =
|
|
PropertyValueNotifier<List<(ViewPB, bool)>>([]);
|
|
late final _borderColor = Theme.of(context).hintColor.withOpacity(0.3);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_databaseStatus.addListener(() {
|
|
final selectedDatabases =
|
|
_databaseStatus.value.where((e) => e.$2).map((e) => e.$1).toList();
|
|
widget.onSelected(selectedDatabases);
|
|
});
|
|
|
|
_databaseStatus.value = context
|
|
.read<DatabaseTabBarBloc>()
|
|
.state
|
|
.tabBars
|
|
.map((e) => (e.view, true))
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_databaseStatus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
|
builder: (context, state) {
|
|
return Container(
|
|
clipBehavior: Clip.antiAlias,
|
|
decoration: ShapeDecoration(
|
|
shape: RoundedRectangleBorder(
|
|
side: BorderSide(color: _borderColor),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const VSpace(10),
|
|
_buildSelectedDatabaseCount(context),
|
|
const VSpace(10),
|
|
_buildDivider(context),
|
|
const VSpace(10),
|
|
...state.tabBars.map(
|
|
(e) => _buildDatabaseSelector(context, e),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildDivider(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Divider(
|
|
color: _borderColor,
|
|
thickness: 1,
|
|
height: 1,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSelectedDatabaseCount(BuildContext context) {
|
|
return ValueListenableBuilder(
|
|
valueListenable: _databaseStatus,
|
|
builder: (context, selectedDatabases, child) {
|
|
final count = selectedDatabases.where((e) => e.$2).length;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: FlowyText(
|
|
LocaleKeys.publish_database.plural(count).tr(),
|
|
color: Theme.of(context).hintColor,
|
|
fontSize: 13,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildDatabaseSelector(BuildContext context, DatabaseTabBar tabBar) {
|
|
final isPrimaryDatabase = tabBar.view.id == widget.view.id;
|
|
return ValueListenableBuilder(
|
|
valueListenable: _databaseStatus,
|
|
builder: (context, selectedDatabases, child) {
|
|
final isSelected = selectedDatabases.any(
|
|
(e) => e.$1.id == tabBar.view.id && e.$2,
|
|
);
|
|
return _DatabaseSelectorItem(
|
|
tabBar: tabBar,
|
|
isSelected: isSelected,
|
|
isPrimaryDatabase: isPrimaryDatabase,
|
|
onTap: () {
|
|
// unable to deselect the primary database
|
|
if (isPrimaryDatabase) {
|
|
showToastNotification(
|
|
context,
|
|
message:
|
|
LocaleKeys.publish_unableToDeselectPrimaryDatabase.tr(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// toggle the selection status
|
|
_databaseStatus.value = _databaseStatus.value
|
|
.map(
|
|
(e) =>
|
|
e.$1.id == tabBar.view.id ? (e.$1, !e.$2) : (e.$1, e.$2),
|
|
)
|
|
.toList();
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DatabaseSelectorItem extends StatelessWidget {
|
|
const _DatabaseSelectorItem({
|
|
required this.tabBar,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
required this.isPrimaryDatabase,
|
|
});
|
|
|
|
final DatabaseTabBar tabBar;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
final bool isPrimaryDatabase;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
Widget child = _buildItem(context);
|
|
|
|
if (!isPrimaryDatabase) {
|
|
child = FlowyHover(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: child,
|
|
),
|
|
);
|
|
} else {
|
|
child = FlowyTooltip(
|
|
message: LocaleKeys.publish_mustSelectPrimaryDatabase.tr(),
|
|
child: MouseRegion(
|
|
cursor: SystemMouseCursors.forbidden,
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
Widget _buildItem(BuildContext context) {
|
|
final svg = isPrimaryDatabase
|
|
? FlowySvgs.unable_select_s
|
|
: isSelected
|
|
? FlowySvgs.check_filled_s
|
|
: FlowySvgs.uncheck_s;
|
|
final blendMode = isPrimaryDatabase ? BlendMode.srcIn : null;
|
|
return Container(
|
|
height: 30,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Row(
|
|
children: [
|
|
FlowySvg(
|
|
svg,
|
|
blendMode: blendMode,
|
|
size: const Size.square(18),
|
|
),
|
|
const HSpace(9.0),
|
|
FlowySvg(
|
|
tabBar.view.layout.icon,
|
|
size: const Size.square(16),
|
|
),
|
|
const HSpace(6.0),
|
|
FlowyText.regular(
|
|
tabBar.view.name,
|
|
fontSize: 14,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|