feat: note markdown+rtl support

This commit is contained in:
2026-04-11 01:09:44 +03:00
parent 46dd3f21d6
commit 0688294605
6 changed files with 238 additions and 19 deletions

View File

@@ -0,0 +1,69 @@
import 'package:flutter/widgets.dart';
/// Returns the text direction of [text] using the same logic as HTML
/// `dir="auto"`: find the first strongly-typed character and return its
/// directionality. Neutral characters (punctuation, numbers, whitespace,
/// emoji, symbols) are skipped.
///
/// Defaults to LTR for empty strings or strings with no strong characters.
TextDirection detectTextDirection(String? text) {
if (text == null) return TextDirection.ltr;
final trimmed = text.trim();
if (trimmed.isEmpty) return TextDirection.ltr;
for (final rune in trimmed.runes) {
final dir = _runeDirection(rune);
if (dir != null) return dir;
}
return TextDirection.ltr;
}
/// Returns the strong directionality of a rune, or null if it's neutral/weak.
/// Based on Unicode Bidirectional Character Type — we only care about the
/// strong classes: L (Left-to-Right), R (Right-to-Left), AL (Arabic Letter).
TextDirection? _runeDirection(int rune) {
// Right-to-Left (R) — Hebrew, NKo, Samaritan, Mandaic
if ((rune >= 0x0590 && rune <= 0x05FF) || // Hebrew
(rune >= 0x07C0 && rune <= 0x07FF) || // NKo
(rune >= 0x0800 && rune <= 0x083F) || // Samaritan
(rune >= 0x0840 && rune <= 0x085F) || // Mandaic
(rune >= 0xFB1D && rune <= 0xFB4F)) {
// Hebrew presentation forms
return TextDirection.rtl;
}
// Right-to-Left Arabic (AL) — Arabic, Syriac, Thaana, Arabic Supplement/Extended
if ((rune >= 0x0600 && rune <= 0x06FF) || // Arabic
(rune >= 0x0700 && rune <= 0x074F) || // Syriac
(rune >= 0x0750 && rune <= 0x077F) || // Arabic Supplement
(rune >= 0x0780 && rune <= 0x07BF) || // Thaana
(rune >= 0x08A0 && rune <= 0x08FF) || // Arabic Extended-A
(rune >= 0xFB50 && rune <= 0xFDFF) || // Arabic presentation forms A
(rune >= 0xFE70 && rune <= 0xFEFF)) {
// Arabic presentation forms B
return TextDirection.rtl;
}
// Left-to-Right (L) — Latin, Greek, Cyrillic, Armenian, most scripts
if ((rune >= 0x0041 && rune <= 0x005A) || // A-Z
(rune >= 0x0061 && rune <= 0x007A) || // a-z
(rune >= 0x00C0 && rune <= 0x00FF) || // Latin-1 Supplement letters
(rune >= 0x0100 && rune <= 0x024F) || // Latin Extended A/B
(rune >= 0x0370 && rune <= 0x03FF) || // Greek
(rune >= 0x0400 && rune <= 0x04FF) || // Cyrillic
(rune >= 0x0500 && rune <= 0x052F) || // Cyrillic Supplement
(rune >= 0x0530 && rune <= 0x058F) || // Armenian
(rune >= 0x1E00 && rune <= 0x1EFF) || // Latin Extended Additional
(rune >= 0x2C60 && rune <= 0x2C7F) || // Latin Extended-C
(rune >= 0xA720 && rune <= 0xA7FF) || // Latin Extended-D
(rune >= 0x3040 && rune <= 0x309F) || // Hiragana
(rune >= 0x30A0 && rune <= 0x30FF) || // Katakana
(rune >= 0x4E00 && rune <= 0x9FFF) || // CJK Unified Ideographs
(rune >= 0xAC00 && rune <= 0xD7AF)) {
// Hangul Syllables
return TextDirection.ltr;
}
// Neutral / weak — skip (punctuation, numbers, whitespace, symbols, emoji)
return null;
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:pantry/models/note.dart';
import 'package:pantry/utils/text_direction.dart';
import 'package:pantry/views/notes/note_form_view.dart';
import 'package:pantry/views/notes/notes_controller.dart';
@@ -20,12 +22,24 @@ class NoteDetailView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final titleDir = detectTextDirection(note.title);
final contentDir = detectTextDirection(note.content);
return Scaffold(
backgroundColor: bgColor,
appBar: AppBar(
backgroundColor: bgColor,
foregroundColor: textColor,
title: Text(note.title),
title: Align(
alignment: titleDir == TextDirection.rtl
? Alignment.centerRight
: Alignment.centerLeft,
child: Directionality(
textDirection: titleDir,
child: Text(note.title),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
@@ -38,12 +52,65 @@ class NoteDetailView extends StatelessWidget {
child: const Icon(Icons.edit),
),
body: note.content != null && note.content!.isNotEmpty
? SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(
note.content!,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: textColor.withAlpha(220),
? Directionality(
textDirection: contentDir,
child: Markdown(
data: note.content!,
padding: const EdgeInsets.all(16),
selectable: true,
styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith(
p: theme.textTheme.bodyLarge?.copyWith(
color: textColor.withAlpha(230),
),
h1: theme.textTheme.headlineMedium?.copyWith(
color: textColor,
fontWeight: FontWeight.bold,
),
h2: theme.textTheme.headlineSmall?.copyWith(
color: textColor,
fontWeight: FontWeight.bold,
),
h3: theme.textTheme.titleLarge?.copyWith(
color: textColor,
fontWeight: FontWeight.bold,
),
h4: theme.textTheme.titleMedium?.copyWith(
color: textColor,
fontWeight: FontWeight.bold,
),
listBullet: theme.textTheme.bodyLarge?.copyWith(
color: textColor.withAlpha(230),
),
code: TextStyle(
color: textColor,
backgroundColor: textColor.withAlpha(30),
fontFamily: 'monospace',
),
codeblockDecoration: BoxDecoration(
color: textColor.withAlpha(30),
borderRadius: BorderRadius.circular(6),
),
blockquote: theme.textTheme.bodyLarge?.copyWith(
color: textColor.withAlpha(180),
fontStyle: FontStyle.italic,
),
blockquoteDecoration: BoxDecoration(
border: Border(
left: BorderSide(
color: textColor.withAlpha(100),
width: 4,
),
),
),
a: TextStyle(
color: textColor,
decoration: TextDecoration.underline,
),
strong: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
),
em: TextStyle(color: textColor, fontStyle: FontStyle.italic),
),
),
)

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:pantry/i18n.dart';
import 'package:pantry/models/note.dart';
import 'package:pantry/utils/text_direction.dart';
import 'package:pantry/views/notes/notes_controller.dart';
const _colorOptions = [
@@ -32,6 +33,8 @@ class _NoteFormViewState extends State<NoteFormView> {
late final TextEditingController _contentController;
String? _selectedColor;
bool _saving = false;
TextDirection _titleDir = TextDirection.ltr;
TextDirection _contentDir = TextDirection.ltr;
bool get _isEditing => widget.note != null;
@@ -43,6 +46,16 @@ class _NoteFormViewState extends State<NoteFormView> {
text: widget.note?.content ?? '',
);
_selectedColor = widget.note?.color;
_titleDir = detectTextDirection(widget.note?.title);
_contentDir = detectTextDirection(widget.note?.content);
_titleController.addListener(() {
final dir = detectTextDirection(_titleController.text);
if (dir != _titleDir) setState(() => _titleDir = dir);
});
_contentController.addListener(() {
final dir = detectTextDirection(_contentController.text);
if (dir != _contentDir) setState(() => _contentDir = dir);
});
}
@override
@@ -112,6 +125,7 @@ class _NoteFormViewState extends State<NoteFormView> {
autofocus: !_isEditing,
textCapitalization: TextCapitalization.sentences,
textInputAction: TextInputAction.next,
textDirection: _titleDir,
),
const SizedBox(height: 16),
TextField(
@@ -124,6 +138,7 @@ class _NoteFormViewState extends State<NoteFormView> {
textCapitalization: TextCapitalization.sentences,
maxLines: 10,
minLines: 4,
textDirection: _contentDir,
),
const SizedBox(height: 16),
Text(

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:pantry/i18n.dart';
import 'package:pantry/models/note.dart';
import 'package:pantry/utils/text_direction.dart';
import 'package:pantry/views/notes/note_detail_view.dart';
import 'package:pantry/views/notes/note_form_view.dart';
import 'package:pantry/views/notes/notes_controller.dart';
@@ -69,6 +71,9 @@ class NoteTile extends StatelessWidget {
}
Widget _buildCard(ThemeData theme, Color bgColor, Color textColor) {
final titleDir = detectTextDirection(note.title);
final contentDir = detectTextDirection(note.content);
return Container(
decoration: BoxDecoration(
color: bgColor,
@@ -81,14 +86,17 @@ class NoteTile extends StatelessWidget {
Row(
children: [
Expanded(
child: Text(
note.title,
style: theme.textTheme.titleSmall?.copyWith(
color: textColor,
fontWeight: FontWeight.bold,
child: Directionality(
textDirection: titleDir,
child: Text(
note.title,
style: theme.textTheme.titleSmall?.copyWith(
color: textColor,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
_NoteMenuButton(
@@ -101,12 +109,55 @@ class NoteTile extends StatelessWidget {
if (note.content != null && note.content!.isNotEmpty) ...[
const SizedBox(height: 6),
Expanded(
child: Text(
note.content!,
style: theme.textTheme.bodySmall?.copyWith(
color: textColor.withAlpha(200),
child: Directionality(
textDirection: contentDir,
child: MarkdownBody(
data: note.content!,
shrinkWrap: true,
fitContent: false,
styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith(
p: theme.textTheme.bodySmall?.copyWith(
color: textColor.withAlpha(200),
),
h1: theme.textTheme.titleMedium?.copyWith(
color: textColor,
fontWeight: FontWeight.bold,
),
h2: theme.textTheme.titleSmall?.copyWith(
color: textColor,
fontWeight: FontWeight.bold,
),
h3: theme.textTheme.bodyMedium?.copyWith(
color: textColor,
fontWeight: FontWeight.bold,
),
listBullet: theme.textTheme.bodySmall?.copyWith(
color: textColor.withAlpha(200),
),
strong: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
),
em: TextStyle(
color: textColor.withAlpha(200),
fontStyle: FontStyle.italic,
),
code: TextStyle(
color: textColor,
backgroundColor: textColor.withAlpha(30),
fontFamily: 'monospace',
fontSize: 12,
),
blockquote: theme.textTheme.bodySmall?.copyWith(
color: textColor.withAlpha(180),
fontStyle: FontStyle.italic,
),
a: TextStyle(
color: textColor,
decoration: TextDecoration.underline,
),
),
),
overflow: TextOverflow.fade,
),
),
],

View File

@@ -350,6 +350,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_markdown_plus:
dependency: "direct main"
description:
name: flutter_markdown_plus
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
flutter_native_splash:
dependency: "direct dev"
description:
@@ -665,6 +673,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
url: "https://pub.dev"
source: hosted
version: "7.3.1"
matcher:
dependency: transitive
description:

View File

@@ -43,6 +43,7 @@ dependencies:
path_provider: ^2.1.5
intl: ^0.20.2
cached_network_image: ^3.4.1
flutter_markdown_plus: ^1.0.3
image_picker: ^1.1.2
i18n:
git: https://github.com/chenasraf/i18n