mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
refactor: move plugin application and presentation together
This commit is contained in:
166
frontend/app_flowy/lib/plugins/doc/application/doc_bloc.dart
Normal file
166
frontend/app_flowy/lib/plugins/doc/application/doc_bloc.dart
Normal file
@ -0,0 +1,166 @@
|
||||
import 'dart:convert';
|
||||
import 'package:app_flowy/plugins/trash/application/trash_service.dart';
|
||||
import 'package:app_flowy/workspace/application/view/view_listener.dart';
|
||||
import 'package:app_flowy/plugins/doc/application/doc_service.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-folder/trash.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart' show Document, Delta;
|
||||
import 'package:flowy_sdk/log.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'dart:async';
|
||||
|
||||
part 'doc_bloc.freezed.dart';
|
||||
|
||||
typedef FlutterQuillDocument = Document;
|
||||
|
||||
class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
final ViewPB view;
|
||||
final DocumentService service;
|
||||
|
||||
final ViewListener listener;
|
||||
final TrashService trashService;
|
||||
late FlutterQuillDocument document;
|
||||
StreamSubscription? _subscription;
|
||||
|
||||
DocumentBloc({
|
||||
required this.view,
|
||||
required this.service,
|
||||
required this.listener,
|
||||
required this.trashService,
|
||||
}) : super(DocumentState.initial()) {
|
||||
on<DocumentEvent>((event, emit) async {
|
||||
await event.map(
|
||||
initial: (Initial value) async {
|
||||
await _initial(value, emit);
|
||||
},
|
||||
deleted: (Deleted value) async {
|
||||
emit(state.copyWith(isDeleted: true));
|
||||
},
|
||||
restore: (Restore value) async {
|
||||
emit(state.copyWith(isDeleted: false));
|
||||
},
|
||||
deletePermanently: (DeletePermanently value) async {
|
||||
final result = await trashService
|
||||
.deleteViews([Tuple2(view.id, TrashType.TrashView)]);
|
||||
|
||||
final newState = result.fold(
|
||||
(l) => state.copyWith(forceClose: true), (r) => state);
|
||||
emit(newState);
|
||||
},
|
||||
restorePage: (RestorePage value) async {
|
||||
final result = await trashService.putback(view.id);
|
||||
final newState = result.fold(
|
||||
(l) => state.copyWith(isDeleted: false), (r) => state);
|
||||
emit(newState);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await listener.stop();
|
||||
|
||||
if (_subscription != null) {
|
||||
await _subscription?.cancel();
|
||||
}
|
||||
|
||||
await service.closeDocument(docId: view.id);
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _initial(Initial value, Emitter<DocumentState> emit) async {
|
||||
listener.start(
|
||||
onViewDeleted: (result) {
|
||||
result.fold(
|
||||
(view) => add(const DocumentEvent.deleted()),
|
||||
(error) {},
|
||||
);
|
||||
},
|
||||
onViewRestored: (result) {
|
||||
result.fold(
|
||||
(view) => add(const DocumentEvent.restore()),
|
||||
(error) {},
|
||||
);
|
||||
},
|
||||
);
|
||||
final result = await service.openDocument(docId: view.id);
|
||||
result.fold(
|
||||
(block) {
|
||||
document = _decodeJsonToDocument(block.deltaStr);
|
||||
_subscription = document.changes.listen((event) {
|
||||
final delta = event.item2;
|
||||
final documentDelta = document.toDelta();
|
||||
_composeDelta(delta, documentDelta);
|
||||
});
|
||||
emit(state.copyWith(
|
||||
loadingState: DocumentLoadingState.finish(left(unit))));
|
||||
},
|
||||
(err) {
|
||||
emit(state.copyWith(
|
||||
loadingState: DocumentLoadingState.finish(right(err))));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Document _decodeListToDocument(Uint8List data) {
|
||||
// final json = jsonDecode(utf8.decode(data));
|
||||
// final document = Document.fromJson(json);
|
||||
// return document;
|
||||
// }
|
||||
|
||||
void _composeDelta(Delta composedDelta, Delta documentDelta) async {
|
||||
final json = jsonEncode(composedDelta.toJson());
|
||||
Log.debug("doc_id: $view.id - Send json: $json");
|
||||
final result = await service.composeDelta(docId: view.id, data: json);
|
||||
|
||||
result.fold((rustDoc) {
|
||||
// final json = utf8.decode(doc.data);
|
||||
final rustDelta = Delta.fromJson(jsonDecode(rustDoc.deltaStr));
|
||||
if (documentDelta != rustDelta) {
|
||||
Log.error("Receive : $rustDelta");
|
||||
Log.error("Expected : $documentDelta");
|
||||
}
|
||||
}, (r) => null);
|
||||
}
|
||||
|
||||
Document _decodeJsonToDocument(String data) {
|
||||
final json = jsonDecode(data);
|
||||
final document = Document.fromJson(json);
|
||||
return document;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class DocumentEvent with _$DocumentEvent {
|
||||
const factory DocumentEvent.initial() = Initial;
|
||||
const factory DocumentEvent.deleted() = Deleted;
|
||||
const factory DocumentEvent.restore() = Restore;
|
||||
const factory DocumentEvent.restorePage() = RestorePage;
|
||||
const factory DocumentEvent.deletePermanently() = DeletePermanently;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class DocumentState with _$DocumentState {
|
||||
const factory DocumentState({
|
||||
required DocumentLoadingState loadingState,
|
||||
required bool isDeleted,
|
||||
required bool forceClose,
|
||||
}) = _DocumentState;
|
||||
|
||||
factory DocumentState.initial() => const DocumentState(
|
||||
loadingState: _Loading(),
|
||||
isDeleted: false,
|
||||
forceClose: false,
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class DocumentLoadingState with _$DocumentLoadingState {
|
||||
const factory DocumentLoadingState.loading() = _Loading;
|
||||
const factory DocumentLoadingState.finish(
|
||||
Either<Unit, FlowyError> successOrFail) = _Finish;
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:flowy_sdk/dispatch/dispatch.dart';
|
||||
|
||||
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-sync/text_block.pb.dart';
|
||||
|
||||
class DocumentService {
|
||||
Future<Either<TextBlockDeltaPB, FlowyError>> openDocument({
|
||||
required String docId,
|
||||
}) async {
|
||||
await FolderEventSetLatestView(ViewIdPB(value: docId)).send();
|
||||
|
||||
final payload = TextBlockIdPB(value: docId);
|
||||
return TextBlockEventGetBlockData(payload).send();
|
||||
}
|
||||
|
||||
Future<Either<TextBlockDeltaPB, FlowyError>> composeDelta({required String docId, required String data}) {
|
||||
final payload = TextBlockDeltaPB.create()
|
||||
..blockId = docId
|
||||
..deltaStr = data;
|
||||
return TextBlockEventApplyDelta(payload).send();
|
||||
}
|
||||
|
||||
Future<Either<Unit, FlowyError>> closeDocument({required String docId}) {
|
||||
final request = ViewIdPB(value: docId);
|
||||
return FolderEventCloseView(request).send();
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
export 'doc_bloc.dart';
|
||||
export 'doc_service.dart';
|
||||
export 'share_bloc.dart';
|
||||
export 'share_service.dart';
|
@ -0,0 +1,80 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:app_flowy/startup/tasks/rust_sdk.dart';
|
||||
import 'package:app_flowy/workspace/application/markdown/delta_markdown.dart';
|
||||
import 'package:app_flowy/plugins/doc/application/share_service.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-text-block/entities.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
part 'share_bloc.freezed.dart';
|
||||
|
||||
class DocShareBloc extends Bloc<DocShareEvent, DocShareState> {
|
||||
ShareService service;
|
||||
ViewPB view;
|
||||
DocShareBloc({required this.view, required this.service})
|
||||
: super(const DocShareState.initial()) {
|
||||
on<DocShareEvent>((event, emit) async {
|
||||
await event.map(
|
||||
shareMarkdown: (ShareMarkdown value) async {
|
||||
await service.exportMarkdown(view.id).then((result) {
|
||||
result.fold(
|
||||
(value) => emit(
|
||||
DocShareState.finish(left(_convertDeltaToMarkdown(value)))),
|
||||
(error) => emit(DocShareState.finish(right(error))),
|
||||
);
|
||||
});
|
||||
|
||||
emit(const DocShareState.loading());
|
||||
},
|
||||
shareLink: (ShareLink value) {},
|
||||
shareText: (ShareText value) {},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ExportDataPB _convertDeltaToMarkdown(ExportDataPB value) {
|
||||
final result = deltaToMarkdown(value.data);
|
||||
value.data = result;
|
||||
writeFile(result);
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<Directory> get _exportDir async {
|
||||
Directory documentsDir = await appFlowyDocumentDirectory();
|
||||
|
||||
return documentsDir;
|
||||
}
|
||||
|
||||
Future<String> get _localPath async {
|
||||
final dir = await _exportDir;
|
||||
return dir.path;
|
||||
}
|
||||
|
||||
Future<File> get _localFile async {
|
||||
final path = await _localPath;
|
||||
return File('$path/${view.name}.md');
|
||||
}
|
||||
|
||||
Future<File> writeFile(String md) async {
|
||||
final file = await _localFile;
|
||||
return file.writeAsString(md);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class DocShareEvent with _$DocShareEvent {
|
||||
const factory DocShareEvent.shareMarkdown() = ShareMarkdown;
|
||||
const factory DocShareEvent.shareText() = ShareText;
|
||||
const factory DocShareEvent.shareLink() = ShareLink;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class DocShareState with _$DocShareState {
|
||||
const factory DocShareState.initial() = _Initial;
|
||||
const factory DocShareState.loading() = _Loading;
|
||||
const factory DocShareState.finish(
|
||||
Either<ExportDataPB, FlowyError> successOrFail) = _Finish;
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import 'dart:async';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:flowy_sdk/dispatch/dispatch.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-text-block/protobuf.dart';
|
||||
|
||||
class ShareService {
|
||||
Future<Either<ExportDataPB, FlowyError>> export(String docId, ExportType type) {
|
||||
final request = ExportPayloadPB.create()
|
||||
..viewId = docId
|
||||
..exportType = type;
|
||||
|
||||
return TextBlockEventExportDocument(request).send();
|
||||
}
|
||||
|
||||
Future<Either<ExportDataPB, FlowyError>> exportText(String docId) {
|
||||
return export(docId, ExportType.Text);
|
||||
}
|
||||
|
||||
Future<Either<ExportDataPB, FlowyError>> exportMarkdown(String docId) {
|
||||
return export(docId, ExportType.Markdown);
|
||||
}
|
||||
|
||||
Future<Either<ExportDataPB, FlowyError>> exportURL(String docId) {
|
||||
return export(docId, ExportType.Link);
|
||||
}
|
||||
}
|
263
frontend/app_flowy/lib/plugins/doc/document.dart
Normal file
263
frontend/app_flowy/lib/plugins/doc/document.dart
Normal file
@ -0,0 +1,263 @@
|
||||
library docuemnt_plugin;
|
||||
|
||||
import 'package:app_flowy/generated/locale_keys.g.dart';
|
||||
import 'package:app_flowy/startup/plugin/plugin.dart';
|
||||
import 'package:app_flowy/startup/startup.dart';
|
||||
import 'package:app_flowy/workspace/application/appearance.dart';
|
||||
import 'package:app_flowy/workspace/application/view/view_listener.dart';
|
||||
import 'package:app_flowy/plugins/doc/application/share_bloc.dart';
|
||||
import 'package:app_flowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:app_flowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:app_flowy/workspace/presentation/widgets/left_bar_item.dart';
|
||||
import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:app_flowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:clipboard/clipboard.dart';
|
||||
import 'package:dartz/dartz.dart' as dartz;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/notifier.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
||||
import 'package:flowy_sdk/log.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-text-block/entities.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'document_page.dart';
|
||||
|
||||
class DocumentPluginBuilder extends PluginBuilder {
|
||||
@override
|
||||
Plugin build(dynamic data) {
|
||||
if (data is ViewPB) {
|
||||
return DocumentPlugin(pluginType: pluginType, view: data);
|
||||
} else {
|
||||
throw FlowyPluginException.invalidData;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String get menuName => LocaleKeys.document_menuName.tr();
|
||||
|
||||
@override
|
||||
PluginType get pluginType => DefaultPlugin.quill.type();
|
||||
|
||||
@override
|
||||
ViewDataType get dataType => ViewDataType.TextBlock;
|
||||
}
|
||||
|
||||
class DocumentPlugin implements Plugin {
|
||||
late ViewPB _view;
|
||||
ViewListener? _listener;
|
||||
late PluginType _pluginType;
|
||||
|
||||
DocumentPlugin(
|
||||
{required PluginType pluginType, required ViewPB view, Key? key})
|
||||
: _view = view {
|
||||
_pluginType = pluginType;
|
||||
_listener = getIt<ViewListener>(param1: view);
|
||||
_listener?.start(onViewUpdated: (result) {
|
||||
result.fold(
|
||||
(newView) {
|
||||
_view = newView;
|
||||
display.notifier!.value = _view.hashCode;
|
||||
},
|
||||
(error) {},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_listener?.stop();
|
||||
_listener = null;
|
||||
}
|
||||
|
||||
@override
|
||||
PluginDisplay<int> get display => DocumentPluginDisplay(view: _view);
|
||||
|
||||
@override
|
||||
PluginType get ty => _pluginType;
|
||||
|
||||
@override
|
||||
PluginId get id => _view.id;
|
||||
}
|
||||
|
||||
class DocumentPluginDisplay extends PluginDisplay<int> with NavigationItem {
|
||||
final PublishNotifier<int> _displayNotifier = PublishNotifier<int>();
|
||||
final ViewPB _view;
|
||||
|
||||
DocumentPluginDisplay({required ViewPB view, Key? key}) : _view = view;
|
||||
|
||||
@override
|
||||
Widget buildWidget() => DocumentPage(view: _view, key: ValueKey(_view.id));
|
||||
|
||||
@override
|
||||
Widget get leftBarItem => ViewLeftBarItem(view: _view);
|
||||
|
||||
@override
|
||||
Widget? get rightBarItem => DocumentShareButton(view: _view);
|
||||
|
||||
@override
|
||||
List<NavigationItem> get navigationItems => [this];
|
||||
|
||||
@override
|
||||
PublishNotifier<int>? get notifier => _displayNotifier;
|
||||
}
|
||||
|
||||
class DocumentShareButton extends StatelessWidget {
|
||||
final ViewPB view;
|
||||
DocumentShareButton({Key? key, required this.view})
|
||||
: super(key: ValueKey(view.hashCode));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
double buttonWidth = 60;
|
||||
return BlocProvider(
|
||||
create: (context) => getIt<DocShareBloc>(param1: view),
|
||||
child: BlocListener<DocShareBloc, DocShareState>(
|
||||
listener: (context, state) {
|
||||
state.map(
|
||||
initial: (_) {},
|
||||
loading: (_) {},
|
||||
finish: (state) {
|
||||
state.successOrFail.fold(
|
||||
_handleExportData,
|
||||
_handleExportError,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: BlocBuilder<DocShareBloc, DocShareState>(
|
||||
builder: (context, state) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: Provider.of<AppearanceSettingModel>(context, listen: true),
|
||||
child: Selector<AppearanceSettingModel, Locale>(
|
||||
selector: (ctx, notifier) => notifier.locale,
|
||||
builder: (ctx, _, child) => ConstrainedBox(
|
||||
constraints: const BoxConstraints.expand(
|
||||
height: 30,
|
||||
// minWidth: buttonWidth,
|
||||
width: 100,
|
||||
),
|
||||
child: RoundedTextButton(
|
||||
title: LocaleKeys.shareAction_buttonText.tr(),
|
||||
fontSize: 12,
|
||||
borderRadius: Corners.s6Border,
|
||||
color: Colors.lightBlue,
|
||||
onPressed: () => _showActionList(
|
||||
context, Offset(-(buttonWidth / 2), 10)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleExportData(ExportDataPB exportData) {
|
||||
switch (exportData.exportType) {
|
||||
case ExportType.Link:
|
||||
break;
|
||||
case ExportType.Markdown:
|
||||
FlutterClipboard.copy(exportData.data)
|
||||
.then((value) => Log.info('copied to clipboard'));
|
||||
break;
|
||||
case ExportType.Text:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleExportError(FlowyError error) {}
|
||||
|
||||
void _showActionList(BuildContext context, Offset offset) {
|
||||
final actionList = ShareActions(onSelected: (result) {
|
||||
result.fold(() {}, (action) {
|
||||
switch (action) {
|
||||
case ShareAction.markdown:
|
||||
context
|
||||
.read<DocShareBloc>()
|
||||
.add(const DocShareEvent.shareMarkdown());
|
||||
showMessageToast(
|
||||
'Exported to: ${LocaleKeys.notifications_export_path.tr()}');
|
||||
break;
|
||||
case ShareAction.copyLink:
|
||||
FlowyAlertDialog(title: LocaleKeys.shareAction_workInProgress.tr())
|
||||
.show(context);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
actionList.show(
|
||||
context,
|
||||
anchorDirection: AnchorDirection.bottomWithCenterAligned,
|
||||
anchorOffset: offset,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShareActions with ActionList<ShareActionWrapper>, FlowyOverlayDelegate {
|
||||
final Function(dartz.Option<ShareAction>) onSelected;
|
||||
final _items =
|
||||
ShareAction.values.map((action) => ShareActionWrapper(action)).toList();
|
||||
|
||||
ShareActions({required this.onSelected});
|
||||
|
||||
@override
|
||||
double get maxWidth => 130;
|
||||
|
||||
@override
|
||||
double get itemHeight => 22;
|
||||
|
||||
@override
|
||||
List<ShareActionWrapper> get items => _items;
|
||||
|
||||
@override
|
||||
void Function(dartz.Option<ShareActionWrapper> p1) get selectCallback =>
|
||||
(result) {
|
||||
result.fold(
|
||||
() => onSelected(dartz.none()),
|
||||
(wrapper) => onSelected(
|
||||
dartz.some(wrapper.inner),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@override
|
||||
FlowyOverlayDelegate? get delegate => this;
|
||||
|
||||
@override
|
||||
void didRemove() => onSelected(dartz.none());
|
||||
}
|
||||
|
||||
enum ShareAction {
|
||||
markdown,
|
||||
copyLink,
|
||||
}
|
||||
|
||||
class ShareActionWrapper extends ActionItem {
|
||||
final ShareAction inner;
|
||||
|
||||
ShareActionWrapper(this.inner);
|
||||
|
||||
@override
|
||||
Widget? get icon => null;
|
||||
|
||||
@override
|
||||
String get name => inner.name;
|
||||
}
|
||||
|
||||
extension QuestionBubbleExtension on ShareAction {
|
||||
String get name {
|
||||
switch (this) {
|
||||
case ShareAction.markdown:
|
||||
return LocaleKeys.shareAction_markdown.tr();
|
||||
case ShareAction.copyLink:
|
||||
return LocaleKeys.shareAction_copyLink.tr();
|
||||
}
|
||||
}
|
||||
}
|
143
frontend/app_flowy/lib/plugins/doc/document_page.dart
Normal file
143
frontend/app_flowy/lib/plugins/doc/document_page.dart
Normal file
@ -0,0 +1,143 @@
|
||||
import 'package:app_flowy/startup/startup.dart';
|
||||
import 'package:app_flowy/workspace/application/appearance.dart';
|
||||
import 'package:app_flowy/plugins/doc/presentation/banner.dart';
|
||||
import 'package:app_flowy/plugins/doc/presentation/toolbar/tool_bar.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'application/doc_bloc.dart';
|
||||
import 'styles.dart';
|
||||
|
||||
class DocumentPage extends StatefulWidget {
|
||||
final ViewPB view;
|
||||
|
||||
DocumentPage({Key? key, required this.view}) : super(key: ValueKey(view.id));
|
||||
|
||||
@override
|
||||
State<DocumentPage> createState() => _DocumentPageState();
|
||||
}
|
||||
|
||||
class _DocumentPageState extends State<DocumentPage> {
|
||||
late DocumentBloc documentBloc;
|
||||
final scrollController = ScrollController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
documentBloc = getIt<DocumentBloc>(param1: super.widget.view)
|
||||
..add(const DocumentEvent.initial());
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<DocumentBloc>.value(value: documentBloc),
|
||||
],
|
||||
child:
|
||||
BlocBuilder<DocumentBloc, DocumentState>(builder: (context, state) {
|
||||
return state.loadingState.map(
|
||||
// loading: (_) => const FlowyProgressIndicator(),
|
||||
loading: (_) =>
|
||||
SizedBox.expand(child: Container(color: Colors.transparent)),
|
||||
finish: (result) => result.successOrFail.fold(
|
||||
(_) {
|
||||
if (state.forceClose) {
|
||||
return _renderAppPage();
|
||||
} else {
|
||||
return _renderDocument(context, state);
|
||||
}
|
||||
},
|
||||
(err) => FlowyErrorPage(err.toString()),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
documentBloc.close();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _renderDocument(BuildContext context, DocumentState state) {
|
||||
quill.QuillController controller = quill.QuillController(
|
||||
document: context.read<DocumentBloc>().document,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
return Column(
|
||||
children: [
|
||||
if (state.isDeleted) _renderBanner(context),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_renderEditor(controller),
|
||||
const VSpace(10),
|
||||
_renderToolbar(controller),
|
||||
const VSpace(10),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderBanner(BuildContext context) {
|
||||
return DocumentBanner(
|
||||
onRestore: () =>
|
||||
context.read<DocumentBloc>().add(const DocumentEvent.restorePage()),
|
||||
onDelete: () => context
|
||||
.read<DocumentBloc>()
|
||||
.add(const DocumentEvent.deletePermanently()),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderEditor(quill.QuillController controller) {
|
||||
final editor = quill.QuillEditor(
|
||||
controller: controller,
|
||||
focusNode: _focusNode,
|
||||
scrollable: true,
|
||||
paintCursorAboveText: true,
|
||||
autoFocus: controller.document.isEmpty(),
|
||||
expands: false,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
readOnly: false,
|
||||
scrollBottomInset: 0,
|
||||
scrollController: scrollController,
|
||||
customStyles: customStyles(context),
|
||||
);
|
||||
|
||||
return Expanded(
|
||||
child: ScrollbarListStack(
|
||||
axis: Axis.vertical,
|
||||
controller: scrollController,
|
||||
barSize: 6.0,
|
||||
child: SizedBox.expand(child: editor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderToolbar(quill.QuillController controller) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: Provider.of<AppearanceSettingModel>(context, listen: true),
|
||||
child: EditorToolbar.basic(
|
||||
controller: controller,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderAppPage() {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
);
|
||||
}
|
||||
}
|
61
frontend/app_flowy/lib/plugins/doc/presentation/banner.dart
Normal file
61
frontend/app_flowy/lib/plugins/doc/presentation/banner.dart
Normal file
@ -0,0 +1,61 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra/theme.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/buttons/base_styled_button.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:app_flowy/generated/locale_keys.g.dart';
|
||||
|
||||
class DocumentBanner extends StatelessWidget {
|
||||
final void Function() onRestore;
|
||||
final void Function() onDelete;
|
||||
const DocumentBanner({required this.onRestore, required this.onDelete, Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AppTheme>();
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 60),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
color: theme.main1,
|
||||
child: FittedBox(
|
||||
alignment: Alignment.center,
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
children: [
|
||||
FlowyText.medium(LocaleKeys.deletePagePrompt_text.tr(), color: Colors.white),
|
||||
const HSpace(20),
|
||||
BaseStyledButton(
|
||||
minWidth: 160,
|
||||
minHeight: 40,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
bgColor: Colors.transparent,
|
||||
hoverColor: theme.main2,
|
||||
downColor: theme.main1,
|
||||
outlineColor: Colors.white,
|
||||
borderRadius: Corners.s8Border,
|
||||
child: FlowyText.medium(LocaleKeys.deletePagePrompt_restore.tr(), color: Colors.white, fontSize: 14),
|
||||
onPressed: onRestore),
|
||||
const HSpace(20),
|
||||
BaseStyledButton(
|
||||
minWidth: 220,
|
||||
minHeight: 40,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
bgColor: Colors.transparent,
|
||||
hoverColor: theme.main2,
|
||||
downColor: theme.main1,
|
||||
outlineColor: Colors.white,
|
||||
borderRadius: Corners.s8Border,
|
||||
child: FlowyText.medium(LocaleKeys.deletePagePrompt_deletePermanent.tr(),
|
||||
color: Colors.white, fontSize: 14),
|
||||
onPressed: onDelete),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra/theme.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
|
||||
class StyleWidgetBuilder {
|
||||
static QuillCheckboxBuilder checkbox(AppTheme theme) {
|
||||
return EditorCheckboxBuilder(theme);
|
||||
}
|
||||
}
|
||||
|
||||
class EditorCheckboxBuilder extends QuillCheckboxBuilder {
|
||||
final AppTheme theme;
|
||||
|
||||
EditorCheckboxBuilder(this.theme);
|
||||
|
||||
@override
|
||||
Widget build({required BuildContext context, required bool isChecked, required ValueChanged<bool> onChanged}) {
|
||||
return FlowyEditorCheckbox(
|
||||
theme: theme,
|
||||
isChecked: isChecked,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FlowyEditorCheckbox extends StatefulWidget {
|
||||
final bool isChecked;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final AppTheme theme;
|
||||
const FlowyEditorCheckbox({
|
||||
required this.theme,
|
||||
required this.isChecked,
|
||||
required this.onChanged,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_FlowyEditorCheckboxState createState() => _FlowyEditorCheckboxState();
|
||||
}
|
||||
|
||||
class _FlowyEditorCheckboxState extends State<FlowyEditorCheckbox> {
|
||||
late bool isChecked;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
isChecked = widget.isChecked;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = isChecked ? svgWidget('editor/editor_check') : svgWidget('editor/editor_uncheck');
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FlowyIconButton(
|
||||
onPressed: () {
|
||||
isChecked = !isChecked;
|
||||
widget.onChanged(isChecked);
|
||||
setState(() {});
|
||||
},
|
||||
iconPadding: EdgeInsets.zero,
|
||||
icon: icon,
|
||||
width: 23,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,97 @@
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:flutter_quill/models/documents/style.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'toolbar_icon_button.dart';
|
||||
|
||||
class FlowyCheckListButton extends StatefulWidget {
|
||||
const FlowyCheckListButton({
|
||||
required this.controller,
|
||||
required this.attribute,
|
||||
required this.tooltipText,
|
||||
this.iconSize = defaultIconSize,
|
||||
this.fillColor,
|
||||
this.childBuilder = defaultToggleStyleButtonBuilder,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
final double iconSize;
|
||||
|
||||
final Color? fillColor;
|
||||
|
||||
final QuillController controller;
|
||||
|
||||
final ToggleStyleButtonBuilder childBuilder;
|
||||
|
||||
final Attribute attribute;
|
||||
|
||||
final String tooltipText;
|
||||
|
||||
@override
|
||||
_FlowyCheckListButtonState createState() => _FlowyCheckListButtonState();
|
||||
}
|
||||
|
||||
class _FlowyCheckListButtonState extends State<FlowyCheckListButton> {
|
||||
bool? _isToggled;
|
||||
|
||||
Style get _selectionStyle => widget.controller.getSelectionStyle();
|
||||
|
||||
void _didChangeEditingValue() {
|
||||
setState(() {
|
||||
_isToggled =
|
||||
_getIsToggled(widget.controller.getSelectionStyle().attributes);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isToggled = _getIsToggled(_selectionStyle.attributes);
|
||||
widget.controller.addListener(_didChangeEditingValue);
|
||||
}
|
||||
|
||||
bool _getIsToggled(Map<String, Attribute> attrs) {
|
||||
if (widget.attribute.key == Attribute.list.key) {
|
||||
final attribute = attrs[widget.attribute.key];
|
||||
if (attribute == null) {
|
||||
return false;
|
||||
}
|
||||
return attribute.value == widget.attribute.value ||
|
||||
attribute.value == Attribute.checked.value;
|
||||
}
|
||||
return attrs.containsKey(widget.attribute.key);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FlowyCheckListButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_didChangeEditingValue);
|
||||
widget.controller.addListener(_didChangeEditingValue);
|
||||
_isToggled = _getIsToggled(_selectionStyle.attributes);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_didChangeEditingValue);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ToolbarIconButton(
|
||||
onPressed: _toggleAttribute,
|
||||
width: widget.iconSize * kIconButtonFactor,
|
||||
iconName: 'editor/checkbox',
|
||||
isToggled: _isToggled ?? false,
|
||||
tooltipText: widget.tooltipText,
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleAttribute() {
|
||||
widget.controller.formatSelection(_isToggled!
|
||||
? Attribute.clone(Attribute.unchecked, null)
|
||||
: Attribute.unchecked);
|
||||
}
|
||||
}
|
@ -0,0 +1,260 @@
|
||||
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:flutter_quill/models/documents/style.dart';
|
||||
import 'package:flutter_quill/utils/color.dart';
|
||||
import 'package:app_flowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'toolbar_icon_button.dart';
|
||||
|
||||
class FlowyColorButton extends StatefulWidget {
|
||||
const FlowyColorButton({
|
||||
required this.icon,
|
||||
required this.controller,
|
||||
required this.background,
|
||||
this.iconSize = defaultIconSize,
|
||||
this.iconTheme,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
final IconData icon;
|
||||
final double iconSize;
|
||||
final bool background;
|
||||
final QuillController controller;
|
||||
final QuillIconTheme? iconTheme;
|
||||
|
||||
@override
|
||||
_FlowyColorButtonState createState() => _FlowyColorButtonState();
|
||||
}
|
||||
|
||||
class _FlowyColorButtonState extends State<FlowyColorButton> {
|
||||
late bool _isToggledColor;
|
||||
late bool _isToggledBackground;
|
||||
late bool _isWhite;
|
||||
late bool _isWhitebackground;
|
||||
|
||||
Style get _selectionStyle => widget.controller.getSelectionStyle();
|
||||
|
||||
void _didChangeEditingValue() {
|
||||
setState(() {
|
||||
_isToggledColor = _getIsToggledColor(widget.controller.getSelectionStyle().attributes);
|
||||
_isToggledBackground = _getIsToggledBackground(widget.controller.getSelectionStyle().attributes);
|
||||
_isWhite = _isToggledColor && _selectionStyle.attributes['color']!.value == '#ffffff';
|
||||
_isWhitebackground = _isToggledBackground && _selectionStyle.attributes['background']!.value == '#ffffff';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isToggledColor = _getIsToggledColor(_selectionStyle.attributes);
|
||||
_isToggledBackground = _getIsToggledBackground(_selectionStyle.attributes);
|
||||
_isWhite = _isToggledColor && _selectionStyle.attributes['color']!.value == '#ffffff';
|
||||
_isWhitebackground = _isToggledBackground && _selectionStyle.attributes['background']!.value == '#ffffff';
|
||||
widget.controller.addListener(_didChangeEditingValue);
|
||||
}
|
||||
|
||||
bool _getIsToggledColor(Map<String, Attribute> attrs) {
|
||||
return attrs.containsKey(Attribute.color.key);
|
||||
}
|
||||
|
||||
bool _getIsToggledBackground(Map<String, Attribute> attrs) {
|
||||
return attrs.containsKey(Attribute.background.key);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FlowyColorButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_didChangeEditingValue);
|
||||
widget.controller.addListener(_didChangeEditingValue);
|
||||
_isToggledColor = _getIsToggledColor(_selectionStyle.attributes);
|
||||
_isToggledBackground = _getIsToggledBackground(_selectionStyle.attributes);
|
||||
_isWhite = _isToggledColor && _selectionStyle.attributes['color']!.value == '#ffffff';
|
||||
_isWhitebackground = _isToggledBackground && _selectionStyle.attributes['background']!.value == '#ffffff';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_didChangeEditingValue);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final fillColor = _isToggledColor && !widget.background && _isWhite
|
||||
? stringToColor('#ffffff')
|
||||
: (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor);
|
||||
final fillColorBackground = _isToggledBackground && widget.background && _isWhitebackground
|
||||
? stringToColor('#ffffff')
|
||||
: (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor);
|
||||
|
||||
return Tooltip(
|
||||
message: LocaleKeys.toolbar_highlight.tr(),
|
||||
showDuration: Duration.zero,
|
||||
child: QuillIconButton(
|
||||
highlightElevation: 0,
|
||||
hoverElevation: 0,
|
||||
size: widget.iconSize * kIconButtonFactor,
|
||||
icon: Icon(widget.icon, size: widget.iconSize, color: theme.iconTheme.color),
|
||||
fillColor: widget.background ? fillColorBackground : fillColor,
|
||||
onPressed: _showColorPicker,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _changeColor(BuildContext context, Color color) {
|
||||
var hex = color.value.toRadixString(16);
|
||||
if (hex.startsWith('ff')) {
|
||||
hex = hex.substring(2);
|
||||
}
|
||||
hex = '#$hex';
|
||||
widget.controller.formatSelection(widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
void _showColorPicker() {
|
||||
final style = widget.controller.getSelectionStyle();
|
||||
final values = style.values.where((v) => v.key == Attribute.background.key).map((v) => v.value);
|
||||
int initialColor = 0;
|
||||
if (values.isNotEmpty) {
|
||||
assert(values.length == 1);
|
||||
initialColor = stringToHex(values.first);
|
||||
}
|
||||
|
||||
StyledDialog(
|
||||
child: SingleChildScrollView(
|
||||
child: FlowyColorPicker(
|
||||
onColorChanged: (color) {
|
||||
if (color == null) {
|
||||
widget.controller.formatSelection(BackgroundAttribute(null));
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
_changeColor(context, color);
|
||||
}
|
||||
},
|
||||
initialColor: initialColor,
|
||||
),
|
||||
),
|
||||
).show(context);
|
||||
}
|
||||
}
|
||||
|
||||
int stringToHex(String code) {
|
||||
return int.parse(code.substring(1, 7), radix: 16) + 0xFF000000;
|
||||
}
|
||||
|
||||
class FlowyColorPicker extends StatefulWidget {
|
||||
final List<int> colors = [
|
||||
0xffe8e0ff,
|
||||
0xffffe7fd,
|
||||
0xffffe7ee,
|
||||
0xffffefe3,
|
||||
0xfffff2cd,
|
||||
0xfff5ffdc,
|
||||
0xffddffd6,
|
||||
0xffdefff1,
|
||||
];
|
||||
final Function(Color?) onColorChanged;
|
||||
final int initialColor;
|
||||
FlowyColorPicker({Key? key, required this.onColorChanged, this.initialColor = 0}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<FlowyColorPicker> createState() => _FlowyColorPickerState();
|
||||
}
|
||||
|
||||
// if (shrinkWrap) {
|
||||
// innerContent = IntrinsicWidth(child: IntrinsicHeight(child: innerContent));
|
||||
// }
|
||||
class _FlowyColorPickerState extends State<FlowyColorPicker> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const double width = 480;
|
||||
const int crossAxisCount = 6;
|
||||
const double mainAxisSpacing = 10;
|
||||
const double crossAxisSpacing = 10;
|
||||
final numberOfRows = (widget.colors.length / crossAxisCount).ceil();
|
||||
|
||||
const perRowHeight = ((width - ((crossAxisCount - 1) * mainAxisSpacing)) / crossAxisCount);
|
||||
final totalHeight = numberOfRows * perRowHeight + numberOfRows * crossAxisSpacing;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints.tightFor(width: width, height: totalHeight),
|
||||
child: CustomScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
controller: ScrollController(),
|
||||
physics: const ClampingScrollPhysics(),
|
||||
slivers: [
|
||||
SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisSpacing: mainAxisSpacing,
|
||||
crossAxisSpacing: crossAxisSpacing,
|
||||
childAspectRatio: 1.0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
if (widget.colors.length > index) {
|
||||
final isSelected = widget.colors[index] == widget.initialColor;
|
||||
return ColorItem(
|
||||
color: Color(widget.colors[index]),
|
||||
onPressed: widget.onColorChanged,
|
||||
isSelected: isSelected,
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
childCount: widget.colors.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ColorItem extends StatelessWidget {
|
||||
final Function(Color?) onPressed;
|
||||
final bool isSelected;
|
||||
final Color color;
|
||||
const ColorItem({
|
||||
Key? key,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
this.isSelected = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!isSelected) {
|
||||
return RawMaterialButton(
|
||||
onPressed: () {
|
||||
onPressed(color);
|
||||
},
|
||||
elevation: 0,
|
||||
hoverElevation: 0.6,
|
||||
fillColor: color,
|
||||
shape: const CircleBorder(),
|
||||
);
|
||||
} else {
|
||||
return RawMaterialButton(
|
||||
shape: const CircleBorder(side: BorderSide(color: Colors.white, width: 8)) +
|
||||
CircleBorder(side: BorderSide(color: color, width: 4)),
|
||||
onPressed: () {
|
||||
if (isSelected) {
|
||||
onPressed(null);
|
||||
} else {
|
||||
onPressed(color);
|
||||
}
|
||||
},
|
||||
elevation: 1.0,
|
||||
hoverElevation: 0.6,
|
||||
fillColor: color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:flutter_quill/models/documents/style.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:app_flowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'toolbar_icon_button.dart';
|
||||
|
||||
class FlowyHeaderStyleButton extends StatefulWidget {
|
||||
const FlowyHeaderStyleButton({
|
||||
required this.controller,
|
||||
this.iconSize = defaultIconSize,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
final QuillController controller;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
_FlowyHeaderStyleButtonState createState() => _FlowyHeaderStyleButtonState();
|
||||
}
|
||||
|
||||
class _FlowyHeaderStyleButtonState extends State<FlowyHeaderStyleButton> {
|
||||
Attribute? _value;
|
||||
|
||||
Style get _selectionStyle => widget.controller.getSelectionStyle();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
setState(() {
|
||||
_value = _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header;
|
||||
});
|
||||
widget.controller.addListener(_didChangeEditingValue);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _valueToText = <Attribute, String>{
|
||||
Attribute.h1: 'H1',
|
||||
Attribute.h2: 'H2',
|
||||
Attribute.h3: 'H3',
|
||||
};
|
||||
|
||||
final _valueAttribute = <Attribute>[Attribute.h1, Attribute.h2, Attribute.h3];
|
||||
final _valueString = <String>['H1', 'H2', 'H3'];
|
||||
final _attributeImageName = <String>['editor/H1', 'editor/H2', 'editor/H3'];
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (index) {
|
||||
// final child =
|
||||
// _valueToText[_value] == _valueString[index] ? svg('editor/H1', color: Colors.white) : svg('editor/H1');
|
||||
|
||||
final headerTitle = "${LocaleKeys.toolbar_header.tr()} ${index + 1}";
|
||||
final _isToggled = _valueToText[_value] == _valueString[index];
|
||||
return ToolbarIconButton(
|
||||
onPressed: () {
|
||||
if (_isToggled) {
|
||||
widget.controller.formatSelection(Attribute.header);
|
||||
} else {
|
||||
widget.controller.formatSelection(_valueAttribute[index]);
|
||||
}
|
||||
},
|
||||
width: widget.iconSize * kIconButtonFactor,
|
||||
iconName: _attributeImageName[index],
|
||||
isToggled: _isToggled,
|
||||
tooltipText: headerTitle,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _didChangeEditingValue() {
|
||||
setState(() {
|
||||
_value = _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FlowyHeaderStyleButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_didChangeEditingValue);
|
||||
widget.controller.addListener(_didChangeEditingValue);
|
||||
_value = _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_didChangeEditingValue);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
|
||||
class FlowyHistoryButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final double iconSize;
|
||||
final bool undo;
|
||||
final QuillController controller;
|
||||
final String tooltipText;
|
||||
|
||||
const FlowyHistoryButton({
|
||||
required this.icon,
|
||||
required this.controller,
|
||||
required this.undo,
|
||||
required this.tooltipText,
|
||||
required this.iconSize,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltipText,
|
||||
showDuration: Duration.zero,
|
||||
child: HistoryButton(
|
||||
icon: icon,
|
||||
iconSize: iconSize,
|
||||
controller: controller,
|
||||
undo: undo,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'toolbar_icon_button.dart';
|
||||
|
||||
class FlowyImageButton extends StatelessWidget {
|
||||
const FlowyImageButton({
|
||||
required this.controller,
|
||||
required this.tooltipText,
|
||||
this.iconSize = defaultIconSize,
|
||||
this.onImagePickCallback,
|
||||
this.fillColor,
|
||||
this.filePickImpl,
|
||||
this.webImagePickImpl,
|
||||
this.mediaPickSettingSelector,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
final double iconSize;
|
||||
|
||||
final Color? fillColor;
|
||||
|
||||
final QuillController controller;
|
||||
|
||||
final OnImagePickCallback? onImagePickCallback;
|
||||
|
||||
final WebImagePickImpl? webImagePickImpl;
|
||||
|
||||
final FilePickImpl? filePickImpl;
|
||||
|
||||
final MediaPickSettingSelector? mediaPickSettingSelector;
|
||||
|
||||
final String tooltipText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ToolbarIconButton(
|
||||
iconName: 'editor/image',
|
||||
width: iconSize * 1.77,
|
||||
onPressed: () => _onPressedHandler(context),
|
||||
isToggled: false,
|
||||
tooltipText: tooltipText,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onPressedHandler(BuildContext context) async {
|
||||
// if (onImagePickCallback != null) {
|
||||
// final selector = mediaPickSettingSelector ?? ImageVideoUtils.selectMediaPickSetting;
|
||||
// final source = await selector(context);
|
||||
// if (source != null) {
|
||||
// if (source == MediaPickSetting.Gallery) {
|
||||
// _pickImage(context);
|
||||
// } else {
|
||||
// _typeLink(context);
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// _typeLink(context);
|
||||
// }
|
||||
}
|
||||
|
||||
// void _pickImage(BuildContext context) => ImageVideoUtils.handleImageButtonTap(
|
||||
// context,
|
||||
// controller,
|
||||
// ImageSource.gallery,
|
||||
// onImagePickCallback!,
|
||||
// filePickImpl: filePickImpl,
|
||||
// webImagePickImpl: webImagePickImpl,
|
||||
// );
|
||||
|
||||
// void _typeLink(BuildContext context) {
|
||||
// TextFieldDialog(
|
||||
// title: 'URL',
|
||||
// value: "",
|
||||
// confirm: (newValue) {
|
||||
// if (newValue.isEmpty) {
|
||||
// return;
|
||||
// }
|
||||
// final index = controller.selection.baseOffset;
|
||||
// final length = controller.selection.extentOffset - index;
|
||||
|
||||
// controller.replaceText(index, length, BlockEmbed.image(newValue), null);
|
||||
// },
|
||||
// ).show(context);
|
||||
// }
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra/theme.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'toolbar_icon_button.dart';
|
||||
|
||||
class FlowyLinkStyleButton extends StatefulWidget {
|
||||
const FlowyLinkStyleButton({
|
||||
required this.controller,
|
||||
this.iconSize = defaultIconSize,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
final QuillController controller;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
_FlowyLinkStyleButtonState createState() => _FlowyLinkStyleButtonState();
|
||||
}
|
||||
|
||||
class _FlowyLinkStyleButtonState extends State<FlowyLinkStyleButton> {
|
||||
void _didChangeSelection() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_didChangeSelection);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FlowyLinkStyleButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_didChangeSelection);
|
||||
widget.controller.addListener(_didChangeSelection);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
widget.controller.removeListener(_didChangeSelection);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AppTheme>();
|
||||
final isEnabled = !widget.controller.selection.isCollapsed;
|
||||
final pressedHandler = isEnabled ? () => _openLinkDialog(context) : null;
|
||||
final icon = isEnabled
|
||||
? svgWidget(
|
||||
'editor/share',
|
||||
color: theme.iconColor,
|
||||
)
|
||||
: svgWidget(
|
||||
'editor/share',
|
||||
color: theme.disableIconColor,
|
||||
);
|
||||
|
||||
return FlowyIconButton(
|
||||
onPressed: pressedHandler,
|
||||
iconPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
icon: icon,
|
||||
fillColor: theme.shader6,
|
||||
hoverColor: theme.shader5,
|
||||
width: widget.iconSize * kIconButtonFactor,
|
||||
);
|
||||
}
|
||||
|
||||
void _openLinkDialog(BuildContext context) {
|
||||
final style = widget.controller.getSelectionStyle();
|
||||
final values = style.values.where((v) => v.key == Attribute.link.key).map((v) => v.value);
|
||||
String value = "";
|
||||
if (values.isNotEmpty) {
|
||||
assert(values.length == 1);
|
||||
value = values.first;
|
||||
}
|
||||
|
||||
TextFieldDialog(
|
||||
title: 'URL',
|
||||
value: value,
|
||||
confirm: (newValue) {
|
||||
if (newValue.isEmpty) {
|
||||
return;
|
||||
}
|
||||
widget.controller.formatSelection(LinkAttribute(newValue));
|
||||
},
|
||||
).show(context);
|
||||
}
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:flutter_quill/models/documents/style.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'toolbar_icon_button.dart';
|
||||
|
||||
class FlowyToggleStyleButton extends StatefulWidget {
|
||||
final Attribute attribute;
|
||||
final String normalIcon;
|
||||
final double iconSize;
|
||||
final QuillController controller;
|
||||
final String tooltipText;
|
||||
|
||||
const FlowyToggleStyleButton({
|
||||
required this.attribute,
|
||||
required this.normalIcon,
|
||||
required this.controller,
|
||||
required this.tooltipText,
|
||||
this.iconSize = defaultIconSize,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_ToggleStyleButtonState createState() => _ToggleStyleButtonState();
|
||||
}
|
||||
|
||||
class _ToggleStyleButtonState extends State<FlowyToggleStyleButton> {
|
||||
bool? _isToggled;
|
||||
Style get _selectionStyle => widget.controller.getSelectionStyle();
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isToggled = _getIsToggled(_selectionStyle.attributes);
|
||||
widget.controller.addListener(_didChangeEditingValue);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ToolbarIconButton(
|
||||
onPressed: _toggleAttribute,
|
||||
width: widget.iconSize * kIconButtonFactor,
|
||||
isToggled: _isToggled ?? false,
|
||||
iconName: widget.normalIcon,
|
||||
tooltipText: widget.tooltipText,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FlowyToggleStyleButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_didChangeEditingValue);
|
||||
widget.controller.addListener(_didChangeEditingValue);
|
||||
_isToggled = _getIsToggled(_selectionStyle.attributes);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_didChangeEditingValue);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _didChangeEditingValue() {
|
||||
setState(() => _isToggled = _getIsToggled(_selectionStyle.attributes));
|
||||
}
|
||||
|
||||
bool _getIsToggled(Map<String, Attribute> attrs) {
|
||||
if (widget.attribute.key == Attribute.list.key) {
|
||||
final attribute = attrs[widget.attribute.key];
|
||||
if (attribute == null) {
|
||||
return false;
|
||||
}
|
||||
return attribute.value == widget.attribute.value;
|
||||
}
|
||||
return attrs.containsKey(widget.attribute.key);
|
||||
}
|
||||
|
||||
void _toggleAttribute() {
|
||||
widget.controller.formatSelection(_isToggled! ? Attribute.clone(widget.attribute, null) : widget.attribute);
|
||||
}
|
||||
}
|
@ -0,0 +1,303 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:app_flowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'check_button.dart';
|
||||
import 'color_picker.dart';
|
||||
import 'header_button.dart';
|
||||
import 'history_button.dart';
|
||||
import 'link_button.dart';
|
||||
import 'toggle_button.dart';
|
||||
import 'toolbar_icon_button.dart';
|
||||
import 'package:app_flowy/generated/locale_keys.g.dart';
|
||||
|
||||
class EditorToolbar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final List<Widget> children;
|
||||
final double toolBarHeight;
|
||||
final Color? color;
|
||||
|
||||
const EditorToolbar({
|
||||
required this.children,
|
||||
this.toolBarHeight = 46,
|
||||
this.color,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Theme.of(context).canvasColor,
|
||||
constraints: BoxConstraints.tightFor(height: preferredSize.height),
|
||||
child: ToolbarButtonList(buttons: children).padding(horizontal: 4, vertical: 4),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => Size.fromHeight(toolBarHeight);
|
||||
|
||||
factory EditorToolbar.basic({
|
||||
required QuillController controller,
|
||||
double toolbarIconSize = defaultIconSize,
|
||||
OnImagePickCallback? onImagePickCallback,
|
||||
OnVideoPickCallback? onVideoPickCallback,
|
||||
MediaPickSettingSelector? mediaPickSettingSelector,
|
||||
FilePickImpl? filePickImpl,
|
||||
WebImagePickImpl? webImagePickImpl,
|
||||
WebVideoPickImpl? webVideoPickImpl,
|
||||
Key? key,
|
||||
}) {
|
||||
return EditorToolbar(
|
||||
key: key,
|
||||
toolBarHeight: toolbarIconSize * 2,
|
||||
children: [
|
||||
FlowyHistoryButton(
|
||||
icon: Icons.undo_outlined,
|
||||
iconSize: toolbarIconSize,
|
||||
controller: controller,
|
||||
undo: true,
|
||||
tooltipText: LocaleKeys.toolbar_undo.tr(),
|
||||
),
|
||||
FlowyHistoryButton(
|
||||
icon: Icons.redo_outlined,
|
||||
iconSize: toolbarIconSize,
|
||||
controller: controller,
|
||||
undo: false,
|
||||
tooltipText: LocaleKeys.toolbar_redo.tr(),
|
||||
),
|
||||
FlowyToggleStyleButton(
|
||||
attribute: Attribute.bold,
|
||||
normalIcon: 'editor/bold',
|
||||
iconSize: toolbarIconSize,
|
||||
controller: controller,
|
||||
tooltipText: LocaleKeys.toolbar_bold.tr(),
|
||||
),
|
||||
FlowyToggleStyleButton(
|
||||
attribute: Attribute.italic,
|
||||
normalIcon: 'editor/italic',
|
||||
iconSize: toolbarIconSize,
|
||||
controller: controller,
|
||||
tooltipText: LocaleKeys.toolbar_italic.tr(),
|
||||
),
|
||||
FlowyToggleStyleButton(
|
||||
attribute: Attribute.underline,
|
||||
normalIcon: 'editor/underline',
|
||||
iconSize: toolbarIconSize,
|
||||
controller: controller,
|
||||
tooltipText: LocaleKeys.toolbar_underline.tr(),
|
||||
),
|
||||
FlowyToggleStyleButton(
|
||||
attribute: Attribute.strikeThrough,
|
||||
normalIcon: 'editor/strikethrough',
|
||||
iconSize: toolbarIconSize,
|
||||
controller: controller,
|
||||
tooltipText: LocaleKeys.toolbar_strike.tr(),
|
||||
),
|
||||
FlowyColorButton(
|
||||
icon: Icons.format_color_fill,
|
||||
iconSize: toolbarIconSize,
|
||||
controller: controller,
|
||||
background: true,
|
||||
),
|
||||
// FlowyImageButton(
|
||||
// iconSize: toolbarIconSize,
|
||||
// controller: controller,
|
||||
// onImagePickCallback: onImagePickCallback,
|
||||
// filePickImpl: filePickImpl,
|
||||
// webImagePickImpl: webImagePickImpl,
|
||||
// mediaPickSettingSelector: mediaPickSettingSelector,
|
||||
// ),
|
||||
FlowyHeaderStyleButton(
|
||||
controller: controller,
|
||||
iconSize: toolbarIconSize,
|
||||
),
|
||||
FlowyToggleStyleButton(
|
||||
attribute: Attribute.ol,
|
||||
controller: controller,
|
||||
normalIcon: 'editor/numbers',
|
||||
iconSize: toolbarIconSize,
|
||||
tooltipText: LocaleKeys.toolbar_numList.tr(),
|
||||
),
|
||||
FlowyToggleStyleButton(
|
||||
attribute: Attribute.ul,
|
||||
controller: controller,
|
||||
normalIcon: 'editor/bullet_list',
|
||||
iconSize: toolbarIconSize,
|
||||
tooltipText: LocaleKeys.toolbar_bulletList.tr(),
|
||||
),
|
||||
FlowyCheckListButton(
|
||||
attribute: Attribute.unchecked,
|
||||
controller: controller,
|
||||
iconSize: toolbarIconSize,
|
||||
tooltipText: LocaleKeys.toolbar_checkList.tr(),
|
||||
),
|
||||
FlowyToggleStyleButton(
|
||||
attribute: Attribute.inlineCode,
|
||||
controller: controller,
|
||||
normalIcon: 'editor/inline_block',
|
||||
iconSize: toolbarIconSize,
|
||||
tooltipText: LocaleKeys.toolbar_inlineCode.tr(),
|
||||
),
|
||||
FlowyToggleStyleButton(
|
||||
attribute: Attribute.blockQuote,
|
||||
controller: controller,
|
||||
normalIcon: 'editor/quote',
|
||||
iconSize: toolbarIconSize,
|
||||
tooltipText: LocaleKeys.toolbar_quote.tr(),
|
||||
),
|
||||
FlowyLinkStyleButton(
|
||||
controller: controller,
|
||||
iconSize: toolbarIconSize,
|
||||
),
|
||||
FlowyEmojiStyleButton(
|
||||
normalIcon: 'editor/insert_emoticon',
|
||||
controller: controller,
|
||||
tooltipText: "Emoji Picker",
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarButtonList extends StatefulWidget {
|
||||
const ToolbarButtonList({required this.buttons, Key? key}) : super(key: key);
|
||||
|
||||
final List<Widget> buttons;
|
||||
|
||||
@override
|
||||
_ToolbarButtonListState createState() => _ToolbarButtonListState();
|
||||
}
|
||||
|
||||
class _ToolbarButtonListState extends State<ToolbarButtonList> with WidgetsBindingObserver {
|
||||
final ScrollController _controller = ScrollController();
|
||||
bool _showLeftArrow = false;
|
||||
bool _showRightArrow = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(_handleScroll);
|
||||
|
||||
// Listening to the WidgetsBinding instance is necessary so that we can
|
||||
// hide the arrows when the window gets a new size and thus the toolbar
|
||||
// becomes scrollable/unscrollable.
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// Workaround to allow the scroll controller attach to our ListView so that
|
||||
// we can detect if overflow arrows need to be shown on init.
|
||||
Timer.run(_handleScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
List<Widget> children = [];
|
||||
double width = (widget.buttons.length + 2) * defaultIconSize * kIconButtonFactor;
|
||||
final isFit = constraints.maxWidth > width;
|
||||
if (!isFit) {
|
||||
children.add(_buildLeftArrow());
|
||||
width = width + 18;
|
||||
}
|
||||
|
||||
children.add(_buildScrollableList(constraints, isFit));
|
||||
|
||||
if (!isFit) {
|
||||
children.add(_buildRightArrow());
|
||||
width = width + 18;
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: min(constraints.maxWidth, width),
|
||||
child: Row(
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() => _handleScroll();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_showLeftArrow = _controller.position.minScrollExtent != _controller.position.pixels;
|
||||
_showRightArrow = _controller.position.maxScrollExtent != _controller.position.pixels;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildLeftArrow() {
|
||||
return SizedBox(
|
||||
width: 8,
|
||||
child: Transform.translate(
|
||||
// Move the icon a few pixels to center it
|
||||
offset: const Offset(-5, 0),
|
||||
child: _showLeftArrow ? const Icon(Icons.arrow_left, size: 18) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// [[sliver: https://medium.com/flutter/slivers-demystified-6ff68ab0296f]]
|
||||
Widget _buildScrollableList(BoxConstraints constraints, bool isFit) {
|
||||
Widget child = Expanded(
|
||||
child: CustomScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: _controller,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return widget.buttons[index];
|
||||
},
|
||||
childCount: widget.buttons.length,
|
||||
addAutomaticKeepAlives: false,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (!isFit) {
|
||||
child = ScrollConfiguration(
|
||||
// Remove the glowing effect, as we already have the arrow indicators
|
||||
behavior: _NoGlowBehavior(),
|
||||
// The CustomScrollView is necessary so that the children are not
|
||||
// stretched to the height of the toolbar, https://bit.ly/3uC3bjI
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget _buildRightArrow() {
|
||||
return SizedBox(
|
||||
width: 8,
|
||||
child: Transform.translate(
|
||||
// Move the icon a few pixels to center it
|
||||
offset: const Offset(-5, 0),
|
||||
child: _showRightArrow ? const Icon(Icons.arrow_right, size: 18) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoGlowBehavior extends ScrollBehavior {
|
||||
@override
|
||||
Widget buildViewportChrome(BuildContext _, Widget child, AxisDirection __) {
|
||||
return child;
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flowy_infra/theme.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const double defaultIconSize = 18;
|
||||
|
||||
class ToolbarIconButton extends StatelessWidget {
|
||||
final double width;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isToggled;
|
||||
final String iconName;
|
||||
final String tooltipText;
|
||||
|
||||
const ToolbarIconButton({
|
||||
Key? key,
|
||||
required this.onPressed,
|
||||
required this.isToggled,
|
||||
required this.width,
|
||||
required this.iconName,
|
||||
required this.tooltipText,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AppTheme>();
|
||||
return FlowyIconButton(
|
||||
iconPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
onPressed: onPressed,
|
||||
width: width,
|
||||
icon: isToggled == true ? svgWidget(iconName, color: Colors.white) : svgWidget(iconName, color: theme.iconColor),
|
||||
fillColor: isToggled == true ? theme.main1 : theme.shader6,
|
||||
hoverColor: isToggled == true ? theme.main1 : theme.hover,
|
||||
tooltipText: tooltipText,
|
||||
);
|
||||
}
|
||||
}
|
133
frontend/app_flowy/lib/plugins/doc/styles.dart
Normal file
133
frontend/app_flowy/lib/plugins/doc/styles.dart
Normal file
@ -0,0 +1,133 @@
|
||||
import 'package:app_flowy/plugins/doc/presentation/style_widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
import 'package:flowy_infra/theme.dart';
|
||||
|
||||
DefaultStyles customStyles(BuildContext context) {
|
||||
const baseSpacing = Tuple2<double, double>(6, 0);
|
||||
|
||||
final theme = context.watch<AppTheme>();
|
||||
final themeData = theme.themeData;
|
||||
final fontFamily = makeFontFamily(themeData);
|
||||
|
||||
final defaultTextStyle = DefaultTextStyle.of(context);
|
||||
final baseStyle = defaultTextStyle.style.copyWith(
|
||||
fontSize: 18,
|
||||
height: 1.3,
|
||||
fontWeight: FontWeight.w300,
|
||||
letterSpacing: 0.6,
|
||||
fontFamily: fontFamily,
|
||||
);
|
||||
|
||||
return DefaultStyles(
|
||||
h1: DefaultTextBlockStyle(
|
||||
defaultTextStyle.style.copyWith(
|
||||
fontSize: 34,
|
||||
color: defaultTextStyle.style.color!.withOpacity(0.70),
|
||||
height: 1.15,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
const Tuple2(16, 0),
|
||||
const Tuple2(0, 0),
|
||||
null),
|
||||
h2: DefaultTextBlockStyle(
|
||||
defaultTextStyle.style.copyWith(
|
||||
fontSize: 24,
|
||||
color: defaultTextStyle.style.color!.withOpacity(0.70),
|
||||
height: 1.15,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
const Tuple2(8, 0),
|
||||
const Tuple2(0, 0),
|
||||
null),
|
||||
h3: DefaultTextBlockStyle(
|
||||
defaultTextStyle.style.copyWith(
|
||||
fontSize: 20,
|
||||
color: defaultTextStyle.style.color!.withOpacity(0.70),
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
const Tuple2(8, 0),
|
||||
const Tuple2(0, 0),
|
||||
null),
|
||||
paragraph: DefaultTextBlockStyle(
|
||||
baseStyle, const Tuple2(10, 0), const Tuple2(0, 0), null),
|
||||
bold: const TextStyle(fontWeight: FontWeight.bold),
|
||||
italic: const TextStyle(fontStyle: FontStyle.italic),
|
||||
small: const TextStyle(fontSize: 12, color: Colors.black45),
|
||||
underline: const TextStyle(decoration: TextDecoration.underline),
|
||||
strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough),
|
||||
inlineCode: TextStyle(
|
||||
color: Colors.blue.shade900.withOpacity(0.9),
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 13,
|
||||
),
|
||||
link: TextStyle(
|
||||
color: themeData.colorScheme.secondary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
color: theme.textColor,
|
||||
placeHolder: DefaultTextBlockStyle(
|
||||
defaultTextStyle.style.copyWith(
|
||||
fontSize: 20,
|
||||
height: 1.5,
|
||||
color: Colors.grey.withOpacity(0.6),
|
||||
),
|
||||
const Tuple2(0, 0),
|
||||
const Tuple2(0, 0),
|
||||
null),
|
||||
lists: DefaultListBlockStyle(baseStyle, baseSpacing, const Tuple2(0, 6),
|
||||
null, StyleWidgetBuilder.checkbox(theme)),
|
||||
quote: DefaultTextBlockStyle(
|
||||
TextStyle(color: baseStyle.color!.withOpacity(0.6)),
|
||||
baseSpacing,
|
||||
const Tuple2(6, 2),
|
||||
BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(width: 4, color: theme.shader5),
|
||||
),
|
||||
)),
|
||||
code: DefaultTextBlockStyle(
|
||||
TextStyle(
|
||||
color: Colors.blue.shade900.withOpacity(0.9),
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 13,
|
||||
height: 1.15,
|
||||
),
|
||||
baseSpacing,
|
||||
const Tuple2(0, 0),
|
||||
BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
)),
|
||||
indent: DefaultTextBlockStyle(
|
||||
baseStyle, baseSpacing, const Tuple2(0, 6), null),
|
||||
align: DefaultTextBlockStyle(
|
||||
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
|
||||
leading: DefaultTextBlockStyle(
|
||||
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
|
||||
sizeSmall: const TextStyle(fontSize: 10),
|
||||
sizeLarge: const TextStyle(fontSize: 18),
|
||||
sizeHuge: const TextStyle(fontSize: 22));
|
||||
}
|
||||
|
||||
String makeFontFamily(ThemeData themeData) {
|
||||
String fontFamily;
|
||||
switch (themeData.platform) {
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
fontFamily = 'Mulish';
|
||||
break;
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.windows:
|
||||
case TargetPlatform.linux:
|
||||
fontFamily = 'Roboto Mono';
|
||||
break;
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
return fontFamily;
|
||||
}
|
Reference in New Issue
Block a user