muzzle-velocity/lib/note_list.dart
randogoth db13702e54 add long-press gesture to open note scrolled to end
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>
2026-06-19 17:50:56 +03:00

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();
},
),
);
},
),
),
),
);
}
}