diff --git a/lib/utils/text_direction.dart b/lib/utils/text_direction.dart new file mode 100644 index 0000000..5cfe9c1 --- /dev/null +++ b/lib/utils/text_direction.dart @@ -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; +} diff --git a/lib/views/notes/note_detail_view.dart b/lib/views/notes/note_detail_view.dart index 7c55824..ab33d05 100644 --- a/lib/views/notes/note_detail_view.dart +++ b/lib/views/notes/note_detail_view.dart @@ -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), ), ), ) diff --git a/lib/views/notes/note_form_view.dart b/lib/views/notes/note_form_view.dart index 3924419..bb01d52 100644 --- a/lib/views/notes/note_form_view.dart +++ b/lib/views/notes/note_form_view.dart @@ -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 { 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 { 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 { autofocus: !_isEditing, textCapitalization: TextCapitalization.sentences, textInputAction: TextInputAction.next, + textDirection: _titleDir, ), const SizedBox(height: 16), TextField( @@ -124,6 +138,7 @@ class _NoteFormViewState extends State { textCapitalization: TextCapitalization.sentences, maxLines: 10, minLines: 4, + textDirection: _contentDir, ), const SizedBox(height: 16), Text( diff --git a/lib/widgets/note_tile.dart b/lib/widgets/note_tile.dart index 55a3f94..cdd9426 100644 --- a/lib/widgets/note_tile.dart +++ b/lib/widgets/note_tile.dart @@ -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, ), ), ], diff --git a/pubspec.lock b/pubspec.lock index d84c063..f08a974 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index e724158..fe3b54a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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