feat: add built-in markdown editor with search jump and typing helpers

This commit is contained in:
randogoth 2026-10-13 10:47:34 +03:00
parent db27f64e42
commit ed2de5e67f
16 changed files with 1146 additions and 561 deletions

View file

@ -1,72 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
class WrappedCodeField extends StatefulWidget {
final CodeController controller;
final bool expands;
final bool wrap;
final TextStyle? textStyle;
final Color? cursorColor;
final EdgeInsets padding;
final bool readOnly;
final FocusNode? focusNode;
const WrappedCodeField({
super.key,
required this.controller,
this.expands = false,
this.wrap = true, // ✅ Enable wrapping by default
this.textStyle,
this.cursorColor,
this.padding = EdgeInsets.zero,
this.readOnly = false,
this.focusNode,
});
@override
_WrappedCodeFieldState createState() => _WrappedCodeFieldState();
}
class _WrappedCodeFieldState extends State<WrappedCodeField> {
late FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.attach(context, onKeyEvent: _onKeyEvent);
}
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
return widget.controller.onKey(event);
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: widget.padding,
child: TextField(
focusNode: _focusNode,
controller: widget.controller,
expands: widget.expands,
minLines: widget.expands ? null : 1,
maxLines: widget.expands ? null : null, // Allows text to grow dynamically
scrollPhysics: widget.wrap ? const NeverScrollableScrollPhysics() : null,
style: widget.textStyle ?? const TextStyle(fontSize: 16),
cursorColor: widget.cursorColor ?? Colors.white,
decoration: const InputDecoration(
isCollapsed: true,
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(vertical: 16),
),
readOnly: widget.readOnly,
),
);
}
}

377
lib/markdown_editing.dart Normal file
View file

@ -0,0 +1,377 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A highlighted range of text, named by a key of the theme maps in `themes/`.
class HighlightSegment {
final int start;
final int end;
final String className;
const HighlightSegment(this.start, this.end, this.className);
}
final _fence = RegExp(r'^\s*(```|~~~)');
final _heading = RegExp(r'^(#{1,6})\s');
final _rule = RegExp(r'^ {0,3}([-*_])( *\1){2,} *$');
final _quote = RegExp(r'^\s*>');
final _listMarker = RegExp(r'^\s*([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?');
final _inline = RegExp(
r'(?<code>`[^`\n]+`)'
r'|(?<bold>\*\*[^*\n]+?\*\*|__[^_\n]+?__)'
r'|(?<italic>\*[^*\s][^*\n]*?\*|(?<!\w)_[^_\s][^_\n]*?_(?!\w))'
r'|(?<strike>~~[^~\n]+?~~)'
r'|(?<link>\[[^\]\n]+\]\([^)\n]*\))'
r'|(?<tag>(?<=^|\s)[#@+][A-Za-z0-9_]+)'
r'|(?<command>(?<=^|\s):[A-Za-z0-9_]+)'
r'|(?<path>(?<=^|\s)/[A-Za-z0-9_/]+)',
);
const _tagClasses = {'#': 'tag-hash', '+': 'tag-plus', '@': 'tag-at'};
/// Splits [text] into sorted, non-overlapping highlight segments.
List<HighlightSegment> highlightMarkdown(String text) {
final segments = <HighlightSegment>[];
var inFence = false;
var lineStart = 0;
while (lineStart <= text.length) {
var lineEnd = text.indexOf('\n', lineStart);
if (lineEnd == -1) lineEnd = text.length;
final line = text.substring(lineStart, lineEnd);
void whole(String className) {
if (line.isNotEmpty) {
segments.add(HighlightSegment(lineStart, lineEnd, className));
}
}
if (_fence.hasMatch(line)) {
inFence = !inFence;
whole('code');
} else if (inFence) {
whole('code');
} else if (_heading.firstMatch(line) case final m?) {
whole('header-h${m.group(1)!.length}');
} else if (_rule.hasMatch(line)) {
whole('horizontal-rule');
} else if (_quote.hasMatch(line)) {
whole('blockquote');
} else {
var inlineStart = 0;
final marker = _listMarker.firstMatch(line);
if (marker != null) {
final className = marker.group(2) != null ? 'task-list' : 'list';
segments.add(HighlightSegment(lineStart, lineStart + marker.end, className));
inlineStart = marker.end;
}
_highlightInline(line, inlineStart, lineStart, segments);
}
lineStart = lineEnd + 1;
}
return segments;
}
void _highlightInline(
String line, int from, int offset, List<HighlightSegment> segments) {
for (final m in _inline.allMatches(line, from)) {
final start = offset + m.start;
final end = offset + m.end;
if (m.namedGroup('link') case final link?) {
final textEnd = start + link.indexOf('](') + 1;
segments
..add(HighlightSegment(start, textEnd, 'link-text'))
..add(HighlightSegment(textEnd, end, 'link-url'));
} else if (m.namedGroup('tag') case final tag?) {
segments.add(HighlightSegment(start, end, _tagClasses[tag[0]]!));
} else {
final className = m.namedGroup('code') != null
? 'code'
: m.namedGroup('bold') != null
? 'bold'
: m.namedGroup('italic') != null
? 'italic'
: m.namedGroup('strike') != null
? 'strikethrough'
: m.namedGroup('command') != null
? 'command'
: 'link-url';
segments.add(HighlightSegment(start, end, className));
}
}
}
const _matchColor = Color(0x40FF9800);
const _currentMatchColor = Color(0xA0FF9800);
/// Text controller that renders markdown highlighting and search matches.
class MarkdownController extends TextEditingController {
MarkdownController({super.text, required this.theme}) {
_updateMatches();
}
final Map<String, TextStyle> theme;
List<String> _terms = const [];
List<TextRange> _matches = const [];
int _currentMatch = 0;
String? _segmentsText;
List<HighlightSegment> _segments = const [];
List<TextRange> get matches => _matches;
int get currentMatch => _currentMatch;
/// Finds case-insensitive occurrences of [terms], kept up to date on edits.
void search(List<String> terms) {
_terms = terms.where((t) => t.isNotEmpty).toList();
_currentMatch = 0;
_updateMatches();
notifyListeners();
}
void selectMatch(int index) {
_currentMatch = index;
notifyListeners();
}
@override
set value(TextEditingValue newValue) {
final textChanged = newValue.text != text;
super.value = newValue;
if (textChanged && _terms.isNotEmpty) {
_updateMatches();
notifyListeners();
}
}
void _updateMatches() {
final found = <TextRange>[
for (final term in _terms)
for (final m in RegExp(RegExp.escape(term), caseSensitive: false)
.allMatches(text))
TextRange(start: m.start, end: m.end),
]..sort((a, b) => a.start.compareTo(b.start));
// Overlapping terms (e.g. a phrase containing a tag) would split spans oddly.
_matches = [];
for (final range in found) {
if (_matches.isEmpty || range.start >= _matches.last.end) {
_matches.add(range);
}
}
if (_currentMatch >= _matches.length) _currentMatch = 0;
}
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
if (_segmentsText != text) {
_segments = highlightMarkdown(text);
_segmentsText = text;
}
final composing =
withComposing && value.isComposingRangeValid ? value.composing : null;
final breaks = <int>{0, text.length};
for (final s in _segments) {
breaks..add(s.start)..add(s.end);
}
for (final m in _matches) {
breaks..add(m.start)..add(m.end);
}
if (composing != null) breaks..add(composing.start)..add(composing.end);
final points = breaks.toList()..sort();
final base = (style ?? const TextStyle())
.merge(TextStyle(color: theme['root']?.color));
final children = <TextSpan>[];
var seg = 0;
var match = 0;
for (var i = 0; i + 1 < points.length; i++) {
final start = points[i];
final end = points[i + 1];
while (seg < _segments.length && _segments[seg].end <= start) {
seg++;
}
while (match < _matches.length && _matches[match].end <= start) {
match++;
}
var spanStyle = base;
if (seg < _segments.length && _segments[seg].start <= start) {
spanStyle = spanStyle.merge(theme[_segments[seg].className]);
}
if (match < _matches.length && _matches[match].start <= start) {
spanStyle = spanStyle.copyWith(
backgroundColor:
match == _currentMatch ? _currentMatchColor : _matchColor,
);
}
if (composing != null &&
composing.start <= start &&
end <= composing.end) {
spanStyle = spanStyle.copyWith(decoration: TextDecoration.underline);
}
children.add(TextSpan(text: text.substring(start, end), style: spanStyle));
}
return TextSpan(style: base, children: children);
}
}
const _wrapPairs = {
'*': '*',
'_': '_',
'`': '`',
'~': '~',
'[': ']',
'(': ')',
};
final _listPrefix = RegExp(r'^([ \t]*)([-*+]|\d+[.)])( +)(\[[ xX]\] +)?');
final _quotePrefix = RegExp(r'^([ \t]*)((?:> ?)+)');
final _indent = RegExp(r'^[ \t]*');
/// Markdown typing helpers that work from the edit diff, so they behave the
/// same for hardware keys and soft keyboards.
class MarkdownInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue newValue) {
return _wrapSelection(oldValue, newValue) ??
_continueLine(oldValue, newValue) ??
newValue;
}
/// Typing a markup character over a selection wraps it instead of replacing it.
TextEditingValue? _wrapSelection(
TextEditingValue oldValue, TextEditingValue newValue) {
final sel = oldValue.selection;
if (!sel.isValid || sel.isCollapsed) return null;
final s = sel.start;
final e = sel.end;
if (newValue.text.length != oldValue.text.length - (e - s) + 1) return null;
final typed = newValue.text.substring(s, s + 1);
final closer = _wrapPairs[typed];
if (closer == null || newValue.text != oldValue.text.replaceRange(s, e, typed)) {
return null;
}
final inner = oldValue.text.substring(s, e);
return TextEditingValue(
text: oldValue.text.replaceRange(s, e, '$typed$inner$closer'),
selection: TextSelection(baseOffset: s + 1, extentOffset: e + 1),
);
}
/// A new line keeps the previous line's indentation and list or quote marker.
TextEditingValue? _continueLine(
TextEditingValue oldValue, TextEditingValue newValue) {
final sel = oldValue.selection;
if (!sel.isValid) return null;
final s = sel.start;
final e = sel.end;
final old = oldValue.text;
if (newValue.text != old.replaceRange(s, e, '\n')) return null;
final lineStart = s == 0 ? 0 : old.lastIndexOf('\n', s - 1) + 1;
final before = old.substring(lineStart, s);
final lineEnd = old.indexOf('\n', e);
final after = old.substring(e, lineEnd == -1 ? old.length : lineEnd);
String prefix;
String? marker;
if (_insideFence(old, lineStart)) {
prefix = _indent.stringMatch(before)!;
} else if (_listPrefix.firstMatch(before) case final m?) {
marker = m.group(0);
final bullet = m.group(2)!;
final number = int.tryParse(bullet.substring(0, bullet.length - 1));
final next = number != null
? '${number + 1}${bullet[bullet.length - 1]}'
: bullet;
prefix = '${m.group(1)}$next${m.group(3)}${m.group(4) != null ? '[ ] ' : ''}';
} else if (_quotePrefix.firstMatch(before) case final m?) {
marker = m.group(0);
prefix = marker!;
} else {
prefix = _indent.stringMatch(before)!;
}
// Enter on an empty item ends the list instead of adding another one.
if (marker != null && before == marker && after.trim().isEmpty) {
return TextEditingValue(
text: old.replaceRange(lineStart, e, ''),
selection: TextSelection.collapsed(offset: lineStart),
);
}
final insert = '\n$prefix';
return TextEditingValue(
text: old.replaceRange(s, e, insert),
selection: TextSelection.collapsed(offset: s + insert.length),
);
}
bool _insideFence(String text, int lineStart) {
var inside = false;
for (final line in text.substring(0, lineStart).split('\n')) {
if (_fence.hasMatch(line)) inside = !inside;
}
return inside;
}
}
/// Indents or outdents every line touched by the selection by two spaces.
TextEditingValue indentLines(TextEditingValue value, {bool outdent = false}) {
final sel = value.selection;
if (!sel.isValid) return value;
final text = value.text;
final firstLine = sel.start == 0 ? 0 : text.lastIndexOf('\n', sel.start - 1) + 1;
// A selection ending at the very start of a line doesn't include that line.
final lastPos =
sel.end > sel.start && text[sel.end - 1] == '\n' ? sel.end - 1 : sel.end;
final buffer = StringBuffer(text.substring(0, firstLine));
var startDelta = 0;
var totalDelta = 0;
var lineStart = firstLine;
while (true) {
var lineEnd = text.indexOf('\n', lineStart);
if (lineEnd == -1) lineEnd = text.length;
var line = text.substring(lineStart, lineEnd);
var delta = 2;
if (outdent) {
final remove = line.startsWith('\t')
? 1
: line.length - line.replaceFirst(RegExp(r'^ {1,2}'), '').length;
line = line.substring(remove);
delta = -remove;
} else {
line = ' $line';
}
if (lineStart == firstLine) startDelta = delta;
totalDelta += delta;
buffer.write(line);
if (lineEnd >= lastPos || lineEnd == text.length) {
buffer.write(text.substring(lineEnd));
break;
}
buffer.write('\n');
lineStart = lineEnd + 1;
}
int shift(int offset, int delta) =>
(offset + delta).clamp(firstLine, buffer.length);
return TextEditingValue(
text: buffer.toString(),
selection: TextSelection(
baseOffset: shift(sel.baseOffset, sel.baseOffset == sel.start ? startDelta : totalDelta),
extentOffset: shift(sel.extentOffset, sel.extentOffset == sel.start ? startDelta : totalDelta),
),
);
}

81
lib/markdown_view.dart Normal file
View file

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'markdown_editing.dart';
/// Highlighted markdown source that is either read-only or editable.
class MarkdownView extends StatefulWidget {
final MarkdownController controller;
final bool readOnly;
final double fontSize;
final FocusNode? focusNode;
const MarkdownView({
super.key,
required this.controller,
this.readOnly = true,
this.fontSize = 16,
this.focusNode,
});
@override
MarkdownViewState createState() => MarkdownViewState();
}
class MarkdownViewState extends State<MarkdownView> {
final _fieldKey = GlobalKey();
final _formatters = [MarkdownInputFormatter()];
/// Scrolls [offset] into view without focusing the field, so read mode
/// doesn't bring up the keyboard.
void revealOffset(int offset) {
final editable = _findEditable(_fieldKey.currentContext?.findRenderObject());
if (editable == null) return;
final caret = editable.getLocalRectForCaret(TextPosition(offset: offset));
editable.showOnScreen(
rect: Rect.fromLTRB(
caret.left, caret.top - 80, caret.right, caret.bottom + 80),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
RenderEditable? _findEditable(RenderObject? node) {
if (node is RenderEditable) return node;
RenderEditable? found;
node?.visitChildren((child) => found ??= _findEditable(child));
return found;
}
void _indent({required bool outdent}) {
final controller = widget.controller;
controller.value = indentLines(controller.value, outdent: outdent);
}
@override
Widget build(BuildContext context) {
// Tab would otherwise move focus out of the editor on desktop.
return CallbackShortcuts(
bindings: widget.readOnly
? const {}
: {
const SingleActivator(LogicalKeyboardKey.tab): () =>
_indent(outdent: false),
const SingleActivator(LogicalKeyboardKey.tab, shift: true): () =>
_indent(outdent: true),
},
child: TextField(
key: _fieldKey,
controller: widget.controller,
focusNode: widget.focusNode,
readOnly: widget.readOnly,
showCursor: !widget.readOnly,
expands: true,
maxLines: null,
decoration: null,
inputFormatters: widget.readOnly ? null : _formatters,
style: TextStyle(fontSize: widget.fontSize),
),
);
}
}

View file

@ -1,33 +1,38 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_markdown_latex/flutter_markdown_latex.dart';
import 'package:flutter_svg/svg.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:share_plus/share_plus.dart';
import 'themes/day.dart';
import 'themes/night.dart';
import 'themes/markdown.dart';
import 'codefield.dart';
import 'markdown_editing.dart';
import 'markdown_view.dart';
class NoteEditorScreen extends StatefulWidget {
final File note;
final String? notesDirectoryPath; // ✅ Add this
final bool isDarkMode;
final List<String> searchTerms;
NoteEditorScreen(
{required this.note,
required this.isDarkMode,
required this.notesDirectoryPath});
required this.notesDirectoryPath,
this.searchTerms = const []});
@override
_NoteEditorScreenState createState() => _NoteEditorScreenState();
}
class _NoteEditorScreenState extends State<NoteEditorScreen> {
late CodeController _controller;
late MarkdownController _controller;
late String _initialContent;
final _viewKey = GlobalKey<MarkdownViewState>();
final _editorFocus = FocusNode();
bool _isEditing = false;
bool _isPreviewMode = false;
bool _isLoading = true;
bool _autoSaveEnabled = false; // Controlled by user toggle
@ -64,13 +69,35 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
}
setState(() {
_controller = CodeController(
_controller = MarkdownController(
text: _initialContent,
language: markdown,
);
theme: widget.isDarkMode ? nightTheme : dayTheme,
)..search(widget.searchTerms);
_controller.addListener(_onTextChanged);
_isLoading = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) => _showMatch(0));
}
void _showMatch(int index) {
final matches = _controller.matches;
if (matches.isEmpty) return;
final current = index % matches.length;
_controller.selectMatch(current);
_viewKey.currentState?.revealOffset(matches[current].start);
}
void _toggleEditing() {
setState(() {
_isEditing = !_isEditing;
_isPreviewMode = false;
});
if (_isEditing) {
WidgetsBinding.instance
.addPostFrameCallback((_) => _editorFocus.requestFocus());
} else {
_editorFocus.unfocus();
}
}
void _onTextChanged() {
@ -92,6 +119,12 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
}
}
void _shareNote() {
if (_controller.text.isNotEmpty) {
Share.share(_controller.text, subject: widget.note.uri.pathSegments.last);
}
}
Future<bool> _onWillPop() async {
if (_controller.text == _initialContent) return true; // No changes, exit
@ -132,6 +165,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
void dispose() {
_controller.removeListener(_onTextChanged);
_controller.dispose();
_editorFocus.dispose();
super.dispose();
}
@ -151,6 +185,11 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
},
),
actions: [
IconButton(
icon: Icon(
_isEditing ? Icons.edit_off_outlined : Icons.edit_outlined),
onPressed: _isLoading ? null : _toggleEditing,
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: PopupMenuButton<String>(
@ -161,6 +200,9 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
),
onSelected: (String value) {
switch (value) {
case 'Share': // ✅ New case for sharing
_shareNote();
break;
case 'Save':
_saveNote();
break;
@ -175,6 +217,14 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
}
},
itemBuilder: (BuildContext context) => [
PopupMenuItem<String>( // ✅ Share button
value: 'Share',
child: ListTile(
leading: Icon(Icons.share_outlined),
title: Text("Share"),
),
),
PopupMenuDivider(),
PopupMenuItem<String>(
value: 'Save',
child: ListTile(
@ -193,6 +243,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
: "Enable Autosave"),
),
),
PopupMenuDivider(),
PopupMenuItem<String>(
enabled: false,
child: Column(
@ -219,6 +270,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
],
),
),
PopupMenuDivider(),
PopupMenuItem<String>(
value: 'Toggle Preview',
child: ListTile(
@ -288,19 +340,38 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
)
: Padding(
padding: const EdgeInsets.all(16.0),
child: CodeTheme(
data: CodeThemeData(
styles: widget.isDarkMode ? nightTheme : dayTheme),
child: SingleChildScrollView(
child: WrappedCodeField(
wrap: true,
child: MarkdownView(
key: _viewKey,
controller: _controller,
textStyle: TextStyle(
readOnly: !_isEditing,
fontSize: _fontSize,
focusNode: _editorFocus,
),
),
floatingActionButton: _isLoading || _isPreviewMode
? null
: ListenableBuilder(
listenable: _controller,
builder: (context, _) => _controller.matches.length > 1
? Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton.small(
heroTag: 'previous-match',
onPressed: () =>
_showMatch(_controller.currentMatch - 1),
child: Icon(Icons.keyboard_arrow_up),
),
SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'next-match',
onPressed: () =>
_showMatch(_controller.currentMatch + 1),
child: Icon(Icons.keyboard_arrow_down),
),
],
)
: SizedBox.shrink(),
),
),
);

View file

@ -32,6 +32,7 @@ class NoteListScreen extends StatefulWidget {
class _NoteListScreenState extends State<NoteListScreen> {
TextEditingController searchController = TextEditingController();
final FocusNode searchFocusNode = FocusNode();
List<File> notes = [];
List<File> filteredNotes = [];
List<File> _visibleNotes = [];
@ -52,6 +53,9 @@ class _NoteListScreenState extends State<NoteListScreen> {
searchController.addListener(() {
_debouncedFilterNotes();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
FocusScope.of(context).requestFocus(searchFocusNode);
});
}
void _debouncedFilterNotes() {
@ -116,12 +120,46 @@ class _NoteListScreenState extends State<NoteListScreen> {
setState(() {
defaultDir = dir.path;
_useExternalEditor = prefs.getBool('use_external_editor') ?? false;
_includeSubdirectories = prefs.getBool('include_subdirectories') ?? true; // ✅ Load setting
_includeSubdirectories = prefs.getBool('include_subdirectories') ?? true;
});
notesDirectoryPath = prefs.getString('notes_directory') ?? defaultDir;
// 🚀 Check if it's the first start OR if the directory is empty
final directory = Directory(notesDirectoryPath!);
if (prefs.getBool('initialized') == null ||
!directory.listSync().any((entity) => entity is File && entity.path.endsWith('.md'))) {
await _createDefaultNotes();
await prefs.setBool('initialized', true);
}
await _loadNotes();
}
Future<void> _createDefaultNotes() async {
final quickStartNote = File(path.join(notesDirectoryPath!, "QuickStart.md"));
if (!quickStartNote.existsSync()) {
quickStartNote.writeAsStringSync("""
# Quick Start Guide
Welcome to *Muzzle Velocity*.
This is your fast and minimal note-taking app.
1. ✍️ Create Notes quickly: just type a title in the search bar and press enter.
2. 🔍 Type keywords to filter notes instantly.
3. 💻 Type commands like :help to get more information.
4. 🏷️ Use hashtags like #quickstart, context tags like @help, or project tags like +muzzle in your notes for categorization.
5. 🔫 Tap the blaster at the top right for more settings or to clear the search field.
*Muzzle Velocity* is built for speed and simplicity! 🚀
Happy note taking! ✨
""");
}
}
void _toggleIncludeSubdirectories(bool value) {
setState(() {
_includeSubdirectories = value;
@ -206,12 +244,19 @@ class _NoteListScreenState extends State<NoteListScreen> {
final entries = dir.listSync(recursive: _includeSubdirectories);
for (var entry in entries) {
if (entry is File && entry.path.endsWith('.md')) {
if (entry is File && _isValidNoteFile(entry)) {
if (path.basename(path.dirname(entry.path)) == "trash") {
continue;
}
final String content;
try {
content = entry.readAsStringSync();
} on FileSystemException {
continue; // Not text, e.g. a Mega Drive ROM with a .md extension.
}
allNotes.add(entry);
final folder = path.dirname(entry.path);
@ -220,7 +265,6 @@ class _NoteListScreenState extends State<NoteListScreen> {
folderTimestamps[folder] = entry.lastModifiedSync();
}
final content = entry.readAsStringSync();
extractedTags[entry] = _extractTags(content);
}
}
@ -268,6 +312,11 @@ class _NoteListScreenState extends State<NoteListScreen> {
return sortedTags.toSet(); // Convert back to a set after sorting
}
bool _isValidNoteFile(File file) {
final validExtensions = ['.md', '.txt', '.markdown'];
return validExtensions.any((ext) => file.path.toLowerCase().endsWith(ext));
}
void _filterNotes() {
final query = searchController.text.trim().toLowerCase();
@ -301,6 +350,17 @@ class _NoteListScreenState extends State<NoteListScreen> {
});
}
/// Search bar terms to locate inside an opened note, split like [_filterNotes].
List<String> _noteSearchTerms() {
final terms = searchController.text
.trim()
.split(' ')
.where((t) => t.isNotEmpty && !t.startsWith(RegExp(r'[/:]')));
final tags = terms.where((t) => t.startsWith(RegExp(r'[#@+]')));
final text = terms.where((t) => !t.startsWith(RegExp(r'[#@+]'))).join(' ');
return [if (text.isNotEmpty) text, ...tags];
}
void _loadMoreNotes() {
if (_isLoadingMore || _visibleNotes.length >= filteredNotes.length) return;
@ -351,6 +411,15 @@ class _NoteListScreenState extends State<NoteListScreen> {
String notePath = title.trim();
String? subfolder;
String safeTitle;
String extension = '.md'; // Default extension
// ✅ Check if user specified an extension manually
if (title.contains('.')) {
final ext = path.extension(title);
if (['.txt', '.markdown', '.md'].contains(ext)) {
extension = ext;
}
}
if (notePath.startsWith('/')) {
final parts = notePath.split('/');
@ -358,15 +427,15 @@ class _NoteListScreenState extends State<NoteListScreen> {
if (parts.length > 1) {
subfolder = parts.sublist(0, parts.length - 1).join('/');
}
safeTitle = _sanitizeFilename(parts.last);
safeTitle = _sanitizeFilename(parts.last.replaceAll(extension, '')); // Remove extension from input
} else {
safeTitle = _sanitizeFilename(title);
safeTitle = _sanitizeFilename(title.replaceAll(extension, ''));
}
// Construct the final file path
String fullPath = subfolder != null
? path.join(notesDirectoryPath!, subfolder, '$safeTitle.md')
: path.join(notesDirectoryPath!, '$safeTitle.md');
? path.join(notesDirectoryPath!, subfolder, '$safeTitle$extension')
: path.join(notesDirectoryPath!, '$safeTitle$extension');
final newNote = File(fullPath);
@ -416,7 +485,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
if (notePath.startsWith('/')) {
final parts = notePath.split('/');
parts.removeWhere((element) => element.isEmpty); // Remove empty elements
parts.removeWhere((element) => element.isEmpty);
if (parts.length > 1) {
subfolder = parts.sublist(0, parts.length - 1).join('/');
}
@ -425,12 +494,18 @@ class _NoteListScreenState extends State<NoteListScreen> {
safeTitle = _sanitizeFilename(title);
}
// Construct the expected file path
// ✅ Allow multiple file extensions
for (var ext in ['.md', '.txt', '.markdown']) {
String fullPath = subfolder != null
? path.join(notesDirectoryPath!, subfolder, '$safeTitle.md')
: path.join(notesDirectoryPath!, '$safeTitle.md');
? path.join(notesDirectoryPath!, subfolder, '$safeTitle$ext')
: path.join(notesDirectoryPath!, '$safeTitle$ext');
return !notes.any((note) => note.path == fullPath);
if (notes.any((note) => note.path == fullPath)) {
return false; // A note with this name already exists
}
}
return true;
}
String _sanitizeFilename(String title) {
@ -603,6 +678,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
title: Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: TextField(
focusNode: searchFocusNode,
enableInteractiveSelection: true,
autocorrect: false,
textInputAction: TextInputAction.done,
@ -626,7 +702,14 @@ class _NoteListScreenState extends State<NoteListScreen> {
actions: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: GestureDetector(
onTap: () {
if (searchController.text.isNotEmpty) {
searchController.clear();
}
},
child: PopupMenuButton<String>(
enabled: searchController.text.isEmpty,
icon: SvgPicture.asset(
"assets/blaster.svg",
width: 30,
@ -688,7 +771,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
value: 'Include Subdirectories',
child: ListTile(
leading: Icon(_includeSubdirectories ? Icons.file_copy_outlined : Icons.folder_copy_outlined),
title: Text(_includeSubdirectories ? 'Exclude Subdirectories' : 'Include Subdirectories'),
title: Text(_includeSubdirectories ? 'Exclude Subdirectories' : 'Search Subdirectories'),
onTap: () {
_toggleIncludeSubdirectories(!_includeSubdirectories);
Navigator.pop(context); // Close menu after toggle
@ -702,7 +785,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
_includeFileContent ? Icons.highlight_off : Icons.file_open_outlined,
),
title: Text(
_includeFileContent ? 'Exclude File Content' : 'Include File Content',
_includeFileContent ? 'Exclude File Content' : 'Search File Content',
),
onTap: () {
_toggleIncludeFileContent(!_includeFileContent);
@ -759,6 +842,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
],
),
),
),
],
),
body: Padding(
@ -857,6 +941,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
note: noteFile,
isDarkMode: widget.isDarkMode,
notesDirectoryPath: notesDirectoryPath,
searchTerms: _noteSearchTerms(),
),
),
);

View file

@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'themes//markdown.dart';
import 'markdown_editing.dart';
import 'markdown_view.dart';
import 'themes/day.dart';
import 'themes/night.dart';
import 'codefield.dart'; // Ensure this includes WrappedCodeField
class ReadOnlyPage extends StatelessWidget {
final String title;
@ -18,7 +17,12 @@ class ReadOnlyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = isDarkMode ? nightTheme : dayTheme;
final view = MarkdownView(
controller: MarkdownController(
text: content,
theme: isDarkMode ? nightTheme : dayTheme,
),
);
return Scaffold(
appBar: AppBar(title: Text(title)),
@ -33,34 +37,10 @@ class ReadOnlyPage extends StatelessWidget {
width: 200,
)
),
CodeTheme(
data: CodeThemeData(styles: theme),
child: SingleChildScrollView(
child: WrappedCodeField(
controller: CodeController(
text: content,
language: markdown,
),
wrap: true,
readOnly: true,
),
),
),
Expanded(child: view),
]
)
: CodeTheme(
data: CodeThemeData(styles: theme),
child: SingleChildScrollView(
child: WrappedCodeField(
controller: CodeController(
text: content,
language: markdown,
),
wrap: true,
readOnly: true,
),
),
),
: view,
),
);
}

View file

@ -3,13 +3,13 @@ String helpContent = """
Type to filter notes by title.
Use #tags, +projects, or @contexts to find notes with specific tags.
Start with a / to filter notes within /subdirectories.
Start with a / to filter notes within /folders and /sub/folders.
Mix text, tags, and folders (/work project #important).
# Create Notes
Type the title of a new note in the search bar and hit Enter.
Use /folder/note title to create a note inside a subdirectory.
Use /folder/note to create a note inside a subdirectory.
Subfolders will be automatically created if they don’t exist.
# Delete Notes
@ -32,17 +32,17 @@ Use tags anywhere in the notes and they will be detected.
# Context Menu
Tap the orange blaster at the top right to open the Preferences.
Tap the orange blaster at the top right to open the Preference menu.
Here you find toggles to show/hide the tags below the note titles and switch between light and dark themes.
You can also exclude subdirectories from your search.
By default the search bar filters by note titles, folders, and tags, but you can also toggle including the file contents. Please be aware that this might slow down the app if you have many files.
If you prefer to use a different Markdown editor for your files you can toggle that and when you tap a file in the list you can choose what to open the file with.
By default the notes are saved in the App's own directory which is inaccessible to other apps. If you want to choose your own custom folder for this, you can set it. You will be asked for special permissions to do so. This will enable you to save the notes wherever you want, for example to sync them with a different app. You can also choose a folder that already has notes in it.
- *Hide/Show Tags* below the note titles in the list.
- *Switch to Light/Dark Mode* themes.
- *Exclude/Search Subdirectories* to control the scope of your search.
- *Search/Exclude File Content* in your search. Please be aware that this might slow down the app if you have a lot of files.
- *Use External/Internal Editor* to enjoy your favorite note editing experience when tapping a note in the list.
- *Set Note Directory* to your preferred folder anywhere on the device (with granted permission request) or the default one.
- *Empty Trash* to purge the trash directory.
- *Help* will show you this text.
- *About* is for credits.
# File Organization
@ -67,12 +67,11 @@ Use commands by starting with a colon :
""";
String aboutContent = """
**Muzzle Velocity** is a fast and minimalist note-taking app inspired by *Notational Velocity* and *Terminal Velocity*.
v.1.0.2
**Muzzle Velocity** is a fast and minimalist but opinionated note-taking app inspired by the legendary *Notational Velocity* on MacOS and *Terminal Velocity* for command line terminals.
It is built for speed and efficiency, eliminating unnecessary UI clutter. Every action should be as fast and frictionless as possible.
Made with 🧡 by randogoth
with the invaluable help of
OpenAI's 🤖 ChatGPT
""";

View file

@ -19,37 +19,64 @@ const TextTheme dayText = TextTheme(
);
const dayTheme = {
'root':
TextStyle(backgroundColor: Color(0xffF6F4F1), color: Color(0xff333333)),
'comment': TextStyle(color: Color(0xff969896)),
'meta': TextStyle(color: Color(0xff969896)),
'variable': TextStyle(color: Color(0xffdf5000)),
'template-variable': TextStyle(color: Color(0xffdf5000)),
'strong': TextStyle(color: Color(0xffdf5000)),
'emphasis': TextStyle(color: Color(0xffdf5000)),
'quote': TextStyle(color: Color(0xffdf5000)),
'keyword': TextStyle(color: Color(0xffd73a49)),
'selector-tag': TextStyle(color: Color(0xffd73a49)),
'type': TextStyle(color: Color(0xffd73a49)),
'literal': TextStyle(color: Color(0xff0086b3)),
'symbol': TextStyle(color: Color(0xff0086b3)),
'bullet': TextStyle(color: Color(0xff0086b3)),
'attribute': TextStyle(color: Color(0xff0086b3)),
'section': TextStyle(color: Color(0xff63a35c)),
'name': TextStyle(color: Color(0xff63a35c)),
'tag': TextStyle(color: Color(0xff333333)),
'title': TextStyle(color: Color(0xff6f42c1)),
'attr': TextStyle(color: Color(0xff6f42c1)),
'selector-id': TextStyle(color: Color(0xff6f42c1)),
'selector-class': TextStyle(color: Color(0xff6f42c1)),
'selector-attr': TextStyle(color: Color(0xff6f42c1)),
'selector-pseudo': TextStyle(color: Color(0xff6f42c1)),
'addition':
TextStyle(color: Color(0xff55a532), backgroundColor: Color(0xffeaffea)),
'deletion':
TextStyle(color: Color(0xffbd2c00), backgroundColor: Color(0xffffecec)),
'number': TextStyle(color: Color(0xff005cc5)),
'string': TextStyle(color: Color(0xff032f62)),
// General text
'root': TextStyle(backgroundColor: Color(0xffF6F4F1), color: Color(0xff333333)),
// Headers
'header-h1': TextStyle(color: Color(0xff63a35c)), // Green for H1
'header-h2': TextStyle(color: Color(0xff63a35c), fontWeight: FontWeight.w600, fontSize: 22), // Green for H2
'header-h3': TextStyle(color: Color(0xff63a35c), fontWeight: FontWeight.w600, fontSize: 20), // Green for H3
'header-h4': TextStyle(color: Color(0xff63a35c), fontWeight: FontWeight.w600, fontSize: 18), // Green for H4
'header-h5': TextStyle(color: Color(0xff63a35c), fontWeight: FontWeight.w600, fontSize: 16), // Green for H5
'header-h6': TextStyle(color: Color(0xff63a35c), fontWeight: FontWeight.w600, fontSize: 14), // Green for H6
'header-underline': TextStyle(color: Color(0xff63a35c)), // Green for underlined headers
// Lists
'list': TextStyle(color: Color(0xff0086b3)), // Blue for lists
// Bold text
'bold': TextStyle(fontWeight: FontWeight.bold, color: Color(0xffdf5000)), // Orange for bold text
// Italic text
'italic': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffdf5000)), // Orange for italic text
// Blockquotes
'blockquote': TextStyle(color: Color(0xffdf5000)), // Orange for blockquotes
// Code blocks and inline code
'code': TextStyle(color: Color(0xff005cc5), backgroundColor: Color(0xffeaffea)), // Blue for code
// Horizontal rules
'horizontal-rule': TextStyle(color: Color(0xffbd2c00)), // Red for horizontal rules
// Horizontal rules
'command': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffbd2c00)), // Red for horizontal rules
// Links and images
'link-text': TextStyle(color: Color(0xff0086b3)), // Blue for link text
'link-url': TextStyle(color: Color(0xffd73a49)), // Red for link URLs
'link-reference': TextStyle(color: Color(0xff0086b3)), // Blue for link references
// Strikethrough text
'strikethrough': TextStyle(color: Color(0xffbd2c00), decoration: TextDecoration.lineThrough), // Red for strikethrough
// Tables
'table': TextStyle(color: Color(0xff333333)), // Black for tables
'table-header': TextStyle(color: Color(0xff6f42c1), fontWeight: FontWeight.w600), // Purple for table headers
'table-row': TextStyle(color: Color(0xff333333)), // Black for table rows
'table-cell': TextStyle(color: Color(0xff333333)), // Black for table cells
// Task lists
'task-list': TextStyle(color: Color(0xff0086b3)), // Blue for task lists
// Emphasis and strong
'emphasis': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffdf5000)), // Orange for emphasis
'strong': TextStyle(fontWeight: FontWeight.bold, color: Color(0xffdf5000)), // Orange for strong
// Comments and quotes
'comment': TextStyle(color: Color(0xff969896)), // Gray for comments
'quote': TextStyle(color: Color(0xffdf5000)), // Orange for quotes
'tag-hash': TextStyle(
color: Colors.pinkAccent,
fontStyle: FontStyle.italic,

View file

@ -1,81 +0,0 @@
import 'package:highlight/highlight.dart';
final markdown = Mode(refs: {}, aliases: [
"md",
"mkdown",
"mkd"
], contains: [
Mode(className: "section", variants: [
Mode(begin: "^#\\s{1,6}", end: "\$"),
Mode(begin: "^.+?\\n[=-]{2,}\$")
]),
Mode(begin: "<", end: ">", subLanguage: ["xml"], relevance: 0),
Mode(className: "bullet", begin: "^\\s*([*+-]|(\\d+\\.))\\s+"),
Mode(className: "strong", begin: "[*_]{2}.+?[*_]{2}"),
Mode(
className: "emphasis",
variants: [Mode(begin: "\\*.+?\\*"), Mode(begin: "_.+?_", relevance: 0)]),
Mode(className: "quote", begin: "^>\\s+", end: "\$"),
Mode(className: "code", variants: [
Mode(begin: "^```\\w*\\s*\$", end: "^```[ ]*\$"),
Mode(begin: "`.+?`"),
Mode(begin: "^( {4}|\\t)", end: "\$", relevance: 0)
]),
Mode(begin: "^[-\\*]{3,}", end: "\$"),
Mode(
begin: "\\[.+?\\][\\(\\[].*?[\\)\\]]",
returnBegin: true,
contains: [
Mode(
className: "string",
begin: "\\[",
end: "\\]",
excludeBegin: true,
returnEnd: true,
relevance: 0),
Mode(
className: "link",
begin: "\\]\\(",
end: "\\)",
excludeBegin: true,
excludeEnd: true),
Mode(
className: "symbol",
begin: "\\]\\[",
end: "\\]",
excludeBegin: true,
excludeEnd: true)
],
relevance: 10),
Mode(begin: "^\\[[^\\n]+\\]:", returnBegin: true, contains: [
Mode(
className: "symbol",
begin: "\\[",
end: "\\]",
excludeBegin: true,
excludeEnd: true),
Mode(className: "link", begin: ":\\s*", end: "\$", excludeBegin: true)
]),
// Custom Tag Highlighting
Mode(
className: "tag-hash",
begin: "(?<=\\s|^)#[a-zA-Z0-9_]+",
relevance: 10), // Magenta
Mode(
className: "tag-plus",
begin: "(?<=\\s|^)\\+[a-zA-Z0-9_]+",
relevance: 10), // Cyan
Mode(
className: "tag-at",
begin: "(?<=\\s|^)@[a-zA-Z0-9_]+",
relevance: 10), // Yellow
Mode(
className: "keyword",
begin: "(?<=\\s|^):[a-zA-Z0-9_]+",
relevance: 10),
Mode(
className: "variable",
begin: "(?<=\\s|^)\/[a-zA-Z0-9_\/]+",
relevance: 10), // Yellow
]);

View file

@ -19,37 +19,64 @@ const TextTheme nightText = TextTheme(
);
const nightTheme = {
'comment': TextStyle(color: Color(0xff969896)),
'quote': TextStyle(color: Color(0xff969896)),
'variable': TextStyle(color: Color(0xffcc6666)),
'template-variable': TextStyle(color: Color(0xffcc6666)),
'tag': TextStyle(color: Color(0xffcc6666)),
'name': TextStyle(color: Color(0xffcc6666)),
'selector-id': TextStyle(color: Color(0xffcc6666)),
'selector-class': TextStyle(color: Color(0xffcc6666)),
'regexp': TextStyle(color: Color(0xffcc6666)),
'deletion': TextStyle(color: Color(0xffcc6666)),
'number': TextStyle(color: Color(0xffde935f)),
'built_in': TextStyle(color: Color(0xffde935f)),
'builtin-name': TextStyle(color: Color(0xffde935f)),
'literal': TextStyle(color: Color(0xffde935f)),
'type': TextStyle(color: Color(0xffde935f)),
'params': TextStyle(color: Color(0xffde935f)),
'meta': TextStyle(color: Color(0xffde935f)),
'link': TextStyle(color: Color(0xffde935f)),
'attribute': TextStyle(color: Color(0xfff0c674)),
'string': TextStyle(color: Color(0xffb5bd68)),
'symbol': TextStyle(color: Color(0xffb5bd68)),
'bullet': TextStyle(color: Color(0xffb5bd68)),
'addition': TextStyle(color: Color(0xffb5bd68)),
'title': TextStyle(color: Color(0xff81a2be)),
'section': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 18),
'keyword': TextStyle(color: Color(0xffb294bb)),
'selector-tag': TextStyle(color: Color(0xffb294bb)),
'root':
TextStyle(backgroundColor: Color(0xFF121212), color: Color(0xffc5c8c6)),
'emphasis': TextStyle(fontStyle: FontStyle.italic),
'strong': TextStyle(fontWeight: FontWeight.bold),
// General text
'root': TextStyle(backgroundColor: Color(0xFF121212), color: Color(0xffc5c8c6)),
// Headers
'header-h1': TextStyle(color: Color(0xff81a2be)), // Blue for H1
'header-h2': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 22), // Blue for H2
'header-h3': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 20), // Blue for H3
'header-h4': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 18), // Blue for H4
'header-h5': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 16), // Blue for H5
'header-h6': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 14), // Blue for H6
'header-underline': TextStyle(color: Color(0xff81a2be)), // Blue for underlined headers
// Lists
'list': TextStyle(color: Color(0xffb5bd68)), // Green for lists
// Bold text
'bold': TextStyle(fontWeight: FontWeight.bold, color: Color(0xffb5bd68)), // White for bold text
// Italic text
'italic': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffb5bd68)), // White for italic text
// Blockquotes
'blockquote': TextStyle(color: Color(0xff969896)), // Gray for blockquotes
// Code blocks and inline code
'code': TextStyle(color: Color(0xffde935f), backgroundColor: Color(0xff1d1f21)), // Orange for code
// Horizontal rules
'horizontal-rule': TextStyle(color: Color(0xffcc6666)), // Red for horizontal rules
// Horizontal rules
'command': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffcc6666)), // Red for horizontal rules
// Links and images
'link-text': TextStyle(color: Color(0xff81a2be)), // Blue for link text
'link-url': TextStyle(color: Color(0xffde935f)), // Orange for link URLs
'link-reference': TextStyle(color: Color(0xffb5bd68)), // Green for link references
// Strikethrough text
'strikethrough': TextStyle(color: Color(0xffcc6666), decoration: TextDecoration.lineThrough), // Red for strikethrough
// Tables
'table': TextStyle(color: Color(0xffc5c8c6)), // White for tables
'table-header': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600), // Blue for table headers
'table-row': TextStyle(color: Color(0xffc5c8c6)), // White for table rows
'table-cell': TextStyle(color: Color(0xffc5c8c6)), // White for table cells
// Task lists
'task-list': TextStyle(color: Color(0xffb5bd68)), // Green for task lists
// Emphasis and strong
'emphasis': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffc5c8c6)), // White for emphasis
'strong': TextStyle(fontWeight: FontWeight.bold, color: Color(0xffc5c8c6)), // White for strong
// Comments and quotes
'comment': TextStyle(color: Color(0xff969896)), // Gray for comments
'quote': TextStyle(color: Color(0xff969896)), // Gray for quotes
'tag-hash': TextStyle(
color: Colors.pinkAccent,
fontStyle: FontStyle.italic,

View file

@ -7,12 +7,12 @@ import Foundation
import file_picker
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View file

@ -25,14 +25,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.11.0"
autotrie:
dependency: transitive
description:
name: autotrie
sha256: "55da6faefb53cfcb0abb2f2ca8636123fb40e35286bb57440d2cf467568188f8"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
boolean_selector:
dependency: transitive
description:
@ -45,18 +37,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.3.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@ -77,18 +61,18 @@ packages:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
version: "1.19.1"
cross_file:
dependency: transitive
description:
@ -113,22 +97,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
equatable:
dbus:
dependency: transitive
description:
name: equatable
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "2.0.7"
version: "0.7.12"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.3"
ffi:
dependency: transitive
description:
@ -149,31 +133,23 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "6f6bfa8797f296965bdc3e1f702574ab49a540c19b9237b401e7c2b25dfe594c"
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
url: "https://pub.dev"
source: hosted
version: "9.0.0"
version: "10.3.10"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_code_editor:
dependency: "direct main"
description:
name: flutter_code_editor
sha256: "505ad56dcc8a7be4b782c8113574571bc4b9723499b0c1f385b3e2c3fae11f5d"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_highlight:
dependency: "direct main"
description:
name: flutter_highlight
sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_launcher_icons:
dependency: "direct main"
description:
@ -210,10 +186,10 @@ packages:
dependency: transitive
description:
name: flutter_math_fork
sha256: "284bab89b2fbf1bc3a0baf13d011c1dd324d004e35d177626b77f2fc056366ac"
sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407"
url: "https://pub.dev"
source: hosted
version: "0.7.3"
version: "0.7.4"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@ -244,26 +220,10 @@ packages:
dependency: "direct main"
description:
name: google_fonts
sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.2.1"
highlight:
dependency: "direct main"
description:
name: highlight
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
hive:
dependency: transitive
description:
name: hive
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
version: "6.3.3"
http:
dependency: transitive
description:
@ -300,34 +260,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.7"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.8"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
linked_scroll_controller:
dependency: transitive
description:
name: linked_scroll_controller
sha256: e6020062bcf4ffc907ee7fd090fa971e65d8dfaac3c62baf601a3ced0b37986a
url: "https://pub.dev"
source: hosted
version: "0.2.0"
version: "3.0.2"
lints:
dependency: transitive
description:
@ -348,34 +300,34 @@ packages:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
mocktail:
version: "1.17.0"
mime:
dependency: transitive
description:
name: mocktail
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
version: "2.0.0"
nested:
dependency: transitive
description:
@ -396,10 +348,10 @@ packages:
dependency: "direct main"
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.9.1"
path_parsing:
dependency: transitive
description:
@ -544,14 +496,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.2"
scrollable_positioned_list:
dependency: transitive
share_plus:
dependency: "direct main"
description:
name: scrollable_positioned_list
sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287"
name: share_plus
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
url: "https://pub.dev"
source: hosted
version: "0.3.8"
version: "10.1.4"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
url: "https://pub.dev"
source: hosted
version: "5.0.2"
shared_preferences:
dependency: "direct main"
description:
@ -621,22 +581,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.0"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.4"
string_scanner:
dependency: transitive
description:
@ -657,10 +625,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.3"
version: "0.7.10"
tuple:
dependency: transitive
description:
@ -677,30 +645,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: transitive
description:
name: url_launcher
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
url: "https://pub.dev"
source: hosted
version: "6.3.1"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193"
url: "https://pub.dev"
source: hosted
version: "6.3.14"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626"
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_linux:
dependency: transitive
description:
@ -709,14 +653,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
@ -741,6 +677,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.4"
uuid:
dependency: transitive
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_graphics:
dependency: transitive
description:
@ -769,10 +713,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
@ -822,5 +766,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.0"
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"

View file

@ -10,9 +10,7 @@ dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
file_picker: ^9.0.0
flutter_highlight: ^0.7.0
flutter_code_editor: ^0.3.2
file_picker: ^10.3.10
flutter_markdown: ^0.7.6+2
flutter_markdown_latex: ^0.3.4
google_fonts: ^6.2.1
@ -21,10 +19,10 @@ dependencies:
path_provider: ^2.1.5
permission_handler: ^11.4.0
shared_preferences: ^2.5.2
highlight: ^0.7.0
open_filex: ^4.6.0
flutter_svg: ^2.0.17
flutter_launcher_icons: ^0.14.3
share_plus: ^10.1.4
dev_dependencies:
flutter_test:

View file

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:muzzle_velocity/markdown_editing.dart';
final _formatter = MarkdownInputFormatter();
TextEditingValue _value(String text, int start, [int? end]) => TextEditingValue(
text: text,
selection: TextSelection(baseOffset: start, extentOffset: end ?? start),
);
/// Simulates typing [typed] over the current selection of [old].
TextEditingValue _type(TextEditingValue old, String typed) {
final s = old.selection.start;
final raw = _value(old.text.replaceRange(s, old.selection.end, typed),
s + typed.length);
return _formatter.formatEditUpdate(old, raw);
}
/// Text with `|` marking the caret.
TextEditingValue _caret(String marked) =>
_value(marked.replaceFirst('|', ''), marked.indexOf('|'));
String _marked(TextEditingValue v) =>
v.text.replaceRange(v.selection.start, v.selection.start, '|');
Map<String, String> _classes(String text) => {
for (final s in highlightMarkdown(text))
text.substring(s.start, s.end): s.className,
};
void main() {
group('wrap selection', () {
test('asterisk wraps and keeps the word selected', () {
final once = _type(_value('a word here', 2, 6), '*');
expect(once.text, 'a *word* here');
expect(once.selection, const TextSelection(baseOffset: 3, extentOffset: 7));
expect(_type(once, '*').text, 'a **word** here');
});
test('bracket uses its closing pair', () {
expect(_type(_value('see link', 4, 8), '[').text, 'see [link]');
});
test('a letter still replaces the selection', () {
final result = _type(_value('a word', 2, 6), 'x');
expect(result.text, 'a x');
});
});
group('new line', () {
test('keeps indentation', () {
expect(_marked(_type(_caret(' code|'), '\n')), ' code\n |');
});
test('continues bullets, numbers, tasks and quotes', () {
expect(_marked(_type(_caret('- one|'), '\n')), '- one\n- |');
expect(_marked(_type(_caret(' * one|'), '\n')), ' * one\n * |');
expect(_marked(_type(_caret('9. nine|'), '\n')), '9. nine\n10. |');
expect(_marked(_type(_caret('1) a|'), '\n')), '1) a\n2) |');
expect(_marked(_type(_caret('- [x] done|'), '\n')), '- [x] done\n- [ ] |');
expect(_marked(_type(_caret('> quote|'), '\n')), '> quote\n> |');
});
test('splits an item when the caret is mid-line', () {
expect(_marked(_type(_caret('- one|two'), '\n')), '- one\n- |two');
});
test('an empty item ends the list', () {
expect(_marked(_type(_caret('- one\n- |'), '\n')), '- one\n|');
expect(_marked(_type(_caret('1. a\n2. |'), '\n')), '1. a\n|');
});
test('inside a code fence only indentation carries over', () {
expect(_marked(_type(_caret('```\n - x|'), '\n')), '```\n - x\n |');
});
test('emphasis at line start is not a bullet', () {
expect(_marked(_type(_caret('*bold* text|'), '\n')), '*bold* text\n|');
});
});
group('indentLines', () {
test('indents and outdents all selected lines', () {
final value = _value('a\nb\nc', 0, 3);
final indented = indentLines(value);
expect(indented.text, ' a\n b\nc');
expect(indentLines(indented, outdent: true).text, 'a\nb\nc');
});
});
group('highlightMarkdown', () {
test('line and inline syntax', () {
final classes = _classes(
'## Title\n- [ ] task **bold** and *it* `code` ~~gone~~\n[text](url) #tag +proj @ctx');
expect(classes['## Title'], 'header-h2');
expect(classes['- [ ] '], 'task-list');
expect(classes['**bold**'], 'bold');
expect(classes['*it*'], 'italic');
expect(classes['`code`'], 'code');
expect(classes['~~gone~~'], 'strikethrough');
expect(classes['[text]'], 'link-text');
expect(classes['(url)'], 'link-url');
expect(classes['#tag'], 'tag-hash');
expect(classes['+proj'], 'tag-plus');
expect(classes['@ctx'], 'tag-at');
});
test('fenced code spans lines without inline highlighting', () {
final classes = _classes('```\n**not bold**\n```\n**bold**');
expect(classes['**not bold**'], 'code');
expect(classes['**bold**'], 'bold');
});
test('segments are sorted and do not overlap', () {
final segments = highlightMarkdown('- a **b** #c\n> q\n---');
for (var i = 1; i < segments.length; i++) {
expect(segments[i].start, greaterThanOrEqualTo(segments[i - 1].end));
}
});
});
group('MarkdownController search', () {
test('finds terms case-insensitively and follows edits', () {
final controller =
MarkdownController(text: 'Foo bar foo #tag', theme: const {});
controller.search(['foo', '#tag']);
expect(controller.matches.map((m) => m.start), [0, 8, 12]);
controller.text = 'bar foo';
expect(controller.matches.map((m) => m.start), [4]);
});
test('builds spans covering the whole text', () {
final controller =
MarkdownController(text: '# Hi **there**', theme: const {});
controller.search(['there']);
final span = controller.buildTextSpan(
context: _FakeContext(), withComposing: false);
expect(span.toPlainText(), '# Hi **there**');
});
});
}
class _FakeContext extends Fake implements BuildContext {}

View file

@ -7,11 +7,14 @@
#include "generated_plugin_registrant.h"
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
permission_handler_windows
share_plus
url_launcher_windows
)