From 5ba7f4937216cadceda85225c06e30e919e0d349 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 23 Feb 2025 10:10:56 +0200 Subject: [PATCH] snappier search, lazy loading --- lib/note_list.dart | 239 +++++++++++++++++++++++++++++++-------------- 1 file changed, 167 insertions(+), 72 deletions(-) diff --git a/lib/note_list.dart b/lib/note_list.dart index e67bdc8..1ead6b8 100644 --- a/lib/note_list.dart +++ b/lib/note_list.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:file_picker/file_picker.dart'; @@ -33,27 +34,55 @@ class _NoteListScreenState extends State { TextEditingController searchController = TextEditingController(); List notes = []; List filteredNotes = []; + List _visibleNotes = []; + int _visibleLimit = 30; + bool _isLoadingMore = false; Map> noteTags = {}; 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(() { - final text = searchController.text.trim(); - if (text.startsWith(':')) { - _handleCommand(text); - } else { - _filterNotes(); - } + _debouncedFilterNotes(); }); } + 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 _toggleExternalEditor(bool value) async { setState(() { _useExternalEditor = value; @@ -93,15 +122,21 @@ class _NoteListScreenState extends State { await _loadNotes(); } - Future _toggleIncludeSubdirectories(bool value) async { + void _toggleIncludeSubdirectories(bool value) { setState(() { _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) { showDialog( @@ -165,36 +200,57 @@ class _NoteListScreenState extends State { List allNotes = []; Map folderTimestamps = {}; + Map> extractedTags = {}; // ✅ Store extracted tags here - void fetchNotes(Directory dir, String relativePath) { - final entries = dir.listSync(recursive: _includeSubdirectories); // ✅ Only include subfolders if enabled + void fetchNotes(Directory dir) { + final entries = dir.listSync(recursive: _includeSubdirectories); for (var entry in entries) { if (entry is File && entry.path.endsWith('.md')) { + + 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); } } } - fetchNotes(notesDir, ''); + 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); + + // ✅ 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; // ✅ Update stored tags }); + + _filterNotes(); // ✅ Apply search immediately if a term is active } /// Extracts unique tags sorted by +, @, # @@ -227,13 +283,14 @@ class _NoteListScreenState extends State { filteredNotes = notes.where((note) { final filename = note.uri.pathSegments.last.toLowerCase(); - final content = note.readAsStringSync().toLowerCase(); - final noteTags = _extractTags(content); + final noteTags = _extractTags(note.readAsStringSync()); + + // ✅ Read file content ONLY if `Include File Content` is enabled + final content = _includeFileContent ? note.readAsStringSync().toLowerCase() : ''; // ✅ Match partial tags instead of requiring full matches final matchesTags = searchTags.every((tag) => - noteTags.any((noteTag) => noteTag.startsWith(tag)) // ✅ Partial match - ); + noteTags.any((noteTag) => noteTag.startsWith(tag))); if (!_includeFileContent) { return filename.contains(searchText) && matchesTags; @@ -244,6 +301,20 @@ class _NoteListScreenState extends State { }); } + 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 _formatTags(String content) { final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)'); final Set tagSet = {}; // Avoid duplicate tags @@ -397,22 +468,34 @@ class _NoteListScreenState extends State { 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); - }); - }, - ), + // ✅ 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 @@ -622,9 +705,7 @@ class _NoteListScreenState extends State { _includeFileContent ? 'Exclude File Content' : 'Include File Content', ), onTap: () { - setState(() { - _includeFileContent = !_includeFileContent; - }); + _toggleIncludeFileContent(!_includeFileContent); Navigator.pop(context); }, ), @@ -682,31 +763,46 @@ class _NoteListScreenState extends State { ), body: Padding( padding: EdgeInsets.only(left: 10), - 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: '')]; + child: NotificationListener( + 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 + ), + ); + } - 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', '')), + final noteFile = filteredNotes[index]; + final noteContent = _includeFileContent ? noteFile.readAsStringSync() : ""; // ✅ Read content only if needed + 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 + final bool hasEnoughSpace = constraints.maxWidth > 200; // Adjust threshold return Row( children: [ @@ -718,14 +814,14 @@ class _NoteListScreenState extends State { style: TextStyle(color: Colors.blueGrey), ), ), - if (widget.showTags && hasEnoughSpace && tagSpans.isNotEmpty) + if (widget.showTags && hasEnoughSpace && noteTags[noteFile]?.isNotEmpty == true) Expanded( child: RichText( text: TextSpan( 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 { ), 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 + await Process.run('xdg-open', [noteFile.path]); } else { print("❌ `xdg-open` is not available."); } } else if (Platform.isAndroid) { await _requestStoragePermission(); final result = await OpenFilex.open( - noteFile.path, // ✅ Use full path + noteFile.path, type: "text/markdown", ); print("✅ OpenFilex result: ${result.type}, message: ${result.message}"); @@ -755,24 +850,24 @@ class _NoteListScreenState extends State { 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 + note: noteFile, isDarkMode: widget.isDarkMode, notesDirectoryPath: notesDirectoryPath, ), ), ); - _loadNotes(); // Reload notes after returning - setState(() {}); // Force UI refresh + _loadNotes(); // ✅ Reload notes after returning + setState(() {}); // ✅ Force UI refresh } - } - ), - ); - }, + }, + ), + ); + }, + ), ), ), );