Files
dungeon-paper-app/lib/app/widgets/atoms/rich_text_field.dart
2024-07-26 16:30:33 +03:00

677 lines
22 KiB
Dart

import 'package:dungeon_paper/app/data/services/intl_service.dart';
import 'package:dungeon_paper/app/widgets/atoms/menu_button.dart';
import 'package:dungeon_paper/core/utils/builder_utils.dart';
import 'package:dungeon_paper/core/utils/markdown_styles.dart';
import 'package:dungeon_paper/i18n.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:intl/intl.dart' as intl;
import 'package:url_launcher/url_launcher.dart';
class RichTextField extends StatelessWidget {
RichTextField({
super.key,
this.controller,
this.label,
this.rich = true,
this.text = '',
this.hintText,
this.initialValue,
this.focusNode,
this.decoration = const InputDecoration(),
this.keyboardType,
this.textCapitalization = TextCapitalization.none,
this.textInputAction,
this.style,
this.strutStyle,
this.textDirection,
this.textAlign = TextAlign.start,
this.textAlignVertical,
this.autofocus = false,
this.readOnly = false,
// this.toolbarOptions,
this.showCursor,
this.obscuringCharacter = '',
this.obscureText = false,
this.autocorrect = true,
this.smartDashesType,
this.smartQuotesType,
this.enableSuggestions = true,
this.maxLengthEnforcement,
this.maxLines = 1,
this.minLines,
this.expands = false,
this.maxLength,
this.onChanged,
this.onTap,
this.onEditingComplete,
this.onFieldSubmitted,
this.onSaved,
this.validator,
this.inputFormatters,
this.enabled,
this.cursorWidth = 2.0,
this.cursorHeight,
this.cursorRadius,
this.cursorColor,
this.keyboardAppearance,
this.scrollPadding = const EdgeInsets.all(20.0),
this.enableInteractiveSelection,
this.selectionControls,
this.buildCounter,
this.scrollPhysics,
this.autofillHints,
this.autovalidateMode,
this.scrollController,
this.restorationId,
this.enableIMEPersonalizedLearning = true,
//
this.customButtons,
});
final String? label;
final String text;
final String? hintText;
final bool rich;
final TextEditingController? controller;
final String? initialValue;
final FocusNode? focusNode;
final InputDecoration? decoration;
final TextInputType? keyboardType;
final TextCapitalization textCapitalization;
final TextInputAction? textInputAction;
final TextStyle? style;
final StrutStyle? strutStyle;
final TextDirection? textDirection;
final TextAlign textAlign;
final TextAlignVertical? textAlignVertical;
final bool autofocus;
final bool readOnly;
// final ToolbarOptions? toolbarOptions;
final bool? showCursor;
final String obscuringCharacter;
final bool obscureText;
final bool autocorrect;
final SmartDashesType? smartDashesType;
final SmartQuotesType? smartQuotesType;
final bool enableSuggestions;
final MaxLengthEnforcement? maxLengthEnforcement;
final int? maxLines;
final int? minLines;
final bool expands;
final int? maxLength;
final ValueChanged<String>? onChanged;
final GestureTapCallback? onTap;
final VoidCallback? onEditingComplete;
final ValueChanged<String>? onFieldSubmitted;
final FormFieldSetter<String>? onSaved;
final FormFieldValidator<String>? validator;
final List<TextInputFormatter>? inputFormatters;
final bool? enabled;
final double cursorWidth;
final double? cursorHeight;
final Radius? cursorRadius;
final Color? cursorColor;
final Brightness? keyboardAppearance;
final EdgeInsets scrollPadding;
final bool? enableInteractiveSelection;
final TextSelectionControls? selectionControls;
final InputCounterWidgetBuilder? buildCounter;
final ScrollPhysics? scrollPhysics;
final Iterable<String>? autofillHints;
final AutovalidateMode? autovalidateMode;
final ScrollController? scrollController;
final String? restorationId;
final bool enableIMEPersonalizedLearning;
final _defaultController = TextEditingController();
TextEditingController get _controller => controller ?? _defaultController;
final List<RichButton>? customButtons;
@override
Widget build(BuildContext context) {
if (!rich) {
return _buildInput(context);
}
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRichControls(context),
_buildInput(context),
],
);
}
Widget _buildInput(BuildContext context) {
final effectiveLabel = label != null ? Text(label!) : null;
return TextFormField(
controller: controller,
initialValue: initialValue,
focusNode: focusNode,
decoration: decoration?.copyWith(
hintText: hintText,
label: effectiveLabel,
) ??
InputDecoration(
filled: true,
hintText: hintText,
label: effectiveLabel,
),
keyboardType: keyboardType,
textCapitalization: textCapitalization,
textInputAction: textInputAction,
style: style,
strutStyle: strutStyle,
textDirection: textDirection,
textAlign: textAlign,
textAlignVertical: textAlignVertical,
autofocus: autofocus,
readOnly: readOnly,
// toolbarOptions: toolbarOptions,
showCursor: showCursor,
obscuringCharacter: obscuringCharacter,
obscureText: obscureText,
autocorrect: autocorrect,
smartDashesType: smartDashesType,
smartQuotesType: smartQuotesType,
enableSuggestions: enableSuggestions,
maxLengthEnforcement: maxLengthEnforcement,
maxLines: maxLines,
minLines: minLines,
expands: expands,
maxLength: maxLength,
onChanged: onChanged,
onTap: onTap,
onEditingComplete: onEditingComplete,
onFieldSubmitted: onFieldSubmitted,
onSaved: onSaved,
validator: validator,
inputFormatters: inputFormatters,
enabled: enabled,
cursorWidth: cursorWidth,
cursorHeight: cursorHeight,
cursorRadius: cursorRadius,
cursorColor: cursorColor,
keyboardAppearance: keyboardAppearance,
scrollPadding: scrollPadding,
enableInteractiveSelection: enableInteractiveSelection,
selectionControls: selectionControls,
buildCounter: buildCounter,
scrollPhysics: scrollPhysics,
autofillHints: autofillHints,
autovalidateMode: autovalidateMode,
scrollController: scrollController,
restorationId: restorationId,
enableIMEPersonalizedLearning: enableIMEPersonalizedLearning,
);
}
SizedBox _buildRichControls(BuildContext context) {
final mdTheme = MarkdownStyles.of(context);
const divider = Padding(
padding: EdgeInsets.symmetric(vertical: 8), child: VerticalDivider());
const thinDivider = Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: VerticalDivider(width: 4),
);
final builder = ItemBuilder.lazyChildren(
children: [
() => _RichButton(
color: Theme.of(context).colorScheme.secondary,
icon: const Icon(Icons.preview_outlined),
tooltip: tr.richText.preview,
onTap: () => _openPreview(context),
),
() => _RichButton(
color: Theme.of(context).colorScheme.secondary,
icon: const Icon(Icons.help),
tooltip: tr.richText.help,
onTap: () => launchUrl(
Uri.parse('https://www.markdownguide.org/basic-syntax')),
),
if (customButtons?.isNotEmpty == true) () => thinDivider,
if (customButtons?.isNotEmpty == true)
...customButtons!.map(
(button) => () => button.buildButton(context, _controller),
),
() => thinDivider,
() => RichButton(
icon: Icons.format_bold,
tooltip: tr.richText.bold,
defaultContent: () => '**bold**',
prefix: () => '**',
behavior: RichButtonTextBehavior.wrap,
selectionStartOffset: 2,
selectionEndOffset: -2,
).buildButton(context, _controller),
() => RichButton(
icon: Icons.format_italic,
tooltip: tr.richText.italic,
defaultContent: () => '*italic*',
prefix: () => '*',
behavior: RichButtonTextBehavior.wrap,
selectionStartOffset: 1,
selectionEndOffset: -1,
).buildButton(context, _controller),
() => RichButton.dropdown(
icon: Icons.format_size,
tooltip: tr.richText.headings,
actions: List.generate(6, (i) => i + 1)
.map(
(i) => RichButtonAction.dropdownItem(
text: Text(
tr.richText.heading(i),
style: {
'h1': mdTheme.h1,
'h2': mdTheme.h2,
'h3': mdTheme.h3,
'h4': mdTheme.h4,
'h5': mdTheme.h5,
'h6': mdTheme.h6,
}['h$i']!,
),
behavior: RichButtonTextBehavior.wrap,
defaultContent: () =>
'\n${List.filled(i, "#").join("")} ${tr.richText.heading(i)}\n',
prefix: () => '\n${List.filled(i, "#").join("")} ',
suffix: () => '\n',
selectionStartOffset: 2 + i,
selectionEndOffset: -1,
),
)
.toList(),
).buildButton(context, _controller),
() => divider,
() => RichButton(
icon: Icons.format_list_bulleted,
tooltip: tr.richText.bulletList,
defaultContent: () => '\n- ',
prefix: () => '\n- ',
behavior: RichButtonTextBehavior.prefix,
).buildButton(context, _controller),
() => RichButton(
icon: Icons.format_list_numbered,
tooltip: tr.richText.numberedList,
defaultContent: () => '\n1. ',
prefix: () => '\n1. ',
behavior: RichButtonTextBehavior.prefix,
).buildButton(context, _controller),
() => RichButton(
icon: Icons.check_box_outline_blank,
tooltip: tr.richText.checkList.unchecked,
defaultContent: () => '\n- [ ] ',
prefix: () => '\n- [ ] ',
selectionStartOffset: 7,
behavior: RichButtonTextBehavior.prefix,
).buildButton(context, _controller),
() => RichButton(
icon: Icons.check_box_outlined,
tooltip: tr.richText.checkList.checked,
defaultContent: () => '\n- [x] ',
prefix: () => '\n- [x] ',
selectionStartOffset: 7,
behavior: RichButtonTextBehavior.prefix,
).buildButton(context, _controller),
() => divider,
() => RichButton(
icon: Icons.link,
tooltip: tr.richText.url,
defaultContent: () => '[text](url)',
prefix: () => '[',
suffix: () => '](url)',
selectionStartOffset: 7,
selectionEndOffset: -1,
behavior: RichButtonTextBehavior.wrap,
).buildButton(context, _controller),
() => RichButton(
icon: Icons.image,
tooltip: tr.richText.imageURL,
defaultContent: () => '![alt](url)',
prefix: () => '![alt][',
suffix: () => ']',
selectionStartOffset: 7,
selectionEndOffset: -1,
behavior: RichButtonTextBehavior.wrap,
).buildButton(context, _controller),
() => RichButton(
icon: Icons.table_chart_outlined,
tooltip: tr.richText.table,
defaultContent: () => '| ${tr.richText.header(1)} '
'| ${tr.richText.header(2)} '
'|\n|---|---|\n'
'| ${tr.richText.cell(1)} '
'| ${tr.richText.cell(2)} |',
prefix: () => '| ${tr.richText.header(' ')}|\n|---|\n| ',
suffix: () => ' |',
selectionStartOffset: 2,
selectionEndOffset: -43,
behavior: RichButtonTextBehavior.wrap,
).buildButton(context, _controller),
() => divider,
() => RichButton(
icon: Icons.calendar_today,
tooltip: tr.richText.date,
prefix: () => _getFormattedDate(DateTime.now()),
defaultContent: () => _getFormattedDate(DateTime.now()),
behavior: RichButtonTextBehavior.prefix,
).buildButton(context, _controller),
() => RichButton(
icon: Icons.access_time_outlined,
tooltip: tr.richText.date,
prefix: () => _getFormattedTime(DateTime.now()),
behavior: RichButtonTextBehavior.prefix,
defaultContent: () => _getFormattedTime(DateTime.now()),
).buildButton(context, _controller),
],
);
return SizedBox(
height: 40,
child: ListView.builder(
scrollDirection: Axis.horizontal,
shrinkWrap: true,
itemBuilder: builder.itemBuilder,
itemCount: builder.itemCount,
),
);
}
void _openPreview(BuildContext context) {
showDialog(
context: context,
builder: (_) => MarkdownPreviewDialog(text: _controller.text),
);
}
String _getFormattedDate(DateTime date) =>
intl.DateFormat(IntlService.dateFormat).format(date);
String _getFormattedTime(DateTime date) =>
intl.DateFormat(IntlService.timeFormat).format(date);
}
class _RichButton extends StatelessWidget {
const _RichButton({
required this.icon,
required this.tooltip,
this.onTap,
this.color,
});
final Widget icon;
final String tooltip;
final void Function()? onTap;
final Color? color;
@override
Widget build(BuildContext context) {
var child = IconTheme.merge(data: IconThemeData(color: color), child: icon);
return SizedBox(
width: 40,
height: 40,
child: Tooltip(
message: tooltip,
child: onTap != null
? InkWell(
splashColor: Theme.of(context).splashColor,
borderRadius: BorderRadius.circular(4),
onTap: onTap,
child: child,
)
: child,
),
);
}
}
class MarkdownPreviewDialog extends StatelessWidget {
const MarkdownPreviewDialog({
super.key,
required this.text,
});
final String text;
@override
Widget build(BuildContext context) {
return OrientationBuilder(builder: (context, orient) {
return AlertDialog(
title: Text(tr.richText.markdownPreview),
content: ConstrainedBox(
constraints: BoxConstraints(
minWidth: MediaQuery.of(context).size.width - 100,
maxWidth: MediaQuery.of(context).size.width - 100,
maxHeight: MediaQuery.of(context).size.height - 100,
minHeight: 10,
),
child: Markdown(
data: text.trim().isNotEmpty ? text : tr.generic.noDescription,
padding: const EdgeInsets.all(0),
onTapLink: (text, href, title) => launchUrl(Uri.parse(href!)),
shrinkWrap: true,
styleSheet: MarkdownStyles.of(context),
),
),
);
});
}
}
class RichButtonAction {
final Widget text;
final String Function() defaultContent;
final String Function() prefix;
final String? Function()? suffix;
final int? selectionStartOffset;
final int? selectionEndOffset;
final RichButtonTextBehavior behavior;
RichButtonAction({
required this.defaultContent,
required this.prefix,
this.suffix,
this.selectionStartOffset,
this.selectionEndOffset,
this.behavior = RichButtonTextBehavior.auto,
}) : text = const Text('');
RichButtonAction.dropdownItem({
required this.text,
required this.defaultContent,
required this.prefix,
this.suffix,
this.selectionStartOffset,
this.selectionEndOffset,
this.behavior = RichButtonTextBehavior.auto,
});
}
enum RichButtonTextBehavior { auto, prefix, suffix, wrap }
class RichButton {
final IconData icon;
final String tooltip;
final List<RichButtonAction> actions;
final Color? color;
final RichButtonTextBehavior behavior;
RichButton.dropdown({
required this.icon,
required this.tooltip,
required this.actions,
this.behavior = RichButtonTextBehavior.auto,
this.color,
});
RichButton({
required this.icon,
required this.tooltip,
this.color,
required String Function() defaultContent,
required String Function() prefix,
this.behavior = RichButtonTextBehavior.auto,
String? Function()? suffix,
int? selectionStartOffset,
int? selectionEndOffset,
}) : actions = [
RichButtonAction(
defaultContent: defaultContent,
prefix: prefix,
suffix: suffix,
selectionStartOffset: selectionStartOffset,
selectionEndOffset: selectionEndOffset,
)
];
bool get isSingle => actions.length == 1;
RichButtonAction get singleAction => actions.first;
Widget buildButton(BuildContext context, TextEditingController controller) {
if (isSingle) {
return _RichButton(
icon: Icon(icon),
tooltip: tooltip,
onTap: _wrapOrAppendCb(
controller,
singleAction.defaultContent(),
singleAction.prefix(),
singleAction.suffix?.call(),
singleAction.selectionStartOffset,
singleAction.selectionEndOffset,
),
);
}
return MenuButton(
items: actions.map(
(action) => MenuEntry(
label: action.text,
value: action.defaultContent,
onSelect: _wrapOrAppendCb(
controller,
action.defaultContent(),
action.prefix(),
action.suffix?.call(),
action.selectionStartOffset,
action.selectionEndOffset,
),
),
),
child: _RichButton(
icon: Icon(icon),
tooltip: tooltip,
),
);
}
void _wrapCursorWith(TextEditingController controller, String prefix,
[String? suffix]) {
if (controller.selection.isValid) {
// has selection - wrap current cursor positions
final selection = controller.selection.copyWith(
baseOffset: controller.selection.baseOffset + prefix.length,
extentOffset: controller.selection.extentOffset + prefix.length,
);
controller.text = [
if (controller.selection.start > 0)
controller.text.substring(0, controller.selection.start),
prefix,
controller.text
.substring(controller.selection.start, controller.selection.end),
suffix ?? prefix,
if (controller.selection.end < controller.text.length)
controller.text
.substring(controller.selection.end, controller.text.length),
].join('');
try {
controller.selection = selection;
} catch (e) {
// don't crash when selection is invalid
}
}
}
void _insertAtCursor(TextEditingController controller, String text,
[int? selectionStartOffset, int? selectionEndOffset]) {
if (controller.selection.isValid) {
// has cursor - append at cursor position
final selection = controller.selection.copyWith(
baseOffset:
controller.selection.baseOffset + (selectionStartOffset ?? 0),
extentOffset: controller.selection.extentOffset +
text.length +
(selectionEndOffset ?? 0),
);
controller.text = [
controller.text.substring(0, controller.selection.start),
text,
controller.text
.substring(controller.selection.start, controller.text.length),
].join('');
try {
controller.selection = selection;
} catch (e) {
// don't crash when selection is invalid
}
} else {
// no cursor - append to end of text
final selection = controller.selection.copyWith(
baseOffset: selectionStartOffset ?? 0,
extentOffset: text.length + (selectionEndOffset ?? 0),
);
controller.text += text;
try {
controller.selection = selection;
} catch (e) {
// don't crash when selection is invalid
}
}
}
void Function() _wrapOrAppendCb(
TextEditingController controller, String defaultContent, String prefix,
[String? suffix, int? selectionStartOffset, int? selectionEndOffset]) {
final isAuto = behavior == RichButtonTextBehavior.auto;
final isWrap = behavior == RichButtonTextBehavior.wrap;
final hasPrefix = isWrap ||
(prefix.isNotEmpty && isAuto) ||
behavior == RichButtonTextBehavior.prefix;
final hasSuffix = isWrap ||
(suffix?.isNotEmpty == true && isAuto) ||
behavior == RichButtonTextBehavior.suffix;
suffix ??= prefix;
// final shouldWrap = (isAuto && !controller.selection.isCollapsed)
// if text is empty, or selection starts directly after newline - remove prefix newlines
// if (controller.text.trim().isEmpty ||
// (controller.selection.isValid &&
// controller.text.substring(
// max(controller.selection.start - 1, 0), controller.selection.start) ==
// '\n')) {
// final originalText = text;
// text = text.trimLeft();
// prefix = prefix.trimLeft();
// // _suffix = _suffix.trimLeft();
// if (originalText.startsWith('\n') && selectionStartOffset != null) {
// selectionStartOffset -= 1;
// }
// if (originalText.endsWith('\n') && selectionEndOffset != null) {
// selectionEndOffset += 1;
// }
// }
return () {
if (!controller.selection.isCollapsed) {
_wrapCursorWith(
controller, hasPrefix ? prefix : '', hasSuffix ? suffix : '');
} else {
_insertAtCursor(controller, defaultContent, selectionStartOffset,
selectionEndOffset);
}
};
}
}