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'; class NoteListScreen extends StatefulWidget { final void Function(bool) toggleTheme; final VoidCallback toggleTags; final bool isDarkMode; final bool showTags; NoteListScreen({ required this.toggleTheme, required this.toggleTags, required this.isDarkMode, required this.showTags, }); @override _NoteListScreenState createState() => _NoteListScreenState(); } class _NoteListScreenState extends State { TextEditingController searchController = TextEditingController(); List notes = []; List filteredNotes = []; Map> noteTags = {}; String? notesDirectoryPath; String? defaultDir; bool _useExternalEditor = false; bool _includeFileContent = false; bool _includeSubdirectories = true; @override void initState() { super.initState(); _initializeApp(); // Call an async method to handle initialization searchController.addListener(() { final text = searchController.text.trim(); if (text.startsWith(':')) { _handleCommand(text); } else { _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) { print("āœ… Basic storage permission granted."); return; } if (await Permission.manageExternalStorage.request().isGranted) { print("āœ… Full file access granted."); return; } print("āŒ 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; // āœ… Load setting }); await _loadNotes(); } Future _toggleIncludeSubdirectories(bool value) async { setState(() { _includeSubdirectories = value; }); final prefs = await SharedPreferences.getInstance(); await prefs.setBool('include_subdirectories', value); _loadNotes(); // Reload notes based on new setting } 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 }); Navigator.pop(context); // Close dialog _loadNotes(); // Reload notes from the default directory }, child: Text("Use Default"), ), TextButton( onPressed: () async { await _requestStoragePermission(); Navigator.pop(context); // Close the current dialog await _selectNotesDirectory(); // Allow user to pick a folder }, child: Text("Choose Folder"), ), ], ); }, ); } Future _selectNotesDirectory() async { String? selectedDirectory = await FilePicker.platform.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 = {}; void fetchNotes(Directory dir, String relativePath) { final entries = dir.listSync(recursive: _includeSubdirectories); // āœ… Only include subfolders if enabled for (var entry in entries) { if (entry is File && entry.path.endsWith('.md')) { allNotes.add(entry); final folder = path.dirname(entry.path); if (!folderTimestamps.containsKey(folder) || entry.lastModifiedSync().isAfter(folderTimestamps[folder]!)) { folderTimestamps[folder] = entry.lastModifiedSync(); } } } } 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); return timeB.compareTo(timeA); }); setState(() { notes = allNotes; filteredNotes = allNotes; }); } /// 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 } 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 content = note.readAsStringSync().toLowerCase(); final noteTags = _extractTags(content); // āœ… Match partial tags instead of requiring full matches final matchesTags = searchTags.every((tag) => noteTags.any((noteTag) => noteTag.startsWith(tag)) // āœ… Partial match ); if (!_includeFileContent) { return filename.contains(searchText) && matchesTags; } else { return (filename.contains(searchText) || content.contains(searchText)) && matchesTags; } }).toList(); }); } 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; 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); } // Construct the final file path String fullPath = subfolder != null ? path.join(notesDirectoryPath!, subfolder, '$safeTitle.md') : path.join(notesDirectoryPath!, '$safeTitle.md'); final newNote = File(fullPath); // Prevent duplicate note creation if (notes.any((note) => note.path == newNote.path)) { print("āŒ 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, ), ), ); } } 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); // Remove empty elements if (parts.length > 1) { subfolder = parts.sublist(0, parts.length - 1).join('/'); } safeTitle = _sanitizeFilename(parts.last); } else { safeTitle = _sanitizeFilename(title); } // Construct the expected file path String fullPath = subfolder != null ? path.join(notesDirectoryPath!, subfolder, '$safeTitle.md') : path.join(notesDirectoryPath!, '$safeTitle.md'); return !notes.any((note) => note.path == fullPath); } 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); }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( duration: const Duration(seconds: 3), content: Text('Note moved to trash'), action: SnackBarAction( label: 'Undo', onPressed: () { trashedNote.renameSync(note.path); setState(() { notes.add(note); filteredNotes.add(note); }); }, ), ), ); } // āœ… 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) { print("āŒ 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; default: return; // Do nothing for unrecognized commands } searchController.clear(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Padding( padding: EdgeInsets.symmetric(horizontal: 15), child: TextField( 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.isNotEmpty && _isNewNote(trimmedText)) { _createNewNote(trimmedText); searchController .clear(); // Clears the search field after creation } }, ), ), actions: [ Padding( padding: EdgeInsets.symmetric(horizontal: 15), child: PopupMenuButton( 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; } }, 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' : 'Include 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' : 'Include File Content', ), onTap: () { setState(() { _includeFileContent = !_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'), ), ), PopupMenuItem( value: 'Empty Trash', child: ListTile( leading: Icon(Icons.delete, color: Colors.redAccent), title: Text('Empty Trash', style: TextStyle(color: Colors.redAccent)), ), ), ], ), ), ], ), body: Padding( padding: EdgeInsets.symmetric(horizontal: 15), child: ListView.builder( itemCount: filteredNotes.length, itemBuilder: (context, index) { final noteFile = filteredNotes[index]; final noteContent = noteFile.readAsStringSync(); final tagSpans = _formatTags(noteContent).isNotEmpty ? _formatTags(noteContent) : [TextSpan(text: '')]; 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 as needed 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 && tagSpans.isNotEmpty) Expanded( child: RichText( text: TextSpan( style: DefaultTextStyle.of(context).style, children: tagSpans, ), overflow: TextOverflow.ellipsis, // Prevents overflow errors ), ), ], ); }, ), onTap: () async { if (_useExternalEditor) { // Open with system's default editor 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]); // āœ… Use full path } else { print("āŒ `xdg-open` is not available."); } } else if (Platform.isAndroid) { await _requestStoragePermission(); final result = await OpenFilex.open( noteFile.path, // āœ… Use full path type: "text/markdown", ); print("āœ… OpenFilex result: ${result.type}, message: ${result.message}"); } } catch (e) { print("āŒ Failed to open external editor: $e"); } } else { // Open internally in NoteEditorScreen await Navigator.push( context, MaterialPageRoute( builder: (context) => NoteEditorScreen( note: noteFile, // āœ… Pass the full file path isDarkMode: widget.isDarkMode, notesDirectoryPath: notesDirectoryPath, ), ), ); _loadNotes(); // Reload notes after returning setState(() {}); // Force UI refresh } } ), ); }, ), ), ); } }