10 Commits

5 changed files with 562 additions and 36 deletions

Binary file not shown.

View File

@@ -6,10 +6,23 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
const settingsDir = '.journaler'; const settingsDir = '.journaler';
const dbFileName = 'journaler.db'; const dbFileName = 'journaler.db';
// Add this at the top level
typedef ShowMessageCallback = void Function(String message);
class DB { class DB {
static late Database 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 ( CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT DEFAULT CURRENT_TIMESTAMP, 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 INDEX IF NOT EXISTS idx_notes_date ON notes (date);
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_date_unique 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 ( CREATE TABLE IF NOT EXISTS scratches (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT DEFAULT CURRENT_TIMESTAMP, date TEXT DEFAULT CURRENT_TIMESTAMP,
@@ -33,15 +43,31 @@ CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL, key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL value TEXT NOT NULL
); );
''';
-- Create virtual FTS5 table for searching notes content static const String _ftsSchemaWithIcu = '''
-- Create virtual FTS5 table with Unicode tokenizer
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
content, content,
date, date,
content='notes', content='notes',
content_rowid='id' 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 -- Trigger to keep FTS table in sync with notes table when inserting
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN 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); INSERT INTO notes_fts(rowid, content, date) VALUES (new.id, new.content, new.date);
@@ -58,6 +84,124 @@ CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
END; 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 { static Future<String> _getDatabasePath() async {
debugPrint('Attempting to get database path...'); debugPrint('Attempting to get database path...');
if (Platform.isWindows || Platform.isLinux) { if (Platform.isWindows || Platform.isLinux) {
@@ -88,26 +232,100 @@ END;
static Future<void> init() async { static Future<void> init() async {
debugPrint('Starting database initialization...'); debugPrint('Starting database initialization...');
// Initialize SQLite FFI
sqfliteFfiInit(); sqfliteFfiInit();
final databaseFactory = databaseFactoryFfi;
// Create a temporary database to check version
try {
debugPrint(
'\n================== SQLITE VERSION CHECK ==================',
);
final tempDb = await databaseFactory.openDatabase(
':memory:',
options: OpenDatabaseOptions(
version: 1,
onCreate: (db, version) async {
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}');
}
},
),
);
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(); final dbPath = await _getDatabasePath();
debugPrint('Database path: $dbPath'); debugPrint('Database path: $dbPath');
try { try {
db = await databaseFactoryFfi.openDatabase( db = await databaseFactory.openDatabase(
dbPath, dbPath,
options: OpenDatabaseOptions( options: OpenDatabaseOptions(
version: 1, version: 2,
onConfigure: (db) async {
debugPrint('Configuring database...');
await db.execute('PRAGMA foreign_keys = ON');
debugPrint('Database configured');
},
onCreate: (db, version) async { onCreate: (db, version) async {
debugPrint('Creating database schema...'); debugPrint('Creating database schema...');
await db.execute(_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'); 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);
}
},
), ),
); );
debugPrint('Database opened and initialized');
} catch (e) { // Store Unicode support status in settings for future reference
debugPrint('Failed to initialize database: $e'); 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; rethrow;
} }
} }
@@ -141,7 +359,6 @@ END;
return []; return [];
} }
// Process the query for partial word matching
// Split into individual terms, filter empty ones // Split into individual terms, filter empty ones
List<String> terms = List<String> terms =
query query
@@ -154,16 +371,16 @@ END;
return []; return [];
} }
// Add wildcards to each term for prefix matching (e.g., "fuck*" will match "fucked") // Process terms for FTS5 query using proper tokenization
// Join terms with AND for all-term matching (results must contain ALL terms)
String ftsQuery = terms String ftsQuery = terms
.map((term) { .map((term) {
// Remove any special characters that might break the query // Remove dangerous characters but preserve Unicode
String sanitizedTerm = term.replaceAll(RegExp(r'[^\w]'), ''); String sanitizedTerm = term.replaceAll(RegExp(r'''['"]'''), '');
if (sanitizedTerm.isEmpty) return ''; if (sanitizedTerm.isEmpty) return '';
// Add wildcard for stemming/prefix matching // Use proper FTS5 syntax: each word becomes a separate token with prefix matching
return '$sanitizedTerm*'; List<String> words = sanitizedTerm.split(RegExp(r'\s+'));
return words.map((word) => '$word*').join(' OR ');
}) })
.where((term) => term.isNotEmpty) .where((term) => term.isNotEmpty)
.join(' AND '); .join(' AND ');
@@ -175,24 +392,25 @@ END;
debugPrint('FTS query: "$ftsQuery"'); debugPrint('FTS query: "$ftsQuery"');
// Execute the FTS query with AND logic // Execute the FTS query
final List<Map<String, dynamic>> results = await db.rawQuery( final List<Map<String, dynamic>> results = await db.rawQuery(
''' '''
SELECT n.id, n.date, n.content, snippet(notes_fts, 0, '<b>', '</b>', '...', 20) as snippet SELECT n.id, n.date, n.content,
snippet(notes_fts, -1, '<b>', '</b>', '...', 64) as snippet
FROM notes_fts FROM notes_fts
JOIN notes n ON notes_fts.rowid = n.id JOIN notes n ON notes_fts.rowid = n.id
WHERE notes_fts MATCH ? WHERE notes_fts MATCH ?
ORDER BY n.date DESC ORDER BY rank
LIMIT 100 LIMIT 100
''', ''',
[ftsQuery], [ftsQuery],
); );
debugPrint('Search query "$ftsQuery" returned ${results.length} results'); debugPrint('Search returned ${results.length} results');
return results; return results;
} catch (e) { } catch (e, stackTrace) {
debugPrint('Search failed: $e'); debugPrint('Search failed: $e');
// Return empty results rather than crashing on malformed queries debugPrint('Stack trace: $stackTrace');
return []; return [];
} }
} }

View File

@@ -550,11 +550,10 @@ class MainPageState extends State<MainPage> with WindowListener {
final scratch = await getLatestScratch(); final scratch = await getLatestScratch();
_scratchController.text = scratch?.content ?? ""; _scratchController.text = scratch?.content ?? "";
_currentEntryController.text = "";
await _checkNavigation(); await _checkNavigation();
debugPrint("Data loaded."); debugPrint("Data loaded");
} }
// Load volume setting from database // Load volume setting from database
@@ -576,6 +575,41 @@ class MainPageState extends State<MainPage> with WindowListener {
debugPrint("Volume saved: $_volume"); debugPrint("Volume saved: $_volume");
} }
// Check if content appears to be keyboard smashing or repeated characters
bool _isLikelyKeyboardSmashing(String content) {
// Skip empty content
if (content.trim().isEmpty) return false;
// Check for repeated characters (like "wwwwwwww")
final repeatedCharPattern = RegExp(
r'(.)\1{4,}',
); // 5 or more repeated chars
if (repeatedCharPattern.hasMatch(content)) {
// But allow if it's a legitimate pattern like base64
if (RegExp(r'^[A-Za-z0-9+/=]+$').hasMatch(content)) return false;
return true;
}
// Check for alternating characters (like "asdfasdf")
final alternatingPattern = RegExp(r'(.)(.)\1\2{2,}');
if (alternatingPattern.hasMatch(content)) return true;
// Check for common keyboard smashing patterns
final commonPatterns = [
r'[qwerty]{5,}', // Common keyboard rows
r'[asdf]{5,}',
r'[zxcv]{5,}',
r'[1234]{5,}',
r'[wasd]{5,}', // Common gaming keys
];
for (final pattern in commonPatterns) {
if (RegExp(pattern, caseSensitive: false).hasMatch(content)) return true;
}
return false;
}
Future<void> _saveData() async { Future<void> _saveData() async {
String previousEntry = _previousEntryController.text; String previousEntry = _previousEntryController.text;
String currentEntry = _currentEntryController.text; String currentEntry = _currentEntryController.text;
@@ -585,7 +619,14 @@ class MainPageState extends State<MainPage> with WindowListener {
// Handle current entry // Handle current entry
if (currentEntry.isNotEmpty) { if (currentEntry.isNotEmpty) {
await createNote(currentEntry); // Skip saving if it looks like keyboard smashing
if (!_isLikelyKeyboardSmashing(currentEntry)) {
await createNote(currentEntry);
_currentEntryController.clear(); // Clear the input field after saving
} else {
debugPrint("Skipping save of likely keyboard smashing: $currentEntry");
_currentEntryController.clear(); // Still clear the input
}
} }
// Handle scratch pad // Handle scratch pad
@@ -755,6 +796,11 @@ class MainPageState extends State<MainPage> with WindowListener {
.where((note) => note.content.isNotEmpty) .where((note) => note.content.isNotEmpty)
.toList(); .toList();
// Sort by date, newest first
filteredResults.sort(
(a, b) => b.date.compareTo(a.date),
);
// Important: update the dialog state after search completes // Important: update the dialog state after search completes
dialogSetState(() { dialogSetState(() {
_searchResults = filteredResults; _searchResults = filteredResults;
@@ -905,6 +951,207 @@ class MainPageState extends State<MainPage> with WindowListener {
); );
} }
// Show cleanup dialog
void _showCleanupDialog() async {
double sensitivity = 0.7; // Default 70%
final problematicEntries = await findProblematicEntries(
maxCharPercentage: sensitivity,
);
if (!mounted) return;
showDialog(
context: context,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, dialogSetState) {
return AlertDialog(
title: const Text('Cleanup Problematic Entries'),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.7,
height: MediaQuery.of(context).size.height * 0.7,
child: Column(
children: [
Row(
children: [
const Text('Sensitivity: '),
Expanded(
child: Slider(
value: sensitivity,
min: 0.3,
max: 0.9,
divisions: 12,
label: '${(sensitivity * 100).toInt()}%',
onChanged: (value) async {
dialogSetState(() {
sensitivity =
(value * 100).round() /
100; // Round to 2 decimal places
});
// Refresh results with new sensitivity
final newResults = await findProblematicEntries(
maxCharPercentage: sensitivity,
);
dialogSetState(() {
problematicEntries.clear();
problematicEntries.addAll(newResults);
});
},
),
),
Text('${(sensitivity * 100).toInt()}%'),
],
),
Text(
'Found ${problematicEntries.length} potentially problematic entries',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Expanded(
child:
problematicEntries.isEmpty
? const Center(
child: Text('No problematic entries found!'),
)
: ListView.builder(
itemCount: problematicEntries.length,
itemBuilder: (context, index) {
final note = problematicEntries[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(
note.displayDate,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Reason: ${note.problemReason}',
style: TextStyle(
color: Colors.red[700],
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(note.content),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.visibility),
tooltip: 'View in main window',
onPressed: () {
// Close the dialog
Navigator.of(context).pop();
// Navigate to the note in main window
setState(() {
_currentlyDisplayedNote = note;
_previousEntryController.text =
note.content;
});
_checkNavigation();
},
),
IconButton(
icon: const Icon(Icons.delete),
color: Colors.red,
tooltip:
'Delete (hold Shift to skip confirmation)',
onPressed: () async {
// Check if shift is pressed
final isShiftPressed =
HardwareKeyboard
.instance
.isShiftPressed;
bool shouldDelete =
isShiftPressed;
if (!isShiftPressed) {
// Show confirmation dialog
shouldDelete =
await showDialog<bool>(
context: context,
builder:
(
context,
) => AlertDialog(
title: const Text(
'Delete Entry?',
),
content: const Text(
'Are you sure you want to delete this entry? This action cannot be undone.',
),
actions: [
TextButton(
onPressed:
() => Navigator.of(
context,
).pop(
false,
),
child:
const Text(
'Cancel',
),
),
TextButton(
onPressed:
() => Navigator.of(
context,
).pop(true),
child:
const Text(
'Delete',
),
),
],
),
) ??
false;
}
if (shouldDelete) {
await deleteNote(note.date);
dialogSetState(() {
problematicEntries.removeAt(
index,
);
});
}
},
),
],
),
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Close'),
),
],
);
},
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Wrap Scaffold with RawKeyboardListener as workaround for Escape key // Wrap Scaffold with RawKeyboardListener as workaround for Escape key
@@ -936,6 +1183,12 @@ class MainPageState extends State<MainPage> with WindowListener {
appBar: AppBar( appBar: AppBar(
title: const Text('Journaler'), title: const Text('Journaler'),
actions: <Widget>[ actions: <Widget>[
// Add cleanup button
IconButton(
icon: const Icon(Icons.cleaning_services),
tooltip: 'Cleanup Problematic Entries',
onPressed: _showCleanupDialog,
),
// Add search button // Add search button
IconButton( IconButton(
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
@@ -1090,7 +1343,7 @@ class MainPageState extends State<MainPage> with WindowListener {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
filled: filled:
_currentlyDisplayedNote?.displayDate != _currentlyDisplayedNote?.displayDate !=
previousNote?.displayDate, previousNote?.displayDate,
fillColor: fillColor:
_currentlyDisplayedNote?.displayDate != _currentlyDisplayedNote?.displayDate !=
previousNote?.displayDate previousNote?.displayDate

View File

@@ -6,8 +6,16 @@ class Note {
late final String displayDate; late final String displayDate;
String content; String content;
String? snippet; String? snippet;
bool isProblematic;
String problemReason;
Note({required this.date, required this.content, this.snippet}) { 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 dtUtc = DateFormat('yyyy-MM-dd HH:mm:ss').parse(date, true);
final dtLocal = dtUtc.toLocal(); final dtLocal = dtUtc.toLocal();
displayDate = DateFormat('yyyy-MM-dd HH:mm:ss').format(dtLocal); displayDate = DateFormat('yyyy-MM-dd HH:mm:ss').format(dtLocal);
@@ -35,11 +43,18 @@ Future<Note?> getLatestNote() async {
} }
Future<void> createNote(String content) async { Future<void> createNote(String content) async {
// Trim the content to avoid saving just whitespace // Trim each line, sometimes we fuck up by doing a lil "foobar "
final trimmedContent = content.trim(); // Maybe I should also look for \s{2,}...
if (trimmedContent.isEmpty) { final lines = content.split('\n');
return; final trimmedLines = <String>[];
for (final line in lines) {
final trimmedContent = line.trim().replaceAll(RegExp(r'\s{2,}'), ' ');
if (trimmedContent.isEmpty) {
continue;
}
trimmedLines.add(trimmedContent);
} }
final trimmedContent = trimmedLines.join('\n');
await DB.db.insert('notes', {'content': trimmedContent}); await DB.db.insert('notes', {'content': trimmedContent});
} }
@@ -152,3 +167,42 @@ Future<List<Note>> searchNotes(String query) async {
) )
.toList(); .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();
}

View File

@@ -69,6 +69,7 @@ flutter:
assets: assets:
- assets/ # Include the main assets directory for the icon - assets/ # Include the main assets directory for the icon
- assets/sounds/ - assets/sounds/
- assets/windows/sqlite3_icu.dll
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg # - images/a_dot_ham.jpeg