import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:journaler/notes.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'; 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' 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 // Default values - will be replaced by DB values if they exist const Duration _defaultPopupInterval = Duration(minutes: 20); const String _defaultNotificationSound = 'MeetTheSniper.mp3'; // Flag to indicate if there's a pending show request from a secondary instance bool _pendingShowRequest = false; // Global navigator key to access the navigator state from anywhere final GlobalKey navigatorKey = GlobalKey(); /// Paths for IPC file in system temp directory class AppFiles { static String get ipcFilePath => path.join(Directory.systemTemp.path, 'journaler_ipc.txt'); } void main() async { WidgetsFlutterBinding.ensureInitialized(); final ipcFile = File(AppFiles.ipcFilePath); if (await alreadyRunning()) { await signalPrimaryInstanceAndExit(ipcFile); } else { await runPrimaryInstance(ipcFile); } } Future alreadyRunning() async { final processes = await PSList.getRunningProcesses(); final executable = Platform.resolvedExecutable; final executableName = path.basename(executable); final journalers = processes.where((process) => process == executableName).toList(); debugPrint("Journalers: $journalers"); return journalers.length > 1; } /// Run the primary instance of the application Future runPrimaryInstance(File ipcFile) async { debugPrint("Starting as primary instance"); // Create or clear the IPC file if (!await ipcFile.exists()) { await ipcFile.create(); } // Start a watcher for the IPC file to detect signals from other instances startIpcWatcher(ipcFile); // Initialize the app try { // Initialize Meilisearch first await meili.init(); debugPrint('Meilisearch initialized successfully'); } catch (e) { debugPrint('Error initializing Meilisearch: $e'); // Continue anyway - the app will work with default values } await windowManager.ensureInitialized(); WindowOptions windowOptions = const WindowOptions( size: Size(1600, 900), center: true, backgroundColor: Colors.transparent, skipTaskbar: false, titleBarStyle: TitleBarStyle.normal, ); windowManager.waitUntilReadyToShow(windowOptions, () async { await windowManager.hide(); }); runApp(const JournalerApp()); // Set up termination handlers to clean up resources setupTerminationHandlers(ipcFile); } /// Handle termination signals by cleaning up IPC file void setupTerminationHandlers(File ipcFile) { // Handle normal termination ProcessSignal.sigterm.watch().listen((_) async { await cleanupFiles(ipcFile); exit(0); }); // Handle Windows and Linux CTRL+C termination if (Platform.isWindows || Platform.isLinux) { ProcessSignal.sigint.watch().listen((_) async { await cleanupFiles(ipcFile); exit(0); }); } } /// Clean up IPC file Future cleanupFiles(File ipcFile) async { try { if (await ipcFile.exists()) { await ipcFile.delete(); } debugPrint("Removed IPC file on app termination"); } catch (e) { debugPrint("Error cleaning up files: $e"); } } /// Signal the primary instance to show itself, then exit Future signalPrimaryInstanceAndExit(File ipcFile) async { debugPrint( "Another instance is already running. Signaling it to show window.", ); try { // Create the IPC file if it doesn't exist if (!await ipcFile.exists()) { await ipcFile.create(); } // Write a 'show' command with a timestamp final timestamp = DateTime.now().millisecondsSinceEpoch; await ipcFile.writeAsString('show:$timestamp'); debugPrint("Signal sent to primary instance via IPC file"); // Give the primary instance a moment to process the signal await Future.delayed(const Duration(seconds: 2)); } catch (e) { debugPrint("Error signaling primary instance: $e"); } // Exit this instance debugPrint("Exiting secondary instance"); exit(0); } /// Start watching the IPC file for signals from other instances void startIpcWatcher(File ipcFile) { // Initial check of the file content checkIpcFileContent(ipcFile); // Set up a file watcher final watcher = ipcFile.watch(); watcher.listen((event) { if (event.type == FileSystemEvent.modify) { checkIpcFileContent(ipcFile); } }); // Also set up a periodic check as a fallback Timer.periodic(const Duration(milliseconds: 200), (_) { checkIpcFileContent(ipcFile); }); debugPrint("IPC file watcher started"); } /// Check the IPC file content for commands Future checkIpcFileContent(File ipcFile) async { try { if (await ipcFile.exists()) { final content = await ipcFile.readAsString(); if (content.isNotEmpty && content.startsWith('show:')) { // Clear the file immediately to avoid processing the same command multiple times await ipcFile.writeAsString(''); // Process the show command if (MainPageState.instance != null) { debugPrint("Received show command - making window visible"); MainPageState.instance!.showWindow(); } else { debugPrint( "MainPageState not initialized yet - setting pending flag", ); _pendingShowRequest = true; } } } } catch (e) { debugPrint("Error processing IPC file: $e"); } } class JournalerApp extends StatelessWidget { const JournalerApp({super.key}); static final TextTheme _baseTextTheme = const TextTheme( bodyMedium: TextStyle(fontSize: 24), ); static final TextStyle _baseTitleTextStyle = const TextStyle( fontSize: 20, fontWeight: FontWeight.w500, ); static final InputDecorationTheme _baseInputDecorationTheme = const InputDecorationTheme( border: OutlineInputBorder(), labelStyle: TextStyle(fontSize: 20), ); static final ThemeData lightTheme = ThemeData.light().copyWith( visualDensity: VisualDensity.adaptivePlatformDensity, colorScheme: ColorScheme.fromSeed( seedColor: Colors.blue, brightness: Brightness.light, ), scaffoldBackgroundColor: Colors.grey[100], appBarTheme: AppBarTheme( backgroundColor: Colors.blue, foregroundColor: Colors.white, titleTextStyle: _baseTitleTextStyle.copyWith(color: Colors.white), ), inputDecorationTheme: _baseInputDecorationTheme.copyWith( focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: Colors.blue.shade600, width: 2.0), ), labelStyle: _baseInputDecorationTheme.labelStyle?.copyWith( color: Colors.grey[700], ), ), textTheme: _baseTextTheme, ); static final ThemeData darkTheme = ThemeData.dark().copyWith( visualDensity: VisualDensity.adaptivePlatformDensity, colorScheme: ColorScheme.fromSeed( seedColor: Colors.blue, brightness: Brightness.dark, ), scaffoldBackgroundColor: Colors.grey[900], appBarTheme: AppBarTheme( backgroundColor: Colors.grey[850], foregroundColor: Colors.white, titleTextStyle: _baseTitleTextStyle.copyWith(color: Colors.white), ), inputDecorationTheme: _baseInputDecorationTheme.copyWith( border: const OutlineInputBorder( borderSide: BorderSide(color: Colors.grey), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: Colors.blue.shade300, width: 2.0), ), labelStyle: _baseInputDecorationTheme.labelStyle?.copyWith( color: Colors.grey[400], ), ), textTheme: _baseTextTheme.copyWith( bodyMedium: _baseTextTheme.bodyMedium?.copyWith(color: Colors.white), ), ); @override Widget build(BuildContext context) { return MaterialApp( title: 'Journaler', theme: lightTheme, darkTheme: darkTheme, themeMode: ThemeMode.system, home: const MainPage(), debugShowCheckedModeBanner: false, navigatorKey: navigatorKey, ); } } class MainPage extends StatefulWidget { const MainPage({super.key}); @override State createState() => MainPageState(); } class MainPageState extends State with WindowListener { // Static reference to the current instance static MainPageState? instance; final SystemTray _systemTray = SystemTray(); final Menu _menu = Menu(); final AudioPlayer _audioPlayer = AudioPlayer(); double _volume = 0.7; // Default volume level (0.0 to 1.0) final TextEditingController _previousEntryController = TextEditingController(); final TextEditingController _currentEntryController = TextEditingController(); final FocusNode _currentEntryFocusNode = FocusNode(); final TextEditingController _scratchController = TextEditingController(); final TextEditingController _intervalController = TextEditingController(); final TextEditingController _soundController = TextEditingController(); final TextEditingController _searchController = TextEditingController(); final FocusNode _keyboardListenerFocusNode = FocusNode(); // Add persistent focus node Note? previousNote; Note? _currentlyDisplayedNote; Duration _currentPopupInterval = _defaultPopupInterval; 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(); // Store reference to this instance instance = this; windowManager.addListener(this); _initSystemTray(); _loadData(); _loadVolume(); windowManager.setPreventClose(true); _setWindowConfig(); // Check if there's a pending show request from another instance WidgetsBinding.instance.addPostFrameCallback((_) { if (_pendingShowRequest) { debugPrint("Processing pending show request during initialization"); _pendingShowRequest = false; showWindow(); } }); } @override void dispose() { // Clear reference to this instance if (instance == this) { instance = null; } windowManager.removeListener(this); _popupTimer?.cancel(); _debounceTimer?.cancel(); _searchDebounceTimer?.cancel(); _previousEntryController.dispose(); _currentEntryController.dispose(); _currentEntryFocusNode.dispose(); _keyboardListenerFocusNode.dispose(); // Dispose the keyboard listener focus node _scratchController.dispose(); _intervalController.dispose(); _soundController.dispose(); _searchController.dispose(); _audioPlayer.dispose(); super.dispose(); } @override void onWindowClose() async { // 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 void onWindowFocus() { 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'; await _systemTray.initSystemTray(iconPath: iconPath, toolTip: "Journaler"); await _menu.buildFrom([ MenuItemLabel( label: 'Commit Sudoku', onClicked: (menuItem) => windowManager.destroy(), ), ]); await _systemTray.setContextMenu(_menu); _systemTray.registerSystemTrayEventHandler((eventName) { debugPrint("System Tray Event: $eventName"); if (eventName == kSystemTrayEventClick) { showWindow(); } else if (eventName == kSystemTrayEventRightClick) { _systemTray.popUpContextMenu(); } }); } void _startPopupTimer() { _popupTimer?.cancel(); _popupTimer = Timer.periodic(_currentPopupInterval, (timer) { showWindow(); }); debugPrint( "Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes", ); } Future showWindow() async { _loadData(); try { bool wasVisible = await windowManager.isVisible(); debugPrint("Current window visibility: $wasVisible"); if (!wasVisible) { // First make sure the window has the right size and position await windowManager.setSize(const Size(1600, 900)); await windowManager.center(); // Show and focus immediately await windowManager.show(); await windowManager.focus(); // Set input focus with a post-frame callback to ensure it sticks WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _currentEntryFocusNode.requestFocus(); } }); // Play notification sound await _playSound(); debugPrint("Window made visible and focused"); } else { // Already visible, just focus await windowManager.focus(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _currentEntryFocusNode.requestFocus(); } }); debugPrint("Window already visible, just focused"); } // Verify the window is now visible bool isNowVisible = await windowManager.isVisible(); debugPrint("Window visibility after show attempt: $isNowVisible"); if (!isNowVisible) { debugPrint( "WARNING: Window still not visible after show attempt, trying again", ); await windowManager.show(); await windowManager.focus(); } } catch (e) { debugPrint("Error showing window: $e"); } } // Convert linear slider value (0.0-1.0) to logarithmic volume (for better human hearing perception) double _linearToLogVolume(double linearValue) { // Prevent log(0) which is -infinity if (linearValue <= 0.01) return 0.0; // This is a common audio perception formula based on the Weber-Fechner law // Using a custom curve that gives good control at low volumes (where human hearing is most sensitive) return pow(linearValue, 2).toDouble(); } Future _playSound() async { await _audioPlayer.stop(); try { // Set volume before playing (convert linear slider value to log volume) await _audioPlayer.setVolume(_linearToLogVolume(_volume)); await _audioPlayer.play(AssetSource('sounds/$_currentNotificationSound')); debugPrint( "Played sound: $_currentNotificationSound at volume: $_volume (log: ${_linearToLogVolume(_volume)})", ); } catch (e) { debugPrint("Error playing sound $_currentNotificationSound: $e"); } } Future _checkNavigation() async { if (_currentlyDisplayedNote == null) { setState(() { _canGoPrevious = false; _canGoNext = false; }); return; } // 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 = canGoPrev; _canGoNext = canGoNext; }); } Future _goToPreviousNote() async { if (!_canGoPrevious || _currentlyDisplayedNote == null) return; // Save the current note content before navigating away if (_currentlyDisplayedNote != null) { if (_currentlyDisplayedNote!.content != _previousEntryController.text) { _currentlyDisplayedNote!.content = _previousEntryController.text; await _trackNetworkActivity( () => meili.updateNote(_currentlyDisplayedNote!), operation: "Saving current note before navigation", ); // Update cache with the modified note _noteCache[_currentlyDisplayedNote!.epochTime] = _currentlyDisplayedNote!; } } // 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(); } } Future _goToNextNote() async { if (!_canGoNext || _currentlyDisplayedNote == null) return; // Save the current note content before navigating away if (_currentlyDisplayedNote != null) { if (_currentlyDisplayedNote!.content != _previousEntryController.text) { _currentlyDisplayedNote!.content = _previousEntryController.text; await _trackNetworkActivity( () => meili.updateNote(_currentlyDisplayedNote!), operation: "Saving current note before navigation", ); // Update cache with the modified note _noteCache[_currentlyDisplayedNote!.epochTime] = _currentlyDisplayedNote!; } } // 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 _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 _trackNetworkActivity( () => meili.getLatest(), operation: "Loading latest note", ); previousNote = note; _currentlyDisplayedNote = note; _previousEntryController.text = _currentlyDisplayedNote?.content ?? ""; 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"); } // Load volume setting from database Future _loadVolume() async { double? volume = await _trackNetworkActivity( () => utils.getVolume(), operation: "Loading volume settings", ); setState(() { _volume = volume ?? 0.7; // Use default value if null _audioPlayer.setVolume(_linearToLogVolume(_volume)); }); } // Save volume setting to database Future _saveVolume() async { await _trackNetworkActivity( () => utils.setVolume(_volume), operation: "Saving volume settings", ); debugPrint("Volume saved: $_volume"); } Future _saveData() async { String previousEntry = _previousEntryController.text; String currentEntry = _currentEntryController.text; String scratchContent = _scratchController.text; String intervalStr = _intervalController.text; String soundStr = _soundController.text; // Handle current entry if (currentEntry.isNotEmpty) { 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 _trackNetworkActivity( () => meili.createScratch(scratchContent), operation: "Saving scratch content", ); _originalScratchContent = scratchContent; // Update original content } // Handle previous/currently displayed note if (_currentlyDisplayedNote != null) { 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 _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!; } } int newIntervalMinutes = int.tryParse(intervalStr) ?? _currentPopupInterval.inMinutes; Duration newInterval = Duration(minutes: newIntervalMinutes); if (newInterval != _currentPopupInterval) { _currentPopupInterval = newInterval; await _trackNetworkActivity( () => utils.setPopupInterval(newInterval), operation: "Updating popup interval", ); _startPopupTimer(); } if (soundStr != _currentNotificationSound) { _currentNotificationSound = soundStr; await _trackNetworkActivity( () => utils.setNotificationSound(soundStr), operation: "Updating notification sound", ); } // Also save volume await _saveVolume(); debugPrint( "Saving data... Current Entry: [${currentEntry.length} chars], Scratch: [${scratchContent.length} chars]", ); } Future _setWindowConfig() async { await windowManager.setAspectRatio(16 / 9); } // Sanitize FTS query String _sanitizeFtsQuery(String query) { // Simple trimming - the DB layer will handle the complex processing return query.trim(); } // Build rich text with highlights for search results List _buildHighlightedText(String highlightedText) { List spans = []; // The text comes with highlighted parts RegExp exp = RegExp(r'(.*?)'); int lastIndex = 0; for (final match in exp.allMatches(highlightedText)) { // Add text before the highlight if (match.start > lastIndex) { spans.add( TextSpan( text: highlightedText.substring(lastIndex, match.start), style: const TextStyle(fontSize: 13), ), ); } // Add the highlighted text spans.add( TextSpan( text: match.group(1), style: const TextStyle( fontWeight: FontWeight.bold, backgroundColor: Colors.yellow, color: Colors.black, fontSize: 13, ), ), ); lastIndex = match.end; } // Add any remaining text if (lastIndex < highlightedText.length) { spans.add( TextSpan( text: highlightedText.substring(lastIndex), style: const TextStyle(fontSize: 13), ), ); } return spans; } // Show search dialog void _showSearchDialog() { _searchController.clear(); _searchResults = []; _isSearching = false; showDialog( context: context, builder: (BuildContext context) { return StatefulBuilder( builder: (context, dialogSetState) { return AlertDialog( title: const Text('Search Notes'), content: SizedBox( width: MediaQuery.of(context).size.width * 0.7, height: MediaQuery.of(context).size.height * 0.7, child: Column( children: [ TextField( controller: _searchController, decoration: const InputDecoration( labelText: 'Search Query', hintText: 'e.g. wifi or meeting', border: OutlineInputBorder(), prefixIcon: Icon(Icons.search), ), autofocus: true, onChanged: (value) async { // Start search and update dialog state if (value.isEmpty) { dialogSetState(() { _searchResults = []; _isSearching = false; }); return; } dialogSetState(() { _isSearching = true; }); // Escape special characters to prevent SQLite FTS syntax errors String trimmedQuery = _sanitizeFtsQuery(value); // Debounce search _searchDebounceTimer?.cancel(); _searchDebounceTimer = Timer( const Duration(milliseconds: 300), () async { try { 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 = results .where((note) => note.content.isNotEmpty) .toList(); // Sort by date, newest first filteredResults.sort( (a, b) => b.epochTime.compareTo(a.epochTime), ); // Important: update the dialog state after search completes dialogSetState(() { _searchResults = filteredResults; _isSearching = false; }); } catch (e) { debugPrint('Search error: $e'); dialogSetState(() { _searchResults = []; _isSearching = false; }); } }, ); }, ), const SizedBox(height: 16), _isSearching ? const Center(child: CircularProgressIndicator()) : Expanded( child: _searchResults.isEmpty ? const Center( child: Text( 'No results. Try a different search term.', ), ) : ListView.builder( itemCount: _searchResults.length, itemBuilder: (context, index) { final note = _searchResults[index]; return Card( margin: const EdgeInsets.only( bottom: 6, // Reduced margin between cards ), child: ListTile( dense: true, // Makes the ListTile more compact contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 2, ), // Tighter padding title: Text( note.displayDate, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 12, // Smaller font for date ), ), subtitle: Text.rich( TextSpan( children: _buildHighlightedText( note.snippet ?? note.content, ), ), ), isThreeLine: true, onTap: () async { // Save current note if needed if (_currentlyDisplayedNote != null) { if (_currentlyDisplayedNote! .content != _previousEntryController .text) { _currentlyDisplayedNote! .content = _previousEntryController .text; await _trackNetworkActivity( () => meili.updateNote( _currentlyDisplayedNote!, ), operation: "Saving note before viewing search result", ); } } // Navigate to the selected note Navigator.of(context).pop(); setState(() { _currentlyDisplayedNote = note; _previousEntryController.text = note.content; // Add to cache _noteCache[note.epochTime] = note; }); // Refresh cache for notes around this one _refreshNoteCache(); _checkNavigation(); }, ), ); }, ), ), ], ), ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('Close'), ), ], ); }, ); }, ); } // Volume slider widget that uses a logarithmic scale Widget _buildVolumeSlider() { return Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.volume_mute, size: 16), SizedBox( width: 100, child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 4.0, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8.0), overlayShape: const RoundSliderOverlayShape(overlayRadius: 14.0), ), child: Slider( value: _volume, min: 0.0, max: 1.0, divisions: 20, // More divisions for finer control onChanged: (value) { setState(() { _volume = value; }); _audioPlayer.setVolume(_linearToLogVolume(value)); }, onChangeEnd: (value) { _saveVolume(); }, ), ), ), const Icon(Icons.volume_up, size: 16), ], ); } // Show cleanup dialog void _showCleanupDialog() async { double sensitivity = 0.7; // Default 70% final problematicEntries = await _trackNetworkActivity( () => meili.getProblematic(threshold: sensitivity), operation: "Finding problematic notes (${sensitivity * 100}% threshold)", ); if (!mounted) return; showDialog( context: context, builder: (BuildContext context) { return StatefulBuilder( builder: (context, dialogSetState) { return AlertDialog( title: const Text('Cleanup Problematic Entries'), content: SizedBox( width: MediaQuery.of(context).size.width * 0.7, height: MediaQuery.of(context).size.height * 0.7, child: Column( children: [ Row( children: [ const Text('Sensitivity: '), Expanded( child: Slider( value: sensitivity, min: 0.3, max: 0.9, divisions: 12, label: '${(sensitivity * 100).toInt()}%', onChanged: (value) async { dialogSetState(() { sensitivity = (value * 100).round() / 100; // Round to 2 decimal places }); // Refresh results with new sensitivity final newResults = await _trackNetworkActivity( () => meili.getProblematic( threshold: sensitivity, ), operation: "Refreshing problematic notes (${sensitivity * 100}% threshold)", ); dialogSetState(() { problematicEntries.clear(); problematicEntries.addAll(newResults); }); }, ), ), Text('${(sensitivity * 100).toInt()}%'), ], ), Text( 'Found ${problematicEntries.length} potentially problematic entries', style: const TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 16), Expanded( child: problematicEntries.isEmpty ? const Center( child: Text('No problematic entries found!'), ) : ListView.builder( itemCount: problematicEntries.length, itemBuilder: (context, index) { final note = problematicEntries[index]; return Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( title: Text( note.displayDate, style: const TextStyle( fontWeight: FontWeight.bold, ), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Reason: ${note.problemReason}', style: TextStyle( color: Colors.red[700], fontWeight: FontWeight.bold, ), ), const SizedBox(height: 4), Text(note.content), ], ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.visibility), tooltip: 'View in main window', onPressed: () { // Close the dialog Navigator.of(context).pop(); // Navigate to the note in main window setState(() { _currentlyDisplayedNote = note; _previousEntryController.text = note.content; }); _checkNavigation(); }, ), IconButton( icon: const Icon(Icons.delete), color: Colors.red, tooltip: 'Delete (hold Shift to skip confirmation)', onPressed: () async { // Check if shift is pressed final isShiftPressed = HardwareKeyboard .instance .isShiftPressed; bool shouldDelete = isShiftPressed; if (!isShiftPressed) { // Show confirmation dialog shouldDelete = await showDialog( context: context, builder: ( context, ) => AlertDialog( title: const Text( 'Delete Entry?', ), content: const Text( 'Are you sure you want to delete this entry? This action cannot be undone.', ), actions: [ TextButton( onPressed: () => Navigator.of( context, ).pop( false, ), child: const Text( 'Cancel', ), ), TextButton( onPressed: () => Navigator.of( context, ).pop(true), child: const Text( 'Delete', ), ), ], ), ) ?? false; } if (shouldDelete) { 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, ); }); } }, ), ], ), ), ); }, ), ), ], ), ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('Close'), ), ], ); }, ); }, ); } // Show Meilisearch settings dialog 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 _trackNetworkActivity( () => config.getMeilisearchEndpoint(), operation: "Loading Meilisearch endpoint", ) .then((value) { endpointController.text = value; isLoading = false; }) .catchError((e) { errorMessage = 'Failed to load endpoint: $e'; isLoading = false; }); _trackNetworkActivity( () => config.getMeilisearchApiKey(), operation: "Loading Meilisearch API key", ) .then((value) { apiKeyController.text = value; }) .catchError((e) { errorMessage = 'Failed to load API key: $e'; }); showDialog( context: context, builder: (BuildContext context) { return StatefulBuilder( builder: (context, setState) { return AlertDialog( 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'), ), ], ), ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('Cancel'), ), TextButton( onPressed: isLoading ? null : () async { try { setState(() { isLoading = true; errorMessage = null; }); // Save Meilisearch connection settings await _trackNetworkActivity( () => config.setMeilisearchEndpoint( endpointController.text, ), operation: "Saving Meilisearch endpoint", ); await _trackNetworkActivity( () => config.setMeilisearchApiKey(apiKeyController.text), operation: "Saving Meilisearch API key", ); // 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; // 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'), ), ], ); }, ); }, ); } // 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 return RawKeyboardListener( focusNode: _keyboardListenerFocusNode, // Use persistent focus node onKey: (RawKeyEvent event) { if (event is RawKeyDownEvent) { // Handle Escape to close window if (event.logicalKey == LogicalKeyboardKey.escape) { debugPrint( "Escape pressed inside MainPage (RawKeyboardListener - Workaround)", ); // Call method directly since we are in the state FocusManager.instance.primaryFocus?.unfocus(); // Keep unfocus attempt onWindowClose(); } // Handle Ctrl+F to open search else if (event.logicalKey == LogicalKeyboardKey.keyF && (event.isControlPressed || event.isMetaPressed)) { debugPrint("Ctrl+F pressed, opening search dialog"); _showSearchDialog(); } } }, child: Scaffold( appBar: AppBar( title: const Text('Journaler'), actions: [ // Add cleanup button IconButton( icon: const Icon(Icons.cleaning_services), tooltip: 'Cleanup Problematic Entries', onPressed: _showCleanupDialog, ), // Add search button IconButton( icon: const Icon(Icons.search), tooltip: 'Search Notes', onPressed: _showSearchDialog, ), // Group Label and Input for Interval Padding( padding: const EdgeInsets.symmetric( vertical: 8.0, ).copyWith(left: 8.0), // Add padding child: Row( mainAxisSize: MainAxisSize.min, // Use minimum space children: [ const Text("Interval (m):"), const SizedBox(width: 4), // Space between label and input SizedBox( width: 60, // Constrain width child: TextField( controller: _intervalController, // textAlignVertical: TextAlignVertical.center, // Let default alignment handle it decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 8.0, vertical: 8.0, ), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], ), ), ], ), ), // Group Label and Input for Sound Padding( padding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 8.0, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Text("Sound:"), const SizedBox(width: 4), SizedBox( width: 150, // Constrain width child: TextField( controller: _soundController, // textAlignVertical: TextAlignVertical.center, decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 8.0, vertical: 8.0, ), hintText: 'sound.mp3', ), ), ), ], ), ), // Volume Control Slider Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: _buildVolumeSlider(), ), // Test Sound Button Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: IconButton( icon: const Icon(Icons.volume_up), tooltip: 'Test Sound', onPressed: _playSound, ), ), // Meilisearch Settings Button Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: IconButton( icon: const Icon(Icons.settings), tooltip: 'Meilisearch Settings', onPressed: _showMeilisearchSettings, ), ), const SizedBox(width: 10), ], ), 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(); } } } }, 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: 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, ), ), ), 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?)', ), ), ), ], ), ), ), 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'), ), ), ], ), ), // 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(), ], ), ), ); } } // --- End Actions and Shortcuts ---