Compare commits
25 Commits
Author | SHA1 | Date | |
---|---|---|---|
4339763261 | |||
621e85c747 | |||
d937ae212c | |||
b5dad28491 | |||
1e11cd53c9 | |||
b74dc5b3c6 | |||
474cb662e5 | |||
a84973def6 | |||
fcb8e41cde | |||
4f54b89689 | |||
eb2ec79150 | |||
e8b9f0ba49 | |||
6c8340d768 | |||
db565f4603 | |||
b2bff9e5c5 | |||
9d39cb09df | |||
5ea62a1216 | |||
c457b5cd5b | |||
722aa34fdf | |||
68c6dd1a95 | |||
5a27ac75c7 | |||
900bcd866c | |||
963f7271a2 | |||
442403b5b9 | |||
ad767ac33a |
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)
|
||||
- [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.
|
||||

|
||||
|
||||
## 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
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
(Stored with Git LFS)
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
(Stored with Git LFS)
Binary file not shown.
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
(Stored with Git LFS)
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
(Stored with Git LFS)
Binary file not shown.
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
(Stored with Git LFS)
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
(Stored with Git LFS)
Binary file not shown.
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
(Stored with Git LFS)
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
(Stored with Git LFS)
Binary file not shown.
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
(Stored with Git LFS)
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
(Stored with Git LFS)
Binary file not shown.
BIN
assets/windows/sqlite3_icu.dll
Normal file
BIN
assets/windows/sqlite3_icu.dll
Normal file
Binary file not shown.
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.
@@ -427,7 +427,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
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_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -484,7 +484,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
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_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
(Stored with Git LFS)
Normal file
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
(Stored with Git LFS)
Normal file
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
(Stored with Git LFS)
Normal file
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
(Stored with Git LFS)
Normal file
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
(Stored with Git LFS)
Normal file
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
(Stored with Git LFS)
Normal file
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
(Stored with Git LFS)
Binary file not shown.
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
(Stored with Git LFS)
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
(Stored with Git LFS)
Binary file not shown.
373
lib/db.dart
373
lib/db.dart
@@ -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,19 +30,178 @@ 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 todos (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scratches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_date ON todos (date);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_date_unique ON todos (date);
|
||||
CREATE INDEX IF NOT EXISTS idx_scratches_date ON scratches (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
|
||||
);
|
||||
''';
|
||||
|
||||
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) {
|
||||
@@ -39,7 +211,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_date_unique ON todos (date);
|
||||
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()) {
|
||||
@@ -53,34 +225,193 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_date_unique ON todos (date);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 [];
|
||||
}
|
||||
|
||||
// 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
1186
lib/main.dart
1186
lib/main.dart
File diff suppressed because it is too large
Load Diff
175
lib/notes.dart
175
lib/notes.dart
@@ -1,17 +1,32 @@
|
||||
import 'package:journaler/db.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class Note {
|
||||
final String date;
|
||||
late final String displayDate;
|
||||
String content;
|
||||
String? snippet;
|
||||
bool isProblematic;
|
||||
String problemReason;
|
||||
|
||||
Note({required this.date, required this.content});
|
||||
Note({
|
||||
required this.date,
|
||||
required this.content,
|
||||
this.snippet,
|
||||
this.isProblematic = false,
|
||||
this.problemReason = '',
|
||||
}) {
|
||||
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 Todo {
|
||||
class Scratch {
|
||||
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 {
|
||||
@@ -28,37 +43,159 @@ 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],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Todo?> getLatestTodo() async {
|
||||
final todo = await DB.db.rawQuery(
|
||||
'SELECT content, date FROM todos ORDER BY date DESC LIMIT 1',
|
||||
Future<Scratch?> getLatestScratch() async {
|
||||
final scratch = await DB.db.rawQuery(
|
||||
'SELECT content, date FROM scratches ORDER BY date DESC LIMIT 1',
|
||||
);
|
||||
if (todo.isEmpty) {
|
||||
|
||||
if (scratch.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return Todo(
|
||||
date: todo[0]['date'] as String,
|
||||
content: todo[0]['content'] as String,
|
||||
} else {
|
||||
return Scratch(
|
||||
date: scratch[0]['date'] as String,
|
||||
content: scratch[0]['content'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createTodo(String content) async {
|
||||
if (content.isEmpty) {
|
||||
return;
|
||||
Future<void> createScratch(String content) async {
|
||||
// Trim content but allow empty scratch notes (they might be intentionally cleared)
|
||||
final trimmedContent = content.trim();
|
||||
await DB.db.insert('scratches', {'content': trimmedContent});
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
||||
// Find potentially problematic entries based on character distribution
|
||||
Future<List<Note>> findProblematicEntries({
|
||||
double maxCharPercentage = 0.7,
|
||||
}) async {
|
||||
// Simple SQLite query that counts character occurrences using replace
|
||||
final List<Map<String, dynamic>> results = await DB.db.rawQuery(
|
||||
'''
|
||||
WITH char_counts AS (
|
||||
SELECT
|
||||
id,
|
||||
date,
|
||||
content,
|
||||
substr(content, 1, 1) as char,
|
||||
(length(content) - length(replace(content, substr(content, 1, 1), ''))) as char_count,
|
||||
length(content) as total_length,
|
||||
cast(length(content) - length(replace(content, substr(content, 1, 1), '')) as float) / length(content) as percentage
|
||||
FROM notes
|
||||
)
|
||||
SELECT *
|
||||
FROM char_counts
|
||||
WHERE percentage > ?
|
||||
ORDER BY date DESC
|
||||
''',
|
||||
[maxCharPercentage],
|
||||
);
|
||||
|
||||
return results
|
||||
.map(
|
||||
(row) => Note(
|
||||
date: row['date'] as String,
|
||||
content: row['content'] as String,
|
||||
isProblematic: true,
|
||||
problemReason:
|
||||
'Character "${row['char']}" makes up ${(row['percentage'] * 100).toStringAsFixed(1)}% of the content',
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
98
pubspec.lock
98
pubspec.lock
@@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -81,6 +97,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -142,6 +174,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -176,6 +216,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
||||
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:
|
||||
@@ -241,7 +297,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
@@ -296,6 +352,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -312,6 +376,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62
|
||||
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:
|
||||
@@ -501,6 +581,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dart: ">=3.7.2 <4.0.0"
|
||||
flutter: ">=3.27.0"
|
||||
|
14
pubspec.yaml
14
pubspec.yaml
@@ -38,10 +38,14 @@ 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:
|
||||
sdk: flutter
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
@@ -65,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
|
||||
|
||||
@@ -93,3 +98,12 @@ flutter:
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# 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)
BIN
windows/runner/resources/app_icon.ico
(Stored with Git LFS)
Binary file not shown.
Reference in New Issue
Block a user