feat: add built-in markdown editor with search jump and typing helpers
This commit is contained in:
parent
db27f64e42
commit
ed2de5e67f
16 changed files with 1146 additions and 561 deletions
|
|
@ -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
|
||||
String fullPath = subfolder != null
|
||||
? path.join(notesDirectoryPath!, subfolder, '$safeTitle.md')
|
||||
: path.join(notesDirectoryPath!, '$safeTitle.md');
|
||||
// ✅ Allow multiple file extensions
|
||||
for (var ext in ['.md', '.txt', '.markdown']) {
|
||||
String fullPath = subfolder != null
|
||||
? 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,137 +702,145 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
actions: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 15),
|
||||
child: PopupMenuButton<String>(
|
||||
icon: SvgPicture.asset(
|
||||
"assets/blaster.svg",
|
||||
width: 30,
|
||||
color: Colors.orange,
|
||||
),
|
||||
onSelected: (String value) {
|
||||
switch (value) {
|
||||
case 'Toggle Tags':
|
||||
widget.toggleTags();
|
||||
break;
|
||||
case 'Toggle Theme':
|
||||
setState(() {
|
||||
widget.toggleTheme(!widget.isDarkMode);
|
||||
});
|
||||
break;
|
||||
case 'External Editor':
|
||||
_toggleExternalEditor(!_useExternalEditor);
|
||||
break;
|
||||
case 'Select Folder':
|
||||
_showDirectoryChoiceDialog(
|
||||
notesDirectoryPath ?? ''); // Re-trigger popup
|
||||
break;
|
||||
case 'Empty Trash':
|
||||
_confirmEmptyTrash();
|
||||
break;
|
||||
case 'Help':
|
||||
_showInfoPage(context, "Help", helpContent);
|
||||
break;
|
||||
case 'About':
|
||||
_showInfoPage(context, "About", aboutContent);
|
||||
break;
|
||||
}
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (searchController.text.isNotEmpty) {
|
||||
searchController.clear();
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem<String>(
|
||||
value: 'Toggle Tags',
|
||||
child: ListTile(
|
||||
leading:
|
||||
Icon(widget.showTags ? Icons.short_text : Icons.tag),
|
||||
title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'),
|
||||
),
|
||||
child: PopupMenuButton<String>(
|
||||
enabled: searchController.text.isEmpty,
|
||||
icon: SvgPicture.asset(
|
||||
"assets/blaster.svg",
|
||||
width: 30,
|
||||
color: Colors.orange,
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Toggle Theme',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? Icons.light_mode
|
||||
: Icons.dark_mode,
|
||||
),
|
||||
title: Text(
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? 'Switch to Light Mode'
|
||||
: 'Switch to Dark Mode',
|
||||
onSelected: (String value) {
|
||||
switch (value) {
|
||||
case 'Toggle Tags':
|
||||
widget.toggleTags();
|
||||
break;
|
||||
case 'Toggle Theme':
|
||||
setState(() {
|
||||
widget.toggleTheme(!widget.isDarkMode);
|
||||
});
|
||||
break;
|
||||
case 'External Editor':
|
||||
_toggleExternalEditor(!_useExternalEditor);
|
||||
break;
|
||||
case 'Select Folder':
|
||||
_showDirectoryChoiceDialog(
|
||||
notesDirectoryPath ?? ''); // Re-trigger popup
|
||||
break;
|
||||
case 'Empty Trash':
|
||||
_confirmEmptyTrash();
|
||||
break;
|
||||
case 'Help':
|
||||
_showInfoPage(context, "Help", helpContent);
|
||||
break;
|
||||
case 'About':
|
||||
_showInfoPage(context, "About", aboutContent);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem<String>(
|
||||
value: 'Toggle Tags',
|
||||
child: ListTile(
|
||||
leading:
|
||||
Icon(widget.showTags ? Icons.short_text : Icons.tag),
|
||||
title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Include Subdirectories',
|
||||
child: ListTile(
|
||||
leading: Icon(_includeSubdirectories ? Icons.file_copy_outlined : Icons.folder_copy_outlined),
|
||||
title: Text(_includeSubdirectories ? 'Exclude Subdirectories' : 'Include Subdirectories'),
|
||||
onTap: () {
|
||||
_toggleIncludeSubdirectories(!_includeSubdirectories);
|
||||
Navigator.pop(context); // Close menu after toggle
|
||||
},
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Include File Content',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
_includeFileContent ? Icons.highlight_off : Icons.file_open_outlined,
|
||||
),
|
||||
title: Text(
|
||||
_includeFileContent ? 'Exclude File Content' : 'Include File Content',
|
||||
),
|
||||
onTap: () {
|
||||
_toggleIncludeFileContent(!_includeFileContent);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'External Editor',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
_useExternalEditor
|
||||
? Icons.open_in_new_off
|
||||
: Icons.open_in_new,
|
||||
),
|
||||
title: Text(
|
||||
!_useExternalEditor
|
||||
? 'Use External Editor'
|
||||
: 'Use Internal Editor',
|
||||
PopupMenuItem<String>(
|
||||
value: 'Toggle Theme',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? Icons.light_mode
|
||||
: Icons.dark_mode,
|
||||
),
|
||||
title: Text(
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? 'Switch to Light Mode'
|
||||
: 'Switch to Dark Mode',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Select Folder',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.folder_shared_outlined),
|
||||
title: Text('Set Notes Directory'),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Include Subdirectories',
|
||||
child: ListTile(
|
||||
leading: Icon(_includeSubdirectories ? Icons.file_copy_outlined : Icons.folder_copy_outlined),
|
||||
title: Text(_includeSubdirectories ? 'Exclude Subdirectories' : 'Search Subdirectories'),
|
||||
onTap: () {
|
||||
_toggleIncludeSubdirectories(!_includeSubdirectories);
|
||||
Navigator.pop(context); // Close menu after toggle
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Empty Trash',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete, color: Colors.redAccent),
|
||||
title: Text('Empty Trash',
|
||||
style: TextStyle(color: Colors.redAccent)),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Include File Content',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
_includeFileContent ? Icons.highlight_off : Icons.file_open_outlined,
|
||||
),
|
||||
title: Text(
|
||||
_includeFileContent ? 'Exclude File Content' : 'Search File Content',
|
||||
),
|
||||
onTap: () {
|
||||
_toggleIncludeFileContent(!_includeFileContent);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Help',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.help_outline),
|
||||
title: Text('Help'),
|
||||
PopupMenuItem<String>(
|
||||
value: 'External Editor',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
_useExternalEditor
|
||||
? Icons.open_in_new_off
|
||||
: Icons.open_in_new,
|
||||
),
|
||||
title: Text(
|
||||
!_useExternalEditor
|
||||
? 'Use External Editor'
|
||||
: 'Use Internal Editor',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'About',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('About'),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Select Folder',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.folder_shared_outlined),
|
||||
title: Text('Set Notes Directory'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Empty Trash',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete, color: Colors.redAccent),
|
||||
title: Text('Empty Trash',
|
||||
style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Help',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.help_outline),
|
||||
title: Text('Help'),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'About',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('About'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -857,6 +941,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
note: noteFile,
|
||||
isDarkMode: widget.isDarkMode,
|
||||
notesDirectoryPath: notesDirectoryPath,
|
||||
searchTerms: _noteSearchTerms(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue