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 createState() => _NoteListScreenState(); } class _NoteListScreenState extends State { TextEditingController searchController = TextEditingController(); final FocusNode searchFocusNode = FocusNode(); List notes = []; List filteredNotes = []; List _visibleNotes = []; int _visibleLimit = 30; bool _isLoadingMore = false; Map> noteTags = {}; 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 _toggleExternalEditor(bool value) async { setState(() { _useExternalEditor = value; }); SharedPreferences.getInstance().then((prefs) { prefs.setBool('use_external_editor', value); }); } Future _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 _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 _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 _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 _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 allNotes = []; Map folderTimestamps = {}; Map> extractedTags = {}; // ✅ Store extracted tags here 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); } } } 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; // ✅ Update stored tags }); _filterNotes(); // ✅ Apply search immediately if a term is active } /// Extracts unique tags sorted by +, @, # Set _extractTags(String content) { final tagPattern = RegExp(r'([#@+][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(' '); filteredNotes = notes.where((note) { final filename = note.uri.pathSegments.last.toLowerCase(); final noteTags = _extractTags(note.readAsStringSync()); // ✅ Read file content ONLY if `Include File Content` is enabled final content = _includeFileContent ? note.readAsStringSync().toLowerCase() : ''; // ✅ Match partial tags instead of requiring full matches final matchesTags = searchTags.every((tag) => noteTags.any((noteTag) => noteTag.startsWith(tag))); if (!_includeFileContent) { return filename.contains(searchText) && matchesTags; } else { return (filename.contains(searchText) || content.contains(searchText)) && 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 _formatTags(String content) { final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)'); final Set 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.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.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()) { newNote.writeAsStringSync('# $safeTitle\n\n'); setState(() { notes.add(newNote); filteredNotes.add(newNote); }); Navigator.push( context, MaterialPageRoute( builder: (context) => NoteEditorScreen( note: newNote, isDarkMode: widget.isDarkMode, notesDirectoryPath: notesDirectoryPath, initialMode: NoteMode.edit, ), ), ); } } 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 _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( 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( value: 'Toggle Tags', child: ListTile( leading: Icon(widget.showTags ? Icons.short_text : Icons.tag), title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'), ), ), PopupMenuItem( 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( 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( 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( 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( value: 'Select Folder', child: ListTile( leading: Icon(Icons.folder_shared_outlined), title: Text('Set Notes Directory'), ), ), PopupMenuDivider(), PopupMenuItem( value: 'Empty Trash', child: ListTile( leading: Icon(Icons.delete, color: Colors.redAccent), title: Text('Empty Trash', style: TextStyle(color: Colors.redAccent)), ), ), PopupMenuDivider(), PopupMenuItem( value: 'Help', child: ListTile( leading: Icon(Icons.help_outline), title: Text('Help'), ), ), PopupMenuItem( value: 'About', child: ListTile( leading: Icon(Icons.info_outline), title: Text('About'), ), ), ], ), ), ), ], ), body: Padding( padding: EdgeInsets.only(left: 10), child: NotificationListener( 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 } }, ), ); }, ), ), ), ); } }