mirror of
https://github.com/chenasraf/pantry-flutter.git
synced 2026-05-17 17:28:03 +00:00
feat: note markdown+rtl support
This commit is contained in:
69
lib/utils/text_direction.dart
Normal file
69
lib/utils/text_direction.dart
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
16
pubspec.lock
16
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user