10 Commits

Author SHA1 Message Date
9d39cb09df Squash merge feature/fts into master 2025-04-23 14:51:24 +02:00
5ea62a1216 Do focus on popup
Because the window APPEARS over our current window
But isn't focuseded
So we canj't see our fucking window
And we can't close the new fucking window
So it's the worst of both worlds
FUCK
2025-04-23 11:23:11 +02:00
c457b5cd5b Add app icons 2025-04-22 19:22:13 +02:00
722aa34fdf Don't focus on popup 2025-04-22 19:21:13 +02:00
68c6dd1a95 Update 2025-04-22 19:11:40 +02:00
5a27ac75c7 Implement scrolling through previous notes 2025-04-22 00:35:03 +02:00
900bcd866c Implement basic settings 2025-04-22 00:29:31 +02:00
963f7271a2 Rename todo to scratch 2025-04-22 00:22:01 +02:00
442403b5b9 DO save empty todo 2025-04-21 23:38:06 +02:00
ad767ac33a Fix backspace delete 2025-04-21 23:33:15 +02:00
33 changed files with 980 additions and 164 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -427,7 +427,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -21,13 +21,41 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_date_unique ON notes (date);
-- ie. they're not really a list but instead a string -- ie. they're not really a list but instead a string
-- But we will also keep a history of all todos -- But we will also keep a history of all todos
-- Because we might as well -- Because we might as well
CREATE TABLE IF NOT EXISTS todos ( CREATE TABLE IF NOT EXISTS scratches (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT DEFAULT CURRENT_TIMESTAMP, date TEXT DEFAULT CURRENT_TIMESTAMP,
content TEXT NOT NULL content TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_todos_date ON todos (date); CREATE INDEX IF NOT EXISTS idx_scratches_date ON scratches (date);
CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_date_unique ON todos (date); CREATE UNIQUE INDEX IF NOT EXISTS idx_scratches_date_unique ON scratches (date);
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<String> _getDatabasePath() async { static Future<String> _getDatabasePath() async {
@@ -39,7 +67,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_date_unique ON todos (date);
if (home == null) { if (home == null) {
throw Exception('Could not find home directory'); throw Exception('Could not find home directory');
} }
debugPrint('Home directory found: home'); debugPrint('Home directory found: $home');
final dbDir = Directory(path.join(home, settingsDir)); final dbDir = Directory(path.join(home, settingsDir));
if (!await dbDir.exists()) { if (!await dbDir.exists()) {
@@ -53,7 +81,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_date_unique ON todos (date);
} else { } else {
// Default path for other platforms // Default path for other platforms
final databasesPath = await databaseFactoryFfi.getDatabasesPath(); final databasesPath = await databaseFactoryFfi.getDatabasesPath();
debugPrint('Using default databases path: databasesPath'); debugPrint('Using default databases path: $databasesPath');
return path.join(databasesPath, dbFileName); return path.join(databasesPath, dbFileName);
} }
} }
@@ -63,7 +91,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_date_unique ON todos (date);
sqfliteFfiInit(); sqfliteFfiInit();
final dbPath = await _getDatabasePath(); final dbPath = await _getDatabasePath();
debugPrint('Database path: dbPath'); debugPrint('Database path: $dbPath');
try { try {
db = await databaseFactoryFfi.openDatabase( db = await databaseFactoryFfi.openDatabase(
@@ -79,8 +107,93 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_date_unique ON todos (date);
); );
debugPrint('Database opened and initialized'); debugPrint('Database opened and initialized');
} catch (e) { } catch (e) {
debugPrint('Failed to initialize database: e'); debugPrint('Failed to initialize database: $e');
rethrow; rethrow;
} }
} }
// Settings Management
static Future<String?> getSetting(String key) async {
final List<Map<String, dynamic>> maps = await db.query(
'settings',
columns: ['value'],
where: 'key = ?',
whereArgs: [key],
);
if (maps.isNotEmpty) {
return maps.first['value'] as String?;
}
return null;
}
static Future<void> setSetting(String key, String value) async {
await db.insert('settings', {
'key': key,
'value': value,
}, conflictAlgorithm: ConflictAlgorithm.replace);
debugPrint("Setting updated: $key = $value");
}
// Search notes using FTS
static Future<List<Map<String, dynamic>>> 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<String> 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<Map<String, dynamic>> results = await db.rawQuery(
'''
SELECT n.id, n.date, n.content, snippet(notes_fts, 0, '<b>', '</b>', '...', 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 [];
}
}
} }

View File

@@ -6,14 +6,15 @@ import 'package:journaler/notes.dart';
import 'package:system_tray/system_tray.dart'; import 'package:system_tray/system_tray.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/gestures.dart';
import 'dart:math';
// TODO: Add an icon to the executable, simply use the existing tray icon // TODO: Sound does not play when ran from a different workdir? Weird
// TODO: Add an entry field for the duration ie. the interval of apperance // TODO: Fix saving the same scratch over and over again
// TODO: Also the sound file, if possible...
// TODO: Cram the above into the database
const Duration popupInterval = Duration(minutes: 20); // Default values - will be replaced by DB values if they exist
const String notificationSound = 'MeetTheSniper.mp3'; const Duration _defaultPopupInterval = Duration(minutes: 20);
const String _defaultNotificationSound = 'MeetTheSniper.mp3';
void main() async { void main() async {
await DB.init(); await DB.init();
@@ -38,9 +39,6 @@ void main() async {
class JournalerApp extends StatelessWidget { class JournalerApp extends StatelessWidget {
const JournalerApp({super.key}); const JournalerApp({super.key});
static final GlobalKey<MainPageState> _mainPageKey =
GlobalKey<MainPageState>();
static final TextTheme _baseTextTheme = const TextTheme( static final TextTheme _baseTextTheme = const TextTheme(
bodyMedium: TextStyle(fontSize: 24), bodyMedium: TextStyle(fontSize: 24),
); );
@@ -114,30 +112,7 @@ class JournalerApp extends StatelessWidget {
theme: lightTheme, theme: lightTheme,
darkTheme: darkTheme, darkTheme: darkTheme,
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
home: Focus( home: const MainPage(),
// Using RawKeyboardListener despite deprecation because KeyboardListener
// and Shortcuts/Actions didn't reliably capture the Escape key press
// before the TextField handled it for unfocusing, requiring two presses.
// RawKeyboardListener intercepts the event earlier.
child: RawKeyboardListener(
// Revert to RawKeyboardListener
focusNode: FocusNode(),
onKey: (RawKeyEvent event) {
// Revert to onKey and RawKeyEvent
if (event is RawKeyDownEvent && // Revert to RawKeyDownEvent
event.logicalKey == LogicalKeyboardKey.escape) {
debugPrint("Escape pressed (RawKeyboardListener - Workaround)");
final state = _mainPageKey.currentState;
if (state != null) {
// Re-add unfocus, as it seemed necessary with Raw listener
FocusManager.instance.primaryFocus?.unfocus();
state.onWindowClose();
}
}
},
child: MainPage(key: _mainPageKey),
),
),
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
); );
} }
@@ -154,17 +129,30 @@ class MainPageState extends State<MainPage> with WindowListener {
final SystemTray _systemTray = SystemTray(); final SystemTray _systemTray = SystemTray();
final Menu _menu = Menu(); final Menu _menu = Menu();
final AudioPlayer _audioPlayer = AudioPlayer(); final AudioPlayer _audioPlayer = AudioPlayer();
double _volume = 0.7; // Default volume level (0.0 to 1.0)
final TextEditingController _previousEntryController = final TextEditingController _previousEntryController =
TextEditingController(); TextEditingController();
final TextEditingController _currentEntryController = TextEditingController(); final TextEditingController _currentEntryController = TextEditingController();
final FocusNode _currentEntryFocusNode = FocusNode(); final FocusNode _currentEntryFocusNode = FocusNode();
final TextEditingController _todoController = TextEditingController(); final TextEditingController _scratchController = TextEditingController();
final TextEditingController _intervalController = TextEditingController();
final TextEditingController _soundController = TextEditingController();
final TextEditingController _searchController = TextEditingController();
Note? previousNote; Note? previousNote;
Note? _currentlyDisplayedNote;
Duration _currentPopupInterval = _defaultPopupInterval;
String _currentNotificationSound = _defaultNotificationSound;
bool _canGoPrevious = false;
bool _canGoNext = false;
bool _isSearching = false;
List<Note> _searchResults = [];
Timer? _popupTimer; Timer? _popupTimer;
Timer? _debounceTimer; Timer? _debounceTimer;
Timer? _searchDebounceTimer;
@override @override
void initState() { void initState() {
@@ -172,7 +160,7 @@ class MainPageState extends State<MainPage> with WindowListener {
windowManager.addListener(this); windowManager.addListener(this);
_initSystemTray(); _initSystemTray();
_loadData(); _loadData();
_startPopupTimer(); _loadVolume();
windowManager.setPreventClose(true); windowManager.setPreventClose(true);
_setWindowConfig(); _setWindowConfig();
} }
@@ -182,10 +170,14 @@ class MainPageState extends State<MainPage> with WindowListener {
windowManager.removeListener(this); windowManager.removeListener(this);
_popupTimer?.cancel(); _popupTimer?.cancel();
_debounceTimer?.cancel(); _debounceTimer?.cancel();
_searchDebounceTimer?.cancel();
_previousEntryController.dispose(); _previousEntryController.dispose();
_currentEntryController.dispose(); _currentEntryController.dispose();
_currentEntryFocusNode.dispose(); _currentEntryFocusNode.dispose();
_todoController.dispose(); _scratchController.dispose();
_intervalController.dispose();
_soundController.dispose();
_searchController.dispose();
_audioPlayer.dispose(); _audioPlayer.dispose();
super.dispose(); super.dispose();
} }
@@ -226,9 +218,13 @@ class MainPageState extends State<MainPage> with WindowListener {
} }
void _startPopupTimer() { void _startPopupTimer() {
_popupTimer = Timer.periodic(popupInterval, (timer) { _popupTimer?.cancel();
_popupTimer = Timer.periodic(_currentPopupInterval, (timer) {
_showWindow(); _showWindow();
}); });
debugPrint(
"Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes",
);
} }
Future<void> _showWindow() async { Future<void> _showWindow() async {
@@ -247,36 +243,159 @@ 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 { Future<void> _playSound() async {
await _audioPlayer.stop(); await _audioPlayer.stop();
await _audioPlayer.play(AssetSource('sounds/$notificationSound')); try {
debugPrint("Played sound: $notificationSound"); // 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<void> _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<void> _goToPreviousNote() async {
if (!_canGoPrevious || _currentlyDisplayedNote == null) return;
final prevNote = await getPreviousNote(_currentlyDisplayedNote!.date);
if (prevNote != null) {
setState(() {
_currentlyDisplayedNote = prevNote;
_previousEntryController.text = prevNote.content;
});
await _checkNavigation();
}
}
Future<void> _goToNextNote() async {
if (!_canGoNext || _currentlyDisplayedNote == null) return;
final nextNote = await getNextNote(_currentlyDisplayedNote!.date);
if (nextNote != null) {
setState(() {
_currentlyDisplayedNote = nextNote;
_previousEntryController.text = nextNote.content;
});
await _checkNavigation();
}
} }
void _loadData() async { 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(); final note = await getLatestNote();
previousNote = note; previousNote = note;
_previousEntryController.text = note?.content ?? ""; _currentlyDisplayedNote = note;
_previousEntryController.text = _currentlyDisplayedNote?.content ?? "";
final todo = await getLatestTodo(); final scratch = await getLatestScratch();
_todoController.text = todo?.content ?? ""; _scratchController.text = scratch?.content ?? "";
_currentEntryController.text = ""; _currentEntryController.text = "";
debugPrint("Data loaded (placeholder)."); await _checkNavigation();
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 { void _saveData() async {
String previousEntry = _previousEntryController.text; String previousEntry = _previousEntryController.text;
String currentEntry = _currentEntryController.text; String currentEntry = _currentEntryController.text;
String todoList = _todoController.text; String scratchContent = _scratchController.text;
String intervalStr = _intervalController.text;
String soundStr = _soundController.text;
await createNote(currentEntry); await createNote(currentEntry);
await createTodo(todoList); await createScratch(scratchContent);
if (previousNote != null) {
previousNote!.content = previousEntry; previousNote!.content = previousEntry;
await updateNote(previousNote!); await updateNote(previousNote!);
}
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( debugPrint(
"Saving data (placeholder)... Current Entry: [${currentEntry.length} chars], Todo: [${todoList.length} chars]", "Saving data... Current Entry: [${currentEntry.length} chars], Scratch: [${scratchContent.length} chars]",
); );
} }
@@ -284,10 +403,368 @@ class MainPageState extends State<MainPage> with WindowListener {
await windowManager.setAspectRatio(16 / 9); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( // Wrap Scaffold with RawKeyboardListener as workaround for Escape key
appBar: AppBar(title: const Text('Journaler'), actions: const []), 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: <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
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: <TextInputFormatter>[
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( body: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Row( child: Row(
@@ -295,17 +772,76 @@ class MainPageState extends State<MainPage> with WindowListener {
children: [ children: [
Expanded( Expanded(
flex: 9, 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Combine Label, Buttons, and TextField for Previous Entry
Row(
children: [
Expanded(
child: Text(
_currentlyDisplayedNote?.date ==
previousNote?.date
? 'Previous Entry (Latest)'
: 'Entry: ${_currentlyDisplayedNote?.date ?? '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( Expanded(
child: TextField( child: TextField(
controller: _previousEntryController, controller: _previousEntryController,
readOnly:
_currentlyDisplayedNote?.date !=
previousNote?.date,
maxLines: null, maxLines: null,
expands: true, expands: true,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Previous Entry', 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,
), ),
), ),
), ),
@@ -321,33 +857,30 @@ class MainPageState extends State<MainPage> with WindowListener {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Current Entry (What\'s on your mind?)', labelText: 'Current Entry (What\'s on your mind?)',
), ),
onChanged: (text) {},
), ),
), ),
], ],
), ),
), ),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
flex: 3, flex: 4, // Adjust flex factor as needed
child: TextField( child: TextField(
controller: _todoController, controller: _scratchController,
maxLines: null, maxLines: null,
expands: true, expands: true,
style: style:
Theme.of( Theme.of(
context, context,
).textTheme.bodyMedium, // Apply theme text style ).textTheme.bodyMedium, // Apply theme text style
decoration: const InputDecoration( decoration: const InputDecoration(labelText: 'Scratch'),
labelText: 'Todo',
// border: OutlineInputBorder(), // Handled by theme
// contentPadding: EdgeInsets.all(8.0), // Handled by theme or default
),
), ),
), ),
], ],
), ),
), ),
),
); );
} }
} }

View File

@@ -3,15 +3,17 @@ import 'package:journaler/db.dart';
class Note { class Note {
final String date; final String date;
String content; 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 Todo { class Scratch {
final String date; final String date;
final String content; String content;
Todo({required this.date, required this.content}); Scratch({required this.date, required this.content});
} }
Future<Note?> getLatestNote() async { Future<Note?> getLatestNote() async {
@@ -43,22 +45,82 @@ Future<void> updateNote(Note note) async {
); );
} }
Future<Todo?> getLatestTodo() async { Future<Scratch?> getLatestScratch() async {
final todo = await DB.db.rawQuery( final scratch = await DB.db.rawQuery(
'SELECT content, date FROM todos ORDER BY date DESC LIMIT 1', 'SELECT content, date FROM scratches ORDER BY date DESC LIMIT 1',
); );
if (todo.isEmpty) {
if (scratch.isEmpty) {
return null; return null;
} } else {
return Todo( return Scratch(
date: todo[0]['date'] as String, date: scratch[0]['date'] as String,
content: todo[0]['content'] as String, content: scratch[0]['content'] as String,
); );
}
} }
Future<void> createTodo(String content) async { Future<void> createScratch(String content) async {
if (content.isEmpty) { await DB.db.insert('scratches', {'content': content});
return; }
}
await DB.db.insert('todos', {'content': content}); // Get the note immediately older than the given date
Future<Note?> getPreviousNote(String currentDate) async {
final List<Map<String, dynamic>> notes = await DB.db.query(
'notes',
where: 'date < ?',
whereArgs: [currentDate],
orderBy: 'date DESC',
limit: 1,
);
if (notes.isNotEmpty) {
return Note(
date: notes.first['date'] as String,
content: notes.first['content'] as String,
);
}
return null;
}
// Get the note immediately newer than the given date
Future<Note?> getNextNote(String currentDate) async {
final List<Map<String, dynamic>> notes = await DB.db.query(
'notes',
where: 'date > ?',
whereArgs: [currentDate],
orderBy: 'date ASC',
limit: 1,
);
if (notes.isNotEmpty) {
return Note(
date: notes.first['date'] as String,
content: notes.first['content'] as String,
);
}
// If there's no newer note, it means we might be at the latest
// but let's double-check by explicitly getting the latest again.
// This handles the case where the `currentDate` might not be the absolute latest.
return getLatestNote();
}
// Search notes using full-text search
Future<List<Note>> searchNotes(String query) async {
if (query.isEmpty) {
return [];
}
// Call DB search function
final List<Map<String, dynamic>> 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();
} }

View File

@@ -1,6 +1,22 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive:
dependency: transitive
description:
name: archive
sha256: a7f37ff061d7abc2fcf213554b9dcaca713c5853afa5c065c44888bc9ccaf813
url: "https://pub.dev"
source: hosted
version: "4.0.6"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -81,6 +97,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.dev"
source: hosted
version: "2.0.3"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -142,6 +174,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -176,6 +216,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.5.4"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
@@ -296,6 +344,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -312,6 +368,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62
url: "https://pub.dev"
source: hosted
version: "6.0.2"
screen_retriever: screen_retriever:
dependency: transitive dependency: transitive
description: description:
@@ -501,6 +565,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.7.2 <4.0.0" dart: ">=3.7.2 <4.0.0"
flutter: ">=3.27.0" flutter: ">=3.27.0"

View File

@@ -42,6 +42,7 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_launcher_icons: ^0.13.1
# The "flutter_lints" package below contains a set of recommended lints to # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # encourage good coding practices. The lint set provided by the package is
@@ -93,3 +94,12 @@ flutter:
# #
# For details regarding fonts from package dependencies, # For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package # see https://flutter.dev/to/font-from-package
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/app_icon.ico"
windows:
generate: true
image_path: "assets/app_icon.ico"
icon_size: 256 # min: 48, max: 256, default: 48

BIN
windows/runner/resources/app_icon.ico (Stored with Git LFS)

Binary file not shown.