forked from eatvio/eatvio_app
feat(md): Issue-746
This commit is contained in:
502
lib/common/widgets/markdown_editor.dart
Normal file
502
lib/common/widgets/markdown_editor.dart
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||||
|
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||||
|
|
||||||
|
class MarkdownEditor extends StatefulWidget {
|
||||||
|
const MarkdownEditor({
|
||||||
|
super.key,
|
||||||
|
this.initialText,
|
||||||
|
this.hintText,
|
||||||
|
this.hasError = false,
|
||||||
|
this.minLines = 4,
|
||||||
|
this.maxLines = 6,
|
||||||
|
this.heightPreview = 120,
|
||||||
|
this.onChanged,
|
||||||
|
this.onToolbarVisibilityChanged,
|
||||||
|
this.showInlineToolbar = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? initialText;
|
||||||
|
final String? hintText;
|
||||||
|
final bool hasError;
|
||||||
|
final int minLines;
|
||||||
|
final int maxLines;
|
||||||
|
final double heightPreview;
|
||||||
|
final void Function(String value)? onChanged;
|
||||||
|
final void Function(bool visible)? onToolbarVisibilityChanged;
|
||||||
|
final bool showInlineToolbar;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MarkdownEditor> createState() => MarkdownEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarkdownEditorState extends State<MarkdownEditor> {
|
||||||
|
late final TextEditingController _controller;
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
bool _isPreview = false;
|
||||||
|
bool _enablePanel = false;
|
||||||
|
late final KeyboardVisibilityController _keyboardVisibilityController;
|
||||||
|
late final StreamSubscription<bool> _keyboardSubscription;
|
||||||
|
|
||||||
|
bool get isToolbarVisible => _enablePanel || _isPreview;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController(text: widget.initialText ?? '');
|
||||||
|
_keyboardVisibilityController = KeyboardVisibilityController();
|
||||||
|
_keyboardSubscription = _keyboardVisibilityController.onChange.listen((visible) {
|
||||||
|
setState(() {
|
||||||
|
_enablePanel = visible;
|
||||||
|
});
|
||||||
|
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_keyboardSubscription.cancel();
|
||||||
|
_focusNode.dispose();
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasFormatting(String prefix, String suffix) {
|
||||||
|
final text = _controller.text;
|
||||||
|
final selection = _controller.selection;
|
||||||
|
if (selection.isValid && !selection.isCollapsed) {
|
||||||
|
final selectedText = text.substring(selection.start, selection.end);
|
||||||
|
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
|
||||||
|
} else {
|
||||||
|
final cursorPosition = selection.baseOffset;
|
||||||
|
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
||||||
|
final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition);
|
||||||
|
final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length);
|
||||||
|
return beforeCursor == prefix && afterCursor == suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeFormatting(String prefix, String suffix) {
|
||||||
|
final text = _controller.text;
|
||||||
|
final selection = _controller.selection;
|
||||||
|
if (selection.isValid && !selection.isCollapsed) {
|
||||||
|
final selectedText = text.substring(selection.start, selection.end);
|
||||||
|
final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length);
|
||||||
|
final newText = text.replaceRange(selection.start, selection.end, unformattedText);
|
||||||
|
_controller.text = newText;
|
||||||
|
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length);
|
||||||
|
} else {
|
||||||
|
final cursorPosition = selection.baseOffset;
|
||||||
|
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
||||||
|
final beforeCursor = text.substring(0, cursorPosition - prefix.length);
|
||||||
|
final afterCursor = text.substring(cursorPosition + suffix.length);
|
||||||
|
final newText = beforeCursor + afterCursor;
|
||||||
|
_controller.text = newText;
|
||||||
|
_controller.selection = TextSelection.collapsed(offset: cursorPosition - prefix.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
widget.onChanged?.call(_controller.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyMarkdownFormat(String prefix, String suffix) {
|
||||||
|
final text = _controller.text;
|
||||||
|
final selection = _controller.selection;
|
||||||
|
if (selection.isValid && !selection.isCollapsed) {
|
||||||
|
final selectedText = text.substring(selection.start, selection.end);
|
||||||
|
final formattedText = '$prefix$selectedText$suffix';
|
||||||
|
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
||||||
|
_controller.text = newText;
|
||||||
|
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length);
|
||||||
|
} else {
|
||||||
|
final cursorPosition = selection.baseOffset;
|
||||||
|
final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
|
||||||
|
_controller.text = newText;
|
||||||
|
_controller.selection = TextSelection.collapsed(offset: cursorPosition + prefix.length);
|
||||||
|
}
|
||||||
|
widget.onChanged?.call(_controller.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyHeading() {
|
||||||
|
if (_hasFormatting('# ', '')) {
|
||||||
|
_removeFormatting('# ', '');
|
||||||
|
} else {
|
||||||
|
_applyMarkdownFormat('# ', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyBold() {
|
||||||
|
if (_hasFormatting('**', '**')) {
|
||||||
|
_removeFormatting('**', '**');
|
||||||
|
} else {
|
||||||
|
_applyMarkdownFormat('**', '**');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyItalic() {
|
||||||
|
if (_hasFormatting('*', '*')) {
|
||||||
|
_removeFormatting('*', '*');
|
||||||
|
} else {
|
||||||
|
_applyMarkdownFormat('*', '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyNumberedList() {
|
||||||
|
if (_hasFormatting('1. ', '')) {
|
||||||
|
_removeFormatting('1. ', '');
|
||||||
|
} else {
|
||||||
|
_applyMarkdownFormat('1. ', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyBulletedList() {
|
||||||
|
if (_hasFormatting('- ', '')) {
|
||||||
|
_removeFormatting('- ', '');
|
||||||
|
} else {
|
||||||
|
_applyMarkdownFormat('- ', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyLink() {
|
||||||
|
final text = _controller.text;
|
||||||
|
final selection = _controller.selection;
|
||||||
|
if (selection.isValid && !selection.isCollapsed) {
|
||||||
|
final selectedText = text.substring(selection.start, selection.end);
|
||||||
|
final linkRegex = RegExp(r'^\[(.*?)\]\((.*?)\)$');
|
||||||
|
if (linkRegex.hasMatch(selectedText)) {
|
||||||
|
final match = linkRegex.firstMatch(selectedText);
|
||||||
|
final linkText = match?.group(1) ?? selectedText;
|
||||||
|
final newText = text.replaceRange(selection.start, selection.end, linkText);
|
||||||
|
_controller.text = newText;
|
||||||
|
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length);
|
||||||
|
} else {
|
||||||
|
final formattedText = '[$selectedText](url)';
|
||||||
|
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
||||||
|
_controller.text = newText;
|
||||||
|
_controller.selection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_applyMarkdownFormat('[', '](url)');
|
||||||
|
}
|
||||||
|
widget.onChanged?.call(_controller.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyHorizontalRule() {
|
||||||
|
final cursorPosition = _controller.selection.baseOffset;
|
||||||
|
final text = _controller.text;
|
||||||
|
final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
|
||||||
|
_controller.text = newText;
|
||||||
|
_controller.selection = TextSelection.collapsed(offset: cursorPosition + 5);
|
||||||
|
widget.onChanged?.call(_controller.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _togglePreview() {
|
||||||
|
setState(() {
|
||||||
|
_isPreview = !_isPreview;
|
||||||
|
});
|
||||||
|
if (!_isPreview) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_focusNode.unfocus();
|
||||||
|
}
|
||||||
|
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _mdButton(IconData icon, VoidCallback onTap) {
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: Icon(icon),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _mdTextButton(String text, VoidCallback onTap) {
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.black87),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMarkdownPreview(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: widget.heightPreview,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF6F6F6),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(child: _parseMarkdown(_controller.text)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildToolbar(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: MediaQuery.of(context).size.width - 76,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdTextButton('H', _applyHeading),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.format_bold, _applyBold),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.format_italic, _applyItalic),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.link, _applyLink),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
|
||||||
|
),
|
||||||
|
child: Row(children: [_mdButton(_isPreview ? Icons.edit : Icons.visibility, _togglePreview)]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _parseMarkdown(String text) {
|
||||||
|
if (text.isEmpty) {
|
||||||
|
return Text(widget.hintText ?? '');
|
||||||
|
}
|
||||||
|
final lines = text.split('\n');
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
for (int i = 0; i < lines.length; i++) {
|
||||||
|
final line = lines[i];
|
||||||
|
if (line.trim().isEmpty) {
|
||||||
|
widgets.add(const SizedBox(height: 8));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# ')) {
|
||||||
|
widgets.add(
|
||||||
|
Text(
|
||||||
|
line.substring(2),
|
||||||
|
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (line.startsWith('## ')) {
|
||||||
|
widgets.add(
|
||||||
|
Text(
|
||||||
|
line.substring(3),
|
||||||
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (line.startsWith('### ')) {
|
||||||
|
widgets.add(
|
||||||
|
Text(
|
||||||
|
line.substring(4),
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||||
|
widgets.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('• ', style: TextStyle(fontSize: 16)),
|
||||||
|
Expanded(child: _parseInlineMarkdown(line.substring(2))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (RegExp(r'^\d+\. ').hasMatch(line)) {
|
||||||
|
final match = RegExp(r'^(\d+)\. ').firstMatch(line);
|
||||||
|
final number = match?.group(1) ?? '1';
|
||||||
|
widgets.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('$number. ', style: const TextStyle(fontSize: 16)),
|
||||||
|
Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (line.trim() == '---') {
|
||||||
|
widgets.add(const Divider(color: Colors.grey, thickness: 1));
|
||||||
|
} else {
|
||||||
|
widgets.add(_parseInlineMarkdown(line));
|
||||||
|
}
|
||||||
|
if (i < lines.length - 1) {
|
||||||
|
widgets.add(const SizedBox(height: 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _parseInlineMarkdown(String text) {
|
||||||
|
final spans = <TextSpan>[];
|
||||||
|
final regex = RegExp(r'(\*\*.*?\*\*|\*.*?\*|\[.*?\]\(.*?\))');
|
||||||
|
int lastEnd = 0;
|
||||||
|
for (final match in regex.allMatches(text)) {
|
||||||
|
if (match.start > lastEnd) {
|
||||||
|
spans.add(
|
||||||
|
TextSpan(
|
||||||
|
text: text.substring(lastEnd, match.start),
|
||||||
|
style: const TextStyle(fontSize: 16, color: Colors.black),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final matchedText = match.group(0)!;
|
||||||
|
if (matchedText.startsWith('**') && matchedText.endsWith('**')) {
|
||||||
|
spans.add(
|
||||||
|
TextSpan(
|
||||||
|
text: matchedText.substring(2, matchedText.length - 2),
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (matchedText.startsWith('*') && matchedText.endsWith('*')) {
|
||||||
|
spans.add(
|
||||||
|
TextSpan(
|
||||||
|
text: matchedText.substring(1, matchedText.length - 1),
|
||||||
|
style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.black),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (matchedText.startsWith('[') && matchedText.contains('](')) {
|
||||||
|
final linkMatch = RegExp(r'\[(.*?)\]\((.*?)\)').firstMatch(matchedText);
|
||||||
|
if (linkMatch != null) {
|
||||||
|
final linkText = linkMatch.group(1)!;
|
||||||
|
spans.add(
|
||||||
|
TextSpan(
|
||||||
|
text: linkText,
|
||||||
|
style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastEnd = match.end;
|
||||||
|
}
|
||||||
|
if (lastEnd < text.length) {
|
||||||
|
spans.add(
|
||||||
|
TextSpan(
|
||||||
|
text: text.substring(lastEnd),
|
||||||
|
style: const TextStyle(fontSize: 16, color: Colors.black),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RichText(text: TextSpan(children: spans));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_isPreview)
|
||||||
|
_buildMarkdownPreview(context)
|
||||||
|
else
|
||||||
|
CustomTextFormField(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
maxLines: widget.maxLines,
|
||||||
|
minLines: widget.minLines,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
hasError: widget.hasError,
|
||||||
|
onChanged: (value) => widget.onChanged?.call(value),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (widget.showInlineToolbar && (isToolbarVisible || !_isPreview))
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdTextButton('H', _applyHeading),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.format_bold, _applyBold),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.format_italic, _applyItalic),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.link, _applyLink),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(children: [_mdButton(_isPreview ? Icons.edit : Icons.visibility, _togglePreview)]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -54,6 +55,8 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
|
|||||||
final nameController = TextEditingController();
|
final nameController = TextEditingController();
|
||||||
final descriptionController = TextEditingController();
|
final descriptionController = TextEditingController();
|
||||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||||
|
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
|
||||||
|
bool _mdToolbarVisible = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -92,6 +95,8 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: appTheme.gray100,
|
backgroundColor: appTheme.gray100,
|
||||||
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||||
|
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null ? _mdKey.currentState!.buildToolbar(context) : null,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -122,20 +127,19 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
|
|||||||
),
|
),
|
||||||
if (state.dietNameError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietNameError ?? '')],
|
if (state.dietNameError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietNameError ?? '')],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
MarkdownEditor(
|
||||||
height: 132,
|
key: _mdKey,
|
||||||
child: CustomTextFormField(
|
initialText: descriptionController.text,
|
||||||
hasError: state.dietDescriptionError != null,
|
hintText: s.enter_diet_description,
|
||||||
controller: descriptionController,
|
hasError: state.dietDescriptionError != null,
|
||||||
textAlignVertical: TextAlignVertical.top,
|
minLines: 6,
|
||||||
maxLines: null,
|
maxLines: 8,
|
||||||
minLines: null,
|
showInlineToolbar: false,
|
||||||
expands: true,
|
onToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
|
||||||
hintText: s.enter_diet_description,
|
onChanged: (value) {
|
||||||
onChanged: (value) {
|
descriptionController.text = value;
|
||||||
context.read<CreateDietCubit>().setDetails(value);
|
context.read<CreateDietCubit>().setDetails(value);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (state.dietDescriptionError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietDescriptionError ?? '')],
|
if (state.dietDescriptionError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietDescriptionError ?? '')],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -32,9 +33,17 @@ class CreatePlanScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CreatePlanScreenContent extends StatelessWidget {
|
class CreatePlanScreenContent extends StatefulWidget {
|
||||||
const CreatePlanScreenContent({super.key});
|
const CreatePlanScreenContent({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CreatePlanScreenContent> createState() => _CreatePlanScreenContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||||
|
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
|
||||||
|
bool _mdToolbarVisible = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
@@ -54,6 +63,8 @@ class CreatePlanScreenContent extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: appTheme.gray100,
|
backgroundColor: appTheme.gray100,
|
||||||
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||||
|
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null ? _mdKey.currentState!.buildToolbar(context) : null,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -131,20 +142,17 @@ class CreatePlanScreenContent extends StatelessWidget {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
MarkdownEditor(
|
||||||
height: 132,
|
key: _mdKey,
|
||||||
child: CustomTextFormField(
|
hintText: s.plan_desc_example,
|
||||||
hasError: state.validationState.descriptionHasError,
|
hasError: state.validationState.descriptionHasError,
|
||||||
textAlignVertical: TextAlignVertical.top,
|
minLines: 6,
|
||||||
maxLength: 1000,
|
maxLines: 8,
|
||||||
maxLines: null,
|
showInlineToolbar: false,
|
||||||
minLines: null,
|
onToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
|
||||||
expands: true,
|
onChanged: (value) {
|
||||||
hintText: s.plan_desc_example,
|
context.read<CreatePlanCubit>().setDetails(value);
|
||||||
onChanged: (value) {
|
},
|
||||||
context.read<CreatePlanCubit>().setDetails(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (state.validationState.descriptionHasError) ...[
|
if (state.validationState.descriptionHasError) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:eatvio_app/core/widgets/success_published_recipe_bottom_sheet.da
|
|||||||
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
|
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
|
||||||
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart';
|
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart';
|
||||||
import 'package:eatvio_app/features/create_recipe/presentation/widgets/info_about_recipe_widget.dart';
|
import 'package:eatvio_app/features/create_recipe/presentation/widgets/info_about_recipe_widget.dart';
|
||||||
|
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
|
||||||
import 'package:eatvio_app/features/create_recipe/presentation/widgets/ingredients_widget.dart';
|
import 'package:eatvio_app/features/create_recipe/presentation/widgets/ingredients_widget.dart';
|
||||||
import 'package:eatvio_app/features/create_recipe/presentation/widgets/steps_widget.dart';
|
import 'package:eatvio_app/features/create_recipe/presentation/widgets/steps_widget.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||||
@@ -30,6 +31,8 @@ class RecipeCreateScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
|
class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
|
||||||
late final CreateRecipeCubit createRecipeCubit;
|
late final CreateRecipeCubit createRecipeCubit;
|
||||||
|
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
|
||||||
|
bool _mdToolbarVisible = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -54,6 +57,10 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
|
|||||||
} else {
|
} else {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF0F2F5),
|
backgroundColor: const Color(0xFFF0F2F5),
|
||||||
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||||
|
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null
|
||||||
|
? _mdKey.currentState!.buildToolbar(context)
|
||||||
|
: null,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: widget.recipe != null ? const Text('Редактирование рецепта') : const Text('Добавление рецепта'),
|
title: widget.recipe != null ? const Text('Редактирование рецепта') : const Text('Добавление рецепта'),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
@@ -65,7 +72,11 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
spacing: 16,
|
spacing: 16,
|
||||||
children: [
|
children: [
|
||||||
InfoAboutRecipeWidget(createRecipeCubit: createRecipeCubit),
|
InfoAboutRecipeWidget(
|
||||||
|
createRecipeCubit: createRecipeCubit,
|
||||||
|
markdownEditorKey: _mdKey,
|
||||||
|
onMarkdownToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
|
||||||
|
),
|
||||||
const IngredientsWidget(),
|
const IngredientsWidget(),
|
||||||
StepsWidget(createRecipeCubit: createRecipeCubit),
|
StepsWidget(createRecipeCubit: createRecipeCubit),
|
||||||
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
|
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
|
||||||
import 'package:eatvio_app/app_keys.dart';
|
import 'package:eatvio_app/app_keys.dart';
|
||||||
import 'package:eatvio_app/core/data/selectionPopupModel/selection_popup_model.dart';
|
import 'package:eatvio_app/core/data/selectionPopupModel/selection_popup_model.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -16,8 +17,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
|
|
||||||
class InfoAboutRecipeWidget extends StatefulWidget {
|
class InfoAboutRecipeWidget extends StatefulWidget {
|
||||||
final CreateRecipeCubit createRecipeCubit;
|
final CreateRecipeCubit createRecipeCubit;
|
||||||
|
final GlobalKey<MarkdownEditorState>? markdownEditorKey;
|
||||||
|
final void Function(bool visible)? onMarkdownToolbarVisibilityChanged;
|
||||||
|
|
||||||
const InfoAboutRecipeWidget({super.key, required this.createRecipeCubit});
|
const InfoAboutRecipeWidget({super.key, required this.createRecipeCubit, this.markdownEditorKey, this.onMarkdownToolbarVisibilityChanged});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<InfoAboutRecipeWidget> createState() => _InfoAboutRecipeWidgetState();
|
State<InfoAboutRecipeWidget> createState() => _InfoAboutRecipeWidgetState();
|
||||||
@@ -105,19 +108,24 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CustomTextFormField(
|
KeyedSubtree(
|
||||||
key: AppKeys.recipeCreateScreenDescriptionField,
|
key: AppKeys.recipeCreateScreenDescriptionField,
|
||||||
|
child: MarkdownEditor(
|
||||||
|
key: widget.markdownEditorKey,
|
||||||
hintText: 'Расскажите, почему вы выбрали именно этот рецепт? Опишите, каким будет готовое блюдо и в чём его достоинства.',
|
hintText: 'Расскажите, почему вы выбрали именно этот рецепт? Опишите, каким будет готовое блюдо и в чём его достоинства.',
|
||||||
minLines: 5,
|
minLines: 5,
|
||||||
initialValue: state.entity.description,
|
|
||||||
maxLines: 5,
|
maxLines: 5,
|
||||||
|
initialText: state.entity.description,
|
||||||
hasError: state.descriptionValidationError != null,
|
hasError: state.descriptionValidationError != null,
|
||||||
|
showInlineToolbar: false,
|
||||||
|
onToolbarVisibilityChanged: widget.onMarkdownToolbarVisibilityChanged,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
widget.createRecipeCubit.replaceDescription(value);
|
widget.createRecipeCubit.replaceDescription(value);
|
||||||
if (state.descriptionValidationError != null) {
|
if (state.descriptionValidationError != null) {
|
||||||
widget.createRecipeCubit.validateDescription();
|
widget.createRecipeCubit.validateDescription();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (state.descriptionValidationError != null) ...[
|
if (state.descriptionValidationError != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|||||||
Reference in New Issue
Block a user