snappier search, lazy loading

This commit is contained in:
randogoth 2025-02-23 10:10:56 +02:00
parent 53849f16d1
commit 5ba7f49372

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
@ -33,27 +34,55 @@ class _NoteListScreenState extends State<NoteListScreen> {
TextEditingController searchController = TextEditingController(); TextEditingController searchController = TextEditingController();
List<File> notes = []; List<File> notes = [];
List<File> filteredNotes = []; List<File> filteredNotes = [];
List<File> _visibleNotes = [];
int _visibleLimit = 30;
bool _isLoadingMore = false;
Map<File, Set<String>> noteTags = {}; Map<File, Set<String>> noteTags = {};
String? notesDirectoryPath; String? notesDirectoryPath;
String? defaultDir; String? defaultDir;
bool _useExternalEditor = false; bool _useExternalEditor = false;
bool _includeFileContent = false; bool _includeFileContent = false;
bool _includeSubdirectories = true; bool _includeSubdirectories = true;
Timer? _debounce;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initializeApp(); // Call an async method to handle initialization _initializeApp(); // Call an async method to handle initialization
searchController.addListener(() { searchController.addListener(() {
final text = searchController.text.trim(); _debouncedFilterNotes();
if (text.startsWith(':')) {
_handleCommand(text);
} else {
_filterNotes();
}
}); });
} }
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 { Future<void> _toggleExternalEditor(bool value) async {
setState(() { setState(() {
_useExternalEditor = value; _useExternalEditor = value;
@ -93,15 +122,21 @@ class _NoteListScreenState extends State<NoteListScreen> {
await _loadNotes(); await _loadNotes();
} }
Future<void> _toggleIncludeSubdirectories(bool value) async { void _toggleIncludeSubdirectories(bool value) {
setState(() { setState(() {
_includeSubdirectories = value; _includeSubdirectories = value;
}); });
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('include_subdirectories', value);
_loadNotes(); // Reload notes based on new setting
}
// Save preference
SharedPreferences.getInstance().then((prefs) {
prefs.setBool('include_subdirectories', value);
});
// Reload notes and reapply search immediately
_loadNotes().then((_) {
_filterNotes();
});
}
void _showDirectoryChoiceDialog(String defaultPath) { void _showDirectoryChoiceDialog(String defaultPath) {
showDialog( showDialog(
@ -165,36 +200,57 @@ class _NoteListScreenState extends State<NoteListScreen> {
List<File> allNotes = []; List<File> allNotes = [];
Map<String, DateTime> folderTimestamps = {}; Map<String, DateTime> folderTimestamps = {};
Map<File, Set<String>> extractedTags = {}; // Store extracted tags here
void fetchNotes(Directory dir, String relativePath) { void fetchNotes(Directory dir) {
final entries = dir.listSync(recursive: _includeSubdirectories); // Only include subfolders if enabled final entries = dir.listSync(recursive: _includeSubdirectories);
for (var entry in entries) { for (var entry in entries) {
if (entry is File && entry.path.endsWith('.md')) { if (entry is File && entry.path.endsWith('.md')) {
if (path.basename(path.dirname(entry.path)) == "trash") {
continue;
}
allNotes.add(entry); allNotes.add(entry);
final folder = path.dirname(entry.path); final folder = path.dirname(entry.path);
if (!folderTimestamps.containsKey(folder) || if (!folderTimestamps.containsKey(folder) ||
entry.lastModifiedSync().isAfter(folderTimestamps[folder]!)) { entry.lastModifiedSync().isAfter(folderTimestamps[folder]!)) {
folderTimestamps[folder] = entry.lastModifiedSync(); folderTimestamps[folder] = entry.lastModifiedSync();
} }
final content = entry.readAsStringSync();
extractedTags[entry] = _extractTags(content);
} }
} }
} }
fetchNotes(notesDir, ''); fetchNotes(notesDir);
allNotes.sort((a, b) { allNotes.sort((a, b) {
String folderA = path.dirname(a.path); String folderA = path.dirname(a.path);
String folderB = path.dirname(b.path); String folderB = path.dirname(b.path);
DateTime timeA = folderTimestamps[folderA] ?? DateTime(0); DateTime timeA = folderTimestamps[folderA] ?? DateTime(0);
DateTime timeB = folderTimestamps[folderB] ?? DateTime(0); DateTime timeB = folderTimestamps[folderB] ?? DateTime(0);
return timeB.compareTo(timeA);
// 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(() { setState(() {
notes = allNotes; notes = allNotes;
filteredNotes = allNotes; filteredNotes = allNotes;
noteTags = extractedTags; // Update stored tags
}); });
_filterNotes(); // Apply search immediately if a term is active
} }
/// Extracts unique tags sorted by +, @, # /// Extracts unique tags sorted by +, @, #
@ -227,13 +283,14 @@ class _NoteListScreenState extends State<NoteListScreen> {
filteredNotes = notes.where((note) { filteredNotes = notes.where((note) {
final filename = note.uri.pathSegments.last.toLowerCase(); final filename = note.uri.pathSegments.last.toLowerCase();
final content = note.readAsStringSync().toLowerCase(); final noteTags = _extractTags(note.readAsStringSync());
final noteTags = _extractTags(content);
// Read file content ONLY if `Include File Content` is enabled
final content = _includeFileContent ? note.readAsStringSync().toLowerCase() : '';
// Match partial tags instead of requiring full matches // Match partial tags instead of requiring full matches
final matchesTags = searchTags.every((tag) => final matchesTags = searchTags.every((tag) =>
noteTags.any((noteTag) => noteTag.startsWith(tag)) // Partial match noteTags.any((noteTag) => noteTag.startsWith(tag)));
);
if (!_includeFileContent) { if (!_includeFileContent) {
return filename.contains(searchText) && matchesTags; return filename.contains(searchText) && matchesTags;
@ -244,6 +301,20 @@ class _NoteListScreenState extends State<NoteListScreen> {
}); });
} }
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) { List<InlineSpan> _formatTags(String content) {
final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)'); final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)');
final Set<String> tagSet = {}; // Avoid duplicate tags final Set<String> tagSet = {}; // Avoid duplicate tags
@ -397,22 +468,34 @@ class _NoteListScreenState extends State<NoteListScreen> {
filteredNotes.remove(note); filteredNotes.remove(note);
}); });
ScaffoldMessenger.of(context).showSnackBar( // Ensure we clear previous Snackbars
SnackBar( ScaffoldMessenger.of(context).clearSnackBars();
duration: const Duration(seconds: 3),
content: Text('Note moved to trash'), // Show Snackbar with automatic dismissal
action: SnackBarAction( final snackBar = SnackBar(
label: 'Undo', duration: const Duration(seconds: 3), // Auto-dismiss after 3 seconds
onPressed: () { content: Text('Note moved to trash'),
trashedNote.renameSync(note.path); action: SnackBarAction(
setState(() { label: 'Undo',
notes.add(note); onPressed: () {
filteredNotes.add(note); 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 // Recursively deletes empty parent folders up to the notes directory
@ -622,9 +705,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
_includeFileContent ? 'Exclude File Content' : 'Include File Content', _includeFileContent ? 'Exclude File Content' : 'Include File Content',
), ),
onTap: () { onTap: () {
setState(() { _toggleIncludeFileContent(!_includeFileContent);
_includeFileContent = !_includeFileContent;
});
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
@ -682,31 +763,46 @@ class _NoteListScreenState extends State<NoteListScreen> {
), ),
body: Padding( body: Padding(
padding: EdgeInsets.only(left: 10), padding: EdgeInsets.only(left: 10),
child: ListView.builder( child: NotificationListener<ScrollNotification>(
itemCount: filteredNotes.length, onNotification: (ScrollNotification scrollInfo) {
itemBuilder: (context, index) { if (!_isLoadingMore && scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 100) {
final noteFile = filteredNotes[index]; _loadMoreNotes(); // Lazy load when user scrolls
final noteContent = noteFile.readAsStringSync(); }
final tagSpans = _formatTags(noteContent).isNotEmpty return false;
? _formatTags(noteContent) },
: [TextSpan(text: '')]; 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
),
);
}
return Dismissible( final noteFile = filteredNotes[index];
key: Key(noteFile.path), final noteContent = _includeFileContent ? noteFile.readAsStringSync() : ""; // Read content only if needed
direction: DismissDirection.endToStart, final tagSpans = _formatTags(noteContent).isNotEmpty
background: Container( ? _formatTags(noteContent)
color: Colors.redAccent, : [TextSpan(text: '')];
alignment: Alignment.centerRight,
padding: EdgeInsets.symmetric(horizontal: 20), return Dismissible(
child: Icon(Icons.delete, color: Colors.white), key: Key(noteFile.path),
), direction: DismissDirection.endToStart,
onDismissed: (direction) => _deleteNote(noteFile), background: Container(
child: ListTile( color: Colors.redAccent,
title: alignment: Alignment.centerRight,
Text(noteFile.uri.pathSegments.last.replaceAll('.md', '')), 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( subtitle: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final bool hasEnoughSpace = constraints.maxWidth > 200; // Adjust as needed final bool hasEnoughSpace = constraints.maxWidth > 200; // Adjust threshold
return Row( return Row(
children: [ children: [
@ -718,14 +814,14 @@ class _NoteListScreenState extends State<NoteListScreen> {
style: TextStyle(color: Colors.blueGrey), style: TextStyle(color: Colors.blueGrey),
), ),
), ),
if (widget.showTags && hasEnoughSpace && tagSpans.isNotEmpty) if (widget.showTags && hasEnoughSpace && noteTags[noteFile]?.isNotEmpty == true)
Expanded( Expanded(
child: RichText( child: RichText(
text: TextSpan( text: TextSpan(
style: DefaultTextStyle.of(context).style, style: DefaultTextStyle.of(context).style,
children: tagSpans, children: _formatTags(noteTags[noteFile]!.join(' ')), // Use stored tags
), ),
overflow: TextOverflow.ellipsis, // Prevents overflow errors overflow: TextOverflow.ellipsis,
), ),
), ),
], ],
@ -734,19 +830,18 @@ class _NoteListScreenState extends State<NoteListScreen> {
), ),
onTap: () async { onTap: () async {
if (_useExternalEditor) { if (_useExternalEditor) {
// Open with system's default editor
try { try {
if (Platform.isLinux) { if (Platform.isLinux) {
final result = await Process.run('which', ['xdg-open']); final result = await Process.run('which', ['xdg-open']);
if (result.stdout.toString().trim().isNotEmpty) { if (result.stdout.toString().trim().isNotEmpty) {
await Process.run('xdg-open', [noteFile.path]); // Use full path await Process.run('xdg-open', [noteFile.path]);
} else { } else {
print("❌ `xdg-open` is not available."); print("❌ `xdg-open` is not available.");
} }
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
await _requestStoragePermission(); await _requestStoragePermission();
final result = await OpenFilex.open( final result = await OpenFilex.open(
noteFile.path, // Use full path noteFile.path,
type: "text/markdown", type: "text/markdown",
); );
print("✅ OpenFilex result: ${result.type}, message: ${result.message}"); print("✅ OpenFilex result: ${result.type}, message: ${result.message}");
@ -755,24 +850,24 @@ class _NoteListScreenState extends State<NoteListScreen> {
print("❌ Failed to open external editor: $e"); print("❌ Failed to open external editor: $e");
} }
} else { } else {
// Open internally in NoteEditorScreen
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => NoteEditorScreen( builder: (context) => NoteEditorScreen(
note: noteFile, // Pass the full file path note: noteFile,
isDarkMode: widget.isDarkMode, isDarkMode: widget.isDarkMode,
notesDirectoryPath: notesDirectoryPath, notesDirectoryPath: notesDirectoryPath,
), ),
), ),
); );
_loadNotes(); // Reload notes after returning _loadNotes(); // Reload notes after returning
setState(() {}); // Force UI refresh setState(() {}); // Force UI refresh
} }
} },
), ),
); );
}, },
),
), ),
), ),
); );