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 createState() => MarkdownEditorState(); } class MarkdownEditorState extends State { late final TextEditingController _controller; final FocusNode _focusNode = FocusNode(); bool _isPreview = false; bool _enablePanel = false; late final KeyboardVisibilityController _keyboardVisibilityController; late final StreamSubscription _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 = []; 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 = []; 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)]), ), ], ), ], ); } }