| 1 | import 'package:cw_core/utils/text_normalizer.dart'; |
| 2 | import 'package:flutter/material.dart'; |
| 3 | |
| 4 | extension Compare<T> on Comparable<T> { |
| 5 | bool operator <=(T other) => compareTo(other) <= 0; |
| 6 | bool operator >=(T other) => compareTo(other) >= 0; |
| 7 | bool operator <(T other) => compareTo(other) < 0; |
| 8 | bool operator >(T other) => compareTo(other) > 0; |
| 9 | } |
| 10 | |
| 11 | class Annotation implements Comparable<Annotation> { |
| 12 | Annotation({required this.range, required this.style}); |
| 13 | |
| 14 | final TextRange range; |
| 15 | final TextStyle style; |
| 16 | |
| 17 | @override |
| 18 | int compareTo(Annotation other) => range.start.compareTo(other.range.start); |
| 19 | } |
| 20 | |
| 21 | class TextAnnotation implements Comparable<TextAnnotation> { |
| 22 | TextAnnotation({required this.text, required this.style}); |
| 23 | |
| 24 | final TextStyle style; |
| 25 | final String text; |
| 26 | |
| 27 | @override |
| 28 | int compareTo(TextAnnotation other) => text.compareTo(other.text); |
| 29 | } |
| 30 | |
| 31 | class ValidatableAnnotatedEditableText extends EditableText { |
| 32 | ValidatableAnnotatedEditableText({ |
| 33 | Key? key, |
| 34 | required FocusNode focusNode, |
| 35 | required TextEditingController controller, |
| 36 | // required List<String> wordList, |
| 37 | required Color cursorColor, |
| 38 | required Color backgroundCursorColor, |
| 39 | required this.validStyle, |
| 40 | required this.invalidStyle, |
| 41 | required this.words, |
| 42 | this.normalizeSeed = false, |
| 43 | TextStyle textStyle = const TextStyle( |
| 44 | color: Colors.black, |
| 45 | backgroundColor: Colors.transparent, |
| 46 | fontWeight: FontWeight.normal, |
| 47 | fontSize: 16, |
| 48 | ), |
| 49 | TextSelectionControls? selectionControls, |
| 50 | Color? selectionColor, |
| 51 | ValueChanged<String>? onChanged, |
| 52 | ValueChanged<String>? onSubmitted, |
| 53 | }) : super( |
| 54 | maxLines: null, |
| 55 | key: key, |
| 56 | focusNode: focusNode, |
| 57 | controller: controller, |
| 58 | cursorColor: cursorColor, |
| 59 | style: validStyle, |
| 60 | keyboardType: TextInputType.visiblePassword, |
| 61 | autocorrect: false, |
| 62 | autofocus: false, |
| 63 | selectionColor: selectionColor, |
| 64 | selectionControls: selectionControls, |
| 65 | backgroundCursorColor: backgroundCursorColor, |
| 66 | onChanged: onChanged, |
| 67 | onSubmitted: onSubmitted, |
| 68 | toolbarOptions: const ToolbarOptions( |
| 69 | copy: true, |
| 70 | cut: true, |
| 71 | paste: true, |
| 72 | selectAll: true, |
| 73 | ), |
| 74 | enableSuggestions: false, |
| 75 | enableInteractiveSelection: true, |
| 76 | showSelectionHandles: true, |
| 77 | showCursor: true, |
| 78 | ); |
| 79 | |
| 80 | final bool normalizeSeed; |
| 81 | final List<String> words; |
| 82 | final TextStyle validStyle; |
| 83 | final TextStyle invalidStyle; |
| 84 | |
| 85 | @override |
| 86 | ValidatableAnnotatedEditableTextState createState() => ValidatableAnnotatedEditableTextState(); |
| 87 | } |
| 88 | |
| 89 | class ValidatableAnnotatedEditableTextState extends EditableTextState { |
| 90 | @override |
| 91 | ValidatableAnnotatedEditableText get widget => super.widget as ValidatableAnnotatedEditableText; |
| 92 | |
| 93 | List<Annotation> getRanges() { |
| 94 | final result = <Annotation>[]; |
| 95 | // Replace Ideographic Space (U+3000) with a normal space |
| 96 | final text = textEditingValue.text.replaceAll("\u3000", " "); |
| 97 | final source = text |
| 98 | .split(' ') |
| 99 | .map((word) { |
| 100 | final ranges = range(word, text); |
| 101 | final isValid = validate(word); |
| 102 | |
| 103 | return ranges.map( |
| 104 | (range) => Annotation( |
| 105 | style: isValid ? widget.validStyle : widget.invalidStyle, |
| 106 | range: range, |
| 107 | ), |
| 108 | ); |
| 109 | }) |
| 110 | .expand((e) => e) |
| 111 | .toList(); |
| 112 | source.sort(); |
| 113 | Annotation? prev; |
| 114 | |
| 115 | for (var item in source) { |
| 116 | Annotation? annotation; |
| 117 | |
| 118 | if (prev == null) { |
| 119 | annotation = Annotation( |
| 120 | range: TextRange(start: 0, end: item.range.start), |
| 121 | style: Theme.of(context).textTheme.bodyMedium!.copyWith( |
| 122 | color: Theme.of(context).colorScheme.onSurfaceVariant, |
| 123 | backgroundColor: Colors.transparent, |
| 124 | ), |
| 125 | ); |
| 126 | } else if (prev.range.end < item.range.start) { |
| 127 | annotation = Annotation( |
| 128 | range: TextRange(start: prev.range.end, end: item.range.start), |
| 129 | style: Theme.of(context).textTheme.bodyMedium!.copyWith( |
| 130 | color: Theme.of(context).colorScheme.onError, |
| 131 | backgroundColor: Colors.transparent, |
| 132 | ), |
| 133 | ); |
| 134 | } |
| 135 | |
| 136 | if (annotation != null) { |
| 137 | result.add(annotation); |
| 138 | result.add(item); |
| 139 | prev = item; |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | if (result.length > 0 && result.last.range.end < text.length) { |
| 144 | result.add( |
| 145 | Annotation( |
| 146 | range: TextRange(start: result.last.range.end, end: text.length), |
| 147 | style: Theme.of(context).textTheme.bodyMedium!.copyWith( |
| 148 | color: Theme.of(context).colorScheme.onSurfaceVariant, |
| 149 | backgroundColor: Colors.transparent, |
| 150 | ), |
| 151 | ), |
| 152 | ); |
| 153 | } |
| 154 | |
| 155 | return result; |
| 156 | } |
| 157 | |
| 158 | bool validate(String source) => |
| 159 | widget.words.indexOf(widget.normalizeSeed ? normalizeText(source) : source) >= 0; |
| 160 | |
| 161 | List<TextRange> range(String pattern, String source) { |
| 162 | final result = <TextRange>[]; |
| 163 | |
| 164 | if (pattern.isEmpty || source.isEmpty) { |
| 165 | return result; |
| 166 | } |
| 167 | |
| 168 | for (int index = source.indexOf(pattern); |
| 169 | index >= 0; |
| 170 | index = source.indexOf(pattern, index + 1)) { |
| 171 | final start = index; |
| 172 | final end = start + pattern.length; |
| 173 | result.add(TextRange(start: start, end: end)); |
| 174 | } |
| 175 | |
| 176 | return result; |
| 177 | } |
| 178 | |
| 179 | @override |
| 180 | TextSpan buildTextSpan() { |
| 181 | final text = textEditingValue.text; |
| 182 | final ranges = getRanges().toSet(); |
| 183 | |
| 184 | if (ranges.isNotEmpty) { |
| 185 | return TextSpan( |
| 186 | style: widget.style, |
| 187 | children: ranges |
| 188 | .map( |
| 189 | (item) => TextSpan( |
| 190 | style: item.style, |
| 191 | text: item.range.textInside(text), |
| 192 | ), |
| 193 | ) |
| 194 | .toList(), |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | return TextSpan(style: widget.style, text: text); |
| 199 | } |
| 200 | } |