|
|
|
import 'dart:async';
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
|
|
import 'package:filesystem_picker/filesystem_picker.dart';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'package:flutter_quill/extensions.dart';
|
|
|
|
import 'package:flutter_quill/flutter_quill.dart' hide Text;
|
|
|
|
import 'package:flutter_quill_extensions/flutter_quill_extensions.dart';
|
|
|
|
import 'package:path/path.dart';
|
|
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import 'package:tuple/tuple.dart';
|
|
|
|
|
|
|
|
import '../universal_ui/universal_ui.dart';
|
|
|
|
import 'read_only_page.dart';
|
|
|
|
|
|
|
|
class HomePage extends StatefulWidget {
|
|
|
|
@override
|
|
|
|
_HomePageState createState() => _HomePageState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _HomePageState extends State<HomePage> {
|
|
|
|
QuillController? _controller;
|
|
|
|
final FocusNode _focusNode = FocusNode();
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
_loadFromAssets();
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _loadFromAssets() async {
|
|
|
|
try {
|
|
|
|
final result = await rootBundle.loadString(isDesktop() ? 'assets/sample_data_nomedia.json' : 'assets/sample_data.json');
|
|
|
|
final doc = Document.fromJson(jsonDecode(result));
|
|
|
|
setState(() {
|
|
|
|
_controller = QuillController(
|
|
|
|
document: doc, selection: const TextSelection.collapsed(offset: 0));
|
|
|
|
});
|
|
|
|
} catch (error) {
|
|
|
|
final doc = Document()..insert(0, 'Empty asset');
|
|
|
|
setState(() {
|
|
|
|
_controller = QuillController(
|
|
|
|
document: doc, selection: const TextSelection.collapsed(offset: 0));
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
if (_controller == null) {
|
|
|
|
return const Scaffold(body: Center(child: Text('Loading...')));
|
|
|
|
}
|
|
|
|
|
|
|
|
return Scaffold(
|
|
|
|
appBar: AppBar(
|
|
|
|
backgroundColor: Colors.grey.shade800,
|
|
|
|
elevation: 0,
|
|
|
|
centerTitle: false,
|
|
|
|
title: const Text(
|
|
|
|
'Flutter Quill',
|
|
|
|
),
|
|
|
|
actions: [
|
|
|
|
IconButton(
|
|
|
|
onPressed: () => _addEditNote(context),
|
|
|
|
icon: const Icon(Icons.note_add),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
drawer: Container(
|
|
|
|
constraints:
|
|
|
|
BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.7),
|
|
|
|
color: Colors.grey.shade800,
|
|
|
|
child: _buildMenuBar(context),
|
|
|
|
),
|
|
|
|
body: RawKeyboardListener(
|
|
|
|
focusNode: FocusNode(),
|
|
|
|
onKey: (event) {
|
|
|
|
if (event.data.isControlPressed && event.character == 'b') {
|
|
|
|
if (_controller!
|
|
|
|
.getSelectionStyle()
|
|
|
|
.attributes
|
|
|
|
.keys
|
|
|
|
.contains('bold')) {
|
|
|
|
_controller!
|
|
|
|
.formatSelection(Attribute.clone(Attribute.bold, null));
|
|
|
|
} else {
|
|
|
|
_controller!.formatSelection(Attribute.bold);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
child: _buildWelcomeEditor(context),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildWelcomeEditor(BuildContext context) {
|
|
|
|
var quillEditor = QuillEditor(
|
|
|
|
controller: _controller!,
|
|
|
|
scrollController: ScrollController(),
|
|
|
|
scrollable: true,
|
|
|
|
focusNode: _focusNode,
|
|
|
|
autoFocus: false,
|
|
|
|
readOnly: false,
|
|
|
|
placeholder: 'Add content',
|
|
|
|
expands: false,
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
customStyles: DefaultStyles(
|
|
|
|
h1: DefaultTextBlockStyle(
|
|
|
|
const TextStyle(
|
|
|
|
fontSize: 32,
|
|
|
|
color: Colors.black,
|
|
|
|
height: 1.15,
|
|
|
|
fontWeight: FontWeight.w300,
|
|
|
|
),
|
|
|
|
const Tuple2(16, 0),
|
|
|
|
const Tuple2(0, 0),
|
|
|
|
null),
|
|
|
|
sizeSmall: const TextStyle(fontSize: 9),
|
|
|
|
),
|
|
|
|
embedBuilders: [
|
|
|
|
...FlutterQuillEmbeds.builders(),
|
|
|
|
NotesEmbedBuilder(addEditNote: _addEditNote)
|
|
|
|
],
|
|
|
|
);
|
|
|
|
if (kIsWeb) {
|
|
|
|
quillEditor = QuillEditor(
|
|
|
|
controller: _controller!,
|
|
|
|
scrollController: ScrollController(),
|
|
|
|
scrollable: true,
|
|
|
|
focusNode: _focusNode,
|
|
|
|
autoFocus: false,
|
|
|
|
readOnly: false,
|
|
|
|
placeholder: 'Add content',
|
|
|
|
expands: false,
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
customStyles: DefaultStyles(
|
|
|
|
h1: DefaultTextBlockStyle(
|
|
|
|
const TextStyle(
|
|
|
|
fontSize: 32,
|
|
|
|
color: Colors.black,
|
|
|
|
height: 1.15,
|
|
|
|
fontWeight: FontWeight.w300,
|
|
|
|
),
|
|
|
|
const Tuple2(16, 0),
|
|
|
|
const Tuple2(0, 0),
|
|
|
|
null),
|
|
|
|
sizeSmall: const TextStyle(fontSize: 9),
|
|
|
|
),
|
|
|
|
embedBuilders: defaultEmbedBuildersWeb);
|
|
|
|
}
|
|
|
|
var toolbar = QuillToolbar.basic(
|
|
|
|
controller: _controller!,
|
|
|
|
embedButtons: FlutterQuillEmbeds.buttons(
|
|
|
|
// provide a callback to enable picking images from device.
|
|
|
|
// if omit, "image" button only allows adding images from url.
|
|
|
|
// same goes for videos.
|
|
|
|
onImagePickCallback: _onImagePickCallback,
|
|
|
|
onVideoPickCallback: _onVideoPickCallback,
|
|
|
|
// uncomment to provide a custom "pick from" dialog.
|
|
|
|
// mediaPickSettingSelector: _selectMediaPickSetting,
|
|
|
|
// uncomment to provide a custom "pick from" dialog.
|
|
|
|
// cameraPickSettingSelector: _selectCameraPickSetting,
|
|
|
|
),
|
|
|
|
showAlignmentButtons: true,
|
|
|
|
);
|
|
|
|
if (kIsWeb) {
|
|
|
|
toolbar = QuillToolbar.basic(
|
|
|
|
controller: _controller!,
|
|
|
|
embedButtons: FlutterQuillEmbeds.buttons(
|
|
|
|
onImagePickCallback: _onImagePickCallback,
|
|
|
|
webImagePickImpl: _webImagePickImpl,
|
|
|
|
),
|
|
|
|
showAlignmentButtons: true,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
if (_isDesktop()) {
|
|
|
|
toolbar = QuillToolbar.basic(
|
|
|
|
controller: _controller!,
|
|
|
|
embedButtons: FlutterQuillEmbeds.buttons(
|
|
|
|
onImagePickCallback: _onImagePickCallback,
|
|
|
|
filePickImpl: openFileSystemPickerForDesktop,
|
|
|
|
),
|
|
|
|
showAlignmentButtons: true,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return SafeArea(
|
|
|
|
child: Column(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
children: <Widget>[
|
|
|
|
Expanded(
|
|
|
|
flex: 15,
|
|
|
|
child: Container(
|
|
|
|
color: Colors.white,
|
|
|
|
padding: const EdgeInsets.only(left: 16, right: 16),
|
|
|
|
child: quillEditor,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
kIsWeb
|
|
|
|
? Expanded(
|
|
|
|
child: Container(
|
|
|
|
padding:
|
|
|
|
const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
|
|
|
child: toolbar,
|
|
|
|
))
|
|
|
|
: Container(child: toolbar)
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
bool _isDesktop() => !kIsWeb && !Platform.isAndroid && !Platform.isIOS;
|
|
|
|
|
|
|
|
Future<String?> openFileSystemPickerForDesktop(BuildContext context) async {
|
|
|
|
return await FilesystemPicker.open(
|
|
|
|
context: context,
|
|
|
|
rootDirectory: await getApplicationDocumentsDirectory(),
|
|
|
|
fsType: FilesystemType.file,
|
|
|
|
fileTileSelectMode: FileTileSelectMode.wholeTile,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Renders the image picked by imagePicker from local file storage
|
|
|
|
// You can also upload the picked image to any server (eg : AWS s3
|
|
|
|
// or Firebase) and then return the uploaded image URL.
|
|
|
|
Future<String> _onImagePickCallback(File file) async {
|
|
|
|
// Copies the picked file from temporary cache to applications directory
|
|
|
|
final appDocDir = await getApplicationDocumentsDirectory();
|
|
|
|
final copiedFile =
|
|
|
|
await file.copy('${appDocDir.path}/${basename(file.path)}');
|
|
|
|
return copiedFile.path.toString();
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<String?> _webImagePickImpl(
|
|
|
|
OnImagePickCallback onImagePickCallback) async {
|
|
|
|
final result = await FilePicker.platform.pickFiles();
|
|
|
|
if (result == null) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Take first, because we don't allow picking multiple files.
|
|
|
|
final fileName = result.files.first.name;
|
|
|
|
final file = File(fileName);
|
|
|
|
|
|
|
|
return onImagePickCallback(file);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Renders the video picked by imagePicker from local file storage
|
|
|
|
// You can also upload the picked video to any server (eg : AWS s3
|
|
|
|
// or Firebase) and then return the uploaded video URL.
|
|
|
|
Future<String> _onVideoPickCallback(File file) async {
|
|
|
|
// Copies the picked file from temporary cache to applications directory
|
|
|
|
final appDocDir = await getApplicationDocumentsDirectory();
|
|
|
|
final copiedFile =
|
|
|
|
await file.copy('${appDocDir.path}/${basename(file.path)}');
|
|
|
|
return copiedFile.path.toString();
|
|
|
|
}
|
|
|
|
|
|
|
|
// ignore: unused_element
|
|
|
|
Future<MediaPickSetting?> _selectMediaPickSetting(BuildContext context) =>
|
|
|
|
showDialog<MediaPickSetting>(
|
|
|
|
context: context,
|
|
|
|
builder: (ctx) => AlertDialog(
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
content: Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
TextButton.icon(
|
|
|
|
icon: const Icon(Icons.collections),
|
|
|
|
label: const Text('Gallery'),
|
|
|
|
onPressed: () => Navigator.pop(ctx, MediaPickSetting.Gallery),
|
|
|
|
),
|
|
|
|
TextButton.icon(
|
|
|
|
icon: const Icon(Icons.link),
|
|
|
|
label: const Text('Link'),
|
|
|
|
onPressed: () => Navigator.pop(ctx, MediaPickSetting.Link),
|
|
|
|
)
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
// ignore: unused_element
|
|
|
|
Future<MediaPickSetting?> _selectCameraPickSetting(BuildContext context) =>
|
|
|
|
showDialog<MediaPickSetting>(
|
|
|
|
context: context,
|
|
|
|
builder: (ctx) => AlertDialog(
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
content: Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
TextButton.icon(
|
|
|
|
icon: const Icon(Icons.camera),
|
|
|
|
label: const Text('Capture a photo'),
|
|
|
|
onPressed: () => Navigator.pop(ctx, MediaPickSetting.Camera),
|
|
|
|
),
|
|
|
|
TextButton.icon(
|
|
|
|
icon: const Icon(Icons.video_call),
|
|
|
|
label: const Text('Capture a video'),
|
|
|
|
onPressed: () => Navigator.pop(ctx, MediaPickSetting.Video),
|
|
|
|
)
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
Widget _buildMenuBar(BuildContext context) {
|
|
|
|
final size = MediaQuery.of(context).size;
|
|
|
|
const itemStyle = TextStyle(
|
|
|
|
color: Colors.white,
|
|
|
|
fontSize: 18,
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
);
|
|
|
|
return Column(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
children: [
|
|
|
|
Divider(
|
|
|
|
thickness: 2,
|
|
|
|
color: Colors.white,
|
|
|
|
indent: size.width * 0.1,
|
|
|
|
endIndent: size.width * 0.1,
|
|
|
|
),
|
|
|
|
ListTile(
|
|
|
|
title: const Center(child: Text('Read only demo', style: itemStyle)),
|
|
|
|
dense: true,
|
|
|
|
visualDensity: VisualDensity.compact,
|
|
|
|
onTap: _readOnly,
|
|
|
|
),
|
|
|
|
Divider(
|
|
|
|
thickness: 2,
|
|
|
|
color: Colors.white,
|
|
|
|
indent: size.width * 0.1,
|
|
|
|
endIndent: size.width * 0.1,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _readOnly() {
|
|
|
|
Navigator.push(
|
|
|
|
super.context,
|
|
|
|
MaterialPageRoute(
|
|
|
|
builder: (context) => ReadOnlyPage(),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _addEditNote(BuildContext context, {Document? document}) async {
|
|
|
|
final isEditing = document != null;
|
|
|
|
final quillEditorController = QuillController(
|
|
|
|
document: document ?? Document(),
|
|
|
|
selection: const TextSelection.collapsed(offset: 0),
|
|
|
|
);
|
|
|
|
|
|
|
|
await showDialog(
|
|
|
|
context: context,
|
|
|
|
builder: (context) => AlertDialog(
|
|
|
|
titlePadding: const EdgeInsets.only(left: 16, top: 8),
|
|
|
|
title: Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
children: [
|
|
|
|
Text('${isEditing ? 'Edit' : 'Add'} note'),
|
|
|
|
IconButton(
|
|
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
|
|
icon: const Icon(Icons.close),
|
|
|
|
)
|
|
|
|
],
|
|
|
|
),
|
|
|
|
content: QuillEditor.basic(
|
|
|
|
controller: quillEditorController,
|
|
|
|
readOnly: false,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
if (quillEditorController.document.isEmpty()) return;
|
|
|
|
|
|
|
|
final block = BlockEmbed.custom(
|
|
|
|
NotesBlockEmbed.fromDocument(quillEditorController.document),
|
|
|
|
);
|
|
|
|
final controller = _controller!;
|
|
|
|
final index = controller.selection.baseOffset;
|
|
|
|
final length = controller.selection.extentOffset - index;
|
|
|
|
|
|
|
|
if (isEditing) {
|
|
|
|
final offset = getEmbedNode(controller, controller.selection.start).item1;
|
|
|
|
controller.replaceText(
|
|
|
|
offset, 1, block, TextSelection.collapsed(offset: offset));
|
|
|
|
} else {
|
|
|
|
controller.replaceText(index, length, block, null);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class NotesEmbedBuilder implements EmbedBuilder {
|
|
|
|
NotesEmbedBuilder({required this.addEditNote});
|
|
|
|
|
|
|
|
Future<void> Function(BuildContext context, {Document? document}) addEditNote;
|
|
|
|
|
|
|
|
@override
|
|
|
|
String get key => 'notes';
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(
|
|
|
|
BuildContext context,
|
|
|
|
QuillController controller,
|
|
|
|
Embed node,
|
|
|
|
bool readOnly,
|
|
|
|
) {
|
|
|
|
final notes = NotesBlockEmbed(node.value.data).document;
|
|
|
|
|
|
|
|
return Material(
|
|
|
|
color: Colors.transparent,
|
|
|
|
child: ListTile(
|
|
|
|
title: Text(
|
|
|
|
notes.toPlainText().replaceAll('\n', ' '),
|
|
|
|
maxLines: 3,
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
),
|
|
|
|
leading: const Icon(Icons.notes),
|
|
|
|
onTap: () => addEditNote(context, document: notes),
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
borderRadius: BorderRadius.circular(10),
|
|
|
|
side: const BorderSide(color: Colors.grey),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class NotesBlockEmbed extends CustomBlockEmbed {
|
|
|
|
const NotesBlockEmbed(String value) : super(noteType, value);
|
|
|
|
|
|
|
|
static const String noteType = 'notes';
|
|
|
|
|
|
|
|
static NotesBlockEmbed fromDocument(Document document) =>
|
|
|
|
NotesBlockEmbed(jsonEncode(document.toDelta().toJson()));
|
|
|
|
|
|
|
|
Document get document => Document.fromJson(jsonDecode(data));
|
|
|
|
}
|