snappier search, lazy loading
This commit is contained in:
parent
53849f16d1
commit
5ba7f49372
1 changed files with 167 additions and 72 deletions
|
|
@ -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,9 +468,12 @@ 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),
|
|
||||||
|
// ✅ Show Snackbar with automatic dismissal
|
||||||
|
final snackBar = SnackBar(
|
||||||
|
duration: const Duration(seconds: 3), // ✅ Auto-dismiss after 3 seconds
|
||||||
content: Text('Note moved to trash'),
|
content: Text('Note moved to trash'),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: 'Undo',
|
label: 'Undo',
|
||||||
|
|
@ -411,8 +485,17 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ✅ 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,11 +763,27 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: EdgeInsets.only(left: 10),
|
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(
|
child: ListView.builder(
|
||||||
itemCount: filteredNotes.length,
|
itemCount: filteredNotes.length > _visibleLimit ? _visibleLimit + 1 : filteredNotes.length,
|
||||||
itemBuilder: (context, index) {
|
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];
|
final noteFile = filteredNotes[index];
|
||||||
final noteContent = noteFile.readAsStringSync();
|
final noteContent = _includeFileContent ? noteFile.readAsStringSync() : ""; // ✅ Read content only if needed
|
||||||
final tagSpans = _formatTags(noteContent).isNotEmpty
|
final tagSpans = _formatTags(noteContent).isNotEmpty
|
||||||
? _formatTags(noteContent)
|
? _formatTags(noteContent)
|
||||||
: [TextSpan(text: '')];
|
: [TextSpan(text: '')];
|
||||||
|
|
@ -702,11 +799,10 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
||||||
),
|
),
|
||||||
onDismissed: (direction) => _deleteNote(noteFile),
|
onDismissed: (direction) => _deleteNote(noteFile),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title:
|
title: Text(noteFile.uri.pathSegments.last.replaceAll('.md', '')),
|
||||||
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,26 +850,26 @@ 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
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue