import 'dart:io'; import 'package:flutter/material.dart'; import 'package:file_picker/file_picker.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 = []; String? notesDirectoryPath; String? defaultDir; bool _useExternalEditor = false; @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; }); await _loadNotes(); } 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 = {}; // Store latest edit time per folder void fetchNotes(Directory dir, String relativePath) { final entries = dir.listSync(recursive: false); for (var entry in entries) { if (entry is File && entry.path.endsWith('.md')) { allNotes.add(entry); // Store latest edit time per folder final folder = path.dirname(entry.path); if (!folderTimestamps.containsKey(folder) || entry.lastModifiedSync().isAfter(folderTimestamps[folder]!)) { folderTimestamps[folder] = entry.lastModifiedSync(); } } else if (entry is Directory && path.basename(entry.path) != 'trash') { fetchNotes(entry, path.join(relativePath, path.basename(entry.path))); } } } fetchNotes(notesDir, ''); // Sorting logic: allNotes.sort((a, b) { String folderA = path.dirname(a.path); String folderB = path.dirname(b.path); bool isRootA = folderA == notesDirectoryPath; // Root folder check bool isRootB = folderB == notesDirectoryPath; if (isRootA && !isRootB) return -1; // Root files first if (!isRootA && isRootB) return 1; // Subfolder files later // If both are in the root OR both are in subfolders, sort by latest edit time DateTime timeA = folderTimestamps[folderA] ?? DateTime(0); DateTime timeB = folderTimestamps[folderB] ?? DateTime(0); return timeB.compareTo(timeA); }); setState(() { notes = allNotes; filteredNotes = allNotes; }); } void _filterNotes() { final query = searchController.text.trim().toLowerCase(); setState(() { if (query.startsWith('/')) { // Extract subfolder path and search term final parts = query.split('/'); final subfolderPath = parts.sublist(1, parts.length - 1).join('/'); final searchTerm = parts.last; filteredNotes = notes.where((note) { final relativePath = path.relative(note.path, from: notesDirectoryPath!); return relativePath.startsWith(subfolderPath) && (relativePath.contains(searchTerm) || note.readAsStringSync().toLowerCase().contains(searchTerm)); }).toList(); } else { // Default search across all notes filteredNotes = notes.where((note) { final content = note.readAsStringSync().toLowerCase(); return note.uri.pathSegments.last.toLowerCase().contains(query) || content.contains(query); }).toList(); } }); } List _formatTags(String content) { final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)'); final List spans = []; for (final match in tagPattern.allMatches(content)) { final tag = match.group(0)!; Color tagColor = Colors.white; if (tag.startsWith('#')) tagColor = Colors.pinkAccent; if (tag.startsWith('+')) tagColor = Colors.cyan; if (tag.startsWith('@')) tagColor = Colors.orange; spans.add(TextSpan( text: tag + ' ', style: TextStyle(color: tagColor, fontWeight: FontWeight.bold))); } return spans; } 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( 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, 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: Icon(Icons.radio_button_checked, color: Colors.orange), // Menu button (ā‹®) 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.label_off : Icons.label), 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: '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), 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: Row( children: [ path.relative(path.dirname(noteFile.path), from: notesDirectoryPath!) != "." ? Text( path.relative(path.dirname(noteFile.path), from: notesDirectoryPath!), style: TextStyle(color: Colors.blueGrey), ) : Text(""), widget.showTags && tagSpans.isNotEmpty ? RichText( text: TextSpan( style: DefaultTextStyle.of(context).style, children: tagSpans) ) : Text("") ], ), 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 } } ), ); }, ), ), ); } }