From fa2a3be6b50ca623efacf0175c2000aef08a3a2f Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 26 Jun 2022 23:41:53 +0200 Subject: [PATCH 01/51] maintenance/autogenerated files --- ios/Podfile.lock | 53 +++++++++++++++++++++++++ ios/Runner/Info.plist | 4 +- linux/flutter/generated_plugins.cmake | 8 ++++ pubspec.lock | 18 ++++----- windows/flutter/generated_plugins.cmake | 8 ++++ 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5209951..051e876 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,38 @@ PODS: + - DKImagePickerController/Core (4.3.3): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.3) + - DKImagePickerController/PhotoGallery (4.3.3): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.3) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter - Flutter (1.0.0) - flutter_keyboard_visibility (0.0.1): - Flutter @@ -8,14 +42,19 @@ PODS: - Flutter - path_provider_ios (0.0.1): - Flutter + - SDWebImage (5.12.5): + - SDWebImage/Core (= 5.12.5) + - SDWebImage/Core (5.12.5) - share_plus (0.0.1): - Flutter - shared_preferences_ios (0.0.1): - Flutter + - SwiftyGif (5.4.3) - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) @@ -25,7 +64,16 @@ DEPENDENCIES: - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - SDWebImage + - SwiftyGif + EXTERNAL SOURCES: + file_picker: + :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter flutter_keyboard_visibility: @@ -44,13 +92,18 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: + DKImagePickerController: 72fd378f244cef3d27288e0aebf217a4467e4012 + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 + SDWebImage: 0905f1b7760fc8ac4198cae0036600d67478751e share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad + SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2b09730..0819297 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -51,5 +51,7 @@ For uploading images for posts/avatars NSMicrophoneUsageDescription For recording videos for posts - + CADisableMinimumFrameDurationOnPhone + + diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 1fc8ed3..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,9 @@ list(APPEND FLUTTER_PLUGIN_LIST url_launcher_linux ) +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + set(PLUGIN_BUNDLED_LIBRARIES) foreach(plugin ${FLUTTER_PLUGIN_LIST}) @@ -14,3 +17,8 @@ foreach(plugin ${FLUTTER_PLUGIN_LIST}) list(APPEND PLUGIN_BUNDLED_LIBRARIES $) list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/pubspec.lock b/pubspec.lock index c83feb4..c5f0b97 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -147,7 +147,7 @@ packages: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.15.0" + version: "1.16.0" convert: dependency: transitive description: @@ -196,7 +196,7 @@ packages: name: fake_async url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.3.0" ffi: dependency: transitive description: @@ -447,7 +447,7 @@ packages: name: js url: "https://pub.dartlang.org" source: hosted - version: "0.6.3" + version: "0.6.4" json_annotation: dependency: "direct main" description: @@ -510,7 +510,7 @@ packages: name: material_color_utilities url: "https://pub.dartlang.org" source: hosted - version: "0.1.3" + version: "0.1.4" matrix4_transform: dependency: "direct main" description: @@ -615,7 +615,7 @@ packages: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.8.0" + version: "1.8.1" path_provider: dependency: transitive description: @@ -865,7 +865,7 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.1" + version: "1.8.2" stack_trace: dependency: transitive description: @@ -907,7 +907,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.8" + version: "0.4.9" timeago: dependency: "direct main" description: @@ -991,7 +991,7 @@ packages: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.1.1" + version: "2.1.2" watcher: dependency: transitive description: @@ -1042,5 +1042,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=2.16.0 <3.0.0" + dart: ">=2.17.0-0 <3.0.0" flutter: ">=2.10.0" diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 411af46..88b22e5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,9 @@ list(APPEND FLUTTER_PLUGIN_LIST url_launcher_windows ) +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + set(PLUGIN_BUNDLED_LIBRARIES) foreach(plugin ${FLUTTER_PLUGIN_LIST}) @@ -14,3 +17,8 @@ foreach(plugin ${FLUTTER_PLUGIN_LIST}) list(APPEND PLUGIN_BUNDLED_LIBRARIES $) list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) From 6f8fed149cb6a10b6f64f2bdceab319b75e6495d Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 26 Jun 2022 23:43:36 +0200 Subject: [PATCH 02/51] editor reorganization + first changes * added toolbar with buttons * bold button works * added input formatter for user convieniance --- lib/formatter.dart | 54 +++++++++++ lib/pages/create_post/create_post.dart | 119 +++++++++++++++---------- lib/pages/manage_account.dart | 2 +- lib/widgets/{ => editor}/editor.dart | 32 ++++--- lib/widgets/editor/toolbar.dart | 80 +++++++++++++++++ lib/widgets/write_comment.dart | 2 +- 6 files changed, 227 insertions(+), 62 deletions(-) create mode 100644 lib/formatter.dart rename lib/widgets/{ => editor}/editor.dart (67%) create mode 100644 lib/widgets/editor/toolbar.dart diff --git a/lib/formatter.dart b/lib/formatter.dart new file mode 100644 index 0000000..70bafad --- /dev/null +++ b/lib/formatter.dart @@ -0,0 +1,54 @@ +import 'package:flutter/services.dart'; + +extension on String { + int getBeginningOfPreviousLine(int startingIndex) { + for (var i = startingIndex; i >= 0; i--) { + if (this[i] == '\n') return i + 1; + } + return 0; + } + + // returns the line that ends at endingIndex + String lineBefore(int endingIndex) { + return substring(getBeginningOfPreviousLine(endingIndex), endingIndex + 1); + } +} + +extension on TextEditingValue { + /// Append a string after the cursor + TextEditingValue append(String s) { + final beg = text.substring(0, selection.baseOffset); + final end = text.substring(selection.baseOffset); + + return copyWith( + text: '$beg$s$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + s.length, + extentOffset: selection.extentOffset + s.length, + ), + ); + } +} + +/// Provides convinience formatting in markdown text fields +class MarkdownFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, TextEditingValue newValue) { + if (oldValue.text.length > newValue.text.length) return newValue; + + final char = newValue.text[newValue.selection.baseOffset - 1]; + if (char == '\n') { + final lineBefore = + newValue.text.lineBefore(newValue.selection.baseOffset - 2); + if (lineBefore.startsWith('- ')) { + return newValue.append('- '); + } + if (lineBefore.startsWith('* ')) { + return newValue.append('* '); + } + } + + return newValue; + } +} diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index eb016ec..c215b69 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -11,7 +11,7 @@ import '../../util/async_store_listener.dart'; import '../../util/extensions/spaced.dart'; import '../../util/mobx_provider.dart'; import '../../util/observer_consumers.dart'; -import '../../widgets/editor.dart'; +import '../../widgets/editor/editor.dart'; import '../../widgets/markdown_mode_icon.dart'; import 'create_post_community_picker.dart'; import 'create_post_instance_picker.dart'; @@ -55,9 +55,12 @@ class CreatePostPage extends HookWidget { ), ); + final bodyController = + useTextEditingController(text: context.read().body); + final body = ObserverBuilder( builder: (context, store) => Editor( - initialValue: store.body, + controller: bodyController, focusNode: bodyFocusNode, onChanged: (body) => store.body = body, labelText: L10n.of(context).body, @@ -82,59 +85,79 @@ class CreatePostPage extends HookWidget { ), ], ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(5), - child: Form( - key: formKey, - child: Column( - children: [ - if (!context.read().isEdit) ...const [ - CreatePostInstancePicker(), - CreatePostCommunityPicker(), - ], - CreatePostUrlField(titleFocusNode), - title, - body, - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + body: Stack( + children: [ + SafeArea( + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(5), + child: Form( + key: formKey, + child: Column( children: [ - ObserverBuilder( - builder: (context, store) => GestureDetector( - onTap: () => store.nsfw = !store.nsfw, - child: Row( - children: [ - Checkbox( - value: store.nsfw, - onChanged: (val) { - if (val != null) store.nsfw = val; - }, + if (!context.read().isEdit) ...const [ + CreatePostInstancePicker(), + CreatePostCommunityPicker(), + ], + CreatePostUrlField(titleFocusNode), + title, + body, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ObserverBuilder( + builder: (context, store) => GestureDetector( + onTap: () => store.nsfw = !store.nsfw, + child: Row( + children: [ + Checkbox( + value: store.nsfw, + onChanged: (val) { + if (val != null) store.nsfw = val; + }, + ), + Text(L10n.of(context).nsfw) + ], ), - Text(L10n.of(context).nsfw) - ], + ), ), - ), + ObserverBuilder( + builder: (context, store) => TextButton( + onPressed: store.submitState.isLoading + ? () {} + : loggedInAction(handleSubmit), + child: store.submitState.isLoading + ? const CircularProgressIndicator.adaptive() + : Text( + store.isEdit + ? L10n.of(context).edit + : L10n.of(context).post, + ), + ), + ) + ], ), - ObserverBuilder( - builder: (context, store) => TextButton( - onPressed: store.submitState.isLoading - ? () {} - : loggedInAction(handleSubmit), - child: store.submitState.isLoading - ? const CircularProgressIndicator.adaptive() - : Text( - store.isEdit - ? L10n.of(context).edit - : L10n.of(context).post, - ), - ), - ) - ], + ].spaced(6), ), - ].spaced(6), + ), ), ), - ), + SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Spacer(), + Toolbar(bodyController), + ], + ), + ), + // Positioned( + // bottom: MediaQuery.of(context).viewInsets.bottom, + // left: 0, + // right: 0, + // child: Toolbar(bodyController), + // ) + ], ), ), ); diff --git a/lib/pages/manage_account.dart b/lib/pages/manage_account.dart index 60871eb..92c16c0 100644 --- a/lib/pages/manage_account.dart +++ b/lib/pages/manage_account.dart @@ -13,7 +13,7 @@ import '../util/pictrs.dart'; import '../widgets/bottom_modal.dart'; import '../widgets/bottom_safe.dart'; import '../widgets/cached_network_image.dart'; -import '../widgets/editor.dart'; +import '../widgets/editor/editor.dart'; /// Page for managing things like username, email, avatar etc /// This page will assume the manage account is logged in and diff --git a/lib/widgets/editor.dart b/lib/widgets/editor/editor.dart similarity index 67% rename from lib/widgets/editor.dart rename to lib/widgets/editor/editor.dart index f4d8a1f..1c6a7a1 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor/editor.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'markdown_text.dart'; +import '../../formatter.dart'; +import '../markdown_text.dart'; + +export 'toolbar.dart'; /// A text field with added functionality for ease of editing class Editor extends HookWidget { @@ -49,17 +52,22 @@ class Editor extends HookWidget { ); } - return TextField( - controller: actualController, - focusNode: focusNode, - autofocus: autofocus, - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - onChanged: onChanged, - onSubmitted: onSubmitted, - maxLines: maxLines, - minLines: minLines, - decoration: InputDecoration(labelText: labelText), + return Stack( + children: [ + TextField( + controller: actualController, + focusNode: focusNode, + autofocus: autofocus, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + onChanged: onChanged, + onSubmitted: onSubmitted, + maxLines: maxLines, + minLines: minLines, + decoration: InputDecoration(labelText: labelText), + inputFormatters: [MarkdownFormatter()], + ), + ], ); } } diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart new file mode 100644 index 0000000..7788124 --- /dev/null +++ b/lib/widgets/editor/toolbar.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +extension on TextEditingController { + /// surroungs selection with given strings. If nothing is selected, placeholder is used in the middle + void surround(String before, String after, + [String placeholder = '[write text here]']) { + final beg = text.substring(0, selection.baseOffset); + final mid = text.substring(selection.baseOffset, selection.extentOffset); + final end = text.substring(selection.extentOffset); + + if (mid.isEmpty) { + value = value.copyWith( + text: '$beg$before$placeholder$after$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + before.length, + extentOffset: + selection.baseOffset + before.length + placeholder.length, + ), + ); + } else { + value = value.copyWith( + text: '$beg$before$mid$after$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + before.length, + extentOffset: selection.extentOffset + before.length, + )); + } + } +} + +class Toolbar extends HookWidget { + final TextEditingController controller; + + const Toolbar(this.controller); + + @override + Widget build(BuildContext context) { + return Container( + height: 50, + width: double.infinity, + color: Theme.of(context).cardColor, + child: Material( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + IconButton( + onPressed: () { + controller.surround('**', '**'); + }, + icon: const Icon(Icons.format_bold)), + IconButton( + onPressed: () {}, icon: const Icon(Icons.format_italic)), + IconButton(onPressed: () {}, icon: const Icon(Icons.link)), + IconButton(onPressed: () {}, icon: const Icon(Icons.image)), + IconButton( + onPressed: () {}, icon: const Icon(Icons.h_mobiledata)), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.format_strikethrough)), + IconButton( + onPressed: () {}, icon: const Icon(Icons.format_quote)), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.format_list_bulleted)), + IconButton(onPressed: () {}, icon: const Icon(Icons.code)), + IconButton(onPressed: () {}, icon: const Icon(Icons.subscript)), + IconButton(onPressed: () {}, icon: const Icon(Icons.superscript)), + // IconButton(onPressed: () {}, icon: const Icon(Icons.warning)),//spoiler + IconButton(onPressed: () {}, icon: const Icon(Icons.superscript)), + IconButton( + onPressed: () {}, icon: const Icon(Icons.question_mark)), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index f7f7a5e..4870975 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -5,7 +5,7 @@ import 'package:lemmy_api_client/v3.dart'; import '../hooks/delayed_loading.dart'; import '../hooks/logged_in_action.dart'; import '../l10n/l10n.dart'; -import 'editor.dart'; +import 'editor/editor.dart'; import 'markdown_mode_icon.dart'; import 'markdown_text.dart'; From b972e4485abc2b0e86427234ebd835e7a2c6a996 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 28 Jun 2022 00:40:37 +0200 Subject: [PATCH 03/51] add functionality to more buttons * add several extensions on TextEditingController for convinience * add "add link" dialog + functionality * add functionality to surround buttons: * italics, * stikethough, * superscript, * subscript, * code --- lib/widgets/editor/toolbar.dart | 199 ++++++++++++++++++++++++++------ 1 file changed, 164 insertions(+), 35 deletions(-) diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart index 7788124..3c4a02b 100644 --- a/lib/widgets/editor/toolbar.dart +++ b/lib/widgets/editor/toolbar.dart @@ -1,31 +1,61 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import '../../util/extensions/spaced.dart'; + +class Reformat { + final String text; + final int selectionBeginningShift; + final int selectionEndingShift; + Reformat({ + required this.text, + this.selectionBeginningShift = 0, + this.selectionEndingShift = 0, + }); +} + extension on TextEditingController { + String get selectionText => + text.substring(selection.baseOffset, selection.extentOffset); + String get beforeSelectionText => text.substring(0, selection.baseOffset); + String get afterSelectionText => text.substring(selection.extentOffset); + /// surroungs selection with given strings. If nothing is selected, placeholder is used in the middle - void surround(String before, String after, - [String placeholder = '[write text here]']) { + void surround( + String before, [ + String? after, + String placeholder = '[write text here]', + ]) { + after ??= before; final beg = text.substring(0, selection.baseOffset); - final mid = text.substring(selection.baseOffset, selection.extentOffset); + final mid = () { + final m = text.substring(selection.baseOffset, selection.extentOffset); + if (m.isEmpty) return placeholder; + return m; + }(); final end = text.substring(selection.extentOffset); - if (mid.isEmpty) { - value = value.copyWith( - text: '$beg$before$placeholder$after$end', + value = value.copyWith( + text: '$beg$before$mid$after$end', selection: selection.copyWith( baseOffset: selection.baseOffset + before.length, - extentOffset: - selection.baseOffset + before.length + placeholder.length, - ), - ); - } else { - value = value.copyWith( - text: '$beg$before$mid$after$end', - selection: selection.copyWith( - baseOffset: selection.baseOffset + before.length, - extentOffset: selection.extentOffset + before.length, - )); - } + extentOffset: selection.baseOffset + before.length + mid.length, + )); + } + + void reformat(Reformat Function(String selection) reformatter) { + final beg = beforeSelectionText; + final mid = selectionText; + final end = afterSelectionText; + + final r = reformatter(mid); + value = value.copyWith( + text: '$beg${r.text}$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + r.selectionBeginningShift, + extentOffset: selection.extentOffset + r.selectionEndingShift, + ), + ); } } @@ -46,31 +76,68 @@ class Toolbar extends HookWidget { child: Row( children: [ IconButton( - onPressed: () { - controller.surround('**', '**'); - }, - icon: const Icon(Icons.format_bold)), + onPressed: () => controller.surround('**'), + icon: const Icon(Icons.format_bold), + ), IconButton( - onPressed: () {}, icon: const Icon(Icons.format_italic)), - IconButton(onPressed: () {}, icon: const Icon(Icons.link)), - IconButton(onPressed: () {}, icon: const Icon(Icons.image)), + onPressed: () => controller.surround('*'), + icon: const Icon(Icons.format_italic), + ), IconButton( - onPressed: () {}, icon: const Icon(Icons.h_mobiledata)), + onPressed: () async { + final r = await AddLinkDialog.show( + context, controller.selectionText); + if (r != null) controller.reformat((_) => r); + }, + icon: const Icon(Icons.link), + ), IconButton( - onPressed: () {}, - icon: const Icon(Icons.format_strikethrough)), + onPressed: () {}, + icon: const Icon(Icons.image), + ), IconButton( - onPressed: () {}, icon: const Icon(Icons.format_quote)), + onPressed: () {}, + icon: const Icon(Icons.person), + ), + IconButton( + onPressed: () { + // + }, + icon: const Icon(Icons.home), + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.h_mobiledata), + ), + IconButton( + onPressed: () => controller.surround('~~'), + icon: const Icon(Icons.format_strikethrough), + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.format_quote), + ), IconButton( onPressed: () {}, icon: const Icon(Icons.format_list_bulleted)), - IconButton(onPressed: () {}, icon: const Icon(Icons.code)), - IconButton(onPressed: () {}, icon: const Icon(Icons.subscript)), - IconButton(onPressed: () {}, icon: const Icon(Icons.superscript)), - // IconButton(onPressed: () {}, icon: const Icon(Icons.warning)),//spoiler - IconButton(onPressed: () {}, icon: const Icon(Icons.superscript)), IconButton( - onPressed: () {}, icon: const Icon(Icons.question_mark)), + onPressed: () => controller.surround('`'), + icon: const Icon(Icons.code), + ), + IconButton( + onPressed: () => controller.surround('~'), + icon: const Icon(Icons.subscript), + ), + IconButton( + onPressed: () => controller.surround('^'), + icon: const Icon(Icons.superscript), + ), + //spoiler + IconButton(onPressed: () {}, icon: const Icon(Icons.warning)), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.question_mark), + ), ], ), ), @@ -78,3 +145,65 @@ class Toolbar extends HookWidget { ); } } + +class AddLinkDialog extends HookWidget { + final String title; + final String url; + final String selection; + + AddLinkDialog(this.selection) + : title = selection.startsWith('http?s://') ? '' : selection, + url = selection.startsWith('http?s://') ? selection : ''; + + @override + Widget build(BuildContext context) { + final titleController = useTextEditingController(text: title); + final urlController = useTextEditingController(text: url); + + void submit() { + final finalString = '(${titleController.text})[${urlController.text}]'; + Navigator.of(context).pop(Reformat( + text: finalString, + selectionBeginningShift: finalString.length, + selectionEndingShift: finalString.length - selection.length, + )); + } + + return AlertDialog( + title: const Text('Add link'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: titleController, + decoration: const InputDecoration(hintText: 'title'), + textInputAction: TextInputAction.next, + autofocus: true, + ), + TextField( + controller: urlController, + decoration: const InputDecoration(hintText: 'https://example.com'), + onEditingComplete: submit, + autocorrect: false, + ), + ].spaced(10), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel')), + ElevatedButton( + onPressed: submit, + child: const Text('Add link'), + ) + ], + ); + } + + static Future show(BuildContext context, String selection) async { + return showDialog( + context: context, + builder: (context) => AddLinkDialog(selection), + ); + } +} From f21c6b7c8c0d3281d6582c502c70f0631b82dc2e Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 28 Jun 2022 00:41:00 +0200 Subject: [PATCH 04/51] remove focus node that is not needed thanks to flutter being smart --- lib/pages/create_post/create_post.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index c215b69..0ffc7a2 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -31,7 +31,6 @@ class CreatePostPage extends HookWidget { ); final titleFocusNode = useFocusNode(); - final bodyFocusNode = useFocusNode(); handleSubmit(Jwt token) async { if (formKey.currentState!.validate()) { @@ -47,7 +46,6 @@ class CreatePostPage extends HookWidget { textCapitalization: TextCapitalization.sentences, textInputAction: TextInputAction.next, validator: Validators.required(L10n.of(context).required_field), - onFieldSubmitted: (_) => bodyFocusNode.requestFocus(), onChanged: (title) => store.title = title, minLines: 1, maxLines: 2, @@ -61,7 +59,6 @@ class CreatePostPage extends HookWidget { final body = ObserverBuilder( builder: (context, store) => Editor( controller: bodyController, - focusNode: bodyFocusNode, onChanged: (body) => store.body = body, labelText: L10n.of(context).body, instanceHost: store.instanceHost, From b5bb5dc1ff97b2a0c894d495fe7164baa5ac4f89 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 28 Jun 2022 01:18:45 +0200 Subject: [PATCH 05/51] regex typo fix --- lib/widgets/editor/toolbar.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart index 3c4a02b..22ffa7d 100644 --- a/lib/widgets/editor/toolbar.dart +++ b/lib/widgets/editor/toolbar.dart @@ -152,8 +152,8 @@ class AddLinkDialog extends HookWidget { final String selection; AddLinkDialog(this.selection) - : title = selection.startsWith('http?s://') ? '' : selection, - url = selection.startsWith('http?s://') ? selection : ''; + : title = selection.startsWith('https?://') ? '' : selection, + url = selection.startsWith('https?://') ? selection : ''; @override Widget build(BuildContext context) { From 7db538084a483c4f65794f49ea13fbad34264d20 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Mon, 4 Jul 2022 17:17:35 +0200 Subject: [PATCH 06/51] improve list continuation, add support for numbered list * make list continuation more universal * add support for indentation * add support for numbered list continuation --- lib/formatter.dart | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/lib/formatter.dart b/lib/formatter.dart index 70bafad..9f7c85c 100644 --- a/lib/formatter.dart +++ b/lib/formatter.dart @@ -37,18 +37,44 @@ class MarkdownFormatter extends TextInputFormatter { TextEditingValue oldValue, TextEditingValue newValue) { if (oldValue.text.length > newValue.text.length) return newValue; + var newVal = newValue; + final char = newValue.text[newValue.selection.baseOffset - 1]; + if (char == '\n') { final lineBefore = newValue.text.lineBefore(newValue.selection.baseOffset - 2); - if (lineBefore.startsWith('- ')) { - return newValue.append('- '); + + TextEditingValue listContinuation(String listChar, TextEditingValue tev) { + final regex = RegExp('(\\s*)${RegExp.escape(listChar)} '); + final match = regex.matchAsPrefix(lineBefore); + if (match == null) { + return tev; + } + final indent = match.group(1); + + return tev.append('$indent$listChar '); } - if (lineBefore.startsWith('* ')) { - return newValue.append('* '); + + TextEditingValue numberedListContinuation( + String afterNumberChar, TextEditingValue tev) { + final regex = RegExp('(\\s*)(\\d+)${RegExp.escape(afterNumberChar)} '); + final match = regex.matchAsPrefix(lineBefore); + if (match == null) { + return tev; + } + final indent = match.group(1); + final number = int.parse(match.group(2)!) + 1; + + return tev.append('$indent$number$afterNumberChar '); } + + newVal = listContinuation('-', newVal); + newVal = listContinuation('*', newVal); + newVal = numberedListContinuation('.', newVal); + newVal = numberedListContinuation(')', newVal); } - return newValue; + return newVal; } } From cab4aeebb76caede9bfb5671eaa8a52006c49d36 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Mon, 4 Jul 2022 17:18:38 +0200 Subject: [PATCH 07/51] put regex to private variable --- lib/widgets/editor/toolbar.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart index 22ffa7d..4dbb6f9 100644 --- a/lib/widgets/editor/toolbar.dart +++ b/lib/widgets/editor/toolbar.dart @@ -151,9 +151,11 @@ class AddLinkDialog extends HookWidget { final String url; final String selection; + static final _websiteRegex = RegExp(r'https?:\/\/', caseSensitive: false); + AddLinkDialog(this.selection) - : title = selection.startsWith('https?://') ? '' : selection, - url = selection.startsWith('https?://') ? selection : ''; + : title = selection.startsWith(_websiteRegex) ? '' : selection, + url = selection.startsWith(_websiteRegex) ? selection : ''; @override Widget build(BuildContext context) { From 8c0c47884731b185bbfd0da3e30d0a5f71d0142d Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Mon, 4 Jul 2022 17:20:41 +0200 Subject: [PATCH 08/51] add safearea for toolbar --- lib/pages/create_post/create_post.dart | 1 + lib/widgets/editor/toolbar.dart | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index 0ffc7a2..20a0339 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -134,6 +134,7 @@ class CreatePostPage extends HookWidget { ) ], ), + Toolbar.safeArea, ].spaced(6), ), ), diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart index 4dbb6f9..d4bafee 100644 --- a/lib/widgets/editor/toolbar.dart +++ b/lib/widgets/editor/toolbar.dart @@ -62,12 +62,13 @@ extension on TextEditingController { class Toolbar extends HookWidget { final TextEditingController controller; + static const _height = 50.0; const Toolbar(this.controller); @override Widget build(BuildContext context) { return Container( - height: 50, + height: _height, width: double.infinity, color: Theme.of(context).cardColor, child: Material( @@ -144,6 +145,8 @@ class Toolbar extends HookWidget { ), ); } + + static Widget safeArea = const SizedBox(height: _height); } class AddLinkDialog extends HookWidget { From 3c274adee7c017fd7abaa389bf575c11a8805f4b Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Wed, 6 Jul 2022 11:28:06 +0200 Subject: [PATCH 09/51] list button implementation --- lib/formatter.dart | 15 ++++-- lib/util/text_lines_iterator.dart | 89 +++++++++++++++++++++++++++++++ lib/widgets/editor/toolbar.dart | 66 +++++++++++++++++++++-- 3 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 lib/util/text_lines_iterator.dart diff --git a/lib/formatter.dart b/lib/formatter.dart index 9f7c85c..5638d86 100644 --- a/lib/formatter.dart +++ b/lib/formatter.dart @@ -1,16 +1,25 @@ import 'package:flutter/services.dart'; -extension on String { - int getBeginningOfPreviousLine(int startingIndex) { +extension Utilities on String { + int getBeginningOfTheLine(int startingIndex) { + if (startingIndex <= 0) return 0; for (var i = startingIndex; i >= 0; i--) { if (this[i] == '\n') return i + 1; } return 0; } + int getEndOfTheLine(int from) { + for (var i = from; i < length; i++) { + if (this[i] == '\n') return i; + } + + return length - 1; + } + // returns the line that ends at endingIndex String lineBefore(int endingIndex) { - return substring(getBeginningOfPreviousLine(endingIndex), endingIndex + 1); + return substring(getBeginningOfTheLine(endingIndex), endingIndex + 1); } } diff --git a/lib/util/text_lines_iterator.dart b/lib/util/text_lines_iterator.dart new file mode 100644 index 0000000..ae930a4 --- /dev/null +++ b/lib/util/text_lines_iterator.dart @@ -0,0 +1,89 @@ +import 'package:flutter/widgets.dart'; + +/// utililty class for traversing through multiline text +class TextLinesIterator extends Iterator { + String text; + int beg; + int end; + TextSelection? selection; + + TextLinesIterator(this.text, {this.selection}) + : end = -1, + beg = -1; + + factory TextLinesIterator.fromController(TextEditingController controller) => + TextLinesIterator(controller.text, selection: controller.selection); + + bool get isWithinSelection { + final selection = this.selection; + if (selection == null || beg == -1) { + return false; + } else { + return (selection.end >= beg && beg >= selection.start) || + (selection.end >= end && end >= selection.start) || + (end >= selection.start && selection.start >= beg) || + (end >= selection.end && selection.end >= beg) || + (beg <= selection.start && + selection.start <= end && + beg <= selection.end && + selection.end <= end); + } + } + + @override + String get current { + return text.substring(beg, end); + } + + set current(String newVal) { + final selected = isWithinSelection; + text = text.replaceRange(beg, end, newVal); + final wordLen = end - beg; + final dif = newVal.length - wordLen; + end += dif; + + final selection = this.selection; + if (selection == null) return; + + if (selected || selection.baseOffset > end) { + this.selection = + selection.copyWith(extentOffset: selection.extentOffset + dif); + } + } + + void reset() { + end = -1; + beg = -1; + } + + @override + bool moveNext() { + if (end == text.length) { + return false; + } + if (beg == -1) { + end = 0; + beg = 0; + } else { + end += 1; + beg = end; + } + for (; end < text.length; end++) { + if (text[end] == '\n') { + return true; + } + } + end = text.length; + return true; + } + + /// returns the lines as a list but also moves the pointer to the back + List get asList { + reset(); + final list = []; + while (moveNext()) { + list.add(current); + } + return list; + } +} diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart index d4bafee..bf4939d 100644 --- a/lib/widgets/editor/toolbar.dart +++ b/lib/widgets/editor/toolbar.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import '../../formatter.dart'; import '../../util/extensions/spaced.dart'; +import '../../util/text_lines_iterator.dart'; class Reformat { final String text; @@ -43,6 +45,54 @@ extension on TextEditingController { )); } + String get firstSelectedLine { + if (text.isEmpty) return ''; + return text.substring(text.getBeginningOfTheLine(selection.start - 1), + text.getEndOfTheLine(selection.end)); + } + + void removeAtBeginningOfEverySelectedLine(String s) { + final lines = TextLinesIterator.fromController(this); + var linesCount = 0; + while (lines.moveNext()) { + if (lines.isWithinSelection) { + if (lines.current.startsWith(RegExp.escape(s))) { + lines.current = lines.current.substring(s.length); + linesCount++; + } + } + } + + value = value.copyWith( + text: lines.text, + selection: selection.copyWith( + baseOffset: selection.baseOffset - s.length, + extentOffset: selection.extentOffset - s.length * linesCount, + ), + ); + } + + void insertAtBeginningOfEverySelectedLine(String s) { + final lines = TextLinesIterator.fromController(this); + var linesCount = 0; + while (lines.moveNext()) { + if (lines.isWithinSelection) { + if (!lines.current.startsWith(RegExp.escape(s))) { + lines.current = '$s${lines.current}'; + linesCount++; + } + } + } + + value = value.copyWith( + text: lines.text, + selection: selection.copyWith( + baseOffset: selection.baseOffset + s.length, + extentOffset: selection.extentOffset + s.length * linesCount, + ), + ); + } + void reformat(Reformat Function(String selection) reformatter) { final beg = beforeSelectionText; final mid = selectionText; @@ -101,9 +151,7 @@ class Toolbar extends HookWidget { icon: const Icon(Icons.person), ), IconButton( - onPressed: () { - // - }, + onPressed: () {}, icon: const Icon(Icons.home), ), IconButton( @@ -119,7 +167,17 @@ class Toolbar extends HookWidget { icon: const Icon(Icons.format_quote), ), IconButton( - onPressed: () {}, + onPressed: () { + final line = controller.firstSelectedLine; + + if (line.startsWith(RegExp.escape('* '))) { + controller.removeAtBeginningOfEverySelectedLine('* '); + } else if (line.startsWith('- ')) { + controller.removeAtBeginningOfEverySelectedLine('- '); + } else { + controller.insertAtBeginningOfEverySelectedLine('- '); + } + }, icon: const Icon(Icons.format_list_bulleted)), IconButton( onPressed: () => controller.surround('`'), From 08831afbcf6b5c1c419a014a192ac077a845af85 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Fri, 5 Aug 2022 10:17:21 +0200 Subject: [PATCH 10/51] minor string tweaks --- lib/formatter.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/formatter.dart b/lib/formatter.dart index 5638d86..a1ddf55 100644 --- a/lib/formatter.dart +++ b/lib/formatter.dart @@ -55,7 +55,7 @@ class MarkdownFormatter extends TextInputFormatter { newValue.text.lineBefore(newValue.selection.baseOffset - 2); TextEditingValue listContinuation(String listChar, TextEditingValue tev) { - final regex = RegExp('(\\s*)${RegExp.escape(listChar)} '); + final regex = RegExp(r'(\s*)' '${RegExp.escape(listChar)} '); final match = regex.matchAsPrefix(lineBefore); if (match == null) { return tev; @@ -67,7 +67,8 @@ class MarkdownFormatter extends TextInputFormatter { TextEditingValue numberedListContinuation( String afterNumberChar, TextEditingValue tev) { - final regex = RegExp('(\\s*)(\\d+)${RegExp.escape(afterNumberChar)} '); + final regex = + RegExp(r'(\s*)(\d+)' '${RegExp.escape(afterNumberChar)} '); final match = regex.matchAsPrefix(lineBefore); if (match == null) { return tev; From 85108d896571b94b926e075e63b44837e781c28b Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 26 Jun 2022 23:41:53 +0200 Subject: [PATCH 11/51] maintenance/autogenerated files --- ios/Runner/Info.plist | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7e54588..ac91e64 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -53,5 +53,7 @@ For uploading images for posts/avatars NSMicrophoneUsageDescription For recording videos for posts - + CADisableMinimumFrameDurationOnPhone + + From ce41b7e18cf8c3b664a90a67dbd45affd5fc5e0f Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 26 Jun 2022 23:43:36 +0200 Subject: [PATCH 12/51] editor reorganization + first changes * added toolbar with buttons * bold button works * added input formatter for user convieniance --- lib/formatter.dart | 54 +++++++++++ lib/pages/create_post/create_post.dart | 119 +++++++++++++++---------- lib/pages/manage_account.dart | 2 +- lib/widgets/{ => editor}/editor.dart | 32 ++++--- lib/widgets/editor/toolbar.dart | 80 +++++++++++++++++ lib/widgets/write_comment.dart | 2 +- 6 files changed, 227 insertions(+), 62 deletions(-) create mode 100644 lib/formatter.dart rename lib/widgets/{ => editor}/editor.dart (67%) create mode 100644 lib/widgets/editor/toolbar.dart diff --git a/lib/formatter.dart b/lib/formatter.dart new file mode 100644 index 0000000..70bafad --- /dev/null +++ b/lib/formatter.dart @@ -0,0 +1,54 @@ +import 'package:flutter/services.dart'; + +extension on String { + int getBeginningOfPreviousLine(int startingIndex) { + for (var i = startingIndex; i >= 0; i--) { + if (this[i] == '\n') return i + 1; + } + return 0; + } + + // returns the line that ends at endingIndex + String lineBefore(int endingIndex) { + return substring(getBeginningOfPreviousLine(endingIndex), endingIndex + 1); + } +} + +extension on TextEditingValue { + /// Append a string after the cursor + TextEditingValue append(String s) { + final beg = text.substring(0, selection.baseOffset); + final end = text.substring(selection.baseOffset); + + return copyWith( + text: '$beg$s$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + s.length, + extentOffset: selection.extentOffset + s.length, + ), + ); + } +} + +/// Provides convinience formatting in markdown text fields +class MarkdownFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, TextEditingValue newValue) { + if (oldValue.text.length > newValue.text.length) return newValue; + + final char = newValue.text[newValue.selection.baseOffset - 1]; + if (char == '\n') { + final lineBefore = + newValue.text.lineBefore(newValue.selection.baseOffset - 2); + if (lineBefore.startsWith('- ')) { + return newValue.append('- '); + } + if (lineBefore.startsWith('* ')) { + return newValue.append('* '); + } + } + + return newValue; + } +} diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index eb016ec..c215b69 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -11,7 +11,7 @@ import '../../util/async_store_listener.dart'; import '../../util/extensions/spaced.dart'; import '../../util/mobx_provider.dart'; import '../../util/observer_consumers.dart'; -import '../../widgets/editor.dart'; +import '../../widgets/editor/editor.dart'; import '../../widgets/markdown_mode_icon.dart'; import 'create_post_community_picker.dart'; import 'create_post_instance_picker.dart'; @@ -55,9 +55,12 @@ class CreatePostPage extends HookWidget { ), ); + final bodyController = + useTextEditingController(text: context.read().body); + final body = ObserverBuilder( builder: (context, store) => Editor( - initialValue: store.body, + controller: bodyController, focusNode: bodyFocusNode, onChanged: (body) => store.body = body, labelText: L10n.of(context).body, @@ -82,59 +85,79 @@ class CreatePostPage extends HookWidget { ), ], ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(5), - child: Form( - key: formKey, - child: Column( - children: [ - if (!context.read().isEdit) ...const [ - CreatePostInstancePicker(), - CreatePostCommunityPicker(), - ], - CreatePostUrlField(titleFocusNode), - title, - body, - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + body: Stack( + children: [ + SafeArea( + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(5), + child: Form( + key: formKey, + child: Column( children: [ - ObserverBuilder( - builder: (context, store) => GestureDetector( - onTap: () => store.nsfw = !store.nsfw, - child: Row( - children: [ - Checkbox( - value: store.nsfw, - onChanged: (val) { - if (val != null) store.nsfw = val; - }, + if (!context.read().isEdit) ...const [ + CreatePostInstancePicker(), + CreatePostCommunityPicker(), + ], + CreatePostUrlField(titleFocusNode), + title, + body, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ObserverBuilder( + builder: (context, store) => GestureDetector( + onTap: () => store.nsfw = !store.nsfw, + child: Row( + children: [ + Checkbox( + value: store.nsfw, + onChanged: (val) { + if (val != null) store.nsfw = val; + }, + ), + Text(L10n.of(context).nsfw) + ], ), - Text(L10n.of(context).nsfw) - ], + ), ), - ), + ObserverBuilder( + builder: (context, store) => TextButton( + onPressed: store.submitState.isLoading + ? () {} + : loggedInAction(handleSubmit), + child: store.submitState.isLoading + ? const CircularProgressIndicator.adaptive() + : Text( + store.isEdit + ? L10n.of(context).edit + : L10n.of(context).post, + ), + ), + ) + ], ), - ObserverBuilder( - builder: (context, store) => TextButton( - onPressed: store.submitState.isLoading - ? () {} - : loggedInAction(handleSubmit), - child: store.submitState.isLoading - ? const CircularProgressIndicator.adaptive() - : Text( - store.isEdit - ? L10n.of(context).edit - : L10n.of(context).post, - ), - ), - ) - ], + ].spaced(6), ), - ].spaced(6), + ), ), ), - ), + SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Spacer(), + Toolbar(bodyController), + ], + ), + ), + // Positioned( + // bottom: MediaQuery.of(context).viewInsets.bottom, + // left: 0, + // right: 0, + // child: Toolbar(bodyController), + // ) + ], ), ), ); diff --git a/lib/pages/manage_account.dart b/lib/pages/manage_account.dart index e9a5121..a3408f1 100644 --- a/lib/pages/manage_account.dart +++ b/lib/pages/manage_account.dart @@ -13,7 +13,7 @@ import '../util/pictrs.dart'; import '../widgets/bottom_modal.dart'; import '../widgets/bottom_safe.dart'; import '../widgets/cached_network_image.dart'; -import '../widgets/editor.dart'; +import '../widgets/editor/editor.dart'; /// Page for managing things like username, email, avatar etc /// This page will assume the manage account is logged in and diff --git a/lib/widgets/editor.dart b/lib/widgets/editor/editor.dart similarity index 67% rename from lib/widgets/editor.dart rename to lib/widgets/editor/editor.dart index 145ab94..3eaad4a 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor/editor.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'markdown_text.dart'; +import '../../formatter.dart'; +import '../markdown_text.dart'; + +export 'toolbar.dart'; /// A text field with added functionality for ease of editing class Editor extends HookWidget { @@ -49,17 +52,22 @@ class Editor extends HookWidget { ); } - return TextField( - controller: actualController, - focusNode: focusNode, - autofocus: autofocus, - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - onChanged: onChanged, - onSubmitted: onSubmitted, - maxLines: maxLines, - minLines: minLines, - decoration: InputDecoration(labelText: labelText), + return Stack( + children: [ + TextField( + controller: actualController, + focusNode: focusNode, + autofocus: autofocus, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + onChanged: onChanged, + onSubmitted: onSubmitted, + maxLines: maxLines, + minLines: minLines, + decoration: InputDecoration(labelText: labelText), + inputFormatters: [MarkdownFormatter()], + ), + ], ); } } diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart new file mode 100644 index 0000000..7788124 --- /dev/null +++ b/lib/widgets/editor/toolbar.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +extension on TextEditingController { + /// surroungs selection with given strings. If nothing is selected, placeholder is used in the middle + void surround(String before, String after, + [String placeholder = '[write text here]']) { + final beg = text.substring(0, selection.baseOffset); + final mid = text.substring(selection.baseOffset, selection.extentOffset); + final end = text.substring(selection.extentOffset); + + if (mid.isEmpty) { + value = value.copyWith( + text: '$beg$before$placeholder$after$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + before.length, + extentOffset: + selection.baseOffset + before.length + placeholder.length, + ), + ); + } else { + value = value.copyWith( + text: '$beg$before$mid$after$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + before.length, + extentOffset: selection.extentOffset + before.length, + )); + } + } +} + +class Toolbar extends HookWidget { + final TextEditingController controller; + + const Toolbar(this.controller); + + @override + Widget build(BuildContext context) { + return Container( + height: 50, + width: double.infinity, + color: Theme.of(context).cardColor, + child: Material( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + IconButton( + onPressed: () { + controller.surround('**', '**'); + }, + icon: const Icon(Icons.format_bold)), + IconButton( + onPressed: () {}, icon: const Icon(Icons.format_italic)), + IconButton(onPressed: () {}, icon: const Icon(Icons.link)), + IconButton(onPressed: () {}, icon: const Icon(Icons.image)), + IconButton( + onPressed: () {}, icon: const Icon(Icons.h_mobiledata)), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.format_strikethrough)), + IconButton( + onPressed: () {}, icon: const Icon(Icons.format_quote)), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.format_list_bulleted)), + IconButton(onPressed: () {}, icon: const Icon(Icons.code)), + IconButton(onPressed: () {}, icon: const Icon(Icons.subscript)), + IconButton(onPressed: () {}, icon: const Icon(Icons.superscript)), + // IconButton(onPressed: () {}, icon: const Icon(Icons.warning)),//spoiler + IconButton(onPressed: () {}, icon: const Icon(Icons.superscript)), + IconButton( + onPressed: () {}, icon: const Icon(Icons.question_mark)), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index f7f7a5e..4870975 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -5,7 +5,7 @@ import 'package:lemmy_api_client/v3.dart'; import '../hooks/delayed_loading.dart'; import '../hooks/logged_in_action.dart'; import '../l10n/l10n.dart'; -import 'editor.dart'; +import 'editor/editor.dart'; import 'markdown_mode_icon.dart'; import 'markdown_text.dart'; From 14b7813243aa74da60d182d514fb94a9b099fcb0 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 28 Jun 2022 00:40:37 +0200 Subject: [PATCH 13/51] add functionality to more buttons * add several extensions on TextEditingController for convinience * add "add link" dialog + functionality * add functionality to surround buttons: * italics, * stikethough, * superscript, * subscript, * code --- lib/widgets/editor/toolbar.dart | 199 ++++++++++++++++++++++++++------ 1 file changed, 164 insertions(+), 35 deletions(-) diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart index 7788124..3c4a02b 100644 --- a/lib/widgets/editor/toolbar.dart +++ b/lib/widgets/editor/toolbar.dart @@ -1,31 +1,61 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import '../../util/extensions/spaced.dart'; + +class Reformat { + final String text; + final int selectionBeginningShift; + final int selectionEndingShift; + Reformat({ + required this.text, + this.selectionBeginningShift = 0, + this.selectionEndingShift = 0, + }); +} + extension on TextEditingController { + String get selectionText => + text.substring(selection.baseOffset, selection.extentOffset); + String get beforeSelectionText => text.substring(0, selection.baseOffset); + String get afterSelectionText => text.substring(selection.extentOffset); + /// surroungs selection with given strings. If nothing is selected, placeholder is used in the middle - void surround(String before, String after, - [String placeholder = '[write text here]']) { + void surround( + String before, [ + String? after, + String placeholder = '[write text here]', + ]) { + after ??= before; final beg = text.substring(0, selection.baseOffset); - final mid = text.substring(selection.baseOffset, selection.extentOffset); + final mid = () { + final m = text.substring(selection.baseOffset, selection.extentOffset); + if (m.isEmpty) return placeholder; + return m; + }(); final end = text.substring(selection.extentOffset); - if (mid.isEmpty) { - value = value.copyWith( - text: '$beg$before$placeholder$after$end', + value = value.copyWith( + text: '$beg$before$mid$after$end', selection: selection.copyWith( baseOffset: selection.baseOffset + before.length, - extentOffset: - selection.baseOffset + before.length + placeholder.length, - ), - ); - } else { - value = value.copyWith( - text: '$beg$before$mid$after$end', - selection: selection.copyWith( - baseOffset: selection.baseOffset + before.length, - extentOffset: selection.extentOffset + before.length, - )); - } + extentOffset: selection.baseOffset + before.length + mid.length, + )); + } + + void reformat(Reformat Function(String selection) reformatter) { + final beg = beforeSelectionText; + final mid = selectionText; + final end = afterSelectionText; + + final r = reformatter(mid); + value = value.copyWith( + text: '$beg${r.text}$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + r.selectionBeginningShift, + extentOffset: selection.extentOffset + r.selectionEndingShift, + ), + ); } } @@ -46,31 +76,68 @@ class Toolbar extends HookWidget { child: Row( children: [ IconButton( - onPressed: () { - controller.surround('**', '**'); - }, - icon: const Icon(Icons.format_bold)), + onPressed: () => controller.surround('**'), + icon: const Icon(Icons.format_bold), + ), IconButton( - onPressed: () {}, icon: const Icon(Icons.format_italic)), - IconButton(onPressed: () {}, icon: const Icon(Icons.link)), - IconButton(onPressed: () {}, icon: const Icon(Icons.image)), + onPressed: () => controller.surround('*'), + icon: const Icon(Icons.format_italic), + ), IconButton( - onPressed: () {}, icon: const Icon(Icons.h_mobiledata)), + onPressed: () async { + final r = await AddLinkDialog.show( + context, controller.selectionText); + if (r != null) controller.reformat((_) => r); + }, + icon: const Icon(Icons.link), + ), IconButton( - onPressed: () {}, - icon: const Icon(Icons.format_strikethrough)), + onPressed: () {}, + icon: const Icon(Icons.image), + ), IconButton( - onPressed: () {}, icon: const Icon(Icons.format_quote)), + onPressed: () {}, + icon: const Icon(Icons.person), + ), + IconButton( + onPressed: () { + // + }, + icon: const Icon(Icons.home), + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.h_mobiledata), + ), + IconButton( + onPressed: () => controller.surround('~~'), + icon: const Icon(Icons.format_strikethrough), + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.format_quote), + ), IconButton( onPressed: () {}, icon: const Icon(Icons.format_list_bulleted)), - IconButton(onPressed: () {}, icon: const Icon(Icons.code)), - IconButton(onPressed: () {}, icon: const Icon(Icons.subscript)), - IconButton(onPressed: () {}, icon: const Icon(Icons.superscript)), - // IconButton(onPressed: () {}, icon: const Icon(Icons.warning)),//spoiler - IconButton(onPressed: () {}, icon: const Icon(Icons.superscript)), IconButton( - onPressed: () {}, icon: const Icon(Icons.question_mark)), + onPressed: () => controller.surround('`'), + icon: const Icon(Icons.code), + ), + IconButton( + onPressed: () => controller.surround('~'), + icon: const Icon(Icons.subscript), + ), + IconButton( + onPressed: () => controller.surround('^'), + icon: const Icon(Icons.superscript), + ), + //spoiler + IconButton(onPressed: () {}, icon: const Icon(Icons.warning)), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.question_mark), + ), ], ), ), @@ -78,3 +145,65 @@ class Toolbar extends HookWidget { ); } } + +class AddLinkDialog extends HookWidget { + final String title; + final String url; + final String selection; + + AddLinkDialog(this.selection) + : title = selection.startsWith('http?s://') ? '' : selection, + url = selection.startsWith('http?s://') ? selection : ''; + + @override + Widget build(BuildContext context) { + final titleController = useTextEditingController(text: title); + final urlController = useTextEditingController(text: url); + + void submit() { + final finalString = '(${titleController.text})[${urlController.text}]'; + Navigator.of(context).pop(Reformat( + text: finalString, + selectionBeginningShift: finalString.length, + selectionEndingShift: finalString.length - selection.length, + )); + } + + return AlertDialog( + title: const Text('Add link'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: titleController, + decoration: const InputDecoration(hintText: 'title'), + textInputAction: TextInputAction.next, + autofocus: true, + ), + TextField( + controller: urlController, + decoration: const InputDecoration(hintText: 'https://example.com'), + onEditingComplete: submit, + autocorrect: false, + ), + ].spaced(10), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel')), + ElevatedButton( + onPressed: submit, + child: const Text('Add link'), + ) + ], + ); + } + + static Future show(BuildContext context, String selection) async { + return showDialog( + context: context, + builder: (context) => AddLinkDialog(selection), + ); + } +} From 6f271ffc91cfe516514741cf29e0d6b48dbe289d Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 28 Jun 2022 00:41:00 +0200 Subject: [PATCH 14/51] remove focus node that is not needed thanks to flutter being smart --- lib/pages/create_post/create_post.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index c215b69..0ffc7a2 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -31,7 +31,6 @@ class CreatePostPage extends HookWidget { ); final titleFocusNode = useFocusNode(); - final bodyFocusNode = useFocusNode(); handleSubmit(Jwt token) async { if (formKey.currentState!.validate()) { @@ -47,7 +46,6 @@ class CreatePostPage extends HookWidget { textCapitalization: TextCapitalization.sentences, textInputAction: TextInputAction.next, validator: Validators.required(L10n.of(context).required_field), - onFieldSubmitted: (_) => bodyFocusNode.requestFocus(), onChanged: (title) => store.title = title, minLines: 1, maxLines: 2, @@ -61,7 +59,6 @@ class CreatePostPage extends HookWidget { final body = ObserverBuilder( builder: (context, store) => Editor( controller: bodyController, - focusNode: bodyFocusNode, onChanged: (body) => store.body = body, labelText: L10n.of(context).body, instanceHost: store.instanceHost, From 81fadffa14633dcf85da6883dd5677a5e6966e73 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 28 Jun 2022 01:18:45 +0200 Subject: [PATCH 15/51] regex typo fix --- lib/widgets/editor/toolbar.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart index 3c4a02b..22ffa7d 100644 --- a/lib/widgets/editor/toolbar.dart +++ b/lib/widgets/editor/toolbar.dart @@ -152,8 +152,8 @@ class AddLinkDialog extends HookWidget { final String selection; AddLinkDialog(this.selection) - : title = selection.startsWith('http?s://') ? '' : selection, - url = selection.startsWith('http?s://') ? selection : ''; + : title = selection.startsWith('https?://') ? '' : selection, + url = selection.startsWith('https?://') ? selection : ''; @override Widget build(BuildContext context) { From e91e5789eea286b3cfd2856fa957f3c5dc7f80a8 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Mon, 4 Jul 2022 17:17:35 +0200 Subject: [PATCH 16/51] improve list continuation, add support for numbered list * make list continuation more universal * add support for indentation * add support for numbered list continuation --- lib/formatter.dart | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/lib/formatter.dart b/lib/formatter.dart index 70bafad..9f7c85c 100644 --- a/lib/formatter.dart +++ b/lib/formatter.dart @@ -37,18 +37,44 @@ class MarkdownFormatter extends TextInputFormatter { TextEditingValue oldValue, TextEditingValue newValue) { if (oldValue.text.length > newValue.text.length) return newValue; + var newVal = newValue; + final char = newValue.text[newValue.selection.baseOffset - 1]; + if (char == '\n') { final lineBefore = newValue.text.lineBefore(newValue.selection.baseOffset - 2); - if (lineBefore.startsWith('- ')) { - return newValue.append('- '); + + TextEditingValue listContinuation(String listChar, TextEditingValue tev) { + final regex = RegExp('(\\s*)${RegExp.escape(listChar)} '); + final match = regex.matchAsPrefix(lineBefore); + if (match == null) { + return tev; + } + final indent = match.group(1); + + return tev.append('$indent$listChar '); } - if (lineBefore.startsWith('* ')) { - return newValue.append('* '); + + TextEditingValue numberedListContinuation( + String afterNumberChar, TextEditingValue tev) { + final regex = RegExp('(\\s*)(\\d+)${RegExp.escape(afterNumberChar)} '); + final match = regex.matchAsPrefix(lineBefore); + if (match == null) { + return tev; + } + final indent = match.group(1); + final number = int.parse(match.group(2)!) + 1; + + return tev.append('$indent$number$afterNumberChar '); } + + newVal = listContinuation('-', newVal); + newVal = listContinuation('*', newVal); + newVal = numberedListContinuation('.', newVal); + newVal = numberedListContinuation(')', newVal); } - return newValue; + return newVal; } } From 33ff99510ff5e65e1191dee5b567a91dde3c76c8 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Mon, 4 Jul 2022 17:18:38 +0200 Subject: [PATCH 17/51] put regex to private variable --- lib/widgets/editor/toolbar.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart index 22ffa7d..4dbb6f9 100644 --- a/lib/widgets/editor/toolbar.dart +++ b/lib/widgets/editor/toolbar.dart @@ -151,9 +151,11 @@ class AddLinkDialog extends HookWidget { final String url; final String selection; + static final _websiteRegex = RegExp(r'https?:\/\/', caseSensitive: false); + AddLinkDialog(this.selection) - : title = selection.startsWith('https?://') ? '' : selection, - url = selection.startsWith('https?://') ? selection : ''; + : title = selection.startsWith(_websiteRegex) ? '' : selection, + url = selection.startsWith(_websiteRegex) ? selection : ''; @override Widget build(BuildContext context) { From 062a53fdd9c943867035a9257c64c90d27b41fe5 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Mon, 4 Jul 2022 17:20:41 +0200 Subject: [PATCH 18/51] add safearea for toolbar --- lib/pages/create_post/create_post.dart | 1 + lib/widgets/editor/toolbar.dart | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index 0ffc7a2..20a0339 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -134,6 +134,7 @@ class CreatePostPage extends HookWidget { ) ], ), + Toolbar.safeArea, ].spaced(6), ), ), diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart index 4dbb6f9..d4bafee 100644 --- a/lib/widgets/editor/toolbar.dart +++ b/lib/widgets/editor/toolbar.dart @@ -62,12 +62,13 @@ extension on TextEditingController { class Toolbar extends HookWidget { final TextEditingController controller; + static const _height = 50.0; const Toolbar(this.controller); @override Widget build(BuildContext context) { return Container( - height: 50, + height: _height, width: double.infinity, color: Theme.of(context).cardColor, child: Material( @@ -144,6 +145,8 @@ class Toolbar extends HookWidget { ), ); } + + static Widget safeArea = const SizedBox(height: _height); } class AddLinkDialog extends HookWidget { From 3c295552df52cc6a1d939563e0894a779ac29df7 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Wed, 6 Jul 2022 11:28:06 +0200 Subject: [PATCH 19/51] list button implementation --- lib/formatter.dart | 15 ++++-- lib/util/text_lines_iterator.dart | 89 +++++++++++++++++++++++++++++++ lib/widgets/editor/toolbar.dart | 66 +++++++++++++++++++++-- 3 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 lib/util/text_lines_iterator.dart diff --git a/lib/formatter.dart b/lib/formatter.dart index 9f7c85c..5638d86 100644 --- a/lib/formatter.dart +++ b/lib/formatter.dart @@ -1,16 +1,25 @@ import 'package:flutter/services.dart'; -extension on String { - int getBeginningOfPreviousLine(int startingIndex) { +extension Utilities on String { + int getBeginningOfTheLine(int startingIndex) { + if (startingIndex <= 0) return 0; for (var i = startingIndex; i >= 0; i--) { if (this[i] == '\n') return i + 1; } return 0; } + int getEndOfTheLine(int from) { + for (var i = from; i < length; i++) { + if (this[i] == '\n') return i; + } + + return length - 1; + } + // returns the line that ends at endingIndex String lineBefore(int endingIndex) { - return substring(getBeginningOfPreviousLine(endingIndex), endingIndex + 1); + return substring(getBeginningOfTheLine(endingIndex), endingIndex + 1); } } diff --git a/lib/util/text_lines_iterator.dart b/lib/util/text_lines_iterator.dart new file mode 100644 index 0000000..ae930a4 --- /dev/null +++ b/lib/util/text_lines_iterator.dart @@ -0,0 +1,89 @@ +import 'package:flutter/widgets.dart'; + +/// utililty class for traversing through multiline text +class TextLinesIterator extends Iterator { + String text; + int beg; + int end; + TextSelection? selection; + + TextLinesIterator(this.text, {this.selection}) + : end = -1, + beg = -1; + + factory TextLinesIterator.fromController(TextEditingController controller) => + TextLinesIterator(controller.text, selection: controller.selection); + + bool get isWithinSelection { + final selection = this.selection; + if (selection == null || beg == -1) { + return false; + } else { + return (selection.end >= beg && beg >= selection.start) || + (selection.end >= end && end >= selection.start) || + (end >= selection.start && selection.start >= beg) || + (end >= selection.end && selection.end >= beg) || + (beg <= selection.start && + selection.start <= end && + beg <= selection.end && + selection.end <= end); + } + } + + @override + String get current { + return text.substring(beg, end); + } + + set current(String newVal) { + final selected = isWithinSelection; + text = text.replaceRange(beg, end, newVal); + final wordLen = end - beg; + final dif = newVal.length - wordLen; + end += dif; + + final selection = this.selection; + if (selection == null) return; + + if (selected || selection.baseOffset > end) { + this.selection = + selection.copyWith(extentOffset: selection.extentOffset + dif); + } + } + + void reset() { + end = -1; + beg = -1; + } + + @override + bool moveNext() { + if (end == text.length) { + return false; + } + if (beg == -1) { + end = 0; + beg = 0; + } else { + end += 1; + beg = end; + } + for (; end < text.length; end++) { + if (text[end] == '\n') { + return true; + } + } + end = text.length; + return true; + } + + /// returns the lines as a list but also moves the pointer to the back + List get asList { + reset(); + final list = []; + while (moveNext()) { + list.add(current); + } + return list; + } +} diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/toolbar.dart index d4bafee..bf4939d 100644 --- a/lib/widgets/editor/toolbar.dart +++ b/lib/widgets/editor/toolbar.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import '../../formatter.dart'; import '../../util/extensions/spaced.dart'; +import '../../util/text_lines_iterator.dart'; class Reformat { final String text; @@ -43,6 +45,54 @@ extension on TextEditingController { )); } + String get firstSelectedLine { + if (text.isEmpty) return ''; + return text.substring(text.getBeginningOfTheLine(selection.start - 1), + text.getEndOfTheLine(selection.end)); + } + + void removeAtBeginningOfEverySelectedLine(String s) { + final lines = TextLinesIterator.fromController(this); + var linesCount = 0; + while (lines.moveNext()) { + if (lines.isWithinSelection) { + if (lines.current.startsWith(RegExp.escape(s))) { + lines.current = lines.current.substring(s.length); + linesCount++; + } + } + } + + value = value.copyWith( + text: lines.text, + selection: selection.copyWith( + baseOffset: selection.baseOffset - s.length, + extentOffset: selection.extentOffset - s.length * linesCount, + ), + ); + } + + void insertAtBeginningOfEverySelectedLine(String s) { + final lines = TextLinesIterator.fromController(this); + var linesCount = 0; + while (lines.moveNext()) { + if (lines.isWithinSelection) { + if (!lines.current.startsWith(RegExp.escape(s))) { + lines.current = '$s${lines.current}'; + linesCount++; + } + } + } + + value = value.copyWith( + text: lines.text, + selection: selection.copyWith( + baseOffset: selection.baseOffset + s.length, + extentOffset: selection.extentOffset + s.length * linesCount, + ), + ); + } + void reformat(Reformat Function(String selection) reformatter) { final beg = beforeSelectionText; final mid = selectionText; @@ -101,9 +151,7 @@ class Toolbar extends HookWidget { icon: const Icon(Icons.person), ), IconButton( - onPressed: () { - // - }, + onPressed: () {}, icon: const Icon(Icons.home), ), IconButton( @@ -119,7 +167,17 @@ class Toolbar extends HookWidget { icon: const Icon(Icons.format_quote), ), IconButton( - onPressed: () {}, + onPressed: () { + final line = controller.firstSelectedLine; + + if (line.startsWith(RegExp.escape('* '))) { + controller.removeAtBeginningOfEverySelectedLine('* '); + } else if (line.startsWith('- ')) { + controller.removeAtBeginningOfEverySelectedLine('- '); + } else { + controller.insertAtBeginningOfEverySelectedLine('- '); + } + }, icon: const Icon(Icons.format_list_bulleted)), IconButton( onPressed: () => controller.surround('`'), From 52498a6be374e5450d24115c388043327867b77f Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Fri, 5 Aug 2022 10:17:21 +0200 Subject: [PATCH 20/51] minor string tweaks --- lib/formatter.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/formatter.dart b/lib/formatter.dart index 5638d86..a1ddf55 100644 --- a/lib/formatter.dart +++ b/lib/formatter.dart @@ -55,7 +55,7 @@ class MarkdownFormatter extends TextInputFormatter { newValue.text.lineBefore(newValue.selection.baseOffset - 2); TextEditingValue listContinuation(String listChar, TextEditingValue tev) { - final regex = RegExp('(\\s*)${RegExp.escape(listChar)} '); + final regex = RegExp(r'(\s*)' '${RegExp.escape(listChar)} '); final match = regex.matchAsPrefix(lineBefore); if (match == null) { return tev; @@ -67,7 +67,8 @@ class MarkdownFormatter extends TextInputFormatter { TextEditingValue numberedListContinuation( String afterNumberChar, TextEditingValue tev) { - final regex = RegExp('(\\s*)(\\d+)${RegExp.escape(afterNumberChar)} '); + final regex = + RegExp(r'(\s*)(\d+)' '${RegExp.escape(afterNumberChar)} '); final match = regex.matchAsPrefix(lineBefore); if (match == null) { return tev; From 79f68ee732f6e66ecf63f0321219fb965f6cabc3 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 9 Aug 2022 00:05:55 +0200 Subject: [PATCH 21/51] rename file to conform to convention --- lib/widgets/editor/{toolbar.dart => editor_toolbar.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/widgets/editor/{toolbar.dart => editor_toolbar.dart} (100%) diff --git a/lib/widgets/editor/toolbar.dart b/lib/widgets/editor/editor_toolbar.dart similarity index 100% rename from lib/widgets/editor/toolbar.dart rename to lib/widgets/editor/editor_toolbar.dart From 663b45bc214284460e88116826e01c73a5589933 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 9 Aug 2022 00:06:14 +0200 Subject: [PATCH 22/51] =?UTF-8?q?oops=20=F0=9F=98=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/widgets/editor/editor.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/editor/editor.dart b/lib/widgets/editor/editor.dart index 3eaad4a..651179a 100644 --- a/lib/widgets/editor/editor.dart +++ b/lib/widgets/editor/editor.dart @@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import '../../formatter.dart'; import '../markdown_text.dart'; -export 'toolbar.dart'; +export 'editor_toolbar.dart'; /// A text field with added functionality for ease of editing class Editor extends HookWidget { From 116b0d7961fe7c4b4637881108427902aae95e29 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 21 Aug 2022 16:00:54 +0200 Subject: [PATCH 23/51] add store with purpose of uploading images --- lib/pages/create_post/create_post.dart | 5 +- lib/widgets/editor/editor_toolbar.dart | 230 +++++++++++------- lib/widgets/editor/editor_toolbar_store.dart | 63 +++++ .../editor/editor_toolbar_store.g.dart | 66 +++++ 4 files changed, 281 insertions(+), 83 deletions(-) create mode 100644 lib/widgets/editor/editor_toolbar_store.dart create mode 100644 lib/widgets/editor/editor_toolbar_store.g.dart diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index 20a0339..161edd9 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -145,7 +145,10 @@ class CreatePostPage extends HookWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Spacer(), - Toolbar(bodyController), + Toolbar( + controller: bodyController, + instanceHost: context.read().instanceHost, + ), ], ), ), diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index bf4939d..d2d4b6f 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -2,8 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import '../../formatter.dart'; +import '../../hooks/logged_in_action.dart'; +import '../../util/async_store_listener.dart'; import '../../util/extensions/spaced.dart'; +import '../../util/files.dart'; +import '../../util/mobx_provider.dart'; +import '../../util/observer_consumers.dart'; import '../../util/text_lines_iterator.dart'; +import 'editor_toolbar_store.dart'; class Reformat { final String text; @@ -111,93 +117,33 @@ extension on TextEditingController { class Toolbar extends HookWidget { final TextEditingController controller; - + final String instanceHost; + final EditorToolbarStore store; static const _height = 50.0; - const Toolbar(this.controller); + + Toolbar({ + required this.controller, + required this.instanceHost, + }) : store = EditorToolbarStore(instanceHost); @override Widget build(BuildContext context) { - return Container( - height: _height, - width: double.infinity, - color: Theme.of(context).cardColor, - child: Material( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - IconButton( - onPressed: () => controller.surround('**'), - icon: const Icon(Icons.format_bold), + return MobxProvider.value( + value: store, + child: AsyncStoreListener( + asyncStore: store.imageUploadState, + child: Container( + height: _height, + width: double.infinity, + color: Theme.of(context).cardColor, + child: Material( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _ToolbarBody( + controller: controller, + instanceHost: instanceHost, ), - IconButton( - onPressed: () => controller.surround('*'), - icon: const Icon(Icons.format_italic), - ), - IconButton( - onPressed: () async { - final r = await AddLinkDialog.show( - context, controller.selectionText); - if (r != null) controller.reformat((_) => r); - }, - icon: const Icon(Icons.link), - ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.image), - ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.person), - ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.home), - ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.h_mobiledata), - ), - IconButton( - onPressed: () => controller.surround('~~'), - icon: const Icon(Icons.format_strikethrough), - ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.format_quote), - ), - IconButton( - onPressed: () { - final line = controller.firstSelectedLine; - - if (line.startsWith(RegExp.escape('* '))) { - controller.removeAtBeginningOfEverySelectedLine('* '); - } else if (line.startsWith('- ')) { - controller.removeAtBeginningOfEverySelectedLine('- '); - } else { - controller.insertAtBeginningOfEverySelectedLine('- '); - } - }, - icon: const Icon(Icons.format_list_bulleted)), - IconButton( - onPressed: () => controller.surround('`'), - icon: const Icon(Icons.code), - ), - IconButton( - onPressed: () => controller.surround('~'), - icon: const Icon(Icons.subscript), - ), - IconButton( - onPressed: () => controller.surround('^'), - icon: const Icon(Icons.superscript), - ), - //spoiler - IconButton(onPressed: () {}, icon: const Icon(Icons.warning)), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.question_mark), - ), - ], + ), ), ), ), @@ -207,6 +153,126 @@ class Toolbar extends HookWidget { static Widget safeArea = const SizedBox(height: _height); } +class _ToolbarBody extends HookWidget { + const _ToolbarBody({ + required this.controller, + required this.instanceHost, + }); + + final TextEditingController controller; + final String instanceHost; + + @override + Widget build(BuildContext context) { + final loggedInAction = useLoggedInAction(instanceHost); + return Row( + children: [ + IconButton( + onPressed: () => controller.surround('**'), + icon: const Icon(Icons.format_bold), + ), + IconButton( + onPressed: () => controller.surround('*'), + icon: const Icon(Icons.format_italic), + ), + IconButton( + onPressed: () async { + final r = + await AddLinkDialog.show(context, controller.selectionText); + if (r != null) controller.reformat((_) => r); + }, + icon: const Icon(Icons.link), + ), + // Insert image + ObserverBuilder( + builder: (context, store) { + return IconButton( + onPressed: loggedInAction((token) async { + if (store.imageUploadState.isLoading) { + return; + } + try { + // FIXME: for some reason it doesn't go past this line on iOS. idk why + final pic = await pickImage(); + // pic is null when the picker was cancelled + + if (pic != null) { + final picUrl = await context + .read() + .uploadImage(pic.path, token); + + if (picUrl != null) { + controller.reformat( + (selection) => Reformat(text: '![]($picUrl)')); + } + } + } on Exception catch (_) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to upload image'))); + } + }), + icon: store.imageUploadState.isLoading + ? const CircularProgressIndicator.adaptive() + : const Icon(Icons.image), + ); + }, + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.person), + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.home), + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.h_mobiledata), + ), + IconButton( + onPressed: () => controller.surround('~~'), + icon: const Icon(Icons.format_strikethrough), + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.format_quote), + ), + IconButton( + onPressed: () { + final line = controller.firstSelectedLine; + + if (line.startsWith(RegExp.escape('* '))) { + controller.removeAtBeginningOfEverySelectedLine('* '); + } else if (line.startsWith('- ')) { + controller.removeAtBeginningOfEverySelectedLine('- '); + } else { + controller.insertAtBeginningOfEverySelectedLine('- '); + } + }, + icon: const Icon(Icons.format_list_bulleted)), + IconButton( + onPressed: () => controller.surround('`'), + icon: const Icon(Icons.code), + ), + IconButton( + onPressed: () => controller.surround('~'), + icon: const Icon(Icons.subscript), + ), + IconButton( + onPressed: () => controller.surround('^'), + icon: const Icon(Icons.superscript), + ), + //spoiler + IconButton(onPressed: () {}, icon: const Icon(Icons.warning)), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.question_mark), + ), + ], + ); + } +} + class AddLinkDialog extends HookWidget { final String title; final String url; diff --git a/lib/widgets/editor/editor_toolbar_store.dart b/lib/widgets/editor/editor_toolbar_store.dart new file mode 100644 index 0000000..d53f2b5 --- /dev/null +++ b/lib/widgets/editor/editor_toolbar_store.dart @@ -0,0 +1,63 @@ +import 'package:lemmy_api_client/pictrs.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:mobx/mobx.dart'; + +import '../../util/async_store.dart'; +import '../../util/pictrs.dart'; + +part 'editor_toolbar_store.g.dart'; + +class EditorToolbarStore = _EditorToolbarStore with _$EditorToolbarStore; + +abstract class _EditorToolbarStore with Store { + final String instanceHost; + + _EditorToolbarStore(this.instanceHost); + + @observable + String? url; + + final imageUploadState = AsyncStore(); + + @computed + bool get hasUploadedImage => imageUploadState.map( + loading: () => false, + error: (_) => false, + data: (_) => true, + ); + + @action + Future uploadImage(String filePath, Jwt token) async { + final instanceHost = this.instanceHost; + + final upload = await imageUploadState.run( + () => PictrsApi(instanceHost) + .upload( + filePath: filePath, + auth: token.raw, + ) + .then((value) => value.files.single), + ); + + if (upload != null) { + final url = pathToPictrs(instanceHost, upload.file); + return url; + } + return null; + } + + @action + void removeImage() { + final pictrsFile = imageUploadState.map( + data: (data) => data, + loading: () => null, + error: (_) => null, + ); + if (pictrsFile == null) return; + + PictrsApi(instanceHost).delete(pictrsFile).catchError((_) {}); + + imageUploadState.reset(); + url = ''; + } +} diff --git a/lib/widgets/editor/editor_toolbar_store.g.dart b/lib/widgets/editor/editor_toolbar_store.g.dart new file mode 100644 index 0000000..a8f256d --- /dev/null +++ b/lib/widgets/editor/editor_toolbar_store.g.dart @@ -0,0 +1,66 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'editor_toolbar_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$EditorToolbarStore on _EditorToolbarStore, Store { + Computed? _$hasUploadedImageComputed; + + @override + bool get hasUploadedImage => (_$hasUploadedImageComputed ??= Computed( + () => super.hasUploadedImage, + name: '_EditorToolbarStore.hasUploadedImage')) + .value; + + late final _$urlAtom = + Atom(name: '_EditorToolbarStore.url', context: context); + + @override + String? get url { + _$urlAtom.reportRead(); + return super.url; + } + + @override + set url(String? value) { + _$urlAtom.reportWrite(value, super.url, () { + super.url = value; + }); + } + + late final _$uploadImageAsyncAction = + AsyncAction('_EditorToolbarStore.uploadImage', context: context); + + @override + Future uploadImage(String filePath, Jwt token) { + return _$uploadImageAsyncAction + .run(() => super.uploadImage(filePath, token)); + } + + late final _$_EditorToolbarStoreActionController = + ActionController(name: '_EditorToolbarStore', context: context); + + @override + void removeImage() { + final _$actionInfo = _$_EditorToolbarStoreActionController.startAction( + name: '_EditorToolbarStore.removeImage'); + try { + return super.removeImage(); + } finally { + _$_EditorToolbarStoreActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +url: ${url}, +hasUploadedImage: ${hasUploadedImage} + '''; + } +} From 63032ebae1353b86e15994a2129659c8b63e0071 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 21 Aug 2022 19:03:22 +0200 Subject: [PATCH 24/51] add selecting of users and communities also made simplified version of reformat --- lib/widgets/editor/editor_picking_dialog.dart | 132 ++++++++++++++++++ lib/widgets/editor/editor_toolbar.dart | 31 +++- 2 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 lib/widgets/editor/editor_picking_dialog.dart diff --git a/lib/widgets/editor/editor_picking_dialog.dart b/lib/widgets/editor/editor_picking_dialog.dart new file mode 100644 index 0000000..df9cc26 --- /dev/null +++ b/lib/widgets/editor/editor_picking_dialog.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:provider/provider.dart'; + +import '../../../util/extensions/api.dart'; +import '../../../widgets/avatar.dart'; +import '../../stores/accounts_store.dart'; +import 'editor_toolbar_store.dart'; + +class PickPersonDialog extends HookWidget { + final EditorToolbarStore store; + + const PickPersonDialog._(this.store); + + @override + Widget build(BuildContext context) { + final userData = + context.read().defaultUserDataFor(store.instanceHost); + + return AlertDialog( + title: const Text('Select User'), + content: TypeAheadField( + suggestionsCallback: (pattern) async { + if (pattern.trim().isEmpty) return const Iterable.empty(); + return LemmyApiV3(store.instanceHost) + .run(Search( + q: pattern, + auth: userData?.jwt.raw, + type: SearchType.users, + limit: 10, + )) + .then((value) => value.users); + }, + itemBuilder: (context, user) { + return ListTile( + leading: Avatar( + url: user.person.avatar, + radius: 20, + ), + title: Text(user.person.originPreferredName), + ); + }, + onSuggestionSelected: (suggestion) => + Navigator.of(context).pop(suggestion), + loadingBuilder: (context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator.adaptive(), + ), + ], + ), + keepSuggestionsOnLoading: false, + noItemsFoundBuilder: (context) => const SizedBox(), + hideOnEmpty: true, + textFieldConfiguration: const TextFieldConfiguration(autofocus: true), + ), + ); + } + + static Future show(BuildContext context) async { + final store = context.read(); + return showDialog( + context: context, + builder: (context) => PickPersonDialog._(store), + ); + } +} + +class PickCommunityDialog extends HookWidget { + final EditorToolbarStore store; + + const PickCommunityDialog._(this.store); + + @override + Widget build(BuildContext context) { + final userData = + context.read().defaultUserDataFor(store.instanceHost); + + return AlertDialog( + title: const Text('Select Community'), + content: TypeAheadField( + suggestionsCallback: (pattern) async { + if (pattern.trim().isEmpty) return const Iterable.empty(); + return LemmyApiV3(store.instanceHost) + .run(Search( + q: pattern, + auth: userData?.jwt.raw, + type: SearchType.communities, + limit: 10, + )) + .then((value) => value.communities); + }, + itemBuilder: (context, community) { + return ListTile( + leading: Avatar( + url: community.community.icon, + radius: 20, + ), + title: Text(community.community.originPreferredName), + ); + }, + onSuggestionSelected: (suggestion) => + Navigator.of(context).pop(suggestion), + loadingBuilder: (context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator.adaptive(), + ), + ], + ), + keepSuggestionsOnLoading: false, + noItemsFoundBuilder: (context) => const SizedBox(), + hideOnEmpty: true, + textFieldConfiguration: const TextFieldConfiguration(autofocus: true), + ), + ); + } + + static Future show(BuildContext context) async { + final store = context.read(); + return showDialog( + context: context, + builder: (context) => PickCommunityDialog._(store), + ); + } +} diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index d2d4b6f..803cfc1 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -4,11 +4,13 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import '../../formatter.dart'; import '../../hooks/logged_in_action.dart'; import '../../util/async_store_listener.dart'; +import '../../util/extensions/api.dart'; import '../../util/extensions/spaced.dart'; import '../../util/files.dart'; import '../../util/mobx_provider.dart'; import '../../util/observer_consumers.dart'; import '../../util/text_lines_iterator.dart'; +import 'editor_picking_dialog.dart'; import 'editor_toolbar_store.dart'; class Reformat { @@ -113,6 +115,9 @@ extension on TextEditingController { ), ); } + + void reformatSimple(String text) => + reformat((selection) => Reformat(text: text)); } class Toolbar extends HookWidget { @@ -202,8 +207,7 @@ class _ToolbarBody extends HookWidget { .uploadImage(pic.path, token); if (picUrl != null) { - controller.reformat( - (selection) => Reformat(text: '![]($picUrl)')); + controller.reformatSimple('![]($picUrl)'); } } } on Exception catch (_) { @@ -218,11 +222,30 @@ class _ToolbarBody extends HookWidget { }, ), IconButton( - onPressed: () {}, + onPressed: () async { + final person = await PickPersonDialog.show(context); + + if (person != null) { + final name = + '@${person.person.name}@${person.person.originInstanceHost}'; + final link = person.person.actorId; + + controller.reformatSimple('[$name]($link)'); + } + }, icon: const Icon(Icons.person), ), IconButton( - onPressed: () {}, + onPressed: () async { + final community = await PickCommunityDialog.show(context); + if (community != null) { + final name = + '!${community.community.name}@${community.community.originInstanceHost}'; + final link = community.community.actorId; + + controller.reformatSimple('[$name]($link)'); + } + }, icon: const Icon(Icons.home), ), IconButton( From 462ce5df7657286510d35d17e223c56540afa869 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 21 Aug 2022 20:47:30 +0200 Subject: [PATCH 25/51] added functionality to: * info button * spoiler button --- lib/resources/links.dart | 2 ++ lib/widgets/editor/editor_toolbar.dart | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/resources/links.dart b/lib/resources/links.dart index ed26356..34bab73 100644 --- a/lib/resources/links.dart +++ b/lib/resources/links.dart @@ -1,3 +1,5 @@ const lemmurRepositoryUrl = 'https://github.com/LemmurOrg/lemmur'; const patreonUrl = 'https://patreon.com/lemmur'; const buyMeACoffeeUrl = 'https://buymeacoff.ee/lemmur'; +const markdownGuide = + 'https://join-lemmy.org/docs/en/about/guide.html#using-markdown'; diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 803cfc1..042a34f 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:logging/logging.dart'; import '../../formatter.dart'; import '../../hooks/logged_in_action.dart'; +import '../../resources/links.dart'; +import '../../url_launcher.dart'; import '../../util/async_store_listener.dart'; import '../../util/extensions/api.dart'; import '../../util/extensions/spaced.dart'; @@ -286,9 +289,24 @@ class _ToolbarBody extends HookWidget { icon: const Icon(Icons.superscript), ), //spoiler - IconButton(onPressed: () {}, icon: const Icon(Icons.warning)), IconButton( - onPressed: () {}, + onPressed: () { + controller.reformat((selection) { + final insides = selection.isNotEmpty ? selection : '___'; + Logger.root + .info([21, 21 + insides.length, insides, insides.length]); + return Reformat( + text: '\n::: spoiler spoiler\n$insides\n:::\n', + selectionBeginningShift: 21, + selectionEndingShift: 21 + insides.length - selection.length, + ); + }); + }, + icon: const Icon(Icons.warning)), + IconButton( + onPressed: () { + launchLink(link: markdownGuide, context: context); + }, icon: const Icon(Icons.question_mark), ), ], From 09f1f54c051a522737d39d448cde7878820178f4 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 21 Aug 2022 21:52:31 +0200 Subject: [PATCH 26/51] implement more buttons * header * quote --- lib/widgets/editor/editor_toolbar.dart | 48 +++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 042a34f..a54fcfe 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:logging/logging.dart'; @@ -62,6 +63,18 @@ extension on TextEditingController { text.getEndOfTheLine(selection.end)); } + void insertAtBeginningOfFirstSelectedLine(String s) { + final lines = TextLinesIterator.fromController(this)..moveNext(); + lines.current = s + lines.current; + value = value.copyWith( + text: lines.text, + selection: selection.copyWith( + baseOffset: selection.baseOffset + s.length, + extentOffset: selection.extentOffset + s.length, + ), + ); + } + void removeAtBeginningOfEverySelectedLine(String s) { final lines = TextLinesIterator.fromController(this); var linesCount = 0; @@ -123,6 +136,18 @@ extension on TextEditingController { reformat((selection) => Reformat(text: text)); } +enum HeaderLevel { + h1(1), + h2(2), + h3(3), + h4(4), + h5(5), + h6(6); + + const HeaderLevel(this.value); + final int value; +} + class Toolbar extends HookWidget { final TextEditingController controller; final String instanceHost; @@ -251,16 +276,31 @@ class _ToolbarBody extends HookWidget { }, icon: const Icon(Icons.home), ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.h_mobiledata), + PopupMenuButton( + itemBuilder: (context) => [ + for (final h in HeaderLevel.values) + PopupMenuItem( + value: h, + child: Text(describeEnum(h).toUpperCase()), + ), + ], + onSelected: (val) { + final header = '${'#' * val.value} '; + + if (!controller.firstSelectedLine.startsWith(header)) { + controller.insertAtBeginningOfFirstSelectedLine(header); + } + }, + child: const Icon(Icons.h_mobiledata), ), IconButton( onPressed: () => controller.surround('~~'), icon: const Icon(Icons.format_strikethrough), ), IconButton( - onPressed: () {}, + onPressed: () { + controller.insertAtBeginningOfEverySelectedLine('> '); + }, icon: const Icon(Icons.format_quote), ), IconButton( From 579b4e1d5da24a3a13381225e6d93b441112491d Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 21 Aug 2022 22:23:25 +0200 Subject: [PATCH 27/51] cleanup --- lib/pages/create_post/create_post.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index 161edd9..79c8799 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -152,12 +152,6 @@ class CreatePostPage extends HookWidget { ], ), ), - // Positioned( - // bottom: MediaQuery.of(context).viewInsets.bottom, - // left: 0, - // right: 0, - // child: Toolbar(bodyController), - // ) ], ), ), From cd1f7a3be3b688fddf2d917bd7098f4c8691924c Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 21 Aug 2022 22:57:56 +0200 Subject: [PATCH 28/51] add tooltips --- assets/l10n/intl_en.arb | 62 +++++++++++++++++++++++- lib/widgets/editor/editor_toolbar.dart | 66 ++++++++++++++++---------- 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 989d925..098ea82 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -351,5 +351,65 @@ "no_communities_found": "No communities found", "@no_communities_found": {}, "network_error": "Network error", - "@network_error": {} + "@network_error": {}, + "editor_bold": "bold", + "@editor_bold": { + "description": "tooltip for button making text bold in markdown editor toolbar" + }, + "editor_italics": "italics", + "@editor_italics": { + "description": "tooltip for button making text italics in markdown editor toolbar" + }, + "editor_link": "insert link", + "@editor_link": { + "description": "tooltip for button that inserts link in markdown editor toolbar" + }, + "editor_image": "insert image", + "@editor_image": { + "description": "tooltip for button that inserts image in markdown editor toolbar" + }, + "editor_user": "link user", + "@editor_user": { + "description": "tooltip for button that opens a popup to select user to be linked in markdown editor toolbar" + }, + "editor_community": "link community", + "@editor_community": { + "description": "tooltip for button that opens a popup to select community to be linked in markdown editor toolbar" + }, + "editor_header": "insert header", + "@editor_header": { + "description": "tooltip for button that inserts header in markdown editor toolbar" + }, + "editor_strikethrough": "strikethrough", + "@editor_strikethrough": { + "description": "tooltip for button that makes text strikethrough in markdown editor toolbar" + }, + "editor_quote": "quote", + "@editor_quote": { + "description": "tooltip for button that makes selected text into quote blocks in markdown editor toolbar" + }, + "editor_list": "list", + "@editor_list": { + "description": "tooltip for button that makes selected text into list in markdown editor toolbar" + }, + "editor_code": "code", + "@editor_code": { + "description": "tooltip for button that makes text into code in markdown editor toolbar" + }, + "editor_subscript": "subscript", + "@editor_subscript": { + "description": "tooltip for button that makes text into subscript in markdown editor toolbar" + }, + "editor_superscript": "superscript", + "@editor_superscript": { + "description": "tooltip for button that makes text into superscript in markdown editor toolbar" + }, + "editor_spoiler": "spoiler", + "@editor_spoiler": { + "description": "tooltip for button that inserts spoiler in markdown editor toolbar" + }, + "editor_help": "markdown guide", + "@editor_help": { + "description": "tooltip for button that goes to page containing a guide for markdown" + } } diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index a54fcfe..b47c892 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -5,6 +5,7 @@ import 'package:logging/logging.dart'; import '../../formatter.dart'; import '../../hooks/logged_in_action.dart'; +import '../../l10n/l10n.dart'; import '../../resources/links.dart'; import '../../url_launcher.dart'; import '../../util/async_store_listener.dart'; @@ -203,10 +204,12 @@ class _ToolbarBody extends HookWidget { IconButton( onPressed: () => controller.surround('**'), icon: const Icon(Icons.format_bold), + tooltip: L10n.of(context).editor_bold, ), IconButton( onPressed: () => controller.surround('*'), icon: const Icon(Icons.format_italic), + tooltip: L10n.of(context).editor_italics, ), IconButton( onPressed: () async { @@ -215,6 +218,7 @@ class _ToolbarBody extends HookWidget { if (r != null) controller.reformat((_) => r); }, icon: const Icon(Icons.link), + tooltip: L10n.of(context).editor_link, ), // Insert image ObserverBuilder( @@ -246,6 +250,7 @@ class _ToolbarBody extends HookWidget { icon: store.imageUploadState.isLoading ? const CircularProgressIndicator.adaptive() : const Icon(Icons.image), + tooltip: L10n.of(context).editor_image, ); }, ), @@ -262,6 +267,7 @@ class _ToolbarBody extends HookWidget { } }, icon: const Icon(Icons.person), + tooltip: L10n.of(context).editor_user, ), IconButton( onPressed: () async { @@ -275,6 +281,7 @@ class _ToolbarBody extends HookWidget { } }, icon: const Icon(Icons.home), + tooltip: L10n.of(context).editor_community, ), PopupMenuButton( itemBuilder: (context) => [ @@ -291,63 +298,74 @@ class _ToolbarBody extends HookWidget { controller.insertAtBeginningOfFirstSelectedLine(header); } }, + tooltip: L10n.of(context).editor_header, child: const Icon(Icons.h_mobiledata), ), IconButton( onPressed: () => controller.surround('~~'), icon: const Icon(Icons.format_strikethrough), + tooltip: L10n.of(context).editor_strikethrough, ), IconButton( onPressed: () { controller.insertAtBeginningOfEverySelectedLine('> '); }, icon: const Icon(Icons.format_quote), + tooltip: L10n.of(context).editor_quote, ), IconButton( - onPressed: () { - final line = controller.firstSelectedLine; + onPressed: () { + final line = controller.firstSelectedLine; - if (line.startsWith(RegExp.escape('* '))) { - controller.removeAtBeginningOfEverySelectedLine('* '); - } else if (line.startsWith('- ')) { - controller.removeAtBeginningOfEverySelectedLine('- '); - } else { - controller.insertAtBeginningOfEverySelectedLine('- '); - } - }, - icon: const Icon(Icons.format_list_bulleted)), + if (line.startsWith(RegExp.escape('* '))) { + controller.removeAtBeginningOfEverySelectedLine('* '); + } else if (line.startsWith('- ')) { + controller.removeAtBeginningOfEverySelectedLine('- '); + } else { + controller.insertAtBeginningOfEverySelectedLine('- '); + } + }, + icon: const Icon(Icons.format_list_bulleted), + tooltip: L10n.of(context).editor_list, + ), IconButton( onPressed: () => controller.surround('`'), icon: const Icon(Icons.code), + tooltip: L10n.of(context).editor_code, ), IconButton( onPressed: () => controller.surround('~'), icon: const Icon(Icons.subscript), + tooltip: L10n.of(context).editor_subscript, ), IconButton( onPressed: () => controller.surround('^'), icon: const Icon(Icons.superscript), + tooltip: L10n.of(context).editor_superscript, ), //spoiler IconButton( - onPressed: () { - controller.reformat((selection) { - final insides = selection.isNotEmpty ? selection : '___'; - Logger.root - .info([21, 21 + insides.length, insides, insides.length]); - return Reformat( - text: '\n::: spoiler spoiler\n$insides\n:::\n', - selectionBeginningShift: 21, - selectionEndingShift: 21 + insides.length - selection.length, - ); - }); - }, - icon: const Icon(Icons.warning)), + onPressed: () { + controller.reformat((selection) { + final insides = selection.isNotEmpty ? selection : '___'; + Logger.root + .info([21, 21 + insides.length, insides, insides.length]); + return Reformat( + text: '\n::: spoiler spoiler\n$insides\n:::\n', + selectionBeginningShift: 21, + selectionEndingShift: 21 + insides.length - selection.length, + ); + }); + }, + icon: const Icon(Icons.warning), + tooltip: L10n.of(context).editor_spoiler, + ), IconButton( onPressed: () { launchLink(link: markdownGuide, context: context); }, icon: const Icon(Icons.question_mark), + tooltip: L10n.of(context).editor_help, ), ], ); From 1fcc95d6b9488df50569b212df616b4b8e140978 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 21 Aug 2022 23:08:00 +0200 Subject: [PATCH 29/51] make sure link is a link --- lib/widgets/editor/editor_toolbar.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index b47c892..614f348 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -389,7 +389,14 @@ class AddLinkDialog extends HookWidget { final urlController = useTextEditingController(text: url); void submit() { - final finalString = '(${titleController.text})[${urlController.text}]'; + final link = () { + if (urlController.text.startsWith('http?s://')) { + return urlController.text; + } else { + return 'https://${urlController.text}'; + } + }(); + final finalString = '(${titleController.text})[$link]'; Navigator.of(context).pop(Reformat( text: finalString, selectionBeginningShift: finalString.length, From 4cd8b9855c8c97d2b7d7c2abb1d92a351d2c7da6 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 21 Aug 2022 23:09:25 +0200 Subject: [PATCH 30/51] add toolbar to comments --- lib/widgets/write_comment.dart | 71 +++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index 4870975..3256b77 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -99,37 +99,54 @@ class WriteComment extends HookWidget { ), ], ), - body: ListView( + body: Stack( children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * .35), - child: SingleChildScrollView( - padding: const EdgeInsets.all(8), - child: preview, - ), - ), - const Divider(), - Editor( - instanceHost: post.instanceHost, - controller: controller, - autofocus: true, - fancy: showFancy.value, - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, + ListView( children: [ - TextButton( - onPressed: - delayed.pending ? () {} : loggedInAction(handleSubmit), - child: delayed.loading - ? const CircularProgressIndicator.adaptive() - : Text(_isEdit - ? L10n.of(context).edit - : L10n.of(context).post), - ) + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * .35), + child: SingleChildScrollView( + padding: const EdgeInsets.all(8), + child: preview, + ), + ), + const Divider(), + Editor( + instanceHost: post.instanceHost, + controller: controller, + autofocus: true, + fancy: showFancy.value, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: + delayed.pending ? () {} : loggedInAction(handleSubmit), + child: delayed.loading + ? const CircularProgressIndicator.adaptive() + : Text(_isEdit + ? L10n.of(context).edit + : L10n.of(context).post), + ) + ], + ), + Toolbar.safeArea, ], ), + SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Spacer(), + Toolbar( + controller: controller, + instanceHost: post.instanceHost, + ), + ], + ), + ), ], ), ); From 43fb2a8ceb82400f6eb3db56f23d8ab61d3c1700 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Sun, 21 Aug 2022 23:49:03 +0200 Subject: [PATCH 31/51] add placeholder text to l10n --- assets/l10n/intl_en.arb | 4 +++ lib/widgets/editor/editor_toolbar.dart | 38 +++++++++++++++++++------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 098ea82..9034a2b 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -411,5 +411,9 @@ "editor_help": "markdown guide", "@editor_help": { "description": "tooltip for button that goes to page containing a guide for markdown" + }, + "insert_text_here_placeholder": "[write text here]", + "@insert_text_here_placeholder": { + "description": "placeholder for text in markdown editor when inserting stuff like * for italics or ** for bold, etc." } } diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 614f348..9fad3c6 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -36,11 +36,13 @@ extension on TextEditingController { String get afterSelectionText => text.substring(selection.extentOffset); /// surroungs selection with given strings. If nothing is selected, placeholder is used in the middle - void surround( - String before, [ + void surround({ + required String before, + required String placeholder, + + /// after = before if null String? after, - String placeholder = '[write text here]', - ]) { + }) { after ??= before; final beg = text.substring(0, selection.baseOffset); final mid = () { @@ -202,12 +204,16 @@ class _ToolbarBody extends HookWidget { return Row( children: [ IconButton( - onPressed: () => controller.surround('**'), + onPressed: () => controller.surround( + before: '**', + placeholder: L10n.of(context).insert_text_here_placeholder), icon: const Icon(Icons.format_bold), tooltip: L10n.of(context).editor_bold, ), IconButton( - onPressed: () => controller.surround('*'), + onPressed: () => controller.surround( + before: '*', + placeholder: L10n.of(context).insert_text_here_placeholder), icon: const Icon(Icons.format_italic), tooltip: L10n.of(context).editor_italics, ), @@ -302,7 +308,10 @@ class _ToolbarBody extends HookWidget { child: const Icon(Icons.h_mobiledata), ), IconButton( - onPressed: () => controller.surround('~~'), + onPressed: () => controller.surround( + before: '~~', + placeholder: L10n.of(context).insert_text_here_placeholder, + ), icon: const Icon(Icons.format_strikethrough), tooltip: L10n.of(context).editor_strikethrough, ), @@ -329,17 +338,26 @@ class _ToolbarBody extends HookWidget { tooltip: L10n.of(context).editor_list, ), IconButton( - onPressed: () => controller.surround('`'), + onPressed: () => controller.surround( + before: '`', + placeholder: L10n.of(context).insert_text_here_placeholder, + ), icon: const Icon(Icons.code), tooltip: L10n.of(context).editor_code, ), IconButton( - onPressed: () => controller.surround('~'), + onPressed: () => controller.surround( + before: '~', + placeholder: L10n.of(context).insert_text_here_placeholder, + ), icon: const Icon(Icons.subscript), tooltip: L10n.of(context).editor_subscript, ), IconButton( - onPressed: () => controller.surround('^'), + onPressed: () => controller.surround( + before: '^', + placeholder: L10n.of(context).insert_text_here_placeholder, + ), icon: const Icon(Icons.superscript), tooltip: L10n.of(context).editor_superscript, ), From ecb95d3bdb006d1e7111223748396e6287926e58 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Mon, 22 Aug 2022 23:04:05 +0200 Subject: [PATCH 32/51] Update lib/util/text_lines_iterator.dart Co-authored-by: Marcin Wojnarowski --- lib/util/text_lines_iterator.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/util/text_lines_iterator.dart b/lib/util/text_lines_iterator.dart index ae930a4..1df1b24 100644 --- a/lib/util/text_lines_iterator.dart +++ b/lib/util/text_lines_iterator.dart @@ -11,8 +11,8 @@ class TextLinesIterator extends Iterator { : end = -1, beg = -1; - factory TextLinesIterator.fromController(TextEditingController controller) => - TextLinesIterator(controller.text, selection: controller.selection); + TextLinesIterator.fromController(TextEditingController controller) : + this(controller.text, selection: controller.selection); bool get isWithinSelection { final selection = this.selection; From 821558314e3d7edb5bb978ea2d6b8ea548484334 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Mon, 22 Aug 2022 23:59:56 +0200 Subject: [PATCH 33/51] Update lib/widgets/editor/editor_toolbar.dart Co-authored-by: Marcin Wojnarowski --- lib/widgets/editor/editor_toolbar.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 9fad3c6..1488b7f 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -294,7 +294,7 @@ class _ToolbarBody extends HookWidget { for (final h in HeaderLevel.values) PopupMenuItem( value: h, - child: Text(describeEnum(h).toUpperCase()), + child: Text(h.name.toUpperCase()), ), ], onSelected: (val) { From cda72a11746263d1c44fe720260458bca15f68f8 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 23 Aug 2022 00:34:10 +0200 Subject: [PATCH 34/51] CR Small Changes * regex fix http?s -> https? * file rename: formatter.dart -> markdown_formatter.dart to match the class name inside * add + as continuoable list * rename: listContinuation -> unorderedListContinuation numberedListContinuation -> orderedListContinuation * fix typo: convenience * fix: doc instead of comment * rename for readability: startingIndex -> from * function & arg rename: lineBefore(int endingIndex) -> lineUpTo(int characterIndex) * parse -> tryParse * localize user picker & commmunity picker * HookWidget -> StatelessWidget where needed * Toolbar -> EditorToolbar for less ambiguity * fix typo: surroungs -> surrounds * remove debug logA * more localization stuff * title -> label on add link dialog * Reformat -> _Reformat * use store when in scope instead of context.read * remove useless Stack (oops) --- assets/l10n/intl_en.arb | 24 ++ ...formatter.dart => markdown_formatter.dart} | 32 +- lib/pages/create_post/create_post.dart | 4 +- lib/widgets/editor/editor.dart | 30 +- lib/widgets/editor/editor_picking_dialog.dart | 10 +- lib/widgets/editor/editor_toolbar.dart | 274 +++++++++--------- lib/widgets/write_comment.dart | 4 +- 7 files changed, 197 insertions(+), 181 deletions(-) rename lib/{formatter.dart => markdown_formatter.dart} (67%) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 9034a2b..327ce54 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -415,5 +415,29 @@ "insert_text_here_placeholder": "[write text here]", "@insert_text_here_placeholder": { "description": "placeholder for text in markdown editor when inserting stuff like * for italics or ** for bold, etc." + }, + "select_user": "Select User", + "@select_user": { + "description": "Title on a popup that lets a user search and select another user" + }, + "select_community": "Select Community", + "@select_community": { + "description": "Title on a popup that lets a user search and select a community" + }, + "add_link": "Add link", + "@add_link": { + "description": "title on top of a link insertion popup in a markdown editor" + }, + "cancel": "Cancel", + "@cancel": { + "description": "Cancel button on popup" + }, + "editor_add_link_label": "label", + "@editor_add_link_label": { + "description": "palceholder for link label on an Add link popup in markdown editor" + }, + "failed_to_upload_image": "Failed to upload image", + "@failed_to_upload_image": { + "description": "shows up on a snackbar when the image upload failed (duh)" } } diff --git a/lib/formatter.dart b/lib/markdown_formatter.dart similarity index 67% rename from lib/formatter.dart rename to lib/markdown_formatter.dart index a1ddf55..6c621f9 100644 --- a/lib/formatter.dart +++ b/lib/markdown_formatter.dart @@ -1,9 +1,9 @@ import 'package:flutter/services.dart'; extension Utilities on String { - int getBeginningOfTheLine(int startingIndex) { - if (startingIndex <= 0) return 0; - for (var i = startingIndex; i >= 0; i--) { + int getBeginningOfTheLine(int from) { + if (from <= 0) return 0; + for (var i = from; i >= 0; i--) { if (this[i] == '\n') return i + 1; } return 0; @@ -17,9 +17,9 @@ extension Utilities on String { return length - 1; } - // returns the line that ends at endingIndex - String lineBefore(int endingIndex) { - return substring(getBeginningOfTheLine(endingIndex), endingIndex + 1); + /// returns the line that ends at endingIndex + String lineUpTo(int characterIndex) { + return substring(getBeginningOfTheLine(characterIndex), characterIndex + 1); } } @@ -39,7 +39,7 @@ extension on TextEditingValue { } } -/// Provides convinience formatting in markdown text fields +/// Provides convenience formatting in markdown text fields class MarkdownFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate( @@ -52,9 +52,10 @@ class MarkdownFormatter extends TextInputFormatter { if (char == '\n') { final lineBefore = - newValue.text.lineBefore(newValue.selection.baseOffset - 2); + newValue.text.lineUpTo(newValue.selection.baseOffset - 2); - TextEditingValue listContinuation(String listChar, TextEditingValue tev) { + TextEditingValue unorderedListContinuation( + String listChar, TextEditingValue tev) { final regex = RegExp(r'(\s*)' '${RegExp.escape(listChar)} '); final match = regex.matchAsPrefix(lineBefore); if (match == null) { @@ -65,7 +66,7 @@ class MarkdownFormatter extends TextInputFormatter { return tev.append('$indent$listChar '); } - TextEditingValue numberedListContinuation( + TextEditingValue orderedListContinuation( String afterNumberChar, TextEditingValue tev) { final regex = RegExp(r'(\s*)(\d+)' '${RegExp.escape(afterNumberChar)} '); @@ -74,15 +75,16 @@ class MarkdownFormatter extends TextInputFormatter { return tev; } final indent = match.group(1); - final number = int.parse(match.group(2)!) + 1; + final number = int.tryParse(match.group(2)!) ?? 0 + 1; return tev.append('$indent$number$afterNumberChar '); } - newVal = listContinuation('-', newVal); - newVal = listContinuation('*', newVal); - newVal = numberedListContinuation('.', newVal); - newVal = numberedListContinuation(')', newVal); + newVal = unorderedListContinuation('-', newVal); + newVal = unorderedListContinuation('*', newVal); + newVal = unorderedListContinuation('+', newVal); + newVal = orderedListContinuation('.', newVal); + newVal = orderedListContinuation(')', newVal); } return newVal; diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index 79c8799..8a74e8e 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -134,7 +134,7 @@ class CreatePostPage extends HookWidget { ) ], ), - Toolbar.safeArea, + EditorToolbar.safeArea, ].spaced(6), ), ), @@ -145,7 +145,7 @@ class CreatePostPage extends HookWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Spacer(), - Toolbar( + EditorToolbar( controller: bodyController, instanceHost: context.read().instanceHost, ), diff --git a/lib/widgets/editor/editor.dart b/lib/widgets/editor/editor.dart index 651179a..be4b99e 100644 --- a/lib/widgets/editor/editor.dart +++ b/lib/widgets/editor/editor.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import '../../formatter.dart'; +import '../../markdown_formatter.dart'; import '../markdown_text.dart'; export 'editor_toolbar.dart'; @@ -52,22 +52,18 @@ class Editor extends HookWidget { ); } - return Stack( - children: [ - TextField( - controller: actualController, - focusNode: focusNode, - autofocus: autofocus, - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - onChanged: onChanged, - onSubmitted: onSubmitted, - maxLines: maxLines, - minLines: minLines, - decoration: InputDecoration(labelText: labelText), - inputFormatters: [MarkdownFormatter()], - ), - ], + return TextField( + controller: actualController, + focusNode: focusNode, + autofocus: autofocus, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + onChanged: onChanged, + onSubmitted: onSubmitted, + maxLines: maxLines, + minLines: minLines, + decoration: InputDecoration(labelText: labelText), + inputFormatters: [MarkdownFormatter()], ); } } diff --git a/lib/widgets/editor/editor_picking_dialog.dart b/lib/widgets/editor/editor_picking_dialog.dart index df9cc26..9fe7239 100644 --- a/lib/widgets/editor/editor_picking_dialog.dart +++ b/lib/widgets/editor/editor_picking_dialog.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:provider/provider.dart'; import '../../../util/extensions/api.dart'; import '../../../widgets/avatar.dart'; +import '../../l10n/l10n.dart'; import '../../stores/accounts_store.dart'; import 'editor_toolbar_store.dart'; -class PickPersonDialog extends HookWidget { +class PickPersonDialog extends StatelessWidget { final EditorToolbarStore store; const PickPersonDialog._(this.store); @@ -20,7 +20,7 @@ class PickPersonDialog extends HookWidget { context.read().defaultUserDataFor(store.instanceHost); return AlertDialog( - title: const Text('Select User'), + title: Text(L10n.of(context).select_user), content: TypeAheadField( suggestionsCallback: (pattern) async { if (pattern.trim().isEmpty) return const Iterable.empty(); @@ -70,7 +70,7 @@ class PickPersonDialog extends HookWidget { } } -class PickCommunityDialog extends HookWidget { +class PickCommunityDialog extends StatelessWidget { final EditorToolbarStore store; const PickCommunityDialog._(this.store); @@ -81,7 +81,7 @@ class PickCommunityDialog extends HookWidget { context.read().defaultUserDataFor(store.instanceHost); return AlertDialog( - title: const Text('Select Community'), + title: Text(L10n.of(context).select_community), content: TypeAheadField( suggestionsCallback: (pattern) async { if (pattern.trim().isEmpty) return const Iterable.empty(); diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 1488b7f..74895ea 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -1,11 +1,9 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:logging/logging.dart'; -import '../../formatter.dart'; import '../../hooks/logged_in_action.dart'; import '../../l10n/l10n.dart'; +import '../../markdown_formatter.dart'; import '../../resources/links.dart'; import '../../url_launcher.dart'; import '../../util/async_store_listener.dart'; @@ -18,127 +16,17 @@ import '../../util/text_lines_iterator.dart'; import 'editor_picking_dialog.dart'; import 'editor_toolbar_store.dart'; -class Reformat { +class _Reformat { final String text; final int selectionBeginningShift; final int selectionEndingShift; - Reformat({ + _Reformat({ required this.text, this.selectionBeginningShift = 0, this.selectionEndingShift = 0, }); } -extension on TextEditingController { - String get selectionText => - text.substring(selection.baseOffset, selection.extentOffset); - String get beforeSelectionText => text.substring(0, selection.baseOffset); - String get afterSelectionText => text.substring(selection.extentOffset); - - /// surroungs selection with given strings. If nothing is selected, placeholder is used in the middle - void surround({ - required String before, - required String placeholder, - - /// after = before if null - String? after, - }) { - after ??= before; - final beg = text.substring(0, selection.baseOffset); - final mid = () { - final m = text.substring(selection.baseOffset, selection.extentOffset); - if (m.isEmpty) return placeholder; - return m; - }(); - final end = text.substring(selection.extentOffset); - - value = value.copyWith( - text: '$beg$before$mid$after$end', - selection: selection.copyWith( - baseOffset: selection.baseOffset + before.length, - extentOffset: selection.baseOffset + before.length + mid.length, - )); - } - - String get firstSelectedLine { - if (text.isEmpty) return ''; - return text.substring(text.getBeginningOfTheLine(selection.start - 1), - text.getEndOfTheLine(selection.end)); - } - - void insertAtBeginningOfFirstSelectedLine(String s) { - final lines = TextLinesIterator.fromController(this)..moveNext(); - lines.current = s + lines.current; - value = value.copyWith( - text: lines.text, - selection: selection.copyWith( - baseOffset: selection.baseOffset + s.length, - extentOffset: selection.extentOffset + s.length, - ), - ); - } - - void removeAtBeginningOfEverySelectedLine(String s) { - final lines = TextLinesIterator.fromController(this); - var linesCount = 0; - while (lines.moveNext()) { - if (lines.isWithinSelection) { - if (lines.current.startsWith(RegExp.escape(s))) { - lines.current = lines.current.substring(s.length); - linesCount++; - } - } - } - - value = value.copyWith( - text: lines.text, - selection: selection.copyWith( - baseOffset: selection.baseOffset - s.length, - extentOffset: selection.extentOffset - s.length * linesCount, - ), - ); - } - - void insertAtBeginningOfEverySelectedLine(String s) { - final lines = TextLinesIterator.fromController(this); - var linesCount = 0; - while (lines.moveNext()) { - if (lines.isWithinSelection) { - if (!lines.current.startsWith(RegExp.escape(s))) { - lines.current = '$s${lines.current}'; - linesCount++; - } - } - } - - value = value.copyWith( - text: lines.text, - selection: selection.copyWith( - baseOffset: selection.baseOffset + s.length, - extentOffset: selection.extentOffset + s.length * linesCount, - ), - ); - } - - void reformat(Reformat Function(String selection) reformatter) { - final beg = beforeSelectionText; - final mid = selectionText; - final end = afterSelectionText; - - final r = reformatter(mid); - value = value.copyWith( - text: '$beg${r.text}$end', - selection: selection.copyWith( - baseOffset: selection.baseOffset + r.selectionBeginningShift, - extentOffset: selection.extentOffset + r.selectionEndingShift, - ), - ); - } - - void reformatSimple(String text) => - reformat((selection) => Reformat(text: text)); -} - enum HeaderLevel { h1(1), h2(2), @@ -151,21 +39,21 @@ enum HeaderLevel { final int value; } -class Toolbar extends HookWidget { +class EditorToolbar extends StatelessWidget { final TextEditingController controller; final String instanceHost; final EditorToolbarStore store; static const _height = 50.0; - Toolbar({ + EditorToolbar({ required this.controller, required this.instanceHost, }) : store = EditorToolbarStore(instanceHost); @override Widget build(BuildContext context) { - return MobxProvider.value( - value: store, + return MobxProvider( + create: (context) => store, child: AsyncStoreListener( asyncStore: store.imageUploadState, child: Container( @@ -235,22 +123,19 @@ class _ToolbarBody extends HookWidget { return; } try { - // FIXME: for some reason it doesn't go past this line on iOS. idk why final pic = await pickImage(); // pic is null when the picker was cancelled if (pic != null) { - final picUrl = await context - .read() - .uploadImage(pic.path, token); + final picUrl = await store.uploadImage(pic.path, token); if (picUrl != null) { controller.reformatSimple('![]($picUrl)'); } } } on Exception catch (_) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to upload image'))); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(L10n.of(context).failed_to_upload_image))); } }), icon: store.imageUploadState.isLoading @@ -366,9 +251,7 @@ class _ToolbarBody extends HookWidget { onPressed: () { controller.reformat((selection) { final insides = selection.isNotEmpty ? selection : '___'; - Logger.root - .info([21, 21 + insides.length, insides, insides.length]); - return Reformat( + return _Reformat( text: '\n::: spoiler spoiler\n$insides\n:::\n', selectionBeginningShift: 21, selectionEndingShift: 21 + insides.length - selection.length, @@ -391,31 +274,31 @@ class _ToolbarBody extends HookWidget { } class AddLinkDialog extends HookWidget { - final String title; + final String label; final String url; final String selection; static final _websiteRegex = RegExp(r'https?:\/\/', caseSensitive: false); AddLinkDialog(this.selection) - : title = selection.startsWith(_websiteRegex) ? '' : selection, + : label = selection.startsWith(_websiteRegex) ? '' : selection, url = selection.startsWith(_websiteRegex) ? selection : ''; @override Widget build(BuildContext context) { - final titleController = useTextEditingController(text: title); + final labelController = useTextEditingController(text: label); final urlController = useTextEditingController(text: url); void submit() { final link = () { - if (urlController.text.startsWith('http?s://')) { + if (urlController.text.startsWith(RegExp('https?://'))) { return urlController.text; } else { return 'https://${urlController.text}'; } }(); - final finalString = '(${titleController.text})[$link]'; - Navigator.of(context).pop(Reformat( + final finalString = '[${labelController.text}]($link)'; + Navigator.of(context).pop(_Reformat( text: finalString, selectionBeginningShift: finalString.length, selectionEndingShift: finalString.length - selection.length, @@ -423,13 +306,14 @@ class AddLinkDialog extends HookWidget { } return AlertDialog( - title: const Text('Add link'), + title: Text(L10n.of(context).add_link), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( - controller: titleController, - decoration: const InputDecoration(hintText: 'title'), + controller: labelController, + decoration: InputDecoration( + hintText: L10n.of(context).editor_add_link_label), textInputAction: TextInputAction.next, autofocus: true, ), @@ -444,19 +328,129 @@ class AddLinkDialog extends HookWidget { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel')), + child: Text(L10n.of(context).cancel)), ElevatedButton( onPressed: submit, - child: const Text('Add link'), + child: Text(L10n.of(context).add_link), ) ], ); } - static Future show(BuildContext context, String selection) async { + static Future<_Reformat?> show(BuildContext context, String selection) async { return showDialog( context: context, builder: (context) => AddLinkDialog(selection), ); } } + +extension on TextEditingController { + String get selectionText => + text.substring(selection.baseOffset, selection.extentOffset); + String get beforeSelectionText => text.substring(0, selection.baseOffset); + String get afterSelectionText => text.substring(selection.extentOffset); + + /// surrounds selection with given strings. If nothing is selected, placeholder is used in the middle + void surround({ + required String before, + required String placeholder, + + /// after = before if null + String? after, + }) { + after ??= before; + final beg = text.substring(0, selection.baseOffset); + final mid = () { + final m = text.substring(selection.baseOffset, selection.extentOffset); + if (m.isEmpty) return placeholder; + return m; + }(); + final end = text.substring(selection.extentOffset); + + value = value.copyWith( + text: '$beg$before$mid$after$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + before.length, + extentOffset: selection.baseOffset + before.length + mid.length, + )); + } + + String get firstSelectedLine { + if (text.isEmpty) return ''; + return text.substring(text.getBeginningOfTheLine(selection.start - 1), + text.getEndOfTheLine(selection.end)); + } + + void insertAtBeginningOfFirstSelectedLine(String s) { + final lines = TextLinesIterator.fromController(this)..moveNext(); + lines.current = s + lines.current; + value = value.copyWith( + text: lines.text, + selection: selection.copyWith( + baseOffset: selection.baseOffset + s.length, + extentOffset: selection.extentOffset + s.length, + ), + ); + } + + void removeAtBeginningOfEverySelectedLine(String s) { + final lines = TextLinesIterator.fromController(this); + var linesCount = 0; + while (lines.moveNext()) { + if (lines.isWithinSelection) { + if (lines.current.startsWith(RegExp.escape(s))) { + lines.current = lines.current.substring(s.length); + linesCount++; + } + } + } + + value = value.copyWith( + text: lines.text, + selection: selection.copyWith( + baseOffset: selection.baseOffset - s.length, + extentOffset: selection.extentOffset - s.length * linesCount, + ), + ); + } + + void insertAtBeginningOfEverySelectedLine(String s) { + final lines = TextLinesIterator.fromController(this); + var linesCount = 0; + while (lines.moveNext()) { + if (lines.isWithinSelection) { + if (!lines.current.startsWith(RegExp.escape(s))) { + lines.current = '$s${lines.current}'; + linesCount++; + } + } + } + + value = value.copyWith( + text: lines.text, + selection: selection.copyWith( + baseOffset: selection.baseOffset + s.length, + extentOffset: selection.extentOffset + s.length * linesCount, + ), + ); + } + + void reformat(_Reformat Function(String selection) reformatter) { + final beg = beforeSelectionText; + final mid = selectionText; + final end = afterSelectionText; + + final r = reformatter(mid); + value = value.copyWith( + text: '$beg${r.text}$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + r.selectionBeginningShift, + extentOffset: selection.extentOffset + r.selectionEndingShift, + ), + ); + } + + void reformatSimple(String text) => + reformat((selection) => _Reformat(text: text)); +} diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index 3256b77..25d29f3 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -132,7 +132,7 @@ class WriteComment extends HookWidget { ) ], ), - Toolbar.safeArea, + EditorToolbar.safeArea, ], ), SafeArea( @@ -140,7 +140,7 @@ class WriteComment extends HookWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Spacer(), - Toolbar( + EditorToolbar( controller: controller, instanceHost: post.instanceHost, ), From 12084a3421b6aa719bdbb4f2fc505e87bd62dee2 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 23 Aug 2022 00:36:31 +0200 Subject: [PATCH 35/51] remove some unnecessary regex escapes --- lib/widgets/editor/editor_toolbar.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 74895ea..28f6d58 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -211,7 +211,7 @@ class _ToolbarBody extends HookWidget { onPressed: () { final line = controller.firstSelectedLine; - if (line.startsWith(RegExp.escape('* '))) { + if (line.startsWith('* ')) { controller.removeAtBeginningOfEverySelectedLine('* '); } else if (line.startsWith('- ')) { controller.removeAtBeginningOfEverySelectedLine('- '); @@ -399,7 +399,7 @@ extension on TextEditingController { var linesCount = 0; while (lines.moveNext()) { if (lines.isWithinSelection) { - if (lines.current.startsWith(RegExp.escape(s))) { + if (lines.current.startsWith(s)) { lines.current = lines.current.substring(s.length); linesCount++; } @@ -420,7 +420,7 @@ extension on TextEditingController { var linesCount = 0; while (lines.moveNext()) { if (lines.isWithinSelection) { - if (!lines.current.startsWith(RegExp.escape(s))) { + if (!lines.current.startsWith(s)) { lines.current = '$s${lines.current}'; linesCount++; } From 55daacf2216f253922e9e288a8a5af595882b731 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 23 Aug 2022 01:07:40 +0200 Subject: [PATCH 36/51] format --- lib/util/text_lines_iterator.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/util/text_lines_iterator.dart b/lib/util/text_lines_iterator.dart index 1df1b24..1a46e2b 100644 --- a/lib/util/text_lines_iterator.dart +++ b/lib/util/text_lines_iterator.dart @@ -11,8 +11,8 @@ class TextLinesIterator extends Iterator { : end = -1, beg = -1; - TextLinesIterator.fromController(TextEditingController controller) : - this(controller.text, selection: controller.selection); + TextLinesIterator.fromController(TextEditingController controller) + : this(controller.text, selection: controller.selection); bool get isWithinSelection { final selection = this.selection; From cc8441dabc3cca9726269f2913f4aea02dd4410f Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Tue, 23 Aug 2022 19:29:01 +0200 Subject: [PATCH 37/51] toolbar is now only present when textfield is focused also added it to manage account page --- lib/pages/create_post/create_post.dart | 3 + lib/pages/manage_account.dart | 299 +++++++++++++------------ lib/widgets/editor/editor.dart | 28 ++- lib/widgets/editor/editor_toolbar.dart | 43 ++-- lib/widgets/write_comment.dart | 3 + 5 files changed, 209 insertions(+), 167 deletions(-) diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index 8a74e8e..af64f45 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -31,6 +31,7 @@ class CreatePostPage extends HookWidget { ); final titleFocusNode = useFocusNode(); + final editorFocusNode = useFocusNode(); handleSubmit(Jwt token) async { if (formKey.currentState!.validate()) { @@ -63,6 +64,7 @@ class CreatePostPage extends HookWidget { labelText: L10n.of(context).body, instanceHost: store.instanceHost, fancy: store.showFancy, + focusNode: editorFocusNode, ), ); @@ -146,6 +148,7 @@ class CreatePostPage extends HookWidget { children: [ const Spacer(), EditorToolbar( + editorFocusNode: editorFocusNode, controller: bodyController, instanceHost: context.read().instanceHost, ), diff --git a/lib/pages/manage_account.dart b/lib/pages/manage_account.dart index a3408f1..84047c3 100644 --- a/lib/pages/manage_account.dart +++ b/lib/pages/manage_account.dart @@ -234,150 +234,169 @@ class _ManageAccount extends HookWidget { } } - return ListView( - padding: const EdgeInsets.symmetric(horizontal: 15), + return Stack( children: [ - _ImagePicker( - user: user, - name: L10n.of(context).avatar, - initialUrl: avatar.value, - onChange: (value) => avatar.value = value, - informAcceptedRef: informAcceptedAvatarRef, + ListView( + padding: const EdgeInsets.symmetric(horizontal: 15), + children: [ + _ImagePicker( + user: user, + name: L10n.of(context).avatar, + initialUrl: avatar.value, + onChange: (value) => avatar.value = value, + informAcceptedRef: informAcceptedAvatarRef, + ), + const SizedBox(height: 8), + _ImagePicker( + user: user, + name: L10n.of(context).banner, + initialUrl: banner.value, + onChange: (value) => banner.value = value, + informAcceptedRef: informAcceptedBannerRef, + ), + const SizedBox(height: 8), + Text(L10n.of(context).display_name, + style: theme.textTheme.headline6), + TextField( + controller: displayNameController, + onSubmitted: (_) => bioFocusNode.requestFocus(), + ), + const SizedBox(height: 8), + Text(L10n.of(context).bio, style: theme.textTheme.headline6), + Editor( + controller: bioController, + focusNode: bioFocusNode, + onSubmitted: (_) => emailFocusNode.requestFocus(), + instanceHost: user.instanceHost, + maxLines: 10, + ), + const SizedBox(height: 8), + Text(L10n.of(context).email, style: theme.textTheme.headline6), + TextField( + focusNode: emailFocusNode, + controller: emailController, + autofillHints: const [AutofillHints.email], + keyboardType: TextInputType.emailAddress, + onSubmitted: (_) => matrixUserFocusNode.requestFocus(), + ), + const SizedBox(height: 8), + Text(L10n.of(context).matrix_user, + style: theme.textTheme.headline6), + TextField( + focusNode: matrixUserFocusNode, + controller: matrixUserController, + onSubmitted: (_) => newPasswordFocusNode.requestFocus(), + ), + const SizedBox(height: 8), + // Text(L10n.of(context)!.new_password, style: theme.textTheme.headline6), + // TextField( + // focusNode: newPasswordFocusNode, + // controller: newPasswordController, + // autofillHints: const [AutofillHints.newPassword], + // keyboardType: TextInputType.visiblePassword, + // obscureText: true, + // onSubmitted: (_) => verifyPasswordFocusNode.requestFocus(), + // ), + // const SizedBox(height: 8), + // Text(L10n.of(context)!.verify_password, + // style: theme.textTheme.headline6), + // TextField( + // focusNode: verifyPasswordFocusNode, + // controller: newPasswordVerifyController, + // autofillHints: const [AutofillHints.newPassword], + // keyboardType: TextInputType.visiblePassword, + // obscureText: true, + // onSubmitted: (_) => oldPasswordFocusNode.requestFocus(), + // ), + // const SizedBox(height: 8), + // Text(L10n.of(context)!.old_password, style: theme.textTheme.headline6), + // TextField( + // focusNode: oldPasswordFocusNode, + // controller: oldPasswordController, + // autofillHints: const [AutofillHints.password], + // keyboardType: TextInputType.visiblePassword, + // obscureText: true, + // ), + // const SizedBox(height: 8), + SwitchListTile.adaptive( + value: showNsfw.value, + onChanged: (checked) { + showNsfw.value = checked; + }, + title: Text(L10n.of(context).show_nsfw), + dense: true, + ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + value: botAccount.value, + onChanged: (checked) { + botAccount.value = checked; + }, + title: Text(L10n.of(context).bot_account), + dense: true, + ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + value: showBotAccounts.value, + onChanged: (checked) { + showBotAccounts.value = checked; + }, + title: Text(L10n.of(context).show_bot_accounts), + dense: true, + ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + value: showReadPosts.value, + onChanged: (checked) { + showReadPosts.value = checked; + }, + title: Text(L10n.of(context).show_read_posts), + dense: true, + ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + value: sendNotificationsToEmail.value, + onChanged: (checked) { + sendNotificationsToEmail.value = checked; + }, + title: Text(L10n.of(context).send_notifications_to_email), + dense: true, + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: saveDelayedLoading.loading ? null : handleSubmit, + child: saveDelayedLoading.loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator.adaptive(), + ) + : Text(L10n.of(context).save), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: deleteAccountDialog, + style: ElevatedButton.styleFrom( + primary: Colors.red, + ), + child: Text(L10n.of(context).delete_account.toUpperCase()), + ), + const BottomSafe(), + ], ), - const SizedBox(height: 8), - _ImagePicker( - user: user, - name: L10n.of(context).banner, - initialUrl: banner.value, - onChange: (value) => banner.value = value, - informAcceptedRef: informAcceptedBannerRef, - ), - const SizedBox(height: 8), - Text(L10n.of(context).display_name, style: theme.textTheme.headline6), - TextField( - controller: displayNameController, - onSubmitted: (_) => bioFocusNode.requestFocus(), - ), - const SizedBox(height: 8), - Text(L10n.of(context).bio, style: theme.textTheme.headline6), - Editor( - controller: bioController, - focusNode: bioFocusNode, - onSubmitted: (_) => emailFocusNode.requestFocus(), - instanceHost: user.instanceHost, - maxLines: 10, - ), - const SizedBox(height: 8), - Text(L10n.of(context).email, style: theme.textTheme.headline6), - TextField( - focusNode: emailFocusNode, - controller: emailController, - autofillHints: const [AutofillHints.email], - keyboardType: TextInputType.emailAddress, - onSubmitted: (_) => matrixUserFocusNode.requestFocus(), - ), - const SizedBox(height: 8), - Text(L10n.of(context).matrix_user, style: theme.textTheme.headline6), - TextField( - focusNode: matrixUserFocusNode, - controller: matrixUserController, - onSubmitted: (_) => newPasswordFocusNode.requestFocus(), - ), - const SizedBox(height: 8), - // Text(L10n.of(context)!.new_password, style: theme.textTheme.headline6), - // TextField( - // focusNode: newPasswordFocusNode, - // controller: newPasswordController, - // autofillHints: const [AutofillHints.newPassword], - // keyboardType: TextInputType.visiblePassword, - // obscureText: true, - // onSubmitted: (_) => verifyPasswordFocusNode.requestFocus(), - // ), - // const SizedBox(height: 8), - // Text(L10n.of(context)!.verify_password, - // style: theme.textTheme.headline6), - // TextField( - // focusNode: verifyPasswordFocusNode, - // controller: newPasswordVerifyController, - // autofillHints: const [AutofillHints.newPassword], - // keyboardType: TextInputType.visiblePassword, - // obscureText: true, - // onSubmitted: (_) => oldPasswordFocusNode.requestFocus(), - // ), - // const SizedBox(height: 8), - // Text(L10n.of(context)!.old_password, style: theme.textTheme.headline6), - // TextField( - // focusNode: oldPasswordFocusNode, - // controller: oldPasswordController, - // autofillHints: const [AutofillHints.password], - // keyboardType: TextInputType.visiblePassword, - // obscureText: true, - // ), - // const SizedBox(height: 8), - SwitchListTile.adaptive( - value: showNsfw.value, - onChanged: (checked) { - showNsfw.value = checked; - }, - title: Text(L10n.of(context).show_nsfw), - dense: true, - ), - const SizedBox(height: 8), - SwitchListTile.adaptive( - value: botAccount.value, - onChanged: (checked) { - botAccount.value = checked; - }, - title: Text(L10n.of(context).bot_account), - dense: true, - ), - const SizedBox(height: 8), - SwitchListTile.adaptive( - value: showBotAccounts.value, - onChanged: (checked) { - showBotAccounts.value = checked; - }, - title: Text(L10n.of(context).show_bot_accounts), - dense: true, - ), - const SizedBox(height: 8), - SwitchListTile.adaptive( - value: showReadPosts.value, - onChanged: (checked) { - showReadPosts.value = checked; - }, - title: Text(L10n.of(context).show_read_posts), - dense: true, - ), - const SizedBox(height: 8), - SwitchListTile.adaptive( - value: sendNotificationsToEmail.value, - onChanged: (checked) { - sendNotificationsToEmail.value = checked; - }, - title: Text(L10n.of(context).send_notifications_to_email), - dense: true, - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: saveDelayedLoading.loading ? null : handleSubmit, - child: saveDelayedLoading.loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator.adaptive(), - ) - : Text(L10n.of(context).save), - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: deleteAccountDialog, - style: ElevatedButton.styleFrom( - primary: Colors.red, + SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Spacer(), + EditorToolbar( + editorFocusNode: bioFocusNode, + controller: bioController, + instanceHost: user.instanceHost, + ), + ], ), - child: Text(L10n.of(context).delete_account.toUpperCase()), ), - const BottomSafe(), ], ); } diff --git a/lib/widgets/editor/editor.dart b/lib/widgets/editor/editor.dart index be4b99e..39ad656 100644 --- a/lib/widgets/editor/editor.dart +++ b/lib/widgets/editor/editor.dart @@ -52,18 +52,22 @@ class Editor extends HookWidget { ); } - return TextField( - controller: actualController, - focusNode: focusNode, - autofocus: autofocus, - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - onChanged: onChanged, - onSubmitted: onSubmitted, - maxLines: maxLines, - minLines: minLines, - decoration: InputDecoration(labelText: labelText), - inputFormatters: [MarkdownFormatter()], + return FocusScope( + child: Focus( + focusNode: focusNode, + child: TextField( + controller: actualController, + autofocus: autofocus, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + onChanged: onChanged, + onSubmitted: onSubmitted, + maxLines: maxLines, + minLines: minLines, + decoration: InputDecoration(labelText: labelText), + inputFormatters: [MarkdownFormatter()], + ), + ), ); } } diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 28f6d58..d29bb2e 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -39,37 +39,50 @@ enum HeaderLevel { final int value; } -class EditorToolbar extends StatelessWidget { +class EditorToolbar extends HookWidget { final TextEditingController controller; final String instanceHost; final EditorToolbarStore store; + final FocusNode editorFocusNode; static const _height = 50.0; EditorToolbar({ required this.controller, required this.instanceHost, + required this.editorFocusNode, }) : store = EditorToolbarStore(instanceHost); @override Widget build(BuildContext context) { + final visible = useState(editorFocusNode.hasFocus); + useEffect(() { + editorFocusNode.addListener(() { + visible.value = editorFocusNode.hasFocus; + }); + + return; + }, [editorFocusNode]); + return MobxProvider( create: (context) => store, child: AsyncStoreListener( asyncStore: store.imageUploadState, - child: Container( - height: _height, - width: double.infinity, - color: Theme.of(context).cardColor, - child: Material( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: _ToolbarBody( - controller: controller, - instanceHost: instanceHost, - ), - ), - ), - ), + child: visible.value + ? Container( + height: _height, + width: double.infinity, + color: Theme.of(context).cardColor, + child: Material( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _ToolbarBody( + controller: controller, + instanceHost: instanceHost, + ), + ), + ), + ) + : const SizedBox.shrink(), ), ); } diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index 25d29f3..5456f2c 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -36,6 +36,7 @@ class WriteComment extends HookWidget { final showFancy = useState(false); final delayed = useDelayedLoading(); final loggedInAction = useLoggedInAction(post.instanceHost); + final editorFocusNode = useFocusNode(); final preview = () { final body = () { @@ -117,6 +118,7 @@ class WriteComment extends HookWidget { controller: controller, autofocus: true, fancy: showFancy.value, + focusNode: editorFocusNode, ), Row( mainAxisAlignment: MainAxisAlignment.end, @@ -141,6 +143,7 @@ class WriteComment extends HookWidget { children: [ const Spacer(), EditorToolbar( + editorFocusNode: editorFocusNode, controller: controller, instanceHost: post.instanceHost, ), From 6729a040ead8e4b684da47e208783758f921e8d6 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 17:24:05 +0200 Subject: [PATCH 38/51] make list completion more reusable --- lib/markdown_formatter.dart | 14 +++++++++----- lib/widgets/editor/editor_toolbar.dart | 21 +++++++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/lib/markdown_formatter.dart b/lib/markdown_formatter.dart index 6c621f9..360d702 100644 --- a/lib/markdown_formatter.dart +++ b/lib/markdown_formatter.dart @@ -1,5 +1,8 @@ import 'package:flutter/services.dart'; +const unorderedListTypes = ['*', '+', '-']; +const orderedListTypes = [')', '.']; + extension Utilities on String { int getBeginningOfTheLine(int from) { if (from <= 0) return 0; @@ -80,11 +83,12 @@ class MarkdownFormatter extends TextInputFormatter { return tev.append('$indent$number$afterNumberChar '); } - newVal = unorderedListContinuation('-', newVal); - newVal = unorderedListContinuation('*', newVal); - newVal = unorderedListContinuation('+', newVal); - newVal = orderedListContinuation('.', newVal); - newVal = orderedListContinuation(')', newVal); + for (final c in unorderedListTypes) { + newVal = unorderedListContinuation(c, newVal); + } + for (final c in orderedListTypes) { + newVal = orderedListContinuation(c, newVal); + } } return newVal; diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index d29bb2e..709560b 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -224,12 +224,21 @@ class _ToolbarBody extends HookWidget { onPressed: () { final line = controller.firstSelectedLine; - if (line.startsWith('* ')) { - controller.removeAtBeginningOfEverySelectedLine('* '); - } else if (line.startsWith('- ')) { - controller.removeAtBeginningOfEverySelectedLine('- '); - } else { - controller.insertAtBeginningOfEverySelectedLine('- '); + // if theres a list in place, remove it + final listRemoved = () { + for (final c in unorderedListTypes) { + if (line.startsWith('$c ')) { + controller.removeAtBeginningOfEverySelectedLine('$c '); + return true; + } + } + return false; + }(); + + // if no list, then let's add one + if (!listRemoved) { + controller.insertAtBeginningOfEverySelectedLine( + '${unorderedListTypes.last} '); } }, icon: const Icon(Icons.format_list_bulleted), From b39d6b06d768a79b93c92e4ccb4fb9a0d7b19ade Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 17:27:40 +0200 Subject: [PATCH 39/51] create widget for stuff that sticks to bottom --- lib/pages/create_post/create_post.dart | 16 +++++----------- lib/pages/manage_account.dart | 18 ++++++------------ lib/widgets/editor/editor_toolbar.dart | 18 +++++++++++++++++- lib/widgets/write_comment.dart | 16 +++++----------- 4 files changed, 33 insertions(+), 35 deletions(-) diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index af64f45..39128d2 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -142,17 +142,11 @@ class CreatePostPage extends HookWidget { ), ), ), - SafeArea( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Spacer(), - EditorToolbar( - editorFocusNode: editorFocusNode, - controller: bodyController, - instanceHost: context.read().instanceHost, - ), - ], + BottomSticky( + child: EditorToolbar( + editorFocusNode: editorFocusNode, + controller: bodyController, + instanceHost: context.read().instanceHost, ), ), ], diff --git a/lib/pages/manage_account.dart b/lib/pages/manage_account.dart index 84047c3..3d914c8 100644 --- a/lib/pages/manage_account.dart +++ b/lib/pages/manage_account.dart @@ -384,19 +384,13 @@ class _ManageAccount extends HookWidget { const BottomSafe(), ], ), - SafeArea( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Spacer(), - EditorToolbar( - editorFocusNode: bioFocusNode, - controller: bioController, - instanceHost: user.instanceHost, - ), - ], + BottomSticky( + child: EditorToolbar( + editorFocusNode: bioFocusNode, + controller: bioController, + instanceHost: user.instanceHost, ), - ), + ) ], ); } diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 709560b..6572cf3 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -87,7 +87,23 @@ class EditorToolbar extends HookWidget { ); } - static Widget safeArea = const SizedBox(height: _height); + static const safeArea = SizedBox(height: _height); +} + +class BottomSticky extends StatelessWidget { + final Widget child; + const BottomSticky({required this.child}); + + @override + Widget build(BuildContext context) => SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Spacer(), + child, + ], + ), + ); } class _ToolbarBody extends HookWidget { diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index 5456f2c..4f6e1c3 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -137,17 +137,11 @@ class WriteComment extends HookWidget { EditorToolbar.safeArea, ], ), - SafeArea( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Spacer(), - EditorToolbar( - editorFocusNode: editorFocusNode, - controller: controller, - instanceHost: post.instanceHost, - ), - ], + BottomSticky( + child: EditorToolbar( + editorFocusNode: editorFocusNode, + controller: controller, + instanceHost: post.instanceHost, ), ), ], From 90553794b2436932440b844ba6c46d8378d8f5ac Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 17:29:28 +0200 Subject: [PATCH 40/51] Update lib/widgets/editor/editor_toolbar.dart Co-authored-by: Marcin Wojnarowski --- lib/widgets/editor/editor_toolbar.dart | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 6572cf3..1689d9b 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -54,14 +54,7 @@ class EditorToolbar extends HookWidget { @override Widget build(BuildContext context) { - final visible = useState(editorFocusNode.hasFocus); - useEffect(() { - editorFocusNode.addListener(() { - visible.value = editorFocusNode.hasFocus; - }); - - return; - }, [editorFocusNode]); + final visible = useListenable(editorFocusNode).hasFocus; return MobxProvider( create: (context) => store, From 2cc82e6a4546790cb6d90c1e739f90a1ff9578d2 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 17:30:02 +0200 Subject: [PATCH 41/51] fix --- lib/widgets/editor/editor_toolbar.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 1689d9b..ef5f20e 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -60,7 +60,7 @@ class EditorToolbar extends HookWidget { create: (context) => store, child: AsyncStoreListener( asyncStore: store.imageUploadState, - child: visible.value + child: visible ? Container( height: _height, width: double.infinity, From c83e93c7554e21eb8168cbde6ad0b271a2ae22ba Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 17:40:37 +0200 Subject: [PATCH 42/51] add transition for toolbar appearing and disappearing --- lib/widgets/editor/editor_toolbar.dart | 31 ++++++++++++++------------ 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index ef5f20e..3be9ad9 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -60,22 +60,25 @@ class EditorToolbar extends HookWidget { create: (context) => store, child: AsyncStoreListener( asyncStore: store.imageUploadState, - child: visible - ? Container( - height: _height, - width: double.infinity, - color: Theme.of(context).cardColor, - child: Material( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: _ToolbarBody( - controller: controller, - instanceHost: instanceHost, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: visible + ? Container( + height: _height, + width: double.infinity, + color: Theme.of(context).cardColor, + child: Material( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _ToolbarBody( + controller: controller, + instanceHost: instanceHost, + ), ), ), - ), - ) - : const SizedBox.shrink(), + ) + : const SizedBox.shrink(), + ), ), ); } From d11a46393be2b55b2cca06b0020213abbfcb74f5 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 17:42:31 +0200 Subject: [PATCH 43/51] remove unneeded material --- lib/widgets/editor/editor_toolbar.dart | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 3be9ad9..95b8a39 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -66,14 +66,12 @@ class EditorToolbar extends HookWidget { ? Container( height: _height, width: double.infinity, - color: Theme.of(context).cardColor, - child: Material( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: _ToolbarBody( - controller: controller, - instanceHost: instanceHost, - ), + color: Theme.of(context).canvasColor, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _ToolbarBody( + controller: controller, + instanceHost: instanceHost, ), ), ) From 9ec5410273442bd4a1df77977aef0b87ea955211 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 17:45:22 +0200 Subject: [PATCH 44/51] remove unneed FocusScope & Focus --- lib/widgets/editor/editor.dart | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/lib/widgets/editor/editor.dart b/lib/widgets/editor/editor.dart index 39ad656..591cfe5 100644 --- a/lib/widgets/editor/editor.dart +++ b/lib/widgets/editor/editor.dart @@ -52,22 +52,18 @@ class Editor extends HookWidget { ); } - return FocusScope( - child: Focus( - focusNode: focusNode, - child: TextField( - controller: actualController, - autofocus: autofocus, - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - onChanged: onChanged, - onSubmitted: onSubmitted, - maxLines: maxLines, - minLines: minLines, - decoration: InputDecoration(labelText: labelText), - inputFormatters: [MarkdownFormatter()], - ), - ), + return TextField( + focusNode: focusNode, + controller: actualController, + autofocus: autofocus, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + onChanged: onChanged, + onSubmitted: onSubmitted, + maxLines: maxLines, + minLines: minLines, + decoration: InputDecoration(labelText: labelText), + inputFormatters: [MarkdownFormatter()], ); } } From e3b561835be49d9f71316e1dafc8a1d0d837ffba Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 17:50:06 +0200 Subject: [PATCH 45/51] remove magic number in favor of calculating it on the fly --- lib/widgets/editor/editor_toolbar.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 95b8a39..4a34967 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -282,11 +282,15 @@ class _ToolbarBody extends HookWidget { IconButton( onPressed: () { controller.reformat((selection) { - final insides = selection.isNotEmpty ? selection : '___'; + const textBeg = '\n::: spoiler spoiler\n'; + final textMid = selection.isNotEmpty ? selection : '___'; + const textEnd = '\n:::\n'; + return _Reformat( - text: '\n::: spoiler spoiler\n$insides\n:::\n', - selectionBeginningShift: 21, - selectionEndingShift: 21 + insides.length - selection.length, + text: textBeg + textMid + textEnd, + selectionBeginningShift: textBeg.length, + selectionEndingShift: + textBeg.length + textMid.length - selection.length, ); }); }, From b2ef0883e30617d837d9774dc93dec1bbd4672c0 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 18:22:14 +0200 Subject: [PATCH 46/51] remove store from being a property. also better animation --- lib/widgets/editor/editor_toolbar.dart | 57 +++++++++++++++----------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 4a34967..aebcf78 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -42,42 +42,53 @@ enum HeaderLevel { class EditorToolbar extends HookWidget { final TextEditingController controller; final String instanceHost; - final EditorToolbarStore store; final FocusNode editorFocusNode; static const _height = 50.0; - EditorToolbar({ + const EditorToolbar({ required this.controller, required this.instanceHost, required this.editorFocusNode, - }) : store = EditorToolbarStore(instanceHost); + }); @override Widget build(BuildContext context) { final visible = useListenable(editorFocusNode).hasFocus; return MobxProvider( - create: (context) => store, - child: AsyncStoreListener( - asyncStore: store.imageUploadState, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: visible - ? Container( - height: _height, - width: double.infinity, - color: Theme.of(context).canvasColor, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: _ToolbarBody( - controller: controller, - instanceHost: instanceHost, + create: (context) => EditorToolbarStore(instanceHost), + child: ObserverBuilder(builder: (context, store) { + return AsyncStoreListener( + asyncStore: store.imageUploadState, + child: AnimatedSwitcher( + duration: kThemeAnimationDuration, + transitionBuilder: (child, animation) { + final offsetAnimation = + Tween(begin: const Offset(0, 1.5), end: Offset.zero) + .animate(animation); + + return SlideTransition( + position: offsetAnimation, + child: child, + ); + }, + child: visible + ? Container( + height: _height, + width: double.infinity, + color: Theme.of(context).canvasColor, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _ToolbarBody( + controller: controller, + instanceHost: instanceHost, + ), ), - ), - ) - : const SizedBox.shrink(), - ), - ), + ) + : const SizedBox.shrink(), + ), + ); + }), ); } From 8aefdfbf27f097df21da8bec6f113993046e6eaa Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 18:46:32 +0200 Subject: [PATCH 47/51] remove list item if empty + enter pressed --- lib/markdown_formatter.dart | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/markdown_formatter.dart b/lib/markdown_formatter.dart index 360d702..2c01df7 100644 --- a/lib/markdown_formatter.dart +++ b/lib/markdown_formatter.dart @@ -40,6 +40,19 @@ extension on TextEditingValue { ), ); } + + /// cuts [characterCount] number of chars from before the cursor + TextEditingValue trimBeforeCursor(int characterCount) { + final beg = text.substring(0, selection.baseOffset); + final end = text.substring(selection.baseOffset); + + return copyWith( + text: beg.substring(0, beg.length - characterCount - 1) + end, + selection: selection.copyWith( + baseOffset: selection.baseOffset - characterCount, + extentOffset: selection.extentOffset - characterCount, + )); + } } /// Provides convenience formatting in markdown text fields @@ -59,27 +72,43 @@ class MarkdownFormatter extends TextInputFormatter { TextEditingValue unorderedListContinuation( String listChar, TextEditingValue tev) { - final regex = RegExp(r'(\s*)' '${RegExp.escape(listChar)} '); + final regex = RegExp(r'(\s*)' '${RegExp.escape(listChar)} (.*)'); final match = regex.matchAsPrefix(lineBefore); + if (match == null) { return tev; } + + final listItemBody = match.group(2); final indent = match.group(1); + if (listItemBody == null || listItemBody.isEmpty) { + return tev.trimBeforeCursor(listChar.length + (indent?.length ?? 1)); + } + return tev.append('$indent$listChar '); } TextEditingValue orderedListContinuation( String afterNumberChar, TextEditingValue tev) { final regex = - RegExp(r'(\s*)(\d+)' '${RegExp.escape(afterNumberChar)} '); + RegExp(r'(\s*)(\d+)' '${RegExp.escape(afterNumberChar)} (.*)'); final match = regex.matchAsPrefix(lineBefore); + if (match == null) { return tev; } - final indent = match.group(1); - final number = int.tryParse(match.group(2)!) ?? 0 + 1; + final listItemBody = match.group(3)!; + final indent = match.group(1)!; + final numberStr = match.group(2)!; + + if (listItemBody.isEmpty) { + return tev.trimBeforeCursor( + indent.length + numberStr.length + afterNumberChar.length + 1); + } + + final number = (int.tryParse(match.group(2)!) ?? 0) + 1; return tev.append('$indent$number$afterNumberChar '); } From 87726f283bd4068189542a2fe5b7a06b87842031 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 21:56:49 +0200 Subject: [PATCH 48/51] make end indice exclusive --- lib/markdown_formatter.dart | 4 ++-- lib/widgets/editor/editor_toolbar.dart | 27 ++++++++++++++------------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/markdown_formatter.dart b/lib/markdown_formatter.dart index 2c01df7..fb65b1a 100644 --- a/lib/markdown_formatter.dart +++ b/lib/markdown_formatter.dart @@ -14,10 +14,10 @@ extension Utilities on String { int getEndOfTheLine(int from) { for (var i = from; i < length; i++) { - if (this[i] == '\n') return i; + if (this[i] == '\n') return i + 1; } - return length - 1; + return length; } /// returns the line that ends at endingIndex diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index aebcf78..33bdc04 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -57,9 +57,9 @@ class EditorToolbar extends HookWidget { return MobxProvider( create: (context) => EditorToolbarStore(instanceHost), - child: ObserverBuilder(builder: (context, store) { + child: Builder(builder: (context) { return AsyncStoreListener( - asyncStore: store.imageUploadState, + asyncStore: context.read().imageUploadState, child: AnimatedSwitcher( duration: kThemeAnimationDuration, transitionBuilder: (child, animation) { @@ -214,15 +214,15 @@ class _ToolbarBody extends HookWidget { PopupMenuItem( value: h, child: Text(h.name.toUpperCase()), + onTap: () { + final header = '${'#' * h.value} '; + + if (!controller.firstSelectedLine.startsWith(header)) { + controller.insertAtBeginningOfFirstSelectedLine(header); + } + }, ), ], - onSelected: (val) { - final header = '${'#' * val.value} '; - - if (!controller.firstSelectedLine.startsWith(header)) { - controller.insertAtBeginningOfFirstSelectedLine(header); - } - }, tooltip: L10n.of(context).editor_header, child: const Icon(Icons.h_mobiledata), ), @@ -424,9 +424,12 @@ extension on TextEditingController { } String get firstSelectedLine { - if (text.isEmpty) return ''; - return text.substring(text.getBeginningOfTheLine(selection.start - 1), - text.getEndOfTheLine(selection.end)); + if (text.isEmpty) { + return ''; + } + final val = text.substring(text.getBeginningOfTheLine(selection.start - 1), + text.getEndOfTheLine(selection.end) - 1); + return val; } void insertAtBeginningOfFirstSelectedLine(String s) { From c15cfdf02c3194de0dc977a17b5adaa48fe5620a Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 23:21:28 +0200 Subject: [PATCH 49/51] add Material back in -_- cuz it's needed for splash --- lib/widgets/editor/editor_toolbar.dart | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 33bdc04..6d6bb06 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -73,15 +73,17 @@ class EditorToolbar extends HookWidget { ); }, child: visible - ? Container( - height: _height, - width: double.infinity, + ? Material( color: Theme.of(context).canvasColor, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: _ToolbarBody( - controller: controller, - instanceHost: instanceHost, + child: SizedBox( + height: _height, + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _ToolbarBody( + controller: controller, + instanceHost: instanceHost, + ), ), ), ) From 2d7d2a64bd26a07131c2ad1e6cb64188776f8d99 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 23:46:43 +0200 Subject: [PATCH 50/51] move part of the functionality to EditorController for clutter reduction --- lib/pages/create_post/create_post.dart | 17 +++------- lib/pages/manage_account.dart | 19 +++++------ lib/widgets/editor/editor.dart | 44 ++++++++++++++++++-------- lib/widgets/editor/editor_toolbar.dart | 20 +++++------- lib/widgets/write_comment.dart | 22 ++++++------- 5 files changed, 61 insertions(+), 61 deletions(-) diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index 39128d2..5f504c8 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -30,8 +30,10 @@ class CreatePostPage extends HookWidget { useStore((CreatePostStore store) => store.instanceHost), ); + final editorController = useEditorController( + instanceHost: context.read().instanceHost, + text: context.read().body); final titleFocusNode = useFocusNode(); - final editorFocusNode = useFocusNode(); handleSubmit(Jwt token) async { if (formKey.currentState!.validate()) { @@ -54,17 +56,12 @@ class CreatePostPage extends HookWidget { ), ); - final bodyController = - useTextEditingController(text: context.read().body); - final body = ObserverBuilder( builder: (context, store) => Editor( - controller: bodyController, + controller: editorController, onChanged: (body) => store.body = body, labelText: L10n.of(context).body, - instanceHost: store.instanceHost, fancy: store.showFancy, - focusNode: editorFocusNode, ), ); @@ -143,11 +140,7 @@ class CreatePostPage extends HookWidget { ), ), BottomSticky( - child: EditorToolbar( - editorFocusNode: editorFocusNode, - controller: bodyController, - instanceHost: context.read().instanceHost, - ), + child: EditorToolbar(editorController), ), ], ), diff --git a/lib/pages/manage_account.dart b/lib/pages/manage_account.dart index 3d914c8..8edc551 100644 --- a/lib/pages/manage_account.dart +++ b/lib/pages/manage_account.dart @@ -99,7 +99,6 @@ class _ManageAccount extends HookWidget { final displayNameController = useTextEditingController(text: user.person.displayName); - final bioController = useTextEditingController(text: user.person.bio); final emailController = useTextEditingController(text: user.localUser.email); final matrixUserController = @@ -122,13 +121,15 @@ class _ManageAccount extends HookWidget { final deleteAccountPasswordController = useTextEditingController(); - final bioFocusNode = useFocusNode(); final emailFocusNode = useFocusNode(); final matrixUserFocusNode = useFocusNode(); final newPasswordFocusNode = useFocusNode(); // final verifyPasswordFocusNode = useFocusNode(); // final oldPasswordFocusNode = useFocusNode(); + final bioController = useEditorController( + instanceHost: user.instanceHost, text: user.person.bio); + final token = accountsStore.userDataFor(user.instanceHost, user.person.name)!.jwt; @@ -156,7 +157,9 @@ class _ManageAccount extends HookWidget { displayName: displayNameController.text.isEmpty ? null : displayNameController.text, - bio: bioController.text.isEmpty ? null : bioController.text, + bio: bioController.textEditingController.text.isEmpty + ? null + : bioController.textEditingController.text, email: emailController.text.isEmpty ? null : emailController.text, )); @@ -259,15 +262,13 @@ class _ManageAccount extends HookWidget { style: theme.textTheme.headline6), TextField( controller: displayNameController, - onSubmitted: (_) => bioFocusNode.requestFocus(), + onSubmitted: (_) => bioController.focusNode.requestFocus(), ), const SizedBox(height: 8), Text(L10n.of(context).bio, style: theme.textTheme.headline6), Editor( controller: bioController, - focusNode: bioFocusNode, onSubmitted: (_) => emailFocusNode.requestFocus(), - instanceHost: user.instanceHost, maxLines: 10, ), const SizedBox(height: 8), @@ -385,11 +386,7 @@ class _ManageAccount extends HookWidget { ], ), BottomSticky( - child: EditorToolbar( - editorFocusNode: bioFocusNode, - controller: bioController, - instanceHost: user.instanceHost, - ), + child: EditorToolbar(bioController), ) ], ); diff --git a/lib/widgets/editor/editor.dart b/lib/widgets/editor/editor.dart index 591cfe5..07536e1 100644 --- a/lib/widgets/editor/editor.dart +++ b/lib/widgets/editor/editor.dart @@ -6,10 +6,34 @@ import '../markdown_text.dart'; export 'editor_toolbar.dart'; +class EditorController { + final TextEditingController textEditingController; + final FocusNode focusNode; + final String instanceHost; + + EditorController({ + required this.textEditingController, + required this.focusNode, + required this.instanceHost, + }); +} + +EditorController useEditorController({ + required String instanceHost, + String? text, +}) { + final focusNode = useFocusNode(); + final textEditingController = useTextEditingController(text: text); + return EditorController( + textEditingController: textEditingController, + focusNode: focusNode, + instanceHost: instanceHost); +} + /// A text field with added functionality for ease of editing class Editor extends HookWidget { - final TextEditingController? controller; - final FocusNode? focusNode; + final EditorController controller; + final ValueChanged? onSubmitted; final ValueChanged? onChanged; final int? minLines; @@ -20,12 +44,10 @@ class Editor extends HookWidget { /// Whether the editor should be preview the contents final bool fancy; - final String instanceHost; const Editor({ super.key, - this.controller, - this.focusNode, + required this.controller, this.onSubmitted, this.onChanged, this.minLines = 5, @@ -33,28 +55,24 @@ class Editor extends HookWidget { this.labelText, this.initialValue, this.fancy = false, - required this.instanceHost, this.autofocus = false, }); @override Widget build(BuildContext context) { - final defaultController = useTextEditingController(text: initialValue); - final actualController = controller ?? defaultController; - if (fancy) { return Padding( padding: const EdgeInsets.all(8), child: MarkdownText( - actualController.text, - instanceHost: instanceHost, + controller.textEditingController.text, + instanceHost: controller.instanceHost, ), ); } return TextField( - focusNode: focusNode, - controller: actualController, + focusNode: controller.focusNode, + controller: controller.textEditingController, autofocus: autofocus, keyboardType: TextInputType.multiline, textCapitalization: TextCapitalization.sentences, diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 6d6bb06..19e79e7 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -13,6 +13,7 @@ import '../../util/files.dart'; import '../../util/mobx_provider.dart'; import '../../util/observer_consumers.dart'; import '../../util/text_lines_iterator.dart'; +import 'editor.dart'; import 'editor_picking_dialog.dart'; import 'editor_toolbar_store.dart'; @@ -40,23 +41,18 @@ enum HeaderLevel { } class EditorToolbar extends HookWidget { - final TextEditingController controller; - final String instanceHost; - final FocusNode editorFocusNode; + final EditorController controller; + static const _height = 50.0; - const EditorToolbar({ - required this.controller, - required this.instanceHost, - required this.editorFocusNode, - }); + const EditorToolbar(this.controller); @override Widget build(BuildContext context) { - final visible = useListenable(editorFocusNode).hasFocus; + final visible = useListenable(controller.focusNode).hasFocus; return MobxProvider( - create: (context) => EditorToolbarStore(instanceHost), + create: (context) => EditorToolbarStore(controller.instanceHost), child: Builder(builder: (context) { return AsyncStoreListener( asyncStore: context.read().imageUploadState, @@ -81,8 +77,8 @@ class EditorToolbar extends HookWidget { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: _ToolbarBody( - controller: controller, - instanceHost: instanceHost, + controller: controller.textEditingController, + instanceHost: controller.instanceHost, ), ), ), diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index 4f6e1c3..960a95b 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -31,12 +31,14 @@ class WriteComment extends HookWidget { @override Widget build(BuildContext context) { - final controller = - useTextEditingController(text: _isEdit ? comment?.content : null); final showFancy = useState(false); final delayed = useDelayedLoading(); final loggedInAction = useLoggedInAction(post.instanceHost); - final editorFocusNode = useFocusNode(); + + final editorController = useEditorController( + instanceHost: post.instanceHost, + text: _isEdit ? comment?.content : null, + ); final preview = () { final body = () { @@ -70,12 +72,12 @@ class WriteComment extends HookWidget { if (_isEdit) { return api.run(EditComment( commentId: comment!.id, - content: controller.text, + content: editorController.textEditingController.text, auth: token.raw, )); } else { return api.run(CreateComment( - content: controller.text, + content: editorController.textEditingController.text, postId: post.id, parentId: comment?.id, auth: token.raw, @@ -114,11 +116,9 @@ class WriteComment extends HookWidget { ), const Divider(), Editor( - instanceHost: post.instanceHost, - controller: controller, + controller: editorController, autofocus: true, fancy: showFancy.value, - focusNode: editorFocusNode, ), Row( mainAxisAlignment: MainAxisAlignment.end, @@ -138,11 +138,7 @@ class WriteComment extends HookWidget { ], ), BottomSticky( - child: EditorToolbar( - editorFocusNode: editorFocusNode, - controller: controller, - instanceHost: post.instanceHost, - ), + child: EditorToolbar(editorController), ), ], ), From 9ff62f86c541c00d8edf2ddcc096aef24ebc79ac Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Aug 2022 23:56:11 +0200 Subject: [PATCH 51/51] revise the way editor toolbar sticks to the bottom --- lib/widgets/editor/editor_toolbar.dart | 30 +++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 19e79e7..f6ee42e 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -71,14 +71,16 @@ class EditorToolbar extends HookWidget { child: visible ? Material( color: Theme.of(context).canvasColor, - child: SizedBox( - height: _height, - width: double.infinity, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: _ToolbarBody( - controller: controller.textEditingController, - instanceHost: controller.instanceHost, + child: SafeArea( + child: SizedBox( + height: _height, + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _ToolbarBody( + controller: controller.textEditingController, + instanceHost: controller.instanceHost, + ), ), ), ), @@ -98,13 +100,11 @@ class BottomSticky extends StatelessWidget { const BottomSticky({required this.child}); @override - Widget build(BuildContext context) => SafeArea( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Spacer(), - child, - ], + Widget build(BuildContext context) => Positioned( + bottom: 0, + child: SizedBox( + width: MediaQuery.of(context).size.width, + child: child, ), ); }