This commit is contained in:
randogoth 2025-02-21 22:51:56 +02:00
parent 7e82ddcbb7
commit 432048dc6a
5 changed files with 535 additions and 507 deletions

View file

@ -1,15 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:muzzlevelocity/themes/day.dart';
import 'package:muzzlevelocity/themes/night.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'note_editor.dart';
import 'package:file_picker/file_picker.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:path/path.dart' as path; import 'themes/day.dart';
import 'themes/night.dart';
import 'note_list.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -30,9 +24,10 @@ class _MuzzleVelocityAppState extends State<MuzzleVelocityApp> {
void initState() { void initState() {
super.initState(); super.initState();
// Ensure the correct theme mode is set on startup // Ensure the correct theme mode is set on startup
_themeMode = WidgetsBinding.instance.window.platformBrightness == Brightness.dark _themeMode =
? ThemeMode.dark WidgetsBinding.instance.window.platformBrightness == Brightness.dark
: ThemeMode.light; ? ThemeMode.dark
: ThemeMode.light;
} }
void _toggleTheme(bool darkMode) { void _toggleTheme(bool darkMode) {
@ -51,16 +46,6 @@ class _MuzzleVelocityAppState extends State<MuzzleVelocityApp> {
} }
} }
Future<void> _emptyTrash() async {
final prefs = await SharedPreferences.getInstance();
final notesPath = prefs.getString('notes_directory') ?? (await getApplicationDocumentsDirectory()).path;
final trashDir = Directory('$notesPath/trash');
if (trashDir.existsSync()) {
trashDir.deleteSync(recursive: true);
trashDir.createSync();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
@ -81,425 +66,9 @@ class _MuzzleVelocityAppState extends State<MuzzleVelocityApp> {
home: NoteListScreen( home: NoteListScreen(
toggleTheme: _toggleTheme, toggleTheme: _toggleTheme,
toggleTags: _toggleTags, toggleTags: _toggleTags,
emptyTrash: _emptyTrash,
isDarkMode: _themeMode == ThemeMode.dark, isDarkMode: _themeMode == ThemeMode.dark,
showTags: showTags, showTags: showTags,
), ),
); );
} }
} }
class NoteListScreen extends StatefulWidget {
final void Function(bool) toggleTheme;
final VoidCallback toggleTags;
final VoidCallback emptyTrash;
final bool isDarkMode;
final bool showTags;
NoteListScreen({
required this.toggleTheme,
required this.toggleTags,
required this.emptyTrash,
required this.isDarkMode,
required this.showTags,
});
@override
_NoteListScreenState createState() => _NoteListScreenState();
}
class _NoteListScreenState extends State<NoteListScreen> {
TextEditingController searchController = TextEditingController();
List<File> notes = [];
List<File> filteredNotes = [];
String? notesDirectoryPath;
String? defaultDir;
@override
void initState() {
super.initState();
_initializeApp(); // Call an async method to handle initialization
searchController.addListener(() {
final text = searchController.text.trim();
if (text.startsWith(':')) {
_handleCommand(text);
} else {
_filterNotes();
}
});
}
Future<void> _initializeApp() async {
final dir = await getApplicationDocumentsDirectory();
setState(() {
defaultDir = dir.path; // Update state with defaultDir
});
await _loadNotes(); // Load notes after setting defaultDir
}
bool _isNewNote(String title) {
final safeTitle = _sanitizeFilename(title);
return !notes.any((note) => note.uri.pathSegments.last == '$safeTitle.md');
}
void _createNewNote(String title) async {
try {
final safeTitle = _sanitizeFilename(title);
final newNote = File(path.join(notesDirectoryPath!, '$safeTitle.md'));
if (!newNote.existsSync()) {
newNote.writeAsStringSync('# $title\n\n');
setState(() {
notes.add(newNote);
filteredNotes.add(newNote);
});
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NoteEditorScreen(
note: newNote,
isDarkMode: widget.isDarkMode,
notesDirectoryPath: notesDirectoryPath,
),
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to create note: $e'),
),
);
}
}
String _sanitizeFilename(String title) {
return title
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '') // Remove invalid characters
.trim();
}
void _handleCommand(String text) {
switch (text) {
case ':tags':
widget.toggleTags();
break;
case ':notags':
widget.toggleTags();
break;
case ':day':
widget.toggleTheme(false);
break;
case ':night':
widget.toggleTheme(true);
break;
case ':emptytrash':
_confirmEmptyTrash();
break;
default:
return; // Do nothing for unrecognized commands
}
searchController.clear();
}
void _confirmEmptyTrash() {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text("Empty Trash"),
content: Text("Are you sure you want to permanently delete all trashed notes?"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text("Cancel"),
),
TextButton(
onPressed: () {
widget.emptyTrash();
Navigator.pop(context);
},
child: Text("Empty Trash"),
),
],
);
},
);
}
Future<void> _loadNotes() async {
final prefs = await SharedPreferences.getInstance();
notesDirectoryPath = prefs.getString('notes_directory') ?? defaultDir; // Use custom or default directory
final notesDir = Directory(notesDirectoryPath!);
if (!notesDir.existsSync()) {
notesDir.createSync(recursive: true);
}
final files = notesDir.listSync().whereType<File>().where((file) => file.path.endsWith('.md')).toList();
setState(() {
notes = files;
filteredNotes = files;
});
}
void _showDirectoryChoiceDialog(String defaultPath) {
showDialog(
context: context,
barrierDismissible: false, // Force user to make a choice
builder: (BuildContext context) {
return AlertDialog(
title: Text("Choose Notes Directory"),
content: Text("Your notes will be saved in:\n\n📂 $defaultPath\n\nWould you like to use a different folder?"),
actions: [
TextButton(
onPressed: () async {
// Reset to default directory
final prefs = await SharedPreferences.getInstance();
await prefs.remove('notes_directory'); // Remove custom directory from preferences
setState(() {
notesDirectoryPath = defaultDir; // Reset to default directory
});
Navigator.pop(context); // Close dialog
_loadNotes(); // Reload notes from the default directory
},
child: Text("Use Default"),
),
TextButton(
onPressed: () async {
Navigator.pop(context); // Close the current dialog
await _selectNotesDirectory(); // Allow user to pick a folder
},
child: Text("Choose Folder"),
),
],
);
},
);
}
void _filterNotes() {
final query = searchController.text.toLowerCase();
setState(() {
filteredNotes = notes.where((note) {
final content = note.readAsStringSync().toLowerCase();
return note.uri.pathSegments.last.toLowerCase().contains(query) || content.contains(query);
}).toList();
});
}
void _deleteNote(File note) {
final trashDir = Directory('${note.parent.path}/trash');
if (!trashDir.existsSync()) {
trashDir.createSync(recursive: true);
}
final trashedNote = File('${trashDir.path}/${note.uri.pathSegments.last}');
note.renameSync(trashedNote.path);
setState(() {
notes.remove(note);
filteredNotes.remove(note);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Note moved to trash'),
action: SnackBarAction(
label: 'Undo',
onPressed: () {
trashedNote.renameSync(note.path);
setState(() {
notes.add(note);
filteredNotes.add(note);
});
},
),
),
);
}
List<InlineSpan> _formatTags(String content) {
final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)');
final List<InlineSpan> spans = [];
for (final match in tagPattern.allMatches(content)) {
final tag = match.group(0)!;
Color tagColor = Colors.white;
if (tag.startsWith('#')) tagColor = Colors.pinkAccent;
if (tag.startsWith('+')) tagColor = Colors.cyan;
if (tag.startsWith('@')) tagColor = Colors.orange;
spans.add(TextSpan(text: tag + ' ', style: TextStyle(color: tagColor, fontWeight: FontWeight.bold)));
}
return spans;
}
Future<void> _selectNotesDirectory() async {
String? selectedDirectory = await FilePicker.platform.getDirectoryPath();
if (selectedDirectory != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notes_directory', selectedDirectory); // Save custom directory
setState(() {
notesDirectoryPath = selectedDirectory; // Update state
});
_loadNotes(); // Reload notes from the new directory
}
}
Future<bool> _requestStoragePermission() async {
if (Platform.isAndroid) {
if (await Permission.storage.request().isGranted) {
print("✅ Basic storage permission granted.");
return true;
}
// For Android 11+ (Scoped Storage)
if (await Permission.manageExternalStorage.request().isGranted) {
print("✅ Full file access granted.");
return true;
}
print("❌ Storage permission denied. Opening settings...");
await openAppSettings(); // Open settings if denied
return false;
}
return true; // No permissions needed for other platforms
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: TextField(
enableInteractiveSelection: true,
autocorrect: false,
textInputAction: TextInputAction.done,
controller: searchController,
decoration: InputDecoration(
hintText: 'Search or enter a command...',
border: InputBorder.none,
),
style: GoogleFonts.ibmPlexMono(color: Colors.grey[300]),
onSubmitted: (text) {
final trimmedText = text.trim();
if (trimmedText.isNotEmpty && _isNewNote(trimmedText)) {
_createNewNote(trimmedText);
searchController.clear(); // Clears the search field after creation
}
},
),
),
actions: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: PopupMenuButton<String>(
icon: Icon(
Icons.radio_button_checked,
color: Colors.orange
), // Menu button ()
onSelected: (String value) {
switch (value) {
case 'Toggle Tags':
widget.toggleTags();
break;
case 'Toggle Theme':
setState(() {
widget.toggleTheme(!widget.isDarkMode);
});
break;
case 'Select Folder':
_showDirectoryChoiceDialog(notesDirectoryPath ?? ''); // Re-trigger popup
break;
case 'Empty Trash':
_confirmEmptyTrash();
break;
}
},
itemBuilder: (BuildContext context) => [
PopupMenuItem<String>(
value: 'Toggle Tags',
child: ListTile(
leading: Icon(widget.showTags ? Icons.label_off : Icons.label),
title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'),
),
),
PopupMenuItem<String>(
value: 'Toggle Theme',
child: ListTile(
leading: Icon(
Theme.of(context).brightness == Brightness.dark ? Icons.light_mode : Icons.dark_mode,
),
title: Text(
Theme.of(context).brightness == Brightness.dark ? 'Switch to Light Mode' : 'Switch to Dark Mode',
),
),
),
PopupMenuItem<String>(
value: 'Select Folder',
child: ListTile(
leading: Icon(Icons.folder),
title: Text('Set Notes Directory'),
),
),
PopupMenuItem<String>(
value: 'Empty Trash',
child: ListTile(
leading: Icon(Icons.delete, color: Colors.redAccent),
title: Text('Empty Trash', style: TextStyle(color: Colors.redAccent)),
),
),
],
),
),
],
),
body: Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: ListView.builder(
itemCount: filteredNotes.length,
itemBuilder: (context, index) {
final noteFile = filteredNotes[index];
final noteContent = noteFile.readAsStringSync();
final tagSpans = _formatTags(noteContent).isNotEmpty ? _formatTags(noteContent) : [TextSpan(text: '')];
return Dismissible(
key: Key(noteFile.path),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.redAccent,
alignment: Alignment.centerRight,
padding: EdgeInsets.symmetric(horizontal: 20),
child: Icon(Icons.delete, color: Colors.white),
),
onDismissed: (direction) => _deleteNote(noteFile),
child: ListTile(
title: Text(noteFile.uri.pathSegments.last.replaceAll('.md', '')),
subtitle: widget.showTags && tagSpans.isNotEmpty
? RichText(text: TextSpan(style: DefaultTextStyle.of(context).style, children: tagSpans))
: null,
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
NoteEditorScreen(
note: File(path.join(notesDirectoryPath!, noteFile.uri.pathSegments.last)),
isDarkMode: widget.isDarkMode,
notesDirectoryPath: notesDirectoryPath,
),
),
);
_loadNotes(); // Reload notes after returning
setState(() {}); // Force UI refresh
},
),
);
},
),
),
);
}
}

View file

@ -1,20 +1,23 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:flutter_markdown_latex/flutter_markdown_latex.dart'; import 'package:flutter_markdown_latex/flutter_markdown_latex.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:path/path.dart' as path;
import 'themes/day.dart'; import 'themes/day.dart';
import 'themes/night.dart'; import 'themes/night.dart';
import 'themes/markdown.dart'; import 'themes/markdown.dart';
import 'dart:io';
import 'package:path/path.dart' as path;
class NoteEditorScreen extends StatefulWidget { class NoteEditorScreen extends StatefulWidget {
final File note; final File note;
final String? notesDirectoryPath; // Add this final String? notesDirectoryPath; // Add this
bool isDarkMode; final bool isDarkMode;
NoteEditorScreen({required this.note, required this.isDarkMode, required this.notesDirectoryPath}); NoteEditorScreen(
{required this.note,
required this.isDarkMode,
required this.notesDirectoryPath});
@override @override
_NoteEditorScreenState createState() => _NoteEditorScreenState(); _NoteEditorScreenState createState() => _NoteEditorScreenState();
@ -26,13 +29,6 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
late CodeController _controller; late CodeController _controller;
double _fontSize = 16.0; // Default font size double _fontSize = 16.0; // Default font size
void _updateFontSize(double newSize) {
setState(() {
_fontSize = newSize;
});
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -58,11 +54,12 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
}); });
} }
Future<void> _saveNote() async { Future<void> _saveNote() async {
try { try {
final notesDirectory = widget.notesDirectoryPath ?? widget.note.parent.path; // Use correct directory final notesDirectory = widget.notesDirectoryPath ??
final newFilePath = path.join(notesDirectory, widget.note.uri.pathSegments.last); widget.note.parent.path; // Use correct directory
final newFilePath =
path.join(notesDirectory, widget.note.uri.pathSegments.last);
print("📝 Attempting to save note at: $newFilePath"); print("📝 Attempting to save note at: $newFilePath");
final newFile = File(newFilePath); final newFile = File(newFilePath);
@ -76,6 +73,12 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
} }
} }
void _updateFontSize(double newSize) {
setState(() {
_fontSize = newSize;
});
}
@override @override
void dispose() { void dispose() {
_controller.removeListener(_saveNote); _controller.removeListener(_saveNote);
@ -92,10 +95,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 15), padding: EdgeInsets.symmetric(horizontal: 15),
child: PopupMenuButton<String>( child: PopupMenuButton<String>(
icon: Icon( icon: Icon(Icons.radio_button_checked, color: Colors.orange),
Icons.radio_button_checked,
color: Colors.orange
),
onSelected: (String value) { onSelected: (String value) {
if (value == 'Toggle Preview') { if (value == 'Toggle Preview') {
setState(() { setState(() {
@ -143,52 +143,75 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
], ],
), ),
body: isLoading body: isLoading
? Center(child: CircularProgressIndicator()) // Show loader while initializing ? Center(
: _controller == null child:
? Center(child: CircularProgressIndicator()) CircularProgressIndicator()) // Show loader while initializing
: _isPreviewMode : _isPreviewMode
? Padding( ? Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView( child: SingleChildScrollView(
child: MarkdownBody( child: MarkdownBody(
builders: { builders: {
'latex': LatexElementBuilder( 'latex': LatexElementBuilder(
textStyle: TextStyle( textStyle: TextStyle(
color: widget.isDarkMode ? Colors.white : Colors.black color: widget.isDarkMode
? Colors.white
: Colors.black)),
},
extensionSet: md.ExtensionSet(
[LatexBlockSyntax()],
[LatexInlineSyntax()],
),
data: _controller
.text, // Plain Markdown without syntax highlighting
selectable: true, // Allows text selection
styleSheet: MarkdownStyleSheet(
p: TextStyle(
fontSize: _fontSize,
color: widget.isDarkMode
? Colors.white
: Colors.black),
h1: TextStyle(
fontSize: _fontSize + 8,
color:
widget.isDarkMode ? Colors.white : Colors.black,
fontWeight: FontWeight.bold),
h2: TextStyle(
fontSize: _fontSize + 6,
color:
widget.isDarkMode ? Colors.white : Colors.black,
fontWeight: FontWeight.bold),
h3: TextStyle(
fontSize: _fontSize + 4,
color:
widget.isDarkMode ? Colors.white : Colors.black,
fontWeight: FontWeight.bold),
code: TextStyle(
fontSize: _fontSize,
backgroundColor: Colors
.transparent), // Disables syntax highlighting
codeblockDecoration: BoxDecoration(
color: Colors
.transparent), // Removes background color from code blocks
),
),
),
) )
), : Padding(
}, padding: const EdgeInsets.all(16.0),
extensionSet: md.ExtensionSet( child: CodeTheme(
[LatexBlockSyntax()], data: CodeThemeData(
[LatexInlineSyntax()], styles: widget.isDarkMode ? nightTheme : dayTheme),
), child: CodeField(
data: _controller.text, // Plain Markdown without syntax highlighting gutterStyle: GutterStyle.none,
selectable: true, // Allows text selection controller: _controller,
styleSheet: MarkdownStyleSheet( expands: true,
p: TextStyle(fontSize: _fontSize, color: widget.isDarkMode ? Colors.white : Colors.black), textStyle: TextStyle(
h1: TextStyle(fontSize: _fontSize + 8, color: widget.isDarkMode ? Colors.white : Colors.black, fontWeight: FontWeight.bold), fontSize: _fontSize,
h2: TextStyle(fontSize: _fontSize + 6, color: widget.isDarkMode ? Colors.white : Colors.black, fontWeight: FontWeight.bold), ),
h3: TextStyle(fontSize: _fontSize + 4, color: widget.isDarkMode ? Colors.white : Colors.black, fontWeight: FontWeight.bold), ),
code: TextStyle(fontSize: _fontSize, backgroundColor: Colors.transparent), // Disables syntax highlighting ),
codeblockDecoration: BoxDecoration(color: Colors.transparent), // Removes background color from code blocks ),
),
),
),
)
: Padding(
padding: const EdgeInsets.all(16.0),
child: CodeTheme(
data: CodeThemeData(styles: widget.isDarkMode ? nightTheme : dayTheme),
child: CodeField(
gutterStyle: GutterStyle.none,
controller: _controller,
expands: true,
textStyle: TextStyle(
fontSize: _fontSize,
),
),
),
),
); );
} }
} }

434
lib/note_list.dart Normal file
View file

@ -0,0 +1,434 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'note_editor.dart';
class NoteListScreen extends StatefulWidget {
final void Function(bool) toggleTheme;
final VoidCallback toggleTags;
final bool isDarkMode;
final bool showTags;
NoteListScreen({
required this.toggleTheme,
required this.toggleTags,
required this.isDarkMode,
required this.showTags,
});
@override
_NoteListScreenState createState() => _NoteListScreenState();
}
class _NoteListScreenState extends State<NoteListScreen> {
TextEditingController searchController = TextEditingController();
List<File> notes = [];
List<File> filteredNotes = [];
String? notesDirectoryPath;
String? defaultDir;
@override
void initState() {
super.initState();
_initializeApp(); // Call an async method to handle initialization
searchController.addListener(() {
final text = searchController.text.trim();
if (text.startsWith(':')) {
_handleCommand(text);
} else {
_filterNotes();
}
});
}
Future<void> _initializeApp() async {
final dir = await getApplicationDocumentsDirectory();
setState(() {
defaultDir = dir.path; // Update state with defaultDir
});
await _loadNotes(); // Load notes after setting defaultDir
}
void _showDirectoryChoiceDialog(String defaultPath) {
showDialog(
context: context,
barrierDismissible: false, // Force user to make a choice
builder: (BuildContext context) {
return AlertDialog(
title: Text("Choose Notes Directory"),
content: Text(
"Your notes will be saved in:\n\n📂 $defaultPath\n\nWould you like to use a different folder?"),
actions: [
TextButton(
onPressed: () async {
// Reset to default directory
final prefs = await SharedPreferences.getInstance();
await prefs.remove(
'notes_directory'); // Remove custom directory from preferences
setState(() {
notesDirectoryPath = defaultDir; // Reset to default directory
});
Navigator.pop(context); // Close dialog
_loadNotes(); // Reload notes from the default directory
},
child: Text("Use Default"),
),
TextButton(
onPressed: () async {
Navigator.pop(context); // Close the current dialog
await _selectNotesDirectory(); // Allow user to pick a folder
},
child: Text("Choose Folder"),
),
],
);
},
);
}
Future<void> _selectNotesDirectory() async {
String? selectedDirectory = await FilePicker.platform.getDirectoryPath();
if (selectedDirectory != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'notes_directory', selectedDirectory); // Save custom directory
setState(() {
notesDirectoryPath = selectedDirectory; // Update state
});
_loadNotes(); // Reload notes from the new directory
}
}
Future<void> _loadNotes() async {
final prefs = await SharedPreferences.getInstance();
notesDirectoryPath = prefs.getString('notes_directory') ??
defaultDir; // Use custom or default directory
final notesDir = Directory(notesDirectoryPath!);
if (!notesDir.existsSync()) {
notesDir.createSync(recursive: true);
}
final files = notesDir
.listSync()
.whereType<File>()
.where((file) => file.path.endsWith('.md'))
.toList();
setState(() {
notes = files;
filteredNotes = files;
});
}
void _filterNotes() {
final query = searchController.text.toLowerCase();
setState(() {
filteredNotes = notes.where((note) {
final content = note.readAsStringSync().toLowerCase();
return note.uri.pathSegments.last.toLowerCase().contains(query) ||
content.contains(query);
}).toList();
});
}
List<InlineSpan> _formatTags(String content) {
final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)');
final List<InlineSpan> spans = [];
for (final match in tagPattern.allMatches(content)) {
final tag = match.group(0)!;
Color tagColor = Colors.white;
if (tag.startsWith('#')) tagColor = Colors.pinkAccent;
if (tag.startsWith('+')) tagColor = Colors.cyan;
if (tag.startsWith('@')) tagColor = Colors.orange;
spans.add(TextSpan(
text: tag + ' ',
style: TextStyle(color: tagColor, fontWeight: FontWeight.bold)));
}
return spans;
}
void _createNewNote(String title) async {
try {
final safeTitle = _sanitizeFilename(title);
final newNote = File(path.join(notesDirectoryPath!, '$safeTitle.md'));
if (!newNote.existsSync()) {
newNote.writeAsStringSync('# $title\n\n');
setState(() {
notes.add(newNote);
filteredNotes.add(newNote);
});
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NoteEditorScreen(
note: newNote,
isDarkMode: widget.isDarkMode,
notesDirectoryPath: notesDirectoryPath,
),
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to create note: $e'),
),
);
}
}
bool _isNewNote(String title) {
final safeTitle = _sanitizeFilename(title);
return !notes.any((note) => note.uri.pathSegments.last == '$safeTitle.md');
}
String _sanitizeFilename(String title) {
return title
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '') // Remove invalid characters
.trim();
}
void _deleteNote(File note) {
final trashDir = Directory('${note.parent.path}/trash');
if (!trashDir.existsSync()) {
trashDir.createSync(recursive: true);
}
final trashedNote = File('${trashDir.path}/${note.uri.pathSegments.last}');
note.renameSync(trashedNote.path);
setState(() {
notes.remove(note);
filteredNotes.remove(note);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Note moved to trash'),
action: SnackBarAction(
label: 'Undo',
onPressed: () {
trashedNote.renameSync(note.path);
setState(() {
notes.add(note);
filteredNotes.add(note);
});
},
),
),
);
}
Future<void> _emptyTrash() async {
final prefs = await SharedPreferences.getInstance();
final notesPath = prefs.getString('notes_directory') ??
(await getApplicationDocumentsDirectory()).path;
final trashDir = Directory('$notesPath/trash');
if (trashDir.existsSync()) {
trashDir.deleteSync(recursive: true);
trashDir.createSync();
}
}
void _confirmEmptyTrash() {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text("Empty Trash"),
content: Text(
"Are you sure you want to permanently delete all trashed notes?"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text("Cancel"),
),
TextButton(
onPressed: () {
_emptyTrash();
Navigator.pop(context);
},
child: Text("Empty Trash"),
),
],
);
},
);
}
void _handleCommand(String text) {
switch (text) {
case ':tags':
widget.toggleTags();
break;
case ':notags':
widget.toggleTags();
break;
case ':day':
widget.toggleTheme(false);
break;
case ':night':
widget.toggleTheme(true);
break;
case ':emptytrash':
_confirmEmptyTrash();
break;
default:
return; // Do nothing for unrecognized commands
}
searchController.clear();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: TextField(
enableInteractiveSelection: true,
autocorrect: false,
textInputAction: TextInputAction.done,
controller: searchController,
decoration: InputDecoration(
hintText: 'Search or enter a command...',
border: InputBorder.none,
),
style: GoogleFonts.ibmPlexMono(color: Colors.grey[300]),
onSubmitted: (text) {
final trimmedText = text.trim();
if (trimmedText.isNotEmpty && _isNewNote(trimmedText)) {
_createNewNote(trimmedText);
searchController
.clear(); // Clears the search field after creation
}
},
),
),
actions: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: PopupMenuButton<String>(
icon: Icon(Icons.radio_button_checked,
color: Colors.orange), // Menu button ()
onSelected: (String value) {
switch (value) {
case 'Toggle Tags':
widget.toggleTags();
break;
case 'Toggle Theme':
setState(() {
widget.toggleTheme(!widget.isDarkMode);
});
break;
case 'Select Folder':
_showDirectoryChoiceDialog(
notesDirectoryPath ?? ''); // Re-trigger popup
break;
case 'Empty Trash':
_confirmEmptyTrash();
break;
}
},
itemBuilder: (BuildContext context) => [
PopupMenuItem<String>(
value: 'Toggle Tags',
child: ListTile(
leading:
Icon(widget.showTags ? Icons.label_off : Icons.label),
title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'),
),
),
PopupMenuItem<String>(
value: 'Toggle Theme',
child: ListTile(
leading: Icon(
Theme.of(context).brightness == Brightness.dark
? Icons.light_mode
: Icons.dark_mode,
),
title: Text(
Theme.of(context).brightness == Brightness.dark
? 'Switch to Light Mode'
: 'Switch to Dark Mode',
),
),
),
PopupMenuItem<String>(
value: 'Select Folder',
child: ListTile(
leading: Icon(Icons.folder),
title: Text('Set Notes Directory'),
),
),
PopupMenuItem<String>(
value: 'Empty Trash',
child: ListTile(
leading: Icon(Icons.delete, color: Colors.redAccent),
title: Text('Empty Trash',
style: TextStyle(color: Colors.redAccent)),
),
),
],
),
),
],
),
body: Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: ListView.builder(
itemCount: filteredNotes.length,
itemBuilder: (context, index) {
final noteFile = filteredNotes[index];
final noteContent = noteFile.readAsStringSync();
final tagSpans = _formatTags(noteContent).isNotEmpty
? _formatTags(noteContent)
: [TextSpan(text: '')];
return Dismissible(
key: Key(noteFile.path),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.redAccent,
alignment: Alignment.centerRight,
padding: EdgeInsets.symmetric(horizontal: 20),
child: Icon(Icons.delete, color: Colors.white),
),
onDismissed: (direction) => _deleteNote(noteFile),
child: ListTile(
title:
Text(noteFile.uri.pathSegments.last.replaceAll('.md', '')),
subtitle: widget.showTags && tagSpans.isNotEmpty
? RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: tagSpans))
: null,
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NoteEditorScreen(
note: File(path.join(notesDirectoryPath!,
noteFile.uri.pathSegments.last)),
isDarkMode: widget.isDarkMode,
notesDirectoryPath: notesDirectoryPath,
),
),
);
_loadNotes(); // Reload notes after returning
setState(() {}); // Force UI refresh
},
),
);
},
),
),
);
}
}

View file

@ -289,7 +289,7 @@ packages:
source: hosted source: hosted
version: "5.1.1" version: "5.1.1"
markdown: markdown:
dependency: transitive dependency: "direct main"
description: description:
name: markdown name: markdown
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
@ -337,7 +337,7 @@ packages:
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
path: path:
dependency: transitive dependency: "direct main"
description: description:
name: path name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"

View file

@ -34,15 +34,17 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
google_fonts: ^6.2.1 file_picker: ^9.0.0
path_provider: ^2.1.5
flutter_highlight: ^0.7.0 flutter_highlight: ^0.7.0
flutter_code_editor: ^0.3.2 flutter_code_editor: ^0.3.2
flutter_markdown: ^0.7.6+2 flutter_markdown: ^0.7.6+2
flutter_markdown_latex: ^0.3.4 flutter_markdown_latex: ^0.3.4
google_fonts: ^6.2.1
markdown: ^7.3.0
path: ^1.9.0
path_provider: ^2.1.5
permission_handler: ^11.4.0 permission_handler: ^11.4.0
shared_preferences: ^2.5.2 shared_preferences: ^2.5.2
file_picker: ^9.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: