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'; 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'; import 'package:journaler/meilisearch_config.dart'; // 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 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 bool _canGoPrevious = false; bool _canGoNext = false; bool _isSearching = false; List _searchResults = []; Timer? _popupTimer; Timer? _debounceTimer; Timer? _searchDebounceTimer; @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 { // Save data when window is closed await _saveData(); windowManager.hide(); } @override void onWindowFocus() { setState(() {}); } 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; } final prev = await getPreviousTo(_currentlyDisplayedNote!.epochTime); final bool isLatest = _currentlyDisplayedNote!.epochTime == previousNote?.epochTime; setState(() { _canGoPrevious = prev != null; _canGoNext = !isLatest; }); } 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 updateNote(_currentlyDisplayedNote!); } } final prevNote = await getPreviousTo(_currentlyDisplayedNote!.epochTime); if (prevNote != null) { setState(() { _currentlyDisplayedNote = prevNote; _previousEntryController.text = prevNote.content; }); 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 updateNote(_currentlyDisplayedNote!); } } final nextNote = await getNextTo(_currentlyDisplayedNote!.epochTime); if (nextNote != null) { setState(() { _currentlyDisplayedNote = nextNote; _previousEntryController.text = nextNote.content; }); await _checkNavigation(); } } void _loadData() async { Duration interval = await getPopupInterval(); String soundFile = await getNotificationSound(); _currentPopupInterval = interval; _currentNotificationSound = soundFile; _intervalController.text = interval.inMinutes.toString(); _soundController.text = _currentNotificationSound; _startPopupTimer(); final note = await getLatest(); previousNote = note; _currentlyDisplayedNote = note; _previousEntryController.text = _currentlyDisplayedNote?.content ?? ""; final scratch = await getLatestScratch(); _scratchController.text = scratch?.content ?? ""; _originalScratchContent = scratch?.content; // Store original content await _checkNavigation(); debugPrint("Data loaded"); } // Load volume setting from database Future _loadVolume() async { double? volume = await getVolume(); setState(() { _volume = volume; _audioPlayer.setVolume(_linearToLogVolume(_volume)); }); } // Save volume setting to database Future _saveVolume() async { await setVolume(_volume); 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) { await createNote(currentEntry); _currentEntryController.clear(); // Clear the input field after saving } // Only create new scratch if content has changed if (scratchContent != _originalScratchContent) { await createScratch(scratchContent); _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) { // Check if we need to navigate to another note Note? nextNote = await getLatest(); setState(() { _currentlyDisplayedNote = nextNote; if (nextNote != null) { _previousEntryController.text = nextNote.content; } else { _previousEntryController.text = ""; } }); await _checkNavigation(); } } int newIntervalMinutes = int.tryParse(intervalStr) ?? _currentPopupInterval.inMinutes; Duration newInterval = Duration(minutes: newIntervalMinutes); if (newInterval != _currentPopupInterval) { _currentPopupInterval = newInterval; await setPopupInterval(newInterval); _startPopupTimer(); } if (soundStr != _currentNotificationSound) { _currentNotificationSound = soundStr; await setNotificationSound(soundStr); } // 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 searchNotes(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 updateNote( _currentlyDisplayedNote!, ); } } // Navigate to the selected note Navigator.of(context).pop(); setState(() { _currentlyDisplayedNote = note; _previousEntryController.text = note.content; }); _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 getProblematic(threshold: sensitivity); 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 getProblematic( threshold: sensitivity, ); 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) { await deleteNote(note.id); 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(); bool isLoading = true; String? errorMessage; // Load current values getMeilisearchEndpoint() .then((value) { endpointController.text = value; isLoading = false; }) .catchError((e) { errorMessage = 'Failed to load endpoint: $e'; isLoading = false; }); getMeilisearchApiKey() .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('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(), ), ), const SizedBox(height: 16), TextField( controller: apiKeyController, decoration: const InputDecoration( labelText: 'API Key', hintText: 'masterKey', border: OutlineInputBorder(), ), obscureText: true, ), ], ], ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('Cancel'), ), TextButton( onPressed: isLoading ? null : () async { try { setState(() { isLoading = true; errorMessage = null; }); await setMeilisearchEndpoint( endpointController.text, ); await setMeilisearchApiKey(apiKeyController.text); // Try to reinitialize Meilisearch with new settings await init(); if (mounted) { Navigator.of(context).pop(); } } catch (e) { setState(() { errorMessage = 'Failed to save settings: $e'; isLoading = false; }); } }, child: const Text('Save'), ), ], ); }, ); }, ); } @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: 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(); } } 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, ), ), ), 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, ), ], ), 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, maxLines: null, expands: true, style: Theme.of( context, ).textTheme.bodyMedium, // Apply theme text style decoration: const InputDecoration(labelText: 'Scratch'), ), ), ], ), ), ), ); } } // --- End Actions and Shortcuts ---