13 Commits

Author SHA1 Message Date
b5dad28491 Clear input field after saving note 2025-05-18 13:50:59 +02:00
1e11cd53c9 Do not reset controller when showing 2025-05-18 09:56:04 +02:00
b74dc5b3c6 Remove unused 2025-05-16 15:44:51 +02:00
474cb662e5 Remove fallback to like 2025-05-16 15:41:28 +02:00
a84973def6 Fix cyrillic 2025-05-16 15:32:58 +02:00
fcb8e41cde Add icu support for cyrillic fts 2025-05-16 14:56:22 +02:00
4f54b89689 Add a date to latest 2025-04-24 10:03:43 +02:00
eb2ec79150 Implement local date via intl
We don't want to display utc
2025-04-24 09:58:57 +02:00
e8b9f0ba49 Enable deleting notes by deleting all their content 2025-04-23 20:56:01 +02:00
6c8340d768 Update 2025-04-23 16:32:24 +02:00
db565f4603 Work out a README 2025-04-23 16:16:03 +02:00
b2bff9e5c5 Implement IPC to show up existing instance when re-running 2025-04-23 15:51:55 +02:00
9d39cb09df Squash merge feature/fts into master 2025-04-23 14:51:24 +02:00
9 changed files with 1150 additions and 99 deletions

View File

@@ -1,16 +1,89 @@
# journaler
# Journaler
A new Flutter project.
![Journaler Logo](assets/app_icon.ico)
## Getting Started
Journaler is a "cross-platform" desktop application designed to encourage regular journaling by providing automated reminders and a clean writing environment.
This project is a starting point for a Flutter application.
## Overview
A few resources to get you started if this is your first Flutter project:
Journaler helps you build a consistent journaling habit by periodically reminding you to record your thoughts throughout the day. It runs silently in the system tray and pops up at customizable intervals, making it perfect for:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
- **Daily reflection and mindfulness practice**
- **Tracking your thoughts and activities**
- **Building a journaling habit**
- **Capturing ideas before they fade**
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
![Journaler Screenshot](docs/screenshots/journaler_main.png)
## Features
- **Automated Reminders**: Customizable popup intervals to remind you to journal
- **System Tray Integration**: Runs silently in the background
- **Scratch Pad**: Quick notes area for temporary thoughts
- **Journal History**: Browse through past entries
- **Full-Text Search**: Find entries containing specific words or phrases
- **Dark/Light Theme**: Automatically follows your system theme
- **Notification Sounds**: Audio cues when it's time to journal
- **Single Instance**: Only one copy of the app runs at a time
## Usage
### Getting Started
1. **First Launch**: When you first launch Journaler, it will start in the system tray
2. **Writing Entries**: The app will automatically pop up at set intervals (default: 20 minutes)
3. **Manual Access**: Click the system tray icon to show the app manually
4. **"Multiple" instances**: Running another instance will make the existing instance show itself
5. **Saving entries**: Entries are saved automatically when the popup is closed either manually or by hitting the escape key
### Main Interface
1. **Current Entry**: Write your thoughts for the current moment
2. **Previous Entry**: View what you wrote last time
3. **Scratch Pad**: Space for quick notes that persist between sessions
4. **Navigation**: Browse through your past entries
5. **Search**: Find specific content in your journal history (May also be invoked by CTRL-F)
![Journaler Interface](docs/screenshots/journaler_annotated.png)
### Settings
You can adjust:
- **Popup Interval**: Change how frequently Journaler reminds you
- **Notification Sound**: Select your preferred audio reminder
- **Volume**: Adjust the sound level of reminders
## Data privacy
All your journal entries are stored securely on your local machine:
- **Windows**: `C:\Users\YourUsername\.journaler\`
No network requests are made, no data sent or received, your data is yours alone.
Data is stored in an SQLite database with a very simple schema that can be fucked with easily.
## Development
This application is built with Flutter. If you want to build from source:
1. Install Flutter (version 3.7.2 or higher)
2. Clone the repository
3. Run `flutter pub get` to install dependencies
4. Use `flutter run` to run the application in debug mode
## Known issues
I don't know whether or not the sound customization works.<br>
In theory only 2 sound files are bundled with the app so, in theory, you may only choose between those two.<br>
But I don't know how flutter handles assets, currently I am not very motivated to figure this out.
Scrolling through long notes is difficult since we're intercepting the scroll event and using it to scroll through notes themselves.<br>
This, again, is not really an issue for me so I'm not very motivated to fix it.
Showing and focusing the window is really annoying for "gamers" and generally whoever is doing anything at the time.<br>
But I have no better ideas.<br>
If we show the window without focusing it, it will still be on top and, if not focused, will be even more annoying because to close it you have to click on it (to focus it) and THEN close it.<br>
If we only play the sound as a reminder we could miss it or simply get used to the sound and learn to ignore it.<br>
I have no ideas for a better solution.

Binary file not shown.

BIN
docs/screenshots/journaler_annotated.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/screenshots/journaler_main.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@@ -6,10 +6,23 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
const settingsDir = '.journaler';
const dbFileName = 'journaler.db';
// Add this at the top level
typedef ShowMessageCallback = void Function(String message);
class DB {
static late Database db;
static bool _hasIcuSupport = false;
static ShowMessageCallback? _showMessage;
static const String _schema = '''
// Add this to register the callback
static void registerMessageCallback(ShowMessageCallback callback) {
_showMessage = callback;
}
// Add this method to check ICU status
static bool get hasIcuSupport => _hasIcuSupport;
static const String _baseSchema = '''
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT DEFAULT CURRENT_TIMESTAMP,
@@ -17,10 +30,7 @@ CREATE TABLE IF NOT EXISTS notes (
);
CREATE INDEX IF NOT EXISTS idx_notes_date ON notes (date);
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_date_unique ON notes (date);
-- Todos are "static", we are only interested in the latest entry
-- ie. they're not really a list but instead a string
-- But we will also keep a history of all todos
-- Because we might as well
CREATE TABLE IF NOT EXISTS scratches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT DEFAULT CURRENT_TIMESTAMP,
@@ -35,6 +45,163 @@ CREATE TABLE IF NOT EXISTS settings (
);
''';
static const String _ftsSchemaWithIcu = '''
-- Create virtual FTS5 table with Unicode tokenizer
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
content,
date,
content='notes',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2 tokenchars "\u0401\u0451\u0410-\u044f"'
);
''';
static const String _ftsSchemaBasic = '''
-- Create virtual FTS5 table with basic tokenizer
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
content,
date,
content='notes',
content_rowid='id',
tokenize='unicode61'
);
''';
static const String _ftsTriggers = '''
-- 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;
''';
// Add this method to check ICU status with visible feedback
static Future<bool> checkAndShowIcuStatus() async {
final status = _hasIcuSupport;
final message =
status
? 'ICU support is enabled - Full Unicode search available'
: 'ICU support is not available - Unicode search will be limited\n'
'Looking for sqlite3_icu.dll in the application directory';
debugPrint(message);
_showMessage?.call(message);
return status;
}
static Future<bool> _checkIcuSupport(Database db) async {
try {
debugPrint(
'\n================== CHECKING UNICODE SUPPORT ==================',
);
// Test Unicode support with a simple query
try {
debugPrint('Testing Unicode support...');
// Test with some Cyrillic characters
await db.rawQuery("SELECT 'тест' LIKE '%ест%'");
await db.rawQuery("SELECT 'ТЕСТ' LIKE '%ЕСТ%'");
final message = 'Unicode support is available';
debugPrint(message);
_showMessage?.call(message);
return true;
} catch (e, stackTrace) {
final message = 'Unicode support test failed';
debugPrint('$message:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
// Try to get SQLite version and compile options for debugging
try {
final version = await db.rawQuery('SELECT sqlite_version()');
final compileOpts = await db.rawQuery('PRAGMA compile_options');
debugPrint('SQLite version: $version');
debugPrint('SQLite compile options: $compileOpts');
} catch (e) {
debugPrint('Could not get SQLite info: $e');
}
_showMessage?.call(message);
return false;
}
} catch (e, stackTrace) {
final message = 'Failed to test Unicode support';
debugPrint('$message:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
_showMessage?.call(message);
return false;
} finally {
debugPrint('=====================================================\n');
}
}
static Future<void> _recreateFtsTable(Database db, bool useIcu) async {
debugPrint('Updating FTS table configuration...');
try {
// Start a transaction to ensure data safety
await db.transaction((txn) async {
// First, create a temporary table with the new configuration
final tempTableName = 'notes_fts_temp';
final schema = useIcu ? _ftsSchemaWithIcu : _ftsSchemaBasic;
// Create temp table with new configuration
await txn.execute(schema.replaceAll('notes_fts', tempTableName));
// Copy data from old FTS table if it exists
try {
debugPrint('Copying existing FTS data to temporary table...');
await txn.execute('''
INSERT INTO $tempTableName(rowid, content, date)
SELECT rowid, content, date FROM notes_fts
''');
debugPrint('Data copied successfully');
} catch (e) {
debugPrint('No existing FTS data to copy: $e');
}
// Drop old triggers
debugPrint('Updating triggers...');
await txn.execute('DROP TRIGGER IF EXISTS notes_ai');
await txn.execute('DROP TRIGGER IF EXISTS notes_ad');
await txn.execute('DROP TRIGGER IF EXISTS notes_au');
// Drop old FTS table
await txn.execute('DROP TABLE IF EXISTS notes_fts');
// Rename temp table to final name
await txn.execute('ALTER TABLE $tempTableName RENAME TO notes_fts');
// Create new triggers
await txn.execute(_ftsTriggers);
// Rebuild FTS index from notes table to ensure consistency
debugPrint('Rebuilding FTS index from notes table...');
await txn.execute('''
INSERT OR REPLACE INTO notes_fts(rowid, content, date)
SELECT id, content, date FROM notes
''');
debugPrint('FTS table update completed successfully');
});
} catch (e, stackTrace) {
debugPrint('Error updating FTS table:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
}
static Future<String> _getDatabasePath() async {
debugPrint('Attempting to get database path...');
if (Platform.isWindows || Platform.isLinux) {
@@ -44,7 +211,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,33 +225,107 @@ 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);
}
}
static Future<void> init() async {
debugPrint('Starting database initialization...');
// Initialize SQLite FFI
sqfliteFfiInit();
final databaseFactory = databaseFactoryFfi;
final dbPath = await _getDatabasePath();
debugPrint('Database path: dbPath');
// Create a temporary database to check version
try {
db = await databaseFactoryFfi.openDatabase(
dbPath,
debugPrint(
'\n================== SQLITE VERSION CHECK ==================',
);
final tempDb = await databaseFactory.openDatabase(
':memory:',
options: OpenDatabaseOptions(
version: 1,
onCreate: (db, version) async {
debugPrint('Creating database schema...');
await db.execute(_schema);
debugPrint('Database schema created successfully');
final results = await db.rawQuery('SELECT sqlite_version()');
debugPrint('SQLite version: ${results.first.values.first}');
final compileOpts = await db.rawQuery('PRAGMA compile_options');
debugPrint('SQLite compile options:');
for (var opt in compileOpts) {
debugPrint(' ${opt.values.first}');
}
},
),
);
debugPrint('Database opened and initialized');
} catch (e) {
debugPrint('Failed to initialize database: e');
await tempDb.close();
debugPrint('=====================================================\n');
} catch (e, stackTrace) {
debugPrint('Error checking SQLite version:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
}
await databaseFactory.setDatabasesPath(
await databaseFactory.getDatabasesPath(),
);
final dbPath = await _getDatabasePath();
debugPrint('Database path: $dbPath');
try {
db = await databaseFactory.openDatabase(
dbPath,
options: OpenDatabaseOptions(
version: 2,
onConfigure: (db) async {
debugPrint('Configuring database...');
await db.execute('PRAGMA foreign_keys = ON');
debugPrint('Database configured');
},
onCreate: (db, version) async {
debugPrint('Creating database schema...');
await db.execute(_baseSchema);
// Check for Unicode support on first creation
_hasIcuSupport = await _checkIcuSupport(db);
await _recreateFtsTable(db, _hasIcuSupport);
debugPrint('Database schema created successfully');
},
onOpen: (db) async {
debugPrint('Database opened, checking Unicode support...');
try {
_hasIcuSupport = await _checkIcuSupport(db);
debugPrint('Unicode support check completed: $_hasIcuSupport');
} catch (e, stackTrace) {
debugPrint('Error during Unicode support check:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
},
onUpgrade: (db, oldVersion, newVersion) async {
debugPrint('Upgrading database from $oldVersion to $newVersion');
if (oldVersion < 2) {
// Check for Unicode support during upgrade
_hasIcuSupport = await _checkIcuSupport(db);
await _recreateFtsTable(db, _hasIcuSupport);
}
},
),
);
// Store Unicode support status in settings for future reference
await setSetting('has_icu_support', _hasIcuSupport.toString());
debugPrint(
'Database opened and initialized (Unicode support: $_hasIcuSupport)',
);
} catch (e, stackTrace) {
debugPrint('Failed to initialize database:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
}
@@ -104,11 +345,72 @@ CREATE TABLE IF NOT EXISTS settings (
}
static Future<void> 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<List<Map<String, dynamic>>> searchNotes(String query) async {
try {
if (query.trim().isEmpty) {
return [];
}
// 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 [];
}
// Process terms for FTS5 query using proper tokenization
String ftsQuery = terms
.map((term) {
// Remove dangerous characters but preserve Unicode
String sanitizedTerm = term.replaceAll(RegExp(r'''['"]'''), '');
if (sanitizedTerm.isEmpty) return '';
// Use proper FTS5 syntax: each word becomes a separate token with prefix matching
List<String> words = sanitizedTerm.split(RegExp(r'\s+'));
return words.map((word) => '$word*').join(' OR ');
})
.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
final List<Map<String, dynamic>> results = await db.rawQuery(
'''
SELECT n.id, n.date, n.content,
snippet(notes_fts, -1, '<b>', '</b>', '...', 64) as snippet
FROM notes_fts
JOIN notes n ON notes_fts.rowid = n.id
WHERE notes_fts MATCH ?
ORDER BY rank
LIMIT 100
''',
[ftsQuery],
);
debugPrint('Search returned ${results.length} results');
return results;
} catch (e, stackTrace) {
debugPrint('Search failed: $e');
debugPrint('Stack trace: $stackTrace');
return [];
}
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:journaler/db.dart';
@@ -7,16 +8,64 @@ 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<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
/// 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 {
await DB.init();
WidgetsFlutterBinding.ensureInitialized();
final ipcFile = File(AppFiles.ipcFilePath);
if (await alreadyRunning()) {
await signalPrimaryInstanceAndExit(ipcFile);
} else {
await runPrimaryInstance(ipcFile);
}
}
Future<bool> 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<void> 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(
@@ -32,6 +81,114 @@ void main() async {
});
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<void> 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<void> 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<void> 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 {
@@ -112,6 +269,7 @@ class JournalerApp extends StatelessWidget {
themeMode: ThemeMode.system,
home: const MainPage(),
debugShowCheckedModeBanner: false,
navigatorKey: navigatorKey,
);
}
}
@@ -124,9 +282,13 @@ class MainPage extends StatefulWidget {
}
class MainPageState extends State<MainPage> 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();
@@ -135,6 +297,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,38 +306,60 @@ 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() {
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() {
_saveData();
void onWindowClose() async {
// Save data when window is closed
await _saveData();
windowManager.hide();
}
@@ -200,7 +385,7 @@ class MainPageState extends State<MainPage> with WindowListener {
_systemTray.registerSystemTrayEventHandler((eventName) {
debugPrint("System Tray Event: $eventName");
if (eventName == kSystemTrayEventClick) {
_showWindow();
showWindow();
} else if (eventName == kSystemTrayEventRightClick) {
_systemTray.popUpContextMenu();
}
@@ -210,32 +395,78 @@ class MainPageState extends State<MainPage> with WindowListener {
void _startPopupTimer() {
_popupTimer?.cancel();
_popupTimer = Timer.periodic(_currentPopupInterval, (timer) {
_showWindow();
showWindow();
});
debugPrint("Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes");
debugPrint(
"Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes",
);
}
Future<void> _showWindow() async {
Future<void> 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();
_currentEntryFocusNode.requestFocus();
await _playSound();
} else {
await windowManager.focus();
_currentEntryFocusNode.requestFocus();
}
} 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<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");
}
@@ -262,6 +493,12 @@ class MainPageState extends State<MainPage> with WindowListener {
Future<void> _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(() {
@@ -275,6 +512,12 @@ class MainPageState extends State<MainPage> with WindowListener {
Future<void> _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(() {
@@ -289,7 +532,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;
@@ -305,28 +550,70 @@ class MainPageState extends State<MainPage> with WindowListener {
final scratch = await getLatestScratch();
_scratchController.text = scratch?.content ?? "";
_currentEntryController.text = "";
await _checkNavigation();
debugPrint("Data loaded.");
debugPrint("Data loaded");
}
void _saveData() async {
// 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");
}
Future<void> _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);
await createScratch(scratchContent);
if (previousNote != null) {
previousNote!.content = previousEntry;
await updateNote(previousNote!);
_currentEntryController.clear(); // Clear the input field after saving
}
int newIntervalMinutes = int.tryParse(intervalStr) ?? _currentPopupInterval.inMinutes;
// 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;
@@ -343,6 +630,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 +642,311 @@ 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);
// 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(), // 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) {
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
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 +960,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 +976,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 +993,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 +1004,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 +1051,21 @@ class MainPageState extends State<MainPage> with WindowListener {
children: [
Expanded(
child: Text(
_currentlyDisplayedNote?.date == previousNote?.date
? 'Previous Entry (Latest)'
: 'Entry: ${_currentlyDisplayedNote?.date ?? 'N/A'}',
style: TextStyle(fontSize: 18, color: Colors.grey),
_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,
onPressed:
_canGoPrevious ? _goToPreviousNote : null,
),
IconButton(
icon: const Icon(Icons.arrow_forward),
@@ -486,17 +1077,23 @@ class MainPageState extends State<MainPage> with WindowListener {
Expanded(
child: TextField(
controller: _previousEntryController,
readOnly: _currentlyDisplayedNote?.date != previousNote?.date,
readOnly: false, // Always allow editing
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?.displayDate !=
previousNote?.displayDate
? 'Viewing note from ${_currentlyDisplayedNote?.displayDate} (Editable)'
: 'Latest Note: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}',
border: const OutlineInputBorder(),
filled: _currentlyDisplayedNote?.date != previousNote?.date,
fillColor: _currentlyDisplayedNote?.date != previousNote?.date
filled:
_currentlyDisplayedNote?.displayDate !=
previousNote?.displayDate,
fillColor:
_currentlyDisplayedNote?.displayDate !=
previousNote?.displayDate
? Colors.grey.withOpacity(0.1)
: null,
),
@@ -527,10 +1124,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'),
),
),
],

View File

@@ -1,10 +1,17 @@
import 'package:journaler/db.dart';
import 'package:intl/intl.dart';
class Note {
final String date;
late final String displayDate;
String content;
String? snippet;
Note({required this.date, required this.content});
Note({required this.date, required this.content, this.snippet}) {
final dtUtc = DateFormat('yyyy-MM-dd HH:mm:ss').parse(date, true);
final dtLocal = dtUtc.toLocal();
displayDate = DateFormat('yyyy-MM-dd HH:mm:ss').format(dtLocal);
}
}
class Scratch {
@@ -28,16 +35,26 @@ Future<Note?> getLatestNote() async {
}
Future<void> createNote(String content) async {
if (content.isEmpty) {
// Trim the content to avoid saving just whitespace
final trimmedContent = content.trim();
if (trimmedContent.isEmpty) {
return;
}
await DB.db.insert('notes', {'content': content});
await DB.db.insert('notes', {'content': trimmedContent});
}
Future<void> updateNote(Note note) async {
// Trim the content to avoid saving just whitespace
final trimmedContent = note.content.trim();
if (trimmedContent.isEmpty) {
// Delete the note if content is empty
await DB.db.delete('notes', where: 'date = ?', whereArgs: [note.date]);
return;
}
await DB.db.update(
'notes',
{'content': note.content},
{'content': trimmedContent},
where: 'date = ?',
whereArgs: [note.date],
);
@@ -59,7 +76,9 @@ Future<Scratch?> getLatestScratch() async {
}
Future<void> createScratch(String content) async {
await DB.db.insert('scratches', {'content': content});
// Trim content but allow empty scratch notes (they might be intentionally cleared)
final trimmedContent = content.trim();
await DB.db.insert('scratches', {'content': trimmedContent});
}
// Get the note immediately older than the given date
@@ -100,3 +119,36 @@ Future<Note?> getNextNote(String currentDate) async {
// This handles the case where the `currentDate` might not be the absolute latest.
return getLatestNote();
}
/// Delete a note by its date
Future<bool> deleteNote(String date) async {
final result = await DB.db.delete(
'notes',
where: 'date = ?',
whereArgs: [date],
);
return result > 0; // Return true if a note was deleted
}
// 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

@@ -224,6 +224,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.4"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
json_annotation:
dependency: transitive
description:
@@ -289,7 +297,7 @@ packages:
source: hosted
version: "1.16.0"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
@@ -376,6 +384,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.2"
ps_list:
dependency: "direct main"
description:
name: ps_list
sha256: "19d32f6c643313cf4f5101bb144b8978b9ba3dc42c9a01b247e8ed90581bc0ab"
url: "https://pub.dev"
source: hosted
version: "0.0.5"
screen_retriever:
dependency: transitive
description:

View File

@@ -38,6 +38,9 @@ dependencies:
window_manager: ^0.4.3
audioplayers: ^6.4.0
sqflite_common_ffi: ^2.3.5
path: ^1.8.0
ps_list: ^0.0.5
intl: ^0.20.2
dev_dependencies:
flutter_test:
@@ -66,6 +69,7 @@ flutter:
assets:
- assets/ # Include the main assets directory for the icon
- assets/sounds/
- assets/windows/sqlite3_icu.dll
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg