Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
4f54b89689 | |||
eb2ec79150 | |||
e8b9f0ba49 | |||
6c8340d768 | |||
db565f4603 | |||
b2bff9e5c5 |
93
README.md
93
README.md
@@ -1,16 +1,89 @@
|
|||||||
# journaler
|
# Journaler
|
||||||
|
|
||||||
A new Flutter project.
|

|
||||||
|
|
||||||
## 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)
|
- **Daily reflection and mindfulness practice**
|
||||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
- **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.
|
## 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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
BIN
docs/screenshots/journaler_annotated.png
(Stored with Git LFS)
Normal file
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
BIN
docs/screenshots/journaler_main.png
(Stored with Git LFS)
Normal file
Binary file not shown.
322
lib/main.dart
322
lib/main.dart
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:journaler/db.dart';
|
import 'package:journaler/db.dart';
|
||||||
@@ -8,6 +9,8 @@ import 'package:window_manager/window_manager.dart';
|
|||||||
import 'package:audioplayers/audioplayers.dart';
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'dart:math';
|
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: Sound does not play when ran from a different workdir? Weird
|
||||||
// TODO: Fix saving the same scratch over and over again
|
// TODO: Fix saving the same scratch over and over again
|
||||||
@@ -16,9 +19,53 @@ import 'dart:math';
|
|||||||
const Duration _defaultPopupInterval = Duration(minutes: 20);
|
const Duration _defaultPopupInterval = Duration(minutes: 20);
|
||||||
const String _defaultNotificationSound = 'MeetTheSniper.mp3';
|
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 {
|
void main() async {
|
||||||
await DB.init();
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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();
|
await windowManager.ensureInitialized();
|
||||||
|
|
||||||
WindowOptions windowOptions = const WindowOptions(
|
WindowOptions windowOptions = const WindowOptions(
|
||||||
@@ -34,6 +81,114 @@ void main() async {
|
|||||||
});
|
});
|
||||||
|
|
||||||
runApp(const JournalerApp());
|
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 {
|
class JournalerApp extends StatelessWidget {
|
||||||
@@ -114,6 +269,7 @@ class JournalerApp extends StatelessWidget {
|
|||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
home: const MainPage(),
|
home: const MainPage(),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
navigatorKey: navigatorKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +282,9 @@ class MainPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MainPageState extends State<MainPage> with WindowListener {
|
class MainPageState extends State<MainPage> with WindowListener {
|
||||||
|
// Static reference to the current instance
|
||||||
|
static MainPageState? instance;
|
||||||
|
|
||||||
final SystemTray _systemTray = SystemTray();
|
final SystemTray _systemTray = SystemTray();
|
||||||
final Menu _menu = Menu();
|
final Menu _menu = Menu();
|
||||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||||
@@ -157,16 +316,31 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Store reference to this instance
|
||||||
|
instance = this;
|
||||||
windowManager.addListener(this);
|
windowManager.addListener(this);
|
||||||
_initSystemTray();
|
_initSystemTray();
|
||||||
_loadData();
|
_loadData();
|
||||||
_loadVolume();
|
_loadVolume();
|
||||||
windowManager.setPreventClose(true);
|
windowManager.setPreventClose(true);
|
||||||
_setWindowConfig();
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
// Clear reference to this instance
|
||||||
|
if (instance == this) {
|
||||||
|
instance = null;
|
||||||
|
}
|
||||||
windowManager.removeListener(this);
|
windowManager.removeListener(this);
|
||||||
_popupTimer?.cancel();
|
_popupTimer?.cancel();
|
||||||
_debounceTimer?.cancel();
|
_debounceTimer?.cancel();
|
||||||
@@ -183,8 +357,9 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onWindowClose() {
|
void onWindowClose() async {
|
||||||
_saveData();
|
// Save data when window is closed
|
||||||
|
await _saveData();
|
||||||
windowManager.hide();
|
windowManager.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +385,7 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
_systemTray.registerSystemTrayEventHandler((eventName) {
|
_systemTray.registerSystemTrayEventHandler((eventName) {
|
||||||
debugPrint("System Tray Event: $eventName");
|
debugPrint("System Tray Event: $eventName");
|
||||||
if (eventName == kSystemTrayEventClick) {
|
if (eventName == kSystemTrayEventClick) {
|
||||||
_showWindow();
|
showWindow();
|
||||||
} else if (eventName == kSystemTrayEventRightClick) {
|
} else if (eventName == kSystemTrayEventRightClick) {
|
||||||
_systemTray.popUpContextMenu();
|
_systemTray.popUpContextMenu();
|
||||||
}
|
}
|
||||||
@@ -220,26 +395,56 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
void _startPopupTimer() {
|
void _startPopupTimer() {
|
||||||
_popupTimer?.cancel();
|
_popupTimer?.cancel();
|
||||||
_popupTimer = Timer.periodic(_currentPopupInterval, (timer) {
|
_popupTimer = Timer.periodic(_currentPopupInterval, (timer) {
|
||||||
_showWindow();
|
showWindow();
|
||||||
});
|
});
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes",
|
"Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showWindow() async {
|
Future<void> showWindow() async {
|
||||||
_loadData();
|
_loadData();
|
||||||
|
try {
|
||||||
bool wasVisible = await windowManager.isVisible();
|
bool wasVisible = await windowManager.isVisible();
|
||||||
|
debugPrint("Current window visibility: $wasVisible");
|
||||||
|
|
||||||
if (!wasVisible) {
|
if (!wasVisible) {
|
||||||
|
// First make sure the window has the right size and position
|
||||||
await windowManager.setSize(const Size(1600, 900));
|
await windowManager.setSize(const Size(1600, 900));
|
||||||
await windowManager.center();
|
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.show();
|
||||||
await windowManager.focus();
|
await windowManager.focus();
|
||||||
_currentEntryFocusNode.requestFocus();
|
}
|
||||||
await _playSound();
|
} catch (e) {
|
||||||
} else {
|
debugPrint("Error showing window: $e");
|
||||||
await windowManager.focus();
|
|
||||||
_currentEntryFocusNode.requestFocus();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +493,12 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
Future<void> _goToPreviousNote() async {
|
Future<void> _goToPreviousNote() async {
|
||||||
if (!_canGoPrevious || _currentlyDisplayedNote == null) return;
|
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);
|
final prevNote = await getPreviousNote(_currentlyDisplayedNote!.date);
|
||||||
if (prevNote != null) {
|
if (prevNote != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -301,6 +512,12 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
Future<void> _goToNextNote() async {
|
Future<void> _goToNextNote() async {
|
||||||
if (!_canGoNext || _currentlyDisplayedNote == null) return;
|
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);
|
final nextNote = await getNextNote(_currentlyDisplayedNote!.date);
|
||||||
if (nextNote != null) {
|
if (nextNote != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -359,18 +576,40 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
debugPrint("Volume saved: $_volume");
|
debugPrint("Volume saved: $_volume");
|
||||||
}
|
}
|
||||||
|
|
||||||
void _saveData() async {
|
Future<void> _saveData() async {
|
||||||
String previousEntry = _previousEntryController.text;
|
String previousEntry = _previousEntryController.text;
|
||||||
String currentEntry = _currentEntryController.text;
|
String currentEntry = _currentEntryController.text;
|
||||||
String scratchContent = _scratchController.text;
|
String scratchContent = _scratchController.text;
|
||||||
String intervalStr = _intervalController.text;
|
String intervalStr = _intervalController.text;
|
||||||
String soundStr = _soundController.text;
|
String soundStr = _soundController.text;
|
||||||
|
|
||||||
|
// Handle current entry
|
||||||
|
if (currentEntry.isNotEmpty) {
|
||||||
await createNote(currentEntry);
|
await createNote(currentEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle scratch pad
|
||||||
await createScratch(scratchContent);
|
await createScratch(scratchContent);
|
||||||
if (previousNote != null) {
|
|
||||||
previousNote!.content = previousEntry;
|
// Handle previous/currently displayed note
|
||||||
await updateNote(previousNote!);
|
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 newIntervalMinutes =
|
||||||
@@ -510,9 +749,15 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
try {
|
try {
|
||||||
final results = await searchNotes(trimmedQuery);
|
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
|
// Important: update the dialog state after search completes
|
||||||
dialogSetState(() {
|
dialogSetState(() {
|
||||||
_searchResults = results;
|
_searchResults = filteredResults;
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -555,7 +800,7 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
vertical: 2,
|
vertical: 2,
|
||||||
), // Tighter padding
|
), // Tighter padding
|
||||||
title: Text(
|
title: Text(
|
||||||
note.date,
|
note.displayDate,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize:
|
fontSize:
|
||||||
@@ -581,9 +826,20 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
), // Smaller font for content
|
), // Smaller font for content
|
||||||
),
|
),
|
||||||
isThreeLine: true,
|
isThreeLine: true,
|
||||||
onTap: () {
|
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();
|
Navigator.of(context).pop();
|
||||||
this.setState(() {
|
setState(() {
|
||||||
_currentlyDisplayedNote = note;
|
_currentlyDisplayedNote = note;
|
||||||
_previousEntryController.text =
|
_previousEntryController.text =
|
||||||
note.content;
|
note.content;
|
||||||
@@ -795,10 +1051,10 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_currentlyDisplayedNote?.date ==
|
_currentlyDisplayedNote?.displayDate ==
|
||||||
previousNote?.date
|
previousNote?.displayDate
|
||||||
? 'Previous Entry (Latest)'
|
? 'Previous Entry: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}'
|
||||||
: 'Entry: ${_currentlyDisplayedNote?.date ?? 'N/A'}',
|
: 'Entry: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
@@ -821,25 +1077,23 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _previousEntryController,
|
controller: _previousEntryController,
|
||||||
readOnly:
|
readOnly: false, // Always allow editing
|
||||||
_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: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText:
|
hintText:
|
||||||
_currentlyDisplayedNote?.date !=
|
_currentlyDisplayedNote?.displayDate !=
|
||||||
previousNote?.date
|
previousNote?.displayDate
|
||||||
? 'Viewing note from ${_currentlyDisplayedNote?.date} (Read-Only)'
|
? 'Viewing note from ${_currentlyDisplayedNote?.displayDate} (Editable)'
|
||||||
: 'Latest Note',
|
: 'Latest Note: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}',
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
filled:
|
filled:
|
||||||
_currentlyDisplayedNote?.date !=
|
_currentlyDisplayedNote?.displayDate !=
|
||||||
previousNote?.date,
|
previousNote?.displayDate,
|
||||||
fillColor:
|
fillColor:
|
||||||
_currentlyDisplayedNote?.date !=
|
_currentlyDisplayedNote?.displayDate !=
|
||||||
previousNote?.date
|
previousNote?.displayDate
|
||||||
? Colors.grey.withOpacity(0.1)
|
? Colors.grey.withOpacity(0.1)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
@@ -1,12 +1,17 @@
|
|||||||
import 'package:journaler/db.dart';
|
import 'package:journaler/db.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class Note {
|
class Note {
|
||||||
final String date;
|
final String date;
|
||||||
|
late final String displayDate;
|
||||||
String content;
|
String content;
|
||||||
String?
|
String? snippet;
|
||||||
snippet; // Optional field to hold highlighted snippets for search results
|
|
||||||
|
|
||||||
Note({required this.date, required this.content, this.snippet});
|
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 {
|
class Scratch {
|
||||||
@@ -30,16 +35,26 @@ Future<Note?> getLatestNote() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createNote(String content) 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;
|
return;
|
||||||
}
|
}
|
||||||
await DB.db.insert('notes', {'content': content});
|
await DB.db.insert('notes', {'content': trimmedContent});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateNote(Note note) async {
|
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(
|
await DB.db.update(
|
||||||
'notes',
|
'notes',
|
||||||
{'content': note.content},
|
{'content': trimmedContent},
|
||||||
where: 'date = ?',
|
where: 'date = ?',
|
||||||
whereArgs: [note.date],
|
whereArgs: [note.date],
|
||||||
);
|
);
|
||||||
@@ -61,7 +76,9 @@ Future<Scratch?> getLatestScratch() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createScratch(String content) 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
|
// Get the note immediately older than the given date
|
||||||
@@ -103,6 +120,17 @@ Future<Note?> getNextNote(String currentDate) async {
|
|||||||
return getLatestNote();
|
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
|
// Search notes using full-text search
|
||||||
Future<List<Note>> searchNotes(String query) async {
|
Future<List<Note>> searchNotes(String query) async {
|
||||||
if (query.isEmpty) {
|
if (query.isEmpty) {
|
||||||
|
18
pubspec.lock
18
pubspec.lock
@@ -224,6 +224,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.4"
|
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:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -289,7 +297,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.16.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
@@ -376,6 +384,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.2"
|
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:
|
screen_retriever:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@@ -38,6 +38,9 @@ dependencies:
|
|||||||
window_manager: ^0.4.3
|
window_manager: ^0.4.3
|
||||||
audioplayers: ^6.4.0
|
audioplayers: ^6.4.0
|
||||||
sqflite_common_ffi: ^2.3.5
|
sqflite_common_ffi: ^2.3.5
|
||||||
|
path: ^1.8.0
|
||||||
|
ps_list: ^0.0.5
|
||||||
|
intl: ^0.20.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
Reference in New Issue
Block a user