import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:journaler/db.dart'; import 'package:journaler/notes.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'; // 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 await DB.init(); 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(); Note? previousNote; Note? _currentlyDisplayedNote; Duration _currentPopupInterval = _defaultPopupInterval; String _currentNotificationSound = _defaultNotificationSound; 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(); _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(); // Now show and focus await windowManager.show(); await Future.delayed(const Duration(milliseconds: 100)); // Short delay await windowManager.focus(); // Set input focus _currentEntryFocusNode.requestFocus(); // Play notification sound await _playSound(); debugPrint("Window made visible and focused"); } else { // Already visible, just focus await windowManager.focus(); _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 getPreviousNote(_currentlyDisplayedNote!.date); final bool isLatest = _currentlyDisplayedNote!.date == previousNote?.date; 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) { _currentlyDisplayedNote!.content = _previousEntryController.text; await updateNote(_currentlyDisplayedNote!); } final prevNote = await getPreviousNote(_currentlyDisplayedNote!.date); 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) { _currentlyDisplayedNote!.content = _previousEntryController.text; await updateNote(_currentlyDisplayedNote!); } final nextNote = await getNextNote(_currentlyDisplayedNote!.date); if (nextNote != null) { setState(() { _currentlyDisplayedNote = nextNote; _previousEntryController.text = nextNote.content; }); await _checkNavigation(); } } void _loadData() async { String? intervalMinutesStr = await DB.getSetting('popupIntervalMinutes'); String? soundFileStr = await DB.getSetting('notificationSound'); int intervalMinutes = int.tryParse(intervalMinutesStr ?? '') ?? _defaultPopupInterval.inMinutes; _currentPopupInterval = Duration(minutes: intervalMinutes); _currentNotificationSound = soundFileStr ?? _defaultNotificationSound; _intervalController.text = intervalMinutes.toString(); _soundController.text = _currentNotificationSound; _startPopupTimer(); final note = await getLatestNote(); previousNote = note; _currentlyDisplayedNote = note; _previousEntryController.text = _currentlyDisplayedNote?.content ?? ""; final scratch = await getLatestScratch(); _scratchController.text = scratch?.content ?? ""; _currentEntryController.text = ""; await _checkNavigation(); debugPrint("Data loaded."); } // Load volume setting from database Future _loadVolume() async { String? volumeStr = await DB.getSetting('notificationVolume'); if (volumeStr != null) { setState(() { _volume = double.tryParse(volumeStr) ?? 0.7; _audioPlayer.setVolume(_linearToLogVolume(_volume)); }); } else { _audioPlayer.setVolume(_linearToLogVolume(_volume)); } } // Save volume setting to database Future _saveVolume() async { await DB.setSetting('notificationVolume', _volume.toString()); 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); } // Handle scratch pad await createScratch(scratchContent); // Handle previous/currently displayed note if (_currentlyDisplayedNote != null) { _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 getLatestNote(); 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; DB.setSetting('popupIntervalMinutes', newIntervalMinutes.toString()); _startPopupTimer(); } else { DB.setSetting('popupIntervalMinutes', newIntervalMinutes.toString()); } if (soundStr != _currentNotificationSound) { _currentNotificationSound = soundStr; DB.setSetting('notificationSound', soundStr); } else { DB.setSetting('notificationSound', 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, ), // Smaller font for regular text ), ); } // 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, // Smaller font for highlighted text ), ), ); lastIndex = match.end; } // Add any remaining text if (lastIndex < highlightedText.length) { spans.add( TextSpan( text: highlightedText.substring(lastIndex), style: const TextStyle(fontSize: 13), // Smaller font for regular text ), ); } 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(); // 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: note.snippet != null ? Text.rich( TextSpan( children: _buildHighlightedText( note.snippet!, ), ), ) : Text( note.content.length > 200 ? '${note.content.substring(0, 200)}...' : note.content, style: const TextStyle( fontSize: 13, ), // Smaller font for content ), isThreeLine: true, onTap: () async { // Save current note if needed if (_currentlyDisplayedNote != null) { _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), ], ); } @override Widget build(BuildContext context) { // Wrap Scaffold with RawKeyboardListener as workaround for Escape key return RawKeyboardListener( focusNode: FocusNode() ..requestFocus(), // Request focus to ensure keyboard events are captured 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 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, ), ), 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 ---