Long-pressing a note in the list opens it in view mode with the scroll position at the bottom of the file. Switching to edit mode the first time positions the cursor at the end of the text and scrolls there too, so the user can immediately append content. Implementation: - NoteEditorScreen gains a scrollToEnd parameter (default false) - A ScrollController is attached to the view/edit SingleChildScrollView - After content loads with scrollToEnd=true, two post-frame callbacks fire (first renders the content, second has a stable maxScrollExtent) then jumpTo(maxScrollExtent) - _cursorAtEnd flag is consumed on the first Mode Edit switch: sets selection to text.length, requests focus, and scrolls to bottom - NoteListScreen ListTile gets onLongPress that navigates with scrollToEnd: true and reloads on return Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
968 lines
No EOL
33 KiB
Dart
Executable file
968 lines
No EOL
33 KiB
Dart
Executable file
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:open_filex/open_filex.dart';
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'note_editor.dart';
|
|
import 'read_only.dart';
|
|
import 'texts.dart';
|
|
|
|
class NoteListScreen extends StatefulWidget {
|
|
final void Function(bool) toggleTheme;
|
|
final VoidCallback toggleTags;
|
|
final bool isDarkMode;
|
|
final bool showTags;
|
|
|
|
const NoteListScreen({
|
|
super.key,
|
|
required this.toggleTheme,
|
|
required this.toggleTags,
|
|
required this.isDarkMode,
|
|
required this.showTags,
|
|
});
|
|
|
|
@override
|
|
State<NoteListScreen> createState() => _NoteListScreenState();
|
|
}
|
|
|
|
class _NoteListScreenState extends State<NoteListScreen> {
|
|
TextEditingController searchController = TextEditingController();
|
|
final FocusNode searchFocusNode = FocusNode();
|
|
List<File> notes = [];
|
|
List<File> filteredNotes = [];
|
|
List<File> _visibleNotes = [];
|
|
int _visibleLimit = 30;
|
|
bool _isLoadingMore = false;
|
|
Map<File, Set<String>> noteTags = {};
|
|
Map<File, String> _noteRelativePaths = {};
|
|
Map<File, String> _noteContentLower = {};
|
|
String? notesDirectoryPath;
|
|
String? defaultDir;
|
|
bool _useExternalEditor = false;
|
|
bool _includeFileContent = false;
|
|
bool _includeSubdirectories = true;
|
|
Timer? _debounce;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initializeApp(); // Call an async method to handle initialization
|
|
searchController.addListener(() {
|
|
_debouncedFilterNotes();
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
FocusScope.of(context).requestFocus(searchFocusNode);
|
|
});
|
|
}
|
|
|
|
void _debouncedFilterNotes() {
|
|
if (_debounce?.isActive ?? false) _debounce?.cancel();
|
|
_debounce = Timer(Duration(milliseconds: 300), () {
|
|
_filterNotes();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debounce?.cancel();
|
|
searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _toggleIncludeFileContent(bool value) {
|
|
setState(() {
|
|
_includeFileContent = value;
|
|
});
|
|
|
|
// ✅ Save preference
|
|
SharedPreferences.getInstance().then((prefs) {
|
|
prefs.setBool('include_file_content', value);
|
|
});
|
|
|
|
// ✅ Immediately reapply search filter to update list
|
|
_filterNotes();
|
|
}
|
|
|
|
|
|
Future<void> _toggleExternalEditor(bool value) async {
|
|
setState(() {
|
|
_useExternalEditor = value;
|
|
});
|
|
SharedPreferences.getInstance().then((prefs) {
|
|
prefs.setBool('use_external_editor', value);
|
|
});
|
|
}
|
|
|
|
Future<void> _requestStoragePermission() async {
|
|
if (Platform.isAndroid) {
|
|
if (await Permission.storage.request().isGranted) {
|
|
debugPrint("✅ Basic storage permission granted.");
|
|
return;
|
|
}
|
|
|
|
if (await Permission.manageExternalStorage.request().isGranted) {
|
|
debugPrint("✅ Full file access granted.");
|
|
return;
|
|
}
|
|
|
|
debugPrint("❌ Storage permission denied. Opening settings...");
|
|
await openAppSettings(); // Open settings if denied
|
|
}
|
|
}
|
|
|
|
Future<void> _initializeApp() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
|
|
setState(() {
|
|
defaultDir = dir.path;
|
|
_useExternalEditor = prefs.getBool('use_external_editor') ?? false;
|
|
_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;
|
|
});
|
|
|
|
// ✅ Save preference
|
|
SharedPreferences.getInstance().then((prefs) {
|
|
prefs.setBool('include_subdirectories', value);
|
|
});
|
|
|
|
// ✅ Reload notes and reapply search immediately
|
|
_loadNotes().then((_) {
|
|
_filterNotes();
|
|
});
|
|
}
|
|
|
|
void _showDirectoryChoiceDialog(String defaultPath) {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false, // Force user to make a choice
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text("Choose Notes Directory"),
|
|
content: Text(
|
|
"Your notes will be saved in:\n\n📂 $defaultPath\n\nWould you like to use a different folder?"),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () async {
|
|
// Reset to default directory
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove(
|
|
'notes_directory'); // Remove custom directory from preferences
|
|
setState(() {
|
|
notesDirectoryPath = defaultDir; // Reset to default directory
|
|
});
|
|
if (context.mounted) Navigator.pop(context);
|
|
_loadNotes(); // Reload notes from the default directory
|
|
},
|
|
child: Text("Use Default"),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
await _requestStoragePermission();
|
|
if (context.mounted) Navigator.pop(context);
|
|
await _selectNotesDirectory(); // Allow user to pick a folder
|
|
},
|
|
child: Text("Choose Folder"),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _selectNotesDirectory() async {
|
|
String? selectedDirectory = await FilePicker.getDirectoryPath();
|
|
if (selectedDirectory != null) {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(
|
|
'notes_directory', selectedDirectory); // Save custom directory
|
|
setState(() {
|
|
notesDirectoryPath = selectedDirectory; // Update state
|
|
});
|
|
_loadNotes(); // Reload notes from the new directory
|
|
}
|
|
}
|
|
|
|
Future<void> _loadNotes() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
notesDirectoryPath = prefs.getString('notes_directory') ?? defaultDir;
|
|
final notesDir = Directory(notesDirectoryPath!);
|
|
|
|
if (!notesDir.existsSync()) {
|
|
notesDir.createSync(recursive: true);
|
|
}
|
|
|
|
List<File> allNotes = [];
|
|
Map<String, DateTime> folderTimestamps = {};
|
|
Map<File, Set<String>> extractedTags = {};
|
|
Map<File, String> extractedRelPaths = {};
|
|
Map<File, String> extractedContentLower = {};
|
|
|
|
void fetchNotes(Directory dir) {
|
|
final entries = dir.listSync(recursive: _includeSubdirectories);
|
|
|
|
for (var entry in entries) {
|
|
if (entry is File && _isValidNoteFile(entry)) {
|
|
|
|
if (path.basename(path.dirname(entry.path)) == "trash") {
|
|
continue;
|
|
}
|
|
|
|
allNotes.add(entry);
|
|
final folder = path.dirname(entry.path);
|
|
|
|
if (!folderTimestamps.containsKey(folder) ||
|
|
entry.lastModifiedSync().isAfter(folderTimestamps[folder]!)) {
|
|
folderTimestamps[folder] = entry.lastModifiedSync();
|
|
}
|
|
|
|
final content = entry.readAsStringSync();
|
|
extractedTags[entry] = _extractTags(content);
|
|
extractedRelPaths[entry] = path.relative(entry.path, from: notesDirectoryPath!).toLowerCase();
|
|
extractedContentLower[entry] = content.toLowerCase();
|
|
}
|
|
}
|
|
}
|
|
|
|
fetchNotes(notesDir);
|
|
|
|
allNotes.sort((a, b) {
|
|
String folderA = path.dirname(a.path);
|
|
String folderB = path.dirname(b.path);
|
|
DateTime timeA = folderTimestamps[folderA] ?? DateTime(0);
|
|
DateTime timeB = folderTimestamps[folderB] ?? DateTime(0);
|
|
|
|
// ✅ Prioritize files in the root directory over subdirectories
|
|
bool isRootA = folderA == notesDirectoryPath;
|
|
bool isRootB = folderB == notesDirectoryPath;
|
|
|
|
if (isRootA && !isRootB) return -1; // Root files come first
|
|
if (!isRootA && isRootB) return 1; // Subdir files come after root files
|
|
|
|
return timeB.compareTo(timeA); // Otherwise, sort by last modified time
|
|
});
|
|
|
|
setState(() {
|
|
notes = allNotes;
|
|
filteredNotes = allNotes;
|
|
noteTags = extractedTags;
|
|
_noteRelativePaths = extractedRelPaths;
|
|
_noteContentLower = extractedContentLower;
|
|
});
|
|
|
|
_filterNotes(); // ✅ Apply search immediately if a term is active
|
|
}
|
|
|
|
/// Extracts unique tags sorted by +, @, #
|
|
Set<String> _extractTags(String content) {
|
|
final tagPattern = RegExp(r'(?<!\S)[#@+][a-zA-Z][a-zA-Z0-9_]*');
|
|
final matches = tagPattern.allMatches(content).map((m) => m.group(0)!).toSet();
|
|
|
|
// Sort the tags in order: +tags first, @contexts second, #hashtags last
|
|
final sortedTags = matches.toList()
|
|
..sort((a, b) {
|
|
const order = {'+': 0, '@': 1, '#': 2};
|
|
return (order[a[0]] ?? 3).compareTo(order[b[0]] ?? 3);
|
|
});
|
|
|
|
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();
|
|
|
|
setState(() {
|
|
if (query.isEmpty) {
|
|
filteredNotes = List.from(notes);
|
|
return;
|
|
}
|
|
|
|
final terms = query.split(' '); // Split search into words
|
|
final searchTags = terms.where((t) => t.startsWith(RegExp(r'[#@+]'))).toSet();
|
|
final searchText = terms.where((t) => !t.startsWith(RegExp(r'[#@+]'))).join(' ');
|
|
|
|
// Strip leading '/' so "/subdir" and "subdir" match the same notes
|
|
final normalizedSearch = searchText.replaceFirst(RegExp(r'^/'), '');
|
|
|
|
filteredNotes = notes.where((note) {
|
|
final relativePath = _noteRelativePaths[note] ?? '';
|
|
final tags = noteTags[note] ?? {};
|
|
final content = _includeFileContent ? (_noteContentLower[note] ?? '') : '';
|
|
|
|
final matchesTags = searchTags.every((tag) =>
|
|
tags.any((noteTag) => noteTag.toLowerCase().startsWith(tag)));
|
|
|
|
if (!_includeFileContent) {
|
|
return relativePath.contains(normalizedSearch) && matchesTags;
|
|
} else {
|
|
return (relativePath.contains(normalizedSearch) || content.contains(normalizedSearch)) && matchesTags;
|
|
}
|
|
}).toList();
|
|
});
|
|
}
|
|
|
|
void _loadMoreNotes() {
|
|
if (_isLoadingMore || _visibleNotes.length >= filteredNotes.length) return;
|
|
|
|
setState(() => _isLoadingMore = true);
|
|
|
|
Future.delayed(Duration(milliseconds: 200), () {
|
|
setState(() {
|
|
_visibleLimit += 20; // Load 20 more each time
|
|
_visibleNotes = filteredNotes.take(_visibleLimit).toList();
|
|
_isLoadingMore = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
List<InlineSpan> _formatTags(String content) {
|
|
final tagPattern = RegExp(r'(?<!\S)[#@+][a-zA-Z][a-zA-Z0-9_]*');
|
|
final Set<String> tagSet = {}; // Avoid duplicate tags
|
|
|
|
for (final match in tagPattern.allMatches(content)) {
|
|
tagSet.add(match.group(0)!);
|
|
}
|
|
|
|
// Sort tags: +tags before @tags before #tags
|
|
final sortedTags = tagSet.toList()
|
|
..sort((a, b) {
|
|
if (a.startsWith('+') && !b.startsWith('+')) return -1;
|
|
if (b.startsWith('+') && !a.startsWith('+')) return 1;
|
|
if (a.startsWith('@') && !b.startsWith('@')) return -1;
|
|
if (b.startsWith('@') && !a.startsWith('@')) return 1;
|
|
return a.compareTo(b); // Default alphabetical order
|
|
});
|
|
|
|
return sortedTags.map((tag) {
|
|
Color tagColor = Colors.white;
|
|
if (tag.startsWith('#')) tagColor = Colors.pinkAccent;
|
|
if (tag.startsWith('+')) tagColor = Colors.cyan;
|
|
if (tag.startsWith('@')) tagColor = Colors.orange;
|
|
|
|
return TextSpan(
|
|
text: "$tag ",
|
|
style: TextStyle(color: tagColor, fontWeight: FontWeight.bold),
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
void _createNewNote(String title) async {
|
|
try {
|
|
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.contains('/')) {
|
|
final parts = notePath.split('/');
|
|
parts.removeWhere((element) => element.isEmpty);
|
|
if (parts.length > 1) {
|
|
subfolder = parts.sublist(0, parts.length - 1).join('/');
|
|
}
|
|
safeTitle = _sanitizeFilename(parts.last.replaceAll(extension, '')); // Remove extension from input
|
|
} else {
|
|
safeTitle = _sanitizeFilename(title.replaceAll(extension, ''));
|
|
}
|
|
|
|
// Construct the final file path
|
|
String fullPath = subfolder != null
|
|
? path.join(notesDirectoryPath!, subfolder, '$safeTitle$extension')
|
|
: path.join(notesDirectoryPath!, '$safeTitle$extension');
|
|
|
|
final newNote = File(fullPath);
|
|
|
|
// Prevent duplicate note creation
|
|
if (notes.any((note) => note.path == newNote.path)) {
|
|
debugPrint("❌ Note already exists at: $fullPath");
|
|
return;
|
|
}
|
|
|
|
// Ensure subfolder exists
|
|
final parentDir = newNote.parent;
|
|
if (!parentDir.existsSync()) {
|
|
parentDir.createSync(recursive: true);
|
|
}
|
|
|
|
if (!newNote.existsSync()) {
|
|
final newContent = '# $safeTitle\n\n';
|
|
newNote.writeAsStringSync(newContent);
|
|
setState(() {
|
|
notes.add(newNote);
|
|
filteredNotes.add(newNote);
|
|
noteTags[newNote] = {};
|
|
_noteRelativePaths[newNote] = path.relative(newNote.path, from: notesDirectoryPath!).toLowerCase();
|
|
_noteContentLower[newNote] = newContent.toLowerCase();
|
|
});
|
|
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => NoteEditorScreen(
|
|
note: newNote,
|
|
isDarkMode: widget.isDarkMode,
|
|
notesDirectoryPath: notesDirectoryPath,
|
|
initialMode: NoteMode.edit,
|
|
),
|
|
),
|
|
);
|
|
if (context.mounted) _loadNotes();
|
|
}
|
|
} catch (e) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to create note: $e'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
bool _isNewNote(String title) {
|
|
String notePath = title.trim();
|
|
String? subfolder;
|
|
String safeTitle;
|
|
|
|
if (notePath.startsWith('/')) {
|
|
final parts = notePath.split('/');
|
|
parts.removeWhere((element) => element.isEmpty);
|
|
if (parts.length > 1) {
|
|
subfolder = parts.sublist(0, parts.length - 1).join('/');
|
|
}
|
|
safeTitle = _sanitizeFilename(parts.last);
|
|
} else {
|
|
safeTitle = _sanitizeFilename(title);
|
|
}
|
|
|
|
// ✅ 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');
|
|
|
|
if (notes.any((note) => note.path == fullPath)) {
|
|
return false; // A note with this name already exists
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
String _sanitizeFilename(String title) {
|
|
return title
|
|
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '') // Remove invalid characters
|
|
.trim();
|
|
}
|
|
|
|
void _deleteNote(File note) {
|
|
final rootTrashDir = Directory(path.join(notesDirectoryPath!, 'trash'));
|
|
|
|
if (!rootTrashDir.existsSync()) {
|
|
rootTrashDir.createSync(recursive: true);
|
|
}
|
|
|
|
// Define the base trashed file path
|
|
String trashedPath = path.join(rootTrashDir.path, note.uri.pathSegments.last);
|
|
File trashedNote = File(trashedPath);
|
|
|
|
// If the file already exists in trash, append a timestamp to avoid conflicts
|
|
if (trashedNote.existsSync()) {
|
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
|
final newFileName = '${path.basenameWithoutExtension(note.path)}_$timestamp.md';
|
|
trashedNote = File(path.join(rootTrashDir.path, newFileName));
|
|
}
|
|
|
|
// Move the file to trash
|
|
note.renameSync(trashedNote.path);
|
|
|
|
// Check if the original directory is now empty and delete it if needed
|
|
_deleteEmptyParentFolders(note.parent);
|
|
|
|
setState(() {
|
|
notes.remove(note);
|
|
filteredNotes.remove(note);
|
|
});
|
|
|
|
// ✅ Ensure we clear previous Snackbars
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
|
|
// ✅ Show Snackbar with automatic dismissal
|
|
final snackBar = SnackBar(
|
|
duration: const Duration(seconds: 3), // ✅ Auto-dismiss after 3 seconds
|
|
content: Text('Note moved to trash'),
|
|
action: SnackBarAction(
|
|
label: 'Undo',
|
|
onPressed: () {
|
|
trashedNote.renameSync(note.path);
|
|
setState(() {
|
|
notes.add(note);
|
|
filteredNotes.add(note);
|
|
});
|
|
},
|
|
),
|
|
);
|
|
|
|
// ✅ Show the Snackbar
|
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
|
|
|
// 🔥 Force dismiss after timeout in case it gets stuck
|
|
Future.delayed(const Duration(seconds: 2), () {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ✅ Recursively deletes empty parent folders up to the notes directory
|
|
void _deleteEmptyParentFolders(Directory folder) {
|
|
if (folder.path == notesDirectoryPath) return; // Don't delete the root folder
|
|
|
|
try {
|
|
if (folder.existsSync() && folder.listSync().isEmpty) {
|
|
folder.deleteSync();
|
|
_deleteEmptyParentFolders(folder.parent); // Recursively check parent
|
|
}
|
|
} catch (e) {
|
|
debugPrint("❌ Error deleting empty folder: $e");
|
|
}
|
|
}
|
|
|
|
Future<void> _emptyTrash() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final notesPath = prefs.getString('notes_directory') ??
|
|
(await getApplicationDocumentsDirectory()).path;
|
|
final trashDir = Directory('$notesPath/trash');
|
|
if (trashDir.existsSync()) {
|
|
trashDir.deleteSync(recursive: true);
|
|
trashDir.createSync();
|
|
}
|
|
}
|
|
|
|
void _confirmEmptyTrash() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
title: Text("Empty Trash"),
|
|
content: Text(
|
|
"Are you sure you want to permanently delete all trashed notes?"),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text("Cancel"),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
_emptyTrash();
|
|
Navigator.pop(context);
|
|
},
|
|
child: Text("Empty Trash"),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _handleCommand(String text) {
|
|
switch (text) {
|
|
case ':tags':
|
|
widget.toggleTags();
|
|
break;
|
|
case ':notags':
|
|
widget.toggleTags();
|
|
break;
|
|
case ':day':
|
|
widget.toggleTheme(false);
|
|
break;
|
|
case ':night':
|
|
widget.toggleTheme(true);
|
|
break;
|
|
case ':extern':
|
|
_toggleExternalEditor(true);
|
|
break;
|
|
case ':intern':
|
|
_toggleExternalEditor(false);
|
|
break;
|
|
case ':emptytrash':
|
|
_confirmEmptyTrash();
|
|
break;
|
|
case ':about':
|
|
_showInfoPage(context, "About", aboutContent);
|
|
break;
|
|
case ':help':
|
|
_showInfoPage(context, "Help", helpContent);
|
|
break;
|
|
default:
|
|
return; // Do nothing for unrecognized commands
|
|
}
|
|
searchController.clear();
|
|
}
|
|
|
|
void _showInfoPage(BuildContext context, String title, String content) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ReadOnlyPage(
|
|
title: title,
|
|
content: content,
|
|
isDarkMode: widget.isDarkMode,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 10),
|
|
child: TextField(
|
|
focusNode: searchFocusNode,
|
|
enableInteractiveSelection: true,
|
|
autocorrect: false,
|
|
textInputAction: TextInputAction.done,
|
|
controller: searchController,
|
|
cursorColor: Colors.orange,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search or enter a command...',
|
|
border: InputBorder.none,
|
|
),
|
|
style: GoogleFonts.ibmPlexMono(color: Colors.grey[300]),
|
|
onSubmitted: (text) {
|
|
final trimmedText = text.trim();
|
|
if (trimmedText.startsWith(':')) {
|
|
_handleCommand(trimmedText);
|
|
} else if (trimmedText.isNotEmpty && _isNewNote(trimmedText)) {
|
|
_createNewNote(trimmedText);
|
|
searchController.clear();
|
|
}
|
|
},
|
|
),
|
|
),
|
|
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,
|
|
colorFilter: const ColorFilter.mode(Colors.orange, BlendMode.srcIn),
|
|
),
|
|
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: '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: '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
|
|
},
|
|
),
|
|
),
|
|
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);
|
|
},
|
|
),
|
|
),
|
|
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: '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'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Padding(
|
|
padding: EdgeInsets.only(left: 10),
|
|
child: NotificationListener<ScrollNotification>(
|
|
onNotification: (ScrollNotification scrollInfo) {
|
|
if (!_isLoadingMore && scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 100) {
|
|
_loadMoreNotes(); // ✅ Lazy load when user scrolls
|
|
}
|
|
return false;
|
|
},
|
|
child: ListView.builder(
|
|
itemCount: filteredNotes.length > _visibleLimit ? _visibleLimit + 1 : filteredNotes.length,
|
|
itemBuilder: (context, index) {
|
|
if (index == _visibleLimit && filteredNotes.length > _visibleLimit) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(10),
|
|
child: CircularProgressIndicator(), // ✅ Show loading indicator
|
|
),
|
|
);
|
|
}
|
|
|
|
final noteFile = filteredNotes[index];
|
|
return Dismissible(
|
|
key: Key(noteFile.path),
|
|
direction: DismissDirection.endToStart,
|
|
background: Container(
|
|
color: Colors.redAccent,
|
|
alignment: Alignment.centerRight,
|
|
padding: EdgeInsets.symmetric(horizontal: 20),
|
|
child: Icon(Icons.delete, color: Colors.white),
|
|
),
|
|
onDismissed: (direction) => _deleteNote(noteFile),
|
|
child: ListTile(
|
|
title: Text(noteFile.uri.pathSegments.last.replaceAll('.md', '')),
|
|
subtitle: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final bool hasEnoughSpace = constraints.maxWidth > 200; // Adjust threshold
|
|
|
|
return Row(
|
|
children: [
|
|
if (path.relative(path.dirname(noteFile.path), from: notesDirectoryPath!) != ".")
|
|
Padding(
|
|
padding: EdgeInsets.only(right: 8),
|
|
child: Text(
|
|
path.relative(path.dirname(noteFile.path), from: notesDirectoryPath!),
|
|
style: TextStyle(color: Colors.blueGrey),
|
|
),
|
|
),
|
|
if (widget.showTags && hasEnoughSpace && noteTags[noteFile]?.isNotEmpty == true)
|
|
Expanded(
|
|
child: RichText(
|
|
text: TextSpan(
|
|
style: DefaultTextStyle.of(context).style,
|
|
children: _formatTags(noteTags[noteFile]!.join(' ')), // ✅ Use stored tags
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
onTap: () async {
|
|
if (_useExternalEditor) {
|
|
try {
|
|
if (Platform.isLinux) {
|
|
final result = await Process.run('which', ['xdg-open']);
|
|
if (result.stdout.toString().trim().isNotEmpty) {
|
|
await Process.run('xdg-open', [noteFile.path]);
|
|
} else {
|
|
debugPrint("❌ `xdg-open` is not available.");
|
|
}
|
|
} else if (Platform.isAndroid) {
|
|
await _requestStoragePermission();
|
|
final result = await OpenFilex.open(
|
|
noteFile.path,
|
|
type: "text/markdown",
|
|
);
|
|
debugPrint("✅ OpenFilex result: ${result.type}, message: ${result.message}");
|
|
}
|
|
} catch (e) {
|
|
debugPrint("❌ Failed to open external editor: $e");
|
|
}
|
|
} else {
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => NoteEditorScreen(
|
|
note: noteFile,
|
|
isDarkMode: widget.isDarkMode,
|
|
notesDirectoryPath: notesDirectoryPath,
|
|
),
|
|
),
|
|
);
|
|
_loadNotes(); // ✅ Reload notes after returning
|
|
setState(() {}); // ✅ Force UI refresh
|
|
}
|
|
},
|
|
onLongPress: () async {
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => NoteEditorScreen(
|
|
note: noteFile,
|
|
isDarkMode: widget.isDarkMode,
|
|
notesDirectoryPath: notesDirectoryPath,
|
|
scrollToEnd: true,
|
|
),
|
|
),
|
|
);
|
|
if (context.mounted) _loadNotes();
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |