Squash merge feature/fts into master

This commit is contained in:
2025-04-23 14:51:24 +02:00
parent 5ea62a1216
commit 9d39cb09df
3 changed files with 496 additions and 43 deletions

View File

@@ -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<MainPage> 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<MainPage> 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<MainPage> with WindowListener {
bool _canGoPrevious = false;
bool _canGoNext = false;
bool _isSearching = false;
List<Note> _searchResults = [];
Timer? _popupTimer;
Timer? _debounceTimer;
Timer? _searchDebounceTimer;
@override
void initState() {
@@ -153,6 +160,7 @@ class MainPageState extends State<MainPage> with WindowListener {
windowManager.addListener(this);
_initSystemTray();
_loadData();
_loadVolume();
windowManager.setPreventClose(true);
_setWindowConfig();
}
@@ -162,12 +170,14 @@ class MainPageState extends State<MainPage> 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<MainPage> 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<void> _showWindow() async {
@@ -231,11 +243,25 @@ class MainPageState extends State<MainPage> 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<void> _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<MainPage> 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<MainPage> with WindowListener {
debugPrint("Data loaded.");
}
// Load volume setting from database
Future<void> _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<void> _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<MainPage> 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<MainPage> 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<MainPage> 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<InlineSpan> _buildHighlightedText(String highlightedText) {
List<InlineSpan> spans = [];
// The text comes with <b>highlighted parts</b>
RegExp exp = RegExp(r'<b>(.*?)</b>');
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: <Widget>[
// 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<MainPage> 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: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly
FilteringTextInputFormatter.digitsOnly,
],
),
),
@@ -401,7 +720,10 @@ class MainPageState extends State<MainPage> 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<MainPage> 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<MainPage> 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<MainPage> 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<MainPage> 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<MainPage> 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'),
),
),
],