From 9d39cb09df09c939e69f08dcdf7b6a19d1a35b8b Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 23 Apr 2025 14:51:24 +0200 Subject: [PATCH] Squash merge feature/fts into master --- lib/db.dart | 103 +++++++++++-- lib/main.dart | 410 +++++++++++++++++++++++++++++++++++++++++++++---- lib/notes.dart | 26 +++- 3 files changed, 496 insertions(+), 43 deletions(-) diff --git a/lib/db.dart b/lib/db.dart index 8392f6f..751d0ab 100644 --- a/lib/db.dart +++ b/lib/db.dart @@ -33,6 +33,29 @@ CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL ); + +-- Create virtual FTS5 table for searching notes content +CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( + content, + date, + content='notes', + content_rowid='id' +); + +-- Trigger to keep FTS table in sync with notes table when inserting +CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN + INSERT INTO notes_fts(rowid, content, date) VALUES (new.id, new.content, new.date); +END; + +-- Trigger to keep FTS table in sync when deleting notes +CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN + DELETE FROM notes_fts WHERE rowid = old.id; +END; + +-- Trigger to keep FTS table in sync when updating notes +CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN + UPDATE notes_fts SET content = new.content, date = new.date WHERE rowid = old.id; +END; '''; static Future _getDatabasePath() async { @@ -44,7 +67,7 @@ CREATE TABLE IF NOT EXISTS settings ( if (home == null) { throw Exception('Could not find home directory'); } - debugPrint('Home directory found: home'); + debugPrint('Home directory found: $home'); final dbDir = Directory(path.join(home, settingsDir)); if (!await dbDir.exists()) { @@ -58,7 +81,7 @@ CREATE TABLE IF NOT EXISTS settings ( } else { // Default path for other platforms final databasesPath = await databaseFactoryFfi.getDatabasesPath(); - debugPrint('Using default databases path: databasesPath'); + debugPrint('Using default databases path: $databasesPath'); return path.join(databasesPath, dbFileName); } } @@ -68,7 +91,7 @@ CREATE TABLE IF NOT EXISTS settings ( sqfliteFfiInit(); final dbPath = await _getDatabasePath(); - debugPrint('Database path: dbPath'); + debugPrint('Database path: $dbPath'); try { db = await databaseFactoryFfi.openDatabase( @@ -84,7 +107,7 @@ CREATE TABLE IF NOT EXISTS settings ( ); debugPrint('Database opened and initialized'); } catch (e) { - debugPrint('Failed to initialize database: e'); + debugPrint('Failed to initialize database: $e'); rethrow; } } @@ -104,11 +127,73 @@ CREATE TABLE IF NOT EXISTS settings ( } static Future setSetting(String key, String value) async { - await db.insert( - 'settings', - {'key': key, 'value': value}, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.insert('settings', { + 'key': key, + 'value': value, + }, conflictAlgorithm: ConflictAlgorithm.replace); debugPrint("Setting updated: $key = $value"); } + + // Search notes using FTS + static Future>> searchNotes(String query) async { + try { + if (query.trim().isEmpty) { + return []; + } + + // Process the query for partial word matching + // Split into individual terms, filter empty ones + List terms = + query + .trim() + .split(RegExp(r'\s+')) + .where((term) => term.isNotEmpty) + .toList(); + + if (terms.isEmpty) { + return []; + } + + // Add wildcards to each term for prefix matching (e.g., "fuck*" will match "fucked") + // Join terms with AND for all-term matching (results must contain ALL terms) + String ftsQuery = terms + .map((term) { + // Remove any special characters that might break the query + String sanitizedTerm = term.replaceAll(RegExp(r'[^\w]'), ''); + if (sanitizedTerm.isEmpty) return ''; + + // Add wildcard for stemming/prefix matching + return '$sanitizedTerm*'; + }) + .where((term) => term.isNotEmpty) + .join(' AND '); + + if (ftsQuery.isEmpty) { + debugPrint('Query was sanitized to empty string'); + return []; + } + + debugPrint('FTS query: "$ftsQuery"'); + + // Execute the FTS query with AND logic + final List> results = await db.rawQuery( + ''' + SELECT n.id, n.date, n.content, snippet(notes_fts, 0, '', '', '...', 20) as snippet + FROM notes_fts + JOIN notes n ON notes_fts.rowid = n.id + WHERE notes_fts MATCH ? + ORDER BY n.date DESC + LIMIT 100 + ''', + [ftsQuery], + ); + + debugPrint('Search query "$ftsQuery" returned ${results.length} results'); + return results; + } catch (e) { + debugPrint('Search failed: $e'); + // Return empty results rather than crashing on malformed queries + return []; + } + } } diff --git a/lib/main.dart b/lib/main.dart index 00aa0e1..a75ea79 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,8 +7,10 @@ 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'; // 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); @@ -127,6 +129,7 @@ class MainPageState extends State with WindowListener { 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(); @@ -135,6 +138,7 @@ class MainPageState extends State with WindowListener { final TextEditingController _scratchController = TextEditingController(); final TextEditingController _intervalController = TextEditingController(); final TextEditingController _soundController = TextEditingController(); + final TextEditingController _searchController = TextEditingController(); Note? previousNote; Note? _currentlyDisplayedNote; @@ -143,9 +147,12 @@ class MainPageState extends State with WindowListener { bool _canGoPrevious = false; bool _canGoNext = false; + bool _isSearching = false; + List _searchResults = []; Timer? _popupTimer; Timer? _debounceTimer; + Timer? _searchDebounceTimer; @override void initState() { @@ -153,6 +160,7 @@ class MainPageState extends State with WindowListener { windowManager.addListener(this); _initSystemTray(); _loadData(); + _loadVolume(); windowManager.setPreventClose(true); _setWindowConfig(); } @@ -162,12 +170,14 @@ class MainPageState extends State with WindowListener { 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(); } @@ -212,7 +222,9 @@ class MainPageState extends State with WindowListener { _popupTimer = Timer.periodic(_currentPopupInterval, (timer) { _showWindow(); }); - debugPrint("Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes"); + debugPrint( + "Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes", + ); } Future _showWindow() async { @@ -231,11 +243,25 @@ class MainPageState extends State with WindowListener { } } + // 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"); + debugPrint( + "Played sound: $_currentNotificationSound at volume: $_volume (log: ${_linearToLogVolume(_volume)})", + ); } catch (e) { debugPrint("Error playing sound $_currentNotificationSound: $e"); } @@ -289,7 +315,9 @@ class MainPageState extends State with WindowListener { String? intervalMinutesStr = await DB.getSetting('popupIntervalMinutes'); String? soundFileStr = await DB.getSetting('notificationSound'); - int intervalMinutes = int.tryParse(intervalMinutesStr ?? '') ?? _defaultPopupInterval.inMinutes; + int intervalMinutes = + int.tryParse(intervalMinutesStr ?? '') ?? + _defaultPopupInterval.inMinutes; _currentPopupInterval = Duration(minutes: intervalMinutes); _currentNotificationSound = soundFileStr ?? _defaultNotificationSound; @@ -312,6 +340,25 @@ class MainPageState extends State with WindowListener { 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"); + } + void _saveData() async { String previousEntry = _previousEntryController.text; String currentEntry = _currentEntryController.text; @@ -326,7 +373,8 @@ class MainPageState extends State with WindowListener { await updateNote(previousNote!); } - int newIntervalMinutes = int.tryParse(intervalStr) ?? _currentPopupInterval.inMinutes; + int newIntervalMinutes = + int.tryParse(intervalStr) ?? _currentPopupInterval.inMinutes; Duration newInterval = Duration(minutes: newIntervalMinutes); if (newInterval != _currentPopupInterval) { _currentPopupInterval = newInterval; @@ -343,6 +391,9 @@ class MainPageState extends State with WindowListener { DB.setSetting('notificationSound', soundStr); } + // Also save volume + await _saveVolume(); + debugPrint( "Saving data... Current Entry: [${currentEntry.length} chars], Scratch: [${scratchContent.length} chars]", ); @@ -352,29 +403,294 @@ class MainPageState extends State with WindowListener { 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); + + // Important: update the dialog state after search completes + dialogSetState(() { + _searchResults = results; + _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.date, + 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: () { + Navigator.of(context).pop(); + this.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(), // Needs its own node + focusNode: + FocusNode() + ..requestFocus(), // Request focus to ensure keyboard events are captured onKey: (RawKeyEvent event) { - if (event is RawKeyDownEvent && - 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(); + 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 + padding: const EdgeInsets.symmetric( + vertical: 8.0, + ).copyWith(left: 8.0), // Add padding child: Row( mainAxisSize: MainAxisSize.min, // Use minimum space children: [ @@ -388,11 +704,14 @@ class MainPageState extends State with WindowListener { decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, - contentPadding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + contentPadding: EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 8.0, + ), ), keyboardType: TextInputType.number, inputFormatters: [ - FilteringTextInputFormatter.digitsOnly + FilteringTextInputFormatter.digitsOnly, ], ), ), @@ -401,7 +720,10 @@ class MainPageState extends State with WindowListener { ), // Group Label and Input for Sound Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 8.0, + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -415,7 +737,10 @@ class MainPageState extends State with WindowListener { decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, - contentPadding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + contentPadding: EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 8.0, + ), hintText: 'sound.mp3', ), ), @@ -423,6 +748,11 @@ class MainPageState extends State with WindowListener { ], ), ), + // Volume Control Slider + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: _buildVolumeSlider(), + ), // Test Sound Button Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -465,16 +795,21 @@ class MainPageState extends State with WindowListener { children: [ Expanded( child: Text( - _currentlyDisplayedNote?.date == previousNote?.date + _currentlyDisplayedNote?.date == + previousNote?.date ? 'Previous Entry (Latest)' : 'Entry: ${_currentlyDisplayedNote?.date ?? 'N/A'}', - style: TextStyle(fontSize: 18, color: Colors.grey), + style: TextStyle( + fontSize: 18, + color: Colors.grey, + ), ), ), IconButton( icon: const Icon(Icons.arrow_back), tooltip: 'Previous Note', - onPressed: _canGoPrevious ? _goToPreviousNote : null, + onPressed: + _canGoPrevious ? _goToPreviousNote : null, ), IconButton( icon: const Icon(Icons.arrow_forward), @@ -486,19 +821,27 @@ class MainPageState extends State with WindowListener { Expanded( child: TextField( controller: _previousEntryController, - readOnly: _currentlyDisplayedNote?.date != previousNote?.date, + readOnly: + _currentlyDisplayedNote?.date != + previousNote?.date, maxLines: null, expands: true, style: Theme.of(context).textTheme.bodyMedium, decoration: InputDecoration( - hintText: _currentlyDisplayedNote?.date != previousNote?.date - ? 'Viewing note from ${_currentlyDisplayedNote?.date} (Read-Only)' - : 'Latest Note', + hintText: + _currentlyDisplayedNote?.date != + previousNote?.date + ? 'Viewing note from ${_currentlyDisplayedNote?.date} (Read-Only)' + : 'Latest Note', border: const OutlineInputBorder(), - filled: _currentlyDisplayedNote?.date != previousNote?.date, - fillColor: _currentlyDisplayedNote?.date != previousNote?.date - ? Colors.grey.withOpacity(0.1) - : null, + filled: + _currentlyDisplayedNote?.date != + previousNote?.date, + fillColor: + _currentlyDisplayedNote?.date != + previousNote?.date + ? Colors.grey.withOpacity(0.1) + : null, ), ), ), @@ -527,10 +870,11 @@ class MainPageState extends State with WindowListener { controller: _scratchController, maxLines: null, expands: true, - style: Theme.of(context).textTheme.bodyMedium, // Apply theme text style - decoration: const InputDecoration( - labelText: 'Scratch', - ), + style: + Theme.of( + context, + ).textTheme.bodyMedium, // Apply theme text style + decoration: const InputDecoration(labelText: 'Scratch'), ), ), ], diff --git a/lib/notes.dart b/lib/notes.dart index b4b254f..8cf9627 100644 --- a/lib/notes.dart +++ b/lib/notes.dart @@ -3,8 +3,10 @@ import 'package:journaler/db.dart'; class Note { final String date; String content; + String? + snippet; // Optional field to hold highlighted snippets for search results - Note({required this.date, required this.content}); + Note({required this.date, required this.content, this.snippet}); } class Scratch { @@ -100,3 +102,25 @@ Future getNextNote(String currentDate) async { // This handles the case where the `currentDate` might not be the absolute latest. return getLatestNote(); } + +// Search notes using full-text search +Future> searchNotes(String query) async { + if (query.isEmpty) { + return []; + } + + // Call DB search function + final List> results = await DB.searchNotes(query); + + // Convert results to Note objects + return results + .map( + (result) => Note( + date: result['date'] as String, + content: result['content'] as String, + snippet: + result['snippet'] as String?, // Highlighted snippets from FTS + ), + ) + .toList(); +}