Skip to content

Commit 4f698e1

Browse files
committed
feat!: store thread settings in Quil Delta document
This commit adds support for saving thread settings in quill delta json files: * Thread title. * Thread type id. * Required read permission. * Price. * Additional options. using versioned Quill Delta json document. Since this commit, the quill delta json output is `Map<String, dynamic>` with two keys: 1. `metadata` - stores all thread settings data (see above). 2. `operations` - stores all quill delta operations (same as previous versions). This document structure provides all data we may access when editing a thread. After the document is imported, UI elements in `PostEditPage` will update to the values in `metadata` section. **Note that values not present in document will not change.** Compatibility: * The `version` field in `metadata` section indicates the document structure and allows for future changes. * Older unversioned Quill Delta documents (List<Map<String, dynamic>>) are recognized and imported with empty metadata, preserving existing thread settings. * Older app versions cannot read documents generated by this version. This commit also added a `tabBar` in bottomsheet. BREAKING CHANGE: Quill Delta json document generated by this version are incompatible with older app versions.
1 parent c58b352 commit 4f698e1

File tree

12 files changed

+603
-14
lines changed

12 files changed

+603
-14
lines changed

lib/features/editor/widgets/toolbar.dart

Lines changed: 266 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
import 'dart:typed_data';
4+
5+
import 'package:dart_bbcode_parser/dart_bbcode_parser.dart';
6+
import 'package:dart_quill_delta/dart_quill_delta.dart';
7+
import 'package:file_picker/file_picker.dart';
18
import 'package:flutter/material.dart';
9+
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
210
import 'package:flutter_bbcode_editor/flutter_bbcode_editor.dart';
311
import 'package:tsdm_client/constants/layout.dart';
12+
import 'package:tsdm_client/extensions/date_time.dart';
413
import 'package:tsdm_client/features/editor/widgets/color_bottom_sheet.dart';
514
import 'package:tsdm_client/features/editor/widgets/emoji_bottom_sheet.dart';
615
import 'package:tsdm_client/features/editor/widgets/image_dialog.dart';
716
import 'package:tsdm_client/features/editor/widgets/url_dialog.dart';
817
import 'package:tsdm_client/features/editor/widgets/username_picker_dialog.dart';
18+
import 'package:tsdm_client/i18n/strings.g.dart';
19+
import 'package:tsdm_client/shared/models/models.dart';
20+
import 'package:tsdm_client/utils/logger.dart';
921
import 'package:tsdm_client/utils/platform.dart';
22+
import 'package:tsdm_client/utils/show_bottom_sheet.dart';
23+
import 'package:tsdm_client/utils/show_toast.dart';
24+
import 'package:tsdm_client/widgets/tips_card.dart';
1025

1126
/// Representing all features types.
1227
enum EditorFeatures {
@@ -99,13 +114,15 @@ enum EditorFeatures {
99114
}
100115

101116
/// Toolbar for the bbcode editor.
102-
class EditorToolbar extends StatelessWidget {
117+
class EditorToolbar extends StatelessWidget with LoggerMixin {
103118
/// Constructor.
104119
const EditorToolbar({
105120
required this.bbcodeController,
106121
this.disabledFeatures = const {},
107122
this.afterButtonPressed,
108123
this.editorFocusNode,
124+
this.applyDocumentMetadata,
125+
this.collectDocumentMetadata,
109126
super.key,
110127
});
111128

@@ -126,8 +143,231 @@ class EditorToolbar extends StatelessWidget {
126143
/// Use This field to update editor focus state.
127144
final FocusNode? editorFocusNode;
128145

146+
/// Optional callback function, collect document metadata info from UI.
147+
///
148+
/// Use this function to send quill delta json document metadata from outside
149+
/// toolbar.
150+
final EditorDocumentMetadata Function()? collectDocumentMetadata;
151+
152+
/// Optional callback function, apply document metadata on UI.
153+
///
154+
/// Use this function to update UI with values in metadata.
155+
final void Function(EditorDocumentMetadata metadata)? applyDocumentMetadata;
156+
129157
bool _hasFeature(EditorFeatures feature) => !disabledFeatures.contains(feature);
130158

159+
/// The callback function when portation button is clicked.
160+
///
161+
/// Show a bottom sheet provides (copy/paste + import/export) -> (bbcode/quill delta)
162+
///
163+
/// To support versioned quill delta document files, provides optional parameters:
164+
///
165+
/// * [collectMetadata] callback function that provides document metadata to
166+
/// save in the quill delta document. Caller may use this parameter where
167+
/// UI have document metadata.
168+
/// * [applyMetadata] callback function to apply document metadata on UI. Caller
169+
/// may use this parameter where updating metadata in UI is needed.
170+
///
171+
/// No matter [collectMetadata] and [applyMetadata] are provided or not, the exported
172+
/// quill delta document will always in the latest format, not orignial quill delta json.
173+
Future<void> _onPortationButtonClicked(
174+
BuildContext context,
175+
BBCodeEditorController controller, {
176+
EditorDocumentMetadata Function()? collectMetadata,
177+
void Function(EditorDocumentMetadata)? applyMetadata,
178+
}) async {
179+
final tr = context.t.postEditPage.portation;
180+
181+
final topBar = BottomSheetTopBar(
182+
height: 110,
183+
alignment: .center,
184+
child: Padding(
185+
padding: edgeInsetsL12R12,
186+
child: Align(
187+
child: Column(
188+
spacing: 10,
189+
children: [
190+
TipsCard(
191+
iconData: Icons.warning_outlined,
192+
tips: '${tr.tip} ',
193+
color: Theme.of(context).colorScheme.onPrimaryContainer,
194+
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
195+
),
196+
TipsCard(
197+
tips: '${tr.typesTip} ',
198+
color: Theme.of(context).colorScheme.onSecondaryContainer,
199+
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
200+
),
201+
],
202+
),
203+
),
204+
),
205+
);
206+
207+
await showCustomBottomSheet<void>(
208+
title: tr.title,
209+
topBar: topBar,
210+
context: context,
211+
childrenBuilder: (_) => [
212+
ListTile(
213+
title: Text(tr.copyBBCode),
214+
onTap: () async {
215+
await Clipboard.setData(ClipboardData(text: controller.toBBCode()));
216+
if (!context.mounted) {
217+
return;
218+
}
219+
showSnackBar(context: context, message: context.t.general.copiedToClipboard);
220+
Navigator.of(context).pop();
221+
},
222+
),
223+
ListTile(
224+
title: Text(tr.pasteBBCode),
225+
onTap: () async {
226+
final bbcode = (await Clipboard.getData(Clipboard.kTextPlain))?.text;
227+
if (bbcode == null) {
228+
return;
229+
}
230+
try {
231+
final delta = parseBBCodeTextToDelta(bbcode.replaceAll('\r', ''));
232+
controller.setDocumentFromDelta(delta);
233+
} on Exception catch (e, st) {
234+
error('failed to paste bbcode: exception thrown');
235+
handleRaw(e, st);
236+
return;
237+
}
238+
239+
if (!context.mounted) {
240+
return;
241+
}
242+
Navigator.of(context).pop();
243+
},
244+
),
245+
ListTile(
246+
title: Text(tr.exportBBCode),
247+
onTap: () async {
248+
await _exportFile(context, 'bbcode_', 'txt', controller.toBBCode());
249+
if (!context.mounted) {
250+
return;
251+
}
252+
Navigator.of(context).pop();
253+
},
254+
),
255+
ListTile(
256+
title: Text(tr.importBBCode),
257+
onTap: () async {
258+
final data = await _importFile(context, ['txt']);
259+
if (data == null) {
260+
return;
261+
}
262+
if (!context.mounted) {
263+
return;
264+
}
265+
try {
266+
final delta = parseBBCodeTextToDelta(data.replaceAll('\r', ''));
267+
controller.setDocumentFromDelta(delta);
268+
} on Exception catch (e, st) {
269+
error('failed to import bbcode: exception thrown');
270+
handleRaw(e, st);
271+
return;
272+
}
273+
Navigator.of(context).pop();
274+
},
275+
),
276+
ListTile(
277+
title: Text(tr.copyQuilllDelta),
278+
onTap: () async {
279+
final data = EditorDocument.build(collectMetadata?.call(), controller.toQuillDeltaJson());
280+
await Clipboard.setData(ClipboardData(text: data.toJson()));
281+
if (!context.mounted) {
282+
return;
283+
}
284+
showSnackBar(context: context, message: context.t.general.copiedToClipboard);
285+
Navigator.of(context).pop();
286+
},
287+
),
288+
ListTile(
289+
title: Text(tr.pasteQuillDelta),
290+
onTap: () async {
291+
final data = (await Clipboard.getData(Clipboard.kTextPlain))?.text;
292+
if (data == null) {
293+
return;
294+
}
295+
try {
296+
final d = jsonDecode(data.replaceAll('\r', ''));
297+
if (d is List<Map<String, dynamic>>) {
298+
// Not versioned.
299+
final delta = Delta.fromJson(d);
300+
controller.setDocumentFromDelta(delta);
301+
} else if (d is Map<String, dynamic>) {
302+
// Versioned.
303+
final doc = EditorDocumentMapper.fromMap(d);
304+
applyMetadata?.call(doc.metadata);
305+
final delta = Delta.fromJson(doc.operations);
306+
controller.setDocumentFromDelta(delta);
307+
} else {
308+
throw FormatException('invalid quill delta document type ${d.runtimeType}');
309+
}
310+
} on Exception catch (e, st) {
311+
error('failed to paste quill delta: exception thrown');
312+
handleRaw(e, st);
313+
return;
314+
}
315+
316+
if (!context.mounted) {
317+
return;
318+
}
319+
Navigator.of(context).pop();
320+
},
321+
),
322+
ListTile(
323+
title: Text(tr.exportQuillDelta),
324+
onTap: () async {
325+
final data = EditorDocument.build(collectMetadata?.call(), controller.toQuillDeltaJson());
326+
await _exportFile(context, 'quilldata_', 'json', data.toJson());
327+
if (!context.mounted) {
328+
return;
329+
}
330+
Navigator.of(context).pop();
331+
},
332+
),
333+
ListTile(
334+
title: Text(tr.importQuillDelta),
335+
onTap: () async {
336+
final data = await _importFile(context, ['json']);
337+
if (data == null) {
338+
return;
339+
}
340+
if (!context.mounted) {
341+
return;
342+
}
343+
344+
try {
345+
final d = jsonDecode(data.replaceAll('\r', ''));
346+
if (d is List<dynamic>) {
347+
// Not versioned.
348+
final delta = Delta.fromJson(d);
349+
controller.setDocumentFromDelta(delta);
350+
} else if (d is Map<String, dynamic>) {
351+
// Versioned.
352+
final doc = EditorDocumentMapper.fromMap(d);
353+
applyMetadata?.call(doc.metadata);
354+
final delta = Delta.fromJson(doc.operations);
355+
controller.setDocumentFromDelta(delta);
356+
} else {
357+
throw FormatException('invalid quill delta document type ${d.runtimeType}');
358+
}
359+
} on Exception catch (e, st) {
360+
error('failed to import quill delta: exception thrown');
361+
handleRaw(e, st);
362+
return;
363+
}
364+
Navigator.of(context).pop();
365+
},
366+
),
367+
],
368+
);
369+
}
370+
131371
@override
132372
Widget build(BuildContext context) {
133373
final toolbar = BBCodeEditorToolbar(
@@ -141,6 +381,12 @@ class EditorToolbar extends StatelessWidget {
141381
showColorPicker(context, initialColor, PickerType.background),
142382
imagePicker: (context, url, width, height) => showImagePicker(context, url: url, width: width, height: height),
143383
usernamePicker: showUsernamePickerDialog,
384+
onPortationButtonClicked: (_, controller) async => _onPortationButtonClicked(
385+
context,
386+
controller,
387+
collectMetadata: collectDocumentMetadata,
388+
applyMetadata: applyDocumentMetadata,
389+
),
144390
// Features.
145391
showUndo: _hasFeature(EditorFeatures.undo),
146392
showRedo: _hasFeature(EditorFeatures.redo),
@@ -178,3 +424,22 @@ class EditorToolbar extends StatelessWidget {
178424
return toolbar;
179425
}
180426
}
427+
428+
Future<String?> _importFile(BuildContext context, List<String> exts) async {
429+
final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: exts);
430+
if (result == null) {
431+
return null;
432+
}
433+
return File(result.files.single.path!).readAsString();
434+
}
435+
436+
Future<void> _exportFile(BuildContext context, String prefix, String ext, String data) async {
437+
final result = await FilePicker.platform.saveFile(
438+
fileName: '$prefix${DateTime.now().yyyyMMDDHHMMSS()}.$ext',
439+
bytes: Uint8List.fromList(utf8.encode(data)),
440+
);
441+
if (result == null) {
442+
return;
443+
}
444+
await File(result).writeAsString(data);
445+
}

0 commit comments

Comments
 (0)