From 559e0fea362cc8b274a82310fc5dec3856d1dea0 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Mon, 9 Jun 2025 08:49:48 +0200 Subject: [PATCH] Implement some form of caching entries while scrolling --- lib/main.dart | 1133 ++++++++++++++++++++++++++++++++++-------- lib/meilisearch.dart | 122 ++++- 2 files changed, 1049 insertions(+), 206 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 735d7ac..f4c0ed4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:journaler/notes.dart'; -import 'package:journaler/utils.dart'; +import 'package:journaler/utils.dart' as utils; import 'package:system_tray/system_tray.dart'; import 'package:window_manager/window_manager.dart'; import 'package:audioplayers/audioplayers.dart'; @@ -11,8 +11,8 @@ import 'package:flutter/gestures.dart'; import 'dart:math'; import 'package:path/path.dart' as path; import 'package:ps_list/ps_list.dart'; -import 'package:journaler/meilisearch.dart'; -import 'package:journaler/meilisearch_config.dart'; +import 'package:journaler/meilisearch.dart' as meili; +import 'package:journaler/meilisearch_config.dart' as config; // TODO: Sound does not play when ran from a different workdir? Weird // TODO: Fix saving the same scratch over and over again @@ -69,7 +69,7 @@ Future runPrimaryInstance(File ipcFile) async { // Initialize the app try { // Initialize Meilisearch first - await init(); + await meili.init(); debugPrint('Meilisearch initialized successfully'); } catch (e) { debugPrint('Error initializing Meilisearch: $e'); @@ -316,15 +316,38 @@ class MainPageState extends State with WindowListener { String _currentNotificationSound = _defaultNotificationSound; String? _originalScratchContent; // Track original scratch content + // Note cache for smoother scrolling + final Map _noteCache = {}; + bool _isCacheLoading = false; + int _cacheSizeBefore = 50; // Notes to cache before current (default) + int _cacheSizeAfter = 50; // Notes to cache after current (default) + + // Cache statistics + int _cacheHits = 0; + int _cacheMisses = 0; + bool _canGoPrevious = false; bool _canGoNext = false; bool _isSearching = false; List _searchResults = []; + // Add network activity tracker + bool _isNetworkActive = false; + int _pendingNetworkRequests = 0; + Timer? _popupTimer; Timer? _debounceTimer; Timer? _searchDebounceTimer; + String _currentOperation = ""; + DateTime _operationStartTime = DateTime.now(); + String _lastOperation = ""; + + // Add tracking variables + int _totalPrefetchOperations = 0; + int _forwardPrefetchOperations = 0; + int _backwardPrefetchOperations = 0; + @override void initState() { super.initState(); @@ -371,9 +394,15 @@ class MainPageState extends State with WindowListener { @override void onWindowClose() async { - // Save data when window is closed - await _saveData(); + // Hide window immediately to provide faster response to ESC key windowManager.hide(); + + // Then save data in the background + _saveData().then((_) { + debugPrint("Data saved after window close"); + }).catchError((e) { + debugPrint("Error saving data after window close: $e"); + }); } @override @@ -381,6 +410,41 @@ class MainPageState extends State with WindowListener { setState(() {}); } + // Wrap API calls to track network activity + Future _trackNetworkActivity(Future Function() apiCall, {String operation = "Unknown operation"}) async { + final startTime = DateTime.now(); + + setState(() { + _pendingNetworkRequests++; + _isNetworkActive = true; + _currentOperation = operation; + _operationStartTime = startTime; + }); + + try { + return await apiCall(); + } finally { + final duration = DateTime.now().difference(startTime); + final durationStr = "${duration.inMilliseconds}ms"; + + if (duration.inMilliseconds > 1000) { + debugPrint("SLOW OPERATION: $operation took $durationStr"); + } + + setState(() { + _pendingNetworkRequests--; + _isNetworkActive = _pendingNetworkRequests > 0; + _lastOperation = "$operation ($durationStr)"; + + if (_pendingNetworkRequests > 0) { + // Keep the current operation if there are still pending requests + } else { + _currentOperation = ""; + } + }); + } + } + Future _initSystemTray() async { String iconPath = 'assets/app_icon.ico'; @@ -501,13 +565,66 @@ class MainPageState extends State with WindowListener { return; } - final prev = await getPreviousTo(_currentlyDisplayedNote!.epochTime); - final bool isLatest = - _currentlyDisplayedNote!.epochTime == previousNote?.epochTime; + // Check if we have previous/next notes in cache + final timestamp = _currentlyDisplayedNote!.epochTime; + final hasPreviousInCache = _noteCache.keys.any((t) => t < timestamp); + final hasNextInCache = _noteCache.keys.any((t) => t > timestamp); + + // Debug log cache state + debugPrint("Current note timestamp: $timestamp"); + debugPrint("Cache has previous: $hasPreviousInCache, Cache has next: $hasNextInCache"); + debugPrint("Cache size: ${_noteCache.length} notes"); + + if (hasNextInCache) { + // Print the next timestamp in cache for debugging + final nextTimestamps = _noteCache.keys + .where((t) => t > timestamp) + .toList() + ..sort(); + debugPrint("Next note in cache: ${nextTimestamps.first}"); + } + + // If not in cache, check with the server - but only if necessary + bool canGoPrev = hasPreviousInCache; + bool canGoNext = hasNextInCache; + + // We only need to check with the server for navigation state - don't prefetch yet + if (!hasPreviousInCache) { + final prev = await _trackNetworkActivity( + () => meili.getPreviousTo(_currentlyDisplayedNote!.epochTime), + operation: "Checking for previous notes", + ); + canGoPrev = prev != null; + if (prev != null) { + // Add to cache + _noteCache[prev.epochTime] = prev; + } + } + + // For forward navigation, only check with server if needed and no next in cache + if (!hasNextInCache) { + // Special case: we're at the latest note (previousNote is same as current) + final isLatestNote = _currentlyDisplayedNote!.epochTime == previousNote?.epochTime; + + if (!isLatestNote) { + final next = await _trackNetworkActivity( + () => meili.getNextTo(_currentlyDisplayedNote!.epochTime), + operation: "Checking for next notes", + ); + canGoNext = next != null; + if (next != null) { + // Add to cache + _noteCache[next.epochTime] = next; + } + } else { + // If we're at the latest note, there's no next note by definition + canGoNext = false; + } + } setState(() { - _canGoPrevious = prev != null; - _canGoNext = !isLatest; + _canGoPrevious = canGoPrev; + _canGoNext = canGoNext; }); } @@ -518,16 +635,36 @@ class MainPageState extends State with WindowListener { if (_currentlyDisplayedNote != null) { if (_currentlyDisplayedNote!.content != _previousEntryController.text) { _currentlyDisplayedNote!.content = _previousEntryController.text; - await updateNote(_currentlyDisplayedNote!); + await _trackNetworkActivity( + () => meili.updateNote(_currentlyDisplayedNote!), + operation: "Saving current note before navigation", + ); + + // Update cache with the modified note + _noteCache[_currentlyDisplayedNote!.epochTime] = _currentlyDisplayedNote!; } } - final prevNote = await getPreviousTo(_currentlyDisplayedNote!.epochTime); + // Use cached previous note if available + final prevNote = await _getCachedPreviousNote(_currentlyDisplayedNote!.epochTime); + if (prevNote != null) { setState(() { _currentlyDisplayedNote = prevNote; _previousEntryController.text = prevNote.content; }); + + // Prefetch more notes in the background if we're getting close to the edge of our cache + final timestamp = prevNote.epochTime; + final countBefore = _noteCache.keys.where((t) => t < timestamp).length; + + // Only prefetch if we have less than 25% of our desired cache size behind + // This prevents excessive prefetching when we already have plenty of notes + if (countBefore < _cacheSizeBefore / 4) { + debugPrint("Only $countBefore notes before in cache, prefetching more"); + _loadNotesBefore(timestamp); + } + await _checkNavigation(); } } @@ -539,41 +676,95 @@ class MainPageState extends State with WindowListener { if (_currentlyDisplayedNote != null) { if (_currentlyDisplayedNote!.content != _previousEntryController.text) { _currentlyDisplayedNote!.content = _previousEntryController.text; - await updateNote(_currentlyDisplayedNote!); + await _trackNetworkActivity( + () => meili.updateNote(_currentlyDisplayedNote!), + operation: "Saving current note before navigation", + ); + + // Update cache with the modified note + _noteCache[_currentlyDisplayedNote!.epochTime] = _currentlyDisplayedNote!; } } - final nextNote = await getNextTo(_currentlyDisplayedNote!.epochTime); + // Use cached next note if available + final nextNote = await _getCachedNextNote(_currentlyDisplayedNote!.epochTime); + if (nextNote != null) { setState(() { _currentlyDisplayedNote = nextNote; _previousEntryController.text = nextNote.content; }); + + // Prefetch more notes in the background if we're getting close to the edge of our cache + final timestamp = nextNote.epochTime; + final countAfter = _noteCache.keys.where((t) => t > timestamp).length; + + // Only prefetch if we have less than 25% of our desired cache size ahead + // This prevents excessive prefetching when we already have plenty of notes + if (countAfter < _cacheSizeAfter / 4) { + debugPrint("Only $countAfter notes ahead in cache, prefetching more"); + _loadNotesAfter(timestamp); + } + await _checkNavigation(); } } void _loadData() async { - Duration interval = await getPopupInterval(); - String soundFile = await getNotificationSound(); + Duration interval = await _trackNetworkActivity( + () => utils.getPopupInterval(), + operation: "Loading popup interval", + ); + + String soundFile = await _trackNetworkActivity( + () => utils.getNotificationSound(), + operation: "Loading notification sound", + ); + + // Load cache size settings + int cacheBefore = await _trackNetworkActivity( + () => meili.getCacheSizeBefore(), + operation: "Loading cache before size", + ); + + int cacheAfter = await _trackNetworkActivity( + () => meili.getCacheSizeAfter(), + operation: "Loading cache after size", + ); _currentPopupInterval = interval; _currentNotificationSound = soundFile; + _cacheSizeBefore = cacheBefore; + _cacheSizeAfter = cacheAfter; _intervalController.text = interval.inMinutes.toString(); _soundController.text = _currentNotificationSound; _startPopupTimer(); - final note = await getLatest(); + final note = await _trackNetworkActivity( + () => meili.getLatest(), + operation: "Loading latest note", + ); + previousNote = note; _currentlyDisplayedNote = note; _previousEntryController.text = _currentlyDisplayedNote?.content ?? ""; - final scratch = await getLatestScratch(); + final scratch = await _trackNetworkActivity( + () => meili.getLatestScratch(), + operation: "Loading latest scratch", + ); + _scratchController.text = scratch?.content ?? ""; _originalScratchContent = scratch?.content; // Store original content + // Initialize cache with the current note and prefetch more + if (note != null) { + _noteCache[note.epochTime] = note; + _refreshNoteCache(); + } + await _checkNavigation(); debugPrint("Data loaded"); @@ -581,16 +772,24 @@ class MainPageState extends State with WindowListener { // Load volume setting from database Future _loadVolume() async { - double? volume = await getVolume(); + double? volume = await _trackNetworkActivity( + () => utils.getVolume(), + operation: "Loading volume settings", + ); + setState(() { - _volume = volume; + _volume = volume ?? 0.7; // Use default value if null _audioPlayer.setVolume(_linearToLogVolume(_volume)); }); } // Save volume setting to database Future _saveVolume() async { - await setVolume(_volume); + await _trackNetworkActivity( + () => utils.setVolume(_volume), + operation: "Saving volume settings", + ); + debugPrint("Volume saved: $_volume"); } @@ -603,36 +802,68 @@ class MainPageState extends State with WindowListener { // Handle current entry if (currentEntry.isNotEmpty) { - await createNote(currentEntry); + final newNote = await _trackNetworkActivity( + () => meili.createNote(currentEntry), + operation: "Creating new note", + ); + + // Add the new note to the cache + _noteCache[newNote.epochTime] = newNote; + _currentEntryController.clear(); // Clear the input field after saving } // Only create new scratch if content has changed if (scratchContent != _originalScratchContent) { - await createScratch(scratchContent); + await _trackNetworkActivity( + () => meili.createScratch(scratchContent), + operation: "Saving scratch content", + ); + _originalScratchContent = scratchContent; // Update original content } // Handle previous/currently displayed note if (_currentlyDisplayedNote != null) { - if (_currentlyDisplayedNote!.content != previousEntry) { - _currentlyDisplayedNote!.content = previousEntry; - await updateNote(_currentlyDisplayedNote!); - } - - // If the note was deleted (due to being empty), update the UI state if (previousEntry.isEmpty) { + // Delete the note if it's empty (fix for issue #1) + final noteId = _currentlyDisplayedNote!.id; + final noteTimestamp = _currentlyDisplayedNote!.epochTime; + + await _trackNetworkActivity( + () => meili.deleteNote(noteId), + operation: "Deleting empty note", + ); + + // Remove from cache + _noteCache.remove(noteTimestamp); + // Check if we need to navigate to another note - Note? nextNote = await getLatest(); + Note? nextNote = await _trackNetworkActivity( + () => meili.getLatest(), + operation: "Finding next note after deletion", + ); + setState(() { _currentlyDisplayedNote = nextNote; if (nextNote != null) { _previousEntryController.text = nextNote.content; + _noteCache[nextNote.epochTime] = nextNote; } else { _previousEntryController.text = ""; } }); await _checkNavigation(); + } else if (_currentlyDisplayedNote!.content != previousEntry) { + // Only update if content has changed and is not empty + _currentlyDisplayedNote!.content = previousEntry; + await _trackNetworkActivity( + () => meili.updateNote(_currentlyDisplayedNote!), + operation: "Updating edited note", + ); + + // Update the cache + _noteCache[_currentlyDisplayedNote!.epochTime] = _currentlyDisplayedNote!; } } @@ -641,13 +872,20 @@ class MainPageState extends State with WindowListener { Duration newInterval = Duration(minutes: newIntervalMinutes); if (newInterval != _currentPopupInterval) { _currentPopupInterval = newInterval; - await setPopupInterval(newInterval); + await _trackNetworkActivity( + () => utils.setPopupInterval(newInterval), + operation: "Updating popup interval", + ); + _startPopupTimer(); } if (soundStr != _currentNotificationSound) { _currentNotificationSound = soundStr; - await setNotificationSound(soundStr); + await _trackNetworkActivity( + () => utils.setNotificationSound(soundStr), + operation: "Updating notification sound", + ); } // Also save volume @@ -765,7 +1003,10 @@ class MainPageState extends State with WindowListener { const Duration(milliseconds: 300), () async { try { - final results = await searchNotes(trimmedQuery); + final results = await _trackNetworkActivity( + () => meili.searchNotes(trimmedQuery), + operation: "Searching notes for: '$trimmedQuery'", + ); // Filter out empty notes (which may exist in the search index but were deleted) final filteredResults = @@ -850,8 +1091,11 @@ class MainPageState extends State with WindowListener { .content = _previousEntryController .text; - await updateNote( - _currentlyDisplayedNote!, + await _trackNetworkActivity( + () => meili.updateNote( + _currentlyDisplayedNote!, + ), + operation: "Saving note before viewing search result", ); } } @@ -862,7 +1106,14 @@ class MainPageState extends State with WindowListener { _currentlyDisplayedNote = note; _previousEntryController.text = note.content; + + // Add to cache + _noteCache[note.epochTime] = note; }); + + // Refresh cache for notes around this one + _refreshNoteCache(); + _checkNavigation(); }, ), @@ -927,7 +1178,10 @@ class MainPageState extends State with WindowListener { // Show cleanup dialog void _showCleanupDialog() async { double sensitivity = 0.7; // Default 70% - final problematicEntries = await getProblematic(threshold: sensitivity); + final problematicEntries = await _trackNetworkActivity( + () => meili.getProblematic(threshold: sensitivity), + operation: "Finding problematic notes (${sensitivity * 100}% threshold)", + ); if (!mounted) return; @@ -960,8 +1214,11 @@ class MainPageState extends State with WindowListener { 100; // Round to 2 decimal places }); // Refresh results with new sensitivity - final newResults = await getProblematic( - threshold: sensitivity, + final newResults = await _trackNetworkActivity( + () => meili.getProblematic( + threshold: sensitivity, + ), + operation: "Refreshing problematic notes (${sensitivity * 100}% threshold)", ); dialogSetState(() { problematicEntries.clear(); @@ -1089,7 +1346,17 @@ class MainPageState extends State with WindowListener { } if (shouldDelete) { - await deleteNote(note.id); + final noteId = note.id; + final noteTimestamp = note.epochTime; + + await _trackNetworkActivity( + () => meili.deleteNote(noteId), + operation: "Deleting problematic note", + ); + + // Remove from cache if present + _noteCache.remove(noteTimestamp); + dialogSetState(() { problematicEntries.removeAt( index, @@ -1127,11 +1394,16 @@ class MainPageState extends State with WindowListener { void _showMeilisearchSettings() { final endpointController = TextEditingController(); final apiKeyController = TextEditingController(); + final cacheSizeBeforeController = TextEditingController(text: _cacheSizeBefore.toString()); + final cacheSizeAfterController = TextEditingController(text: _cacheSizeAfter.toString()); bool isLoading = true; String? errorMessage; // Load current values - getMeilisearchEndpoint() + _trackNetworkActivity( + () => config.getMeilisearchEndpoint(), + operation: "Loading Meilisearch endpoint", + ) .then((value) { endpointController.text = value; isLoading = false; @@ -1141,7 +1413,10 @@ class MainPageState extends State with WindowListener { isLoading = false; }); - getMeilisearchApiKey() + _trackNetworkActivity( + () => config.getMeilisearchApiKey(), + operation: "Loading Meilisearch API key", + ) .then((value) { apiKeyController.text = value; }) @@ -1155,39 +1430,122 @@ class MainPageState extends State with WindowListener { return StatefulBuilder( builder: (context, setState) { return AlertDialog( - title: const Text('Meilisearch Settings'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (isLoading) - const Center(child: CircularProgressIndicator()) - else if (errorMessage != null) - Text( - errorMessage!, - style: const TextStyle(color: Colors.red), - ) - else ...[ - TextField( - controller: endpointController, - decoration: const InputDecoration( - labelText: 'Endpoint URL', - hintText: 'http://localhost:7700', - border: OutlineInputBorder(), - ), + title: const Text('Settings'), + content: isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + errorMessage!, + style: const TextStyle(color: Colors.red), + ), + ), + const Text( + 'Meilisearch Connection', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + TextField( + controller: endpointController, + decoration: const InputDecoration( + labelText: 'Endpoint URL', + hintText: 'http://localhost:7700', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextField( + controller: apiKeyController, + decoration: const InputDecoration( + labelText: 'API Key', + hintText: 'masterKey', + border: OutlineInputBorder(), + ), + obscureText: true, + ), + const SizedBox(height: 24), + const Text( + 'Cache Settings', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + const Text( + 'Number of notes to prefetch and cache for smoother scrolling:', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: cacheSizeBeforeController, + decoration: const InputDecoration( + labelText: 'Notes Before', + hintText: '50', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: cacheSizeAfterController, + decoration: const InputDecoration( + labelText: 'Notes After', + hintText: '50', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Current cache: ${_noteCache.length} notes (${_cacheHits + _cacheMisses > 0 ? (_cacheHits / (_cacheHits + _cacheMisses) * 100).toStringAsFixed(1) : "0"}% hit rate)', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + TextButton( + onPressed: () { + setState(() { + _noteCache.clear(); + _cacheHits = 0; + _cacheMisses = 0; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cache cleared'), + duration: Duration(seconds: 1), + ), + ); + }, + child: const Text('Clear Cache'), + ), + ], ), - const SizedBox(height: 16), - TextField( - controller: apiKeyController, - decoration: const InputDecoration( - labelText: 'API Key', - hintText: 'masterKey', - border: OutlineInputBorder(), - ), - obscureText: true, - ), - ], - ], - ), + ), actions: [ TextButton( onPressed: () { @@ -1196,34 +1554,68 @@ class MainPageState extends State with WindowListener { child: const Text('Cancel'), ), TextButton( - onPressed: - isLoading - ? null - : () async { - try { - setState(() { - isLoading = true; - errorMessage = null; - }); + onPressed: isLoading + ? null + : () async { + try { + setState(() { + isLoading = true; + errorMessage = null; + }); - await setMeilisearchEndpoint( + // Save Meilisearch connection settings + await _trackNetworkActivity( + () => config.setMeilisearchEndpoint( endpointController.text, - ); - await setMeilisearchApiKey(apiKeyController.text); + ), + operation: "Saving Meilisearch endpoint", + ); + + await _trackNetworkActivity( + () => config.setMeilisearchApiKey(apiKeyController.text), + operation: "Saving Meilisearch API key", + ); - // Try to reinitialize Meilisearch with new settings - await init(); + // Save cache size settings + final newCacheBefore = int.tryParse(cacheSizeBeforeController.text) ?? 50; + final newCacheAfter = int.tryParse(cacheSizeAfterController.text) ?? 50; + + await _trackNetworkActivity( + () => meili.setCacheSizeBefore(newCacheBefore), + operation: "Saving cache before size", + ); + + await _trackNetworkActivity( + () => meili.setCacheSizeAfter(newCacheAfter), + operation: "Saving cache after size", + ); + + // Update current values + _cacheSizeBefore = newCacheBefore; + _cacheSizeAfter = newCacheAfter; - if (mounted) { - Navigator.of(context).pop(); - } - } catch (e) { - setState(() { - errorMessage = 'Failed to save settings: $e'; - isLoading = false; - }); + // Try to reinitialize Meilisearch with new settings + await _trackNetworkActivity( + () => meili.init(), + operation: "Reinitializing Meilisearch", + ); + + // Clear and refresh cache with new sizes + _noteCache.clear(); + if (_currentlyDisplayedNote != null) { + _refreshNoteCache(); } - }, + + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + setState(() { + errorMessage = 'Failed to save settings: $e'; + isLoading = false; + }); + } + }, child: const Text('Save'), ), ], @@ -1234,6 +1626,320 @@ class MainPageState extends State with WindowListener { ); } + // Add network activity indicator widget + Widget _buildNetworkActivityIndicator() { + // If not active, show a more compact version with just the last operation + if (!_isNetworkActive) { + if (_lastOperation.isEmpty) return const SizedBox.shrink(); + + return AnimatedOpacity( + opacity: 0.7, // Slightly transparent when not active + duration: const Duration(milliseconds: 300), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Last: $_lastOperation', + style: TextStyle(color: Colors.white70, fontSize: 11), + ), + ), + ); + } + + // Calculate elapsed time if there's an active operation + String timeDisplay = ""; + final elapsed = DateTime.now().difference(_operationStartTime); + timeDisplay = " (${elapsed.inSeconds}s)"; + + // Show warning color for slow operations (> 5 seconds) + final isSlowOperation = elapsed.inSeconds > 5; + final textColor = isSlowOperation ? Colors.orange : Colors.white; + + return AnimatedOpacity( + opacity: 1.0, + duration: const Duration(milliseconds: 300), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: textColor, + ), + ), + const SizedBox(width: 8), + Text( + 'Syncing$timeDisplay (${_pendingNetworkRequests} pending)', + style: TextStyle( + color: textColor, + fontSize: 13, + ), + ), + ], + ), + if (_currentOperation.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4, left: 24), + child: Text( + 'Current: $_currentOperation', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + ], + ), + ), + ); + } + + // Load a batch of notes before the given timestamp + Future _loadNotesBefore(int timestamp) async { + if (_isCacheLoading) return; + _isCacheLoading = true; + + try { + // Use the proper cache size as configured + _totalPrefetchOperations++; + _backwardPrefetchOperations++; + + final notes = await _trackNetworkActivity( + () => meili.getNotesBefore(timestamp, limit: _cacheSizeBefore), + operation: "Prefetching $_cacheSizeBefore older notes (op #$_totalPrefetchOperations)", + ); + + setState(() { + for (var note in notes) { + _noteCache[note.epochTime] = note; + } + }); + + debugPrint("Cached ${notes.length} notes before $timestamp (total prefetch ops: $_totalPrefetchOperations)"); + } catch (e) { + debugPrint("Error loading notes batch before $timestamp: $e"); + } finally { + _isCacheLoading = false; + } + } + + // Load a batch of notes after the given timestamp + Future _loadNotesAfter(int timestamp) async { + if (_isCacheLoading) return; + _isCacheLoading = true; + + try { + // Use the proper cache size as configured + _totalPrefetchOperations++; + _forwardPrefetchOperations++; + + final notes = await _trackNetworkActivity( + () => meili.getNotesAfter(timestamp, limit: _cacheSizeAfter), + operation: "Prefetching $_cacheSizeAfter newer notes (op #$_totalPrefetchOperations)", + ); + + setState(() { + for (var note in notes) { + _noteCache[note.epochTime] = note; + } + }); + + debugPrint("Cached ${notes.length} notes after $timestamp (total prefetch ops: $_totalPrefetchOperations, forward: $_forwardPrefetchOperations)"); + } catch (e) { + debugPrint("Error loading notes batch after $timestamp: $e"); + } finally { + _isCacheLoading = false; + } + } + + // Refresh the cache around the current note + Future _refreshNoteCache() async { + if (_currentlyDisplayedNote == null) return; + + final currentTimestamp = _currentlyDisplayedNote!.epochTime; + + // Add current note to cache + _noteCache[currentTimestamp] = _currentlyDisplayedNote!; + + // Load notes in both directions + await Future.wait([ + _loadNotesBefore(currentTimestamp), + _loadNotesAfter(currentTimestamp), + ]); + } + + // Get a note from cache if available, otherwise fetch from server + Future _getNote(int timestamp, {bool isPrevious = true}) async { + // Check cache first + if (_noteCache.containsKey(timestamp)) { + _cacheHits++; + debugPrint("Note cache hit for timestamp $timestamp (Hits: $_cacheHits, Misses: $_cacheMisses)"); + return _noteCache[timestamp]; + } + + _cacheMisses++; + debugPrint("Note cache miss for timestamp $timestamp (Hits: $_cacheHits, Misses: $_cacheMisses)"); + + // If not in cache, fetch individually + final note = await _trackNetworkActivity( + () => isPrevious + ? meili.getPreviousTo(timestamp) + : meili.getNextTo(timestamp), + operation: "Loading ${isPrevious ? 'previous' : 'next'} note", + ); + + // Add to cache if found + if (note != null) { + _noteCache[note.epochTime] = note; + } + + return note; + } + + // Get previous note with cache support + Future _getCachedPreviousNote(int timestamp) async { + // Find the closest timestamp that is less than current + final previousTimestamps = _noteCache.keys + .where((t) => t < timestamp) + .toList() + ..sort((a, b) => b.compareTo(a)); // Sort descending + + debugPrint("Looking for previous note before $timestamp"); + debugPrint("Cache contains ${_noteCache.length} notes"); + + if (previousTimestamps.isNotEmpty) { + final prevTimestamp = previousTimestamps.first; + debugPrint("Found previous note in cache: $prevTimestamp"); + + _cacheHits++; + debugPrint("Previous note cache hit (Hits: $_cacheHits, Misses: $_cacheMisses)"); + + final note = _noteCache[prevTimestamp]; + if (note == null) { + debugPrint("ERROR: Note found in cache keys but not in cache values!"); + } + return note; + } + + // Not in cache, fetch from server + _cacheMisses++; + debugPrint("Previous note cache miss (Hits: $_cacheHits, Misses: $_cacheMisses)"); + + // The timestamp used when fetching from server needs to be the current note's timestamp + final note = await _trackNetworkActivity( + () => meili.getPreviousTo(timestamp), + operation: "Loading previous note", + ); + + // Add to cache if found + if (note != null) { + debugPrint("Adding note from server to cache: ${note.epochTime}"); + _noteCache[note.epochTime] = note; + + // Don't automatically prefetch here - let the _goToPreviousNote method decide + // This prevents redundant prefetching when we just need one note + } else { + debugPrint("No previous note found on server"); + } + + return note; + } + + // Get next note with cache support + Future _getCachedNextNote(int timestamp) async { + // Find the closest timestamp that is greater than current + final nextTimestamps = _noteCache.keys + .where((t) => t > timestamp) + .toList() + ..sort(); // Sort ascending + + debugPrint("Looking for next note after $timestamp"); + debugPrint("Cache contains ${_noteCache.length} notes"); + + if (nextTimestamps.isNotEmpty) { + final nextTimestamp = nextTimestamps.first; + debugPrint("Found next note in cache: $nextTimestamp"); + + _cacheHits++; + debugPrint("Next note cache hit (Hits: $_cacheHits, Misses: $_cacheMisses)"); + + final note = _noteCache[nextTimestamp]; + if (note == null) { + debugPrint("ERROR: Note found in cache keys but not in cache values!"); + } + return note; + } + + // Not in cache, fetch from server + _cacheMisses++; + debugPrint("Next note cache miss (Hits: $_cacheHits, Misses: $_cacheMisses)"); + + // The timestamp used when fetching from server needs to be the current note's timestamp + final note = await _trackNetworkActivity( + () => meili.getNextTo(timestamp), + operation: "Loading next note", + ); + + // Add to cache if found + if (note != null) { + debugPrint("Adding note from server to cache: ${note.epochTime}"); + _noteCache[note.epochTime] = note; + + // Don't automatically prefetch here - let the _goToNextNote method decide + // This prevents redundant prefetching when we just need one note + } else { + debugPrint("No next note found on server"); + } + + return note; + } + + // Build a widget to display cache statistics + Widget _buildCacheStatistics() { + final cacheSize = _noteCache.length; + final hitRate = _cacheHits + _cacheMisses > 0 + ? (_cacheHits / (_cacheHits + _cacheMisses) * 100).toStringAsFixed(1) + : "0"; + + return Positioned( + right: 16, + top: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Cache: $cacheSize notes | Hit rate: $hitRate%', + style: const TextStyle(color: Colors.white70, fontSize: 10), + ), + if (_totalPrefetchOperations > 0) + Text( + 'Prefetch ops: $_totalPrefetchOperations (↑$_forwardPrefetchOperations, ↓$_backwardPrefetchOperations)', + style: const TextStyle(color: Colors.white70, fontSize: 10), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { // Wrap Scaffold with RawKeyboardListener as workaround for Escape key @@ -1362,118 +2068,149 @@ class MainPageState extends State with WindowListener { const SizedBox(width: 10), ], ), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 9, - child: Listener( - behavior: HitTestBehavior.opaque, - onPointerSignal: (pointerSignal) { - if (pointerSignal is PointerScrollEvent) { - if (pointerSignal.scrollDelta.dy < 0) { - if (_canGoPrevious) { - _goToPreviousNote(); + body: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 9, + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerSignal: (pointerSignal) { + // Only handle scroll events if not in the middle of a network request + if (!_isNetworkActive && pointerSignal is PointerScrollEvent) { + if (pointerSignal.scrollDelta.dy < 0) { + if (_canGoPrevious) { + _goToPreviousNote(); + } + } else if (pointerSignal.scrollDelta.dy > 0) { + if (_canGoNext) { + _goToNextNote(); + } + } } - } else if (pointerSignal.scrollDelta.dy > 0) { - if (_canGoNext) { - _goToNextNote(); - } - } - } - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Combine Label, Buttons, and TextField for Previous Entry - Row( + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Combine Label, Buttons, and TextField for Previous Entry + Row( + children: [ + Expanded( + child: Text( + _currentlyDisplayedNote?.displayDate == + previousNote?.displayDate + ? 'Previous Entry: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}' + : 'Entry: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}', + style: TextStyle( + fontSize: 18, + color: Colors.grey, + ), + ), + ), + // Add a badge showing cache size + Container( + padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${_noteCache.length} notes cached', + style: TextStyle( + fontSize: 12, + color: Colors.blue.shade700, + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: 'Previous Note', + onPressed: + _canGoPrevious && !_isNetworkActive ? _goToPreviousNote : null, + ), + IconButton( + icon: const Icon(Icons.arrow_forward), + tooltip: 'Next Note', + onPressed: _canGoNext && !_isNetworkActive ? _goToNextNote : null, + ), + ], + ), Expanded( - child: Text( - _currentlyDisplayedNote?.displayDate == - previousNote?.displayDate - ? 'Previous Entry: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}' - : 'Entry: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}', - style: TextStyle( - fontSize: 18, - color: Colors.grey, + child: TextField( + controller: _previousEntryController, + readOnly: _isNetworkActive, // Disable during network activity + maxLines: null, + expands: true, + style: Theme.of(context).textTheme.bodyMedium, + decoration: InputDecoration( + hintText: + _currentlyDisplayedNote?.displayDate != + previousNote?.displayDate + ? 'Viewing note from ${_currentlyDisplayedNote?.displayDate} (Editable)' + : 'Latest Note: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}', + border: const OutlineInputBorder(), + filled: + _currentlyDisplayedNote?.displayDate != + previousNote?.displayDate, + fillColor: + _currentlyDisplayedNote?.displayDate != + previousNote?.displayDate + ? Colors.grey.withOpacity(0.1) + : null, ), ), ), - IconButton( - icon: const Icon(Icons.arrow_back), - tooltip: 'Previous Note', - onPressed: - _canGoPrevious ? _goToPreviousNote : null, - ), - IconButton( - icon: const Icon(Icons.arrow_forward), - tooltip: 'Next Note', - onPressed: _canGoNext ? _goToNextNote : null, + const SizedBox(height: 8), + Expanded( + child: TextField( + controller: _currentEntryController, + focusNode: _currentEntryFocusNode, + readOnly: _isNetworkActive, // Disable during network activity + maxLines: null, + expands: true, + autofocus: true, + style: Theme.of(context).textTheme.bodyMedium, + decoration: const InputDecoration( + labelText: 'Current Entry (What\'s on your mind?)', + ), + ), ), ], ), - Expanded( - child: TextField( - controller: _previousEntryController, - readOnly: false, // Always allow editing - maxLines: null, - expands: true, - style: Theme.of(context).textTheme.bodyMedium, - decoration: InputDecoration( - hintText: - _currentlyDisplayedNote?.displayDate != - previousNote?.displayDate - ? 'Viewing note from ${_currentlyDisplayedNote?.displayDate} (Editable)' - : 'Latest Note: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}', - border: const OutlineInputBorder(), - filled: - _currentlyDisplayedNote?.displayDate != - previousNote?.displayDate, - fillColor: - _currentlyDisplayedNote?.displayDate != - previousNote?.displayDate - ? Colors.grey.withOpacity(0.1) - : null, - ), - ), - ), - const SizedBox(height: 8), - Expanded( - child: TextField( - controller: _currentEntryController, - focusNode: _currentEntryFocusNode, - maxLines: null, - expands: true, - autofocus: true, - style: Theme.of(context).textTheme.bodyMedium, - decoration: const InputDecoration( - labelText: 'Current Entry (What\'s on your mind?)', - ), - ), - ), - ], + ), ), - ), + const SizedBox(width: 8), + Expanded( + flex: 4, // Adjust flex factor as needed + child: TextField( + controller: _scratchController, + readOnly: _isNetworkActive, // Disable during network activity + maxLines: null, + expands: true, + style: + Theme.of( + context, + ).textTheme.bodyMedium, // Apply theme text style + decoration: const InputDecoration(labelText: 'Scratch'), + ), + ), + ], ), - const SizedBox(width: 8), - Expanded( - flex: 4, // Adjust flex factor as needed - child: TextField( - controller: _scratchController, - maxLines: null, - expands: true, - style: - Theme.of( - context, - ).textTheme.bodyMedium, // Apply theme text style - decoration: const InputDecoration(labelText: 'Scratch'), - ), - ), - ], - ), + ), + // Position the network activity indicator in the bottom right + Positioned( + right: 16, + bottom: 16, + child: _buildNetworkActivityIndicator(), + ), + // Position the cache statistics in the top right + _buildCacheStatistics(), + ], ), ), ); diff --git a/lib/meilisearch.dart b/lib/meilisearch.dart index fd1abc7..fbf3350 100644 --- a/lib/meilisearch.dart +++ b/lib/meilisearch.dart @@ -348,10 +348,14 @@ Future createNote(String content) async { } } - final mostFrequentLetter = - letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key; - final mostFrequentLetterCount = - letterFrequency[mostFrequentLetter]! / trimmedContent.length; + // Handle the case where there are no alphanumeric characters + String mostFrequentLetter = 'a'; // Default value + double mostFrequentLetterCount = 0.0; // Default value + + if (letterFrequency.isNotEmpty) { + mostFrequentLetter = letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key; + mostFrequentLetterCount = letterFrequency[mostFrequentLetter]! / (trimmedContent.length > 0 ? trimmedContent.length : 1); + } final document = { 'id': generateRandomString(32), @@ -431,10 +435,14 @@ Future updateNote(Note note) async { } } - final mostFrequentLetter = - letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key; - final mostFrequentLetterRatio = - letterFrequency[mostFrequentLetter]! / trimmedContent.length; + // Handle the case where there are no alphanumeric characters + String mostFrequentLetter = 'a'; // Default value + double mostFrequentLetterRatio = 0.0; // Default value + + if (letterFrequency.isNotEmpty) { + mostFrequentLetter = letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key; + mostFrequentLetterRatio = letterFrequency[mostFrequentLetter]! / (trimmedContent.length > 0 ? trimmedContent.length : 1); + } final document = { 'id': note.id, @@ -527,3 +535,101 @@ Future createScratch(String content) async { content: document['content'] as String, ); } + +Future> getNotesBefore(int epochTime, {int limit = 50}) async { + final endpoint = await _getEndpoint(); + final headers = await _getHeaders(); + final searchCondition = MeilisearchQuery( + q: '', + filter: 'date < $epochTime', + sort: ['date:desc'], + limit: limit, + ); + final response = await http.post( + Uri.parse('$endpoint/indexes/$noteIndex/search'), + headers: headers, + body: jsonEncode(searchCondition.toJson()), + ); + if (response.statusCode != 200) { + throw Exception( + 'Failed to get notes before timestamp, backend responded with ${response.statusCode}', + ); + } + final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); + return responseJson.hits + .map( + (hit) => Note( + id: hit['id'] as String, + epochTime: hit['date'] as int, + content: hit['content'] as String, + ), + ) + .toList(); +} + +Future> getNotesAfter(int epochTime, {int limit = 50}) async { + final endpoint = await _getEndpoint(); + final headers = await _getHeaders(); + final searchCondition = MeilisearchQuery( + q: '', + filter: 'date > $epochTime', + sort: ['date:asc'], + limit: limit, + ); + final response = await http.post( + Uri.parse('$endpoint/indexes/$noteIndex/search'), + headers: headers, + body: jsonEncode(searchCondition.toJson()), + ); + if (response.statusCode != 200) { + throw Exception( + 'Failed to get notes after timestamp, backend responded with ${response.statusCode}', + ); + } + final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); + return responseJson.hits + .map( + (hit) => Note( + id: hit['id'] as String, + epochTime: hit['date'] as int, + content: hit['content'] as String, + ), + ) + .toList(); +} + +Future getPopupInterval() async { + final value = await getSetting('popupInterval'); + if (value == null) { + return const Duration(minutes: 20); + } + return Duration(minutes: int.parse(value)); +} + +Future setPopupInterval(Duration interval) async { + await setSetting('popupInterval', interval.inMinutes.toString()); +} + +Future getCacheSizeBefore() async { + final value = await getSetting('cacheSizeBefore'); + if (value == null) { + return 50; // Default value + } + return int.parse(value); +} + +Future setCacheSizeBefore(int size) async { + await setSetting('cacheSizeBefore', size.toString()); +} + +Future getCacheSizeAfter() async { + final value = await getSetting('cacheSizeAfter'); + if (value == null) { + return 50; // Default value + } + return int.parse(value); +} + +Future setCacheSizeAfter(int size) async { + await setSetting('cacheSizeAfter', size.toString()); +}