muzzle-velocity/lib/note_list.dart

697 lines
23 KiB
Dart
Raw Normal View History

2025-02-21 22:51:56 +02:00
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';
2025-02-21 22:51:56 +02:00
import 'package:path/path.dart' as path;
import 'package:permission_handler/permission_handler.dart';
2025-02-21 22:51:56 +02:00
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<NoteListScreen> {
TextEditingController searchController = TextEditingController();
List<File> notes = [];
List<File> filteredNotes = [];
String? notesDirectoryPath;
String? defaultDir;
bool _useExternalEditor = false;
2025-02-21 22:51:56 +02:00
@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();
}
});
}
2025-02-22 14:38:38 +02:00
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) {
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
}
}
2025-02-21 22:51:56 +02:00
Future<void> _initializeApp() async {
final prefs = await SharedPreferences.getInstance();
2025-02-21 22:51:56 +02:00
final dir = await getApplicationDocumentsDirectory();
2025-02-21 22:51:56 +02:00
setState(() {
defaultDir = dir.path;
_useExternalEditor = prefs.getBool('use_external_editor') ?? false;
2025-02-21 22:51:56 +02:00
});
await _loadNotes();
2025-02-21 22:51:56 +02:00
}
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();
2025-02-21 22:51:56 +02:00
Navigator.pop(context); // Close the current dialog
await _selectNotesDirectory(); // Allow user to pick a folder
},
child: Text("Choose Folder"),
),
],
);
},
);
}
Future<void> _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<void> _loadNotes() async {
final prefs = await SharedPreferences.getInstance();
2025-02-22 15:45:04 +02:00
notesDirectoryPath = prefs.getString('notes_directory') ?? defaultDir;
2025-02-21 22:51:56 +02:00
final notesDir = Directory(notesDirectoryPath!);
if (!notesDir.existsSync()) {
notesDir.createSync(recursive: true);
}
2025-02-22 15:45:04 +02:00
List<File> allNotes = [];
Map<String, DateTime> 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);
});
2025-02-21 22:51:56 +02:00
setState(() {
2025-02-22 15:45:04 +02:00
notes = allNotes;
filteredNotes = allNotes;
2025-02-21 22:51:56 +02:00
});
}
void _filterNotes() {
2025-02-22 15:58:17 +02:00
final query = searchController.text.trim();
if (query.isEmpty) {
setState(() {
filteredNotes = notes;
});
return;
}
2025-02-22 15:45:04 +02:00
2025-02-22 15:58:17 +02:00
final List<String> words = query.split(RegExp(r'\s+')); // Split by spaces
final List<String> terms = [];
final List<String> tags = [];
for (final word in words) {
if (word.startsWith('#') || word.startsWith('+') || word.startsWith('@')) {
tags.add(word.toLowerCase());
2025-02-22 15:45:04 +02:00
} else {
2025-02-22 15:58:17 +02:00
terms.add(word.toLowerCase());
2025-02-22 15:45:04 +02:00
}
2025-02-22 15:58:17 +02:00
}
setState(() {
filteredNotes = notes.where((note) {
final content = note.readAsStringSync().toLowerCase();
final filename = note.uri.pathSegments.last.toLowerCase();
// Check if all terms are found in filename or content
final bool matchesTerms = terms.isEmpty ||
terms.every((term) => filename.contains(term) || content.contains(term));
// Check if all tags are found in content
final bool matchesTags = tags.isEmpty ||
tags.every((tag) => content.contains(tag));
return matchesTerms && matchesTags;
}).toList();
2025-02-21 22:51:56 +02:00
});
}
2025-02-22 15:58:17 +02:00
2025-02-21 22:51:56 +02:00
List<InlineSpan> _formatTags(String content) {
final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)');
2025-02-22 15:58:17 +02:00
final Set<String> tagSet = {}; // Avoid duplicate tags
2025-02-21 22:51:56 +02:00
for (final match in tagPattern.allMatches(content)) {
2025-02-22 15:58:17 +02:00
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) {
2025-02-21 22:51:56 +02:00
Color tagColor = Colors.white;
if (tag.startsWith('#')) tagColor = Colors.pinkAccent;
if (tag.startsWith('+')) tagColor = Colors.cyan;
if (tag.startsWith('@')) tagColor = Colors.orange;
2025-02-22 15:58:17 +02:00
return TextSpan(
text: "$tag ",
style: TextStyle(color: tagColor, fontWeight: FontWeight.bold),
);
}).toList();
2025-02-21 22:51:56 +02:00
}
void _createNewNote(String title) async {
try {
2025-02-22 15:45:04 +02:00
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);
}
2025-02-21 22:51:56 +02:00
if (!newNote.existsSync()) {
2025-02-22 15:45:04 +02:00
newNote.writeAsStringSync('# $safeTitle\n\n');
2025-02-21 22:51:56 +02:00
setState(() {
notes.add(newNote);
filteredNotes.add(newNote);
});
2025-02-22 15:45:04 +02:00
2025-02-21 22:51:56 +02:00
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) {
2025-02-22 15:45:04 +02:00
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);
2025-02-21 22:51:56 +02:00
}
String _sanitizeFilename(String title) {
return title
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '') // Remove invalid characters
.trim();
}
void _deleteNote(File note) {
2025-02-22 15:45:04 +02:00
final rootTrashDir = Directory(path.join(notesDirectoryPath!, 'trash'));
if (!rootTrashDir.existsSync()) {
rootTrashDir.createSync(recursive: true);
2025-02-21 22:51:56 +02:00
}
2025-02-22 15:45:04 +02:00
// 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
2025-02-21 22:51:56 +02:00
note.renameSync(trashedNote.path);
2025-02-22 15:45:04 +02:00
// Check if the original directory is now empty and delete it if needed
_deleteEmptyParentFolders(note.parent);
2025-02-21 22:51:56 +02:00
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);
});
},
),
),
);
}
2025-02-22 15:45:04 +02:00
// ✅ 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");
}
}
2025-02-21 22:51:56 +02:00
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;
2025-02-22 14:38:38 +02:00
case ':extern':
_toggleExternalEditor(true);
break;
case ':intern':
_toggleExternalEditor(false);
break;
2025-02-21 22:51:56 +02:00
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<String>(
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':
2025-02-22 14:38:38 +02:00
_toggleExternalEditor(!_useExternalEditor);
break;
2025-02-21 22:51:56 +02:00
case 'Select Folder':
_showDirectoryChoiceDialog(
notesDirectoryPath ?? ''); // Re-trigger popup
break;
case 'Empty Trash':
_confirmEmptyTrash();
break;
}
},
itemBuilder: (BuildContext context) => [
PopupMenuItem<String>(
value: 'Toggle Tags',
child: ListTile(
leading:
Icon(widget.showTags ? Icons.label_off : Icons.label),
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: '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',
),
),
),
2025-02-21 22:51:56 +02:00
PopupMenuItem<String>(
value: 'Select Folder',
child: ListTile(
leading: Icon(Icons.folder),
title: Text('Set Notes Directory'),
),
),
PopupMenuItem<String>(
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', '')),
2025-02-22 15:58:17 +02:00
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
),
),
],
);
},
),
2025-02-22 15:45:04 +02:00
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}");
}
2025-02-22 15:45:04 +02:00
} catch (e) {
print("❌ Failed to open external editor: $e");
}
2025-02-22 15:45:04 +02:00
} 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,
),
),
2025-02-22 15:45:04 +02:00
);
_loadNotes(); // Reload notes after returning
setState(() {}); // Force UI refresh
}
}
2025-02-21 22:51:56 +02:00
),
);
},
),
),
);
}
}