diff --git a/lib/main.dart b/lib/main.dart index e168e24..45c4b9f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,9 @@ import 'package:flutter/material.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:path/path.dart' as path; - +import 'themes/day.dart'; +import 'themes/night.dart'; +import 'note_list.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -30,9 +24,10 @@ class _MuzzleVelocityAppState extends State { void initState() { super.initState(); // Ensure the correct theme mode is set on startup - _themeMode = WidgetsBinding.instance.window.platformBrightness == Brightness.dark - ? ThemeMode.dark - : ThemeMode.light; + _themeMode = + WidgetsBinding.instance.window.platformBrightness == Brightness.dark + ? ThemeMode.dark + : ThemeMode.light; } void _toggleTheme(bool darkMode) { @@ -51,16 +46,6 @@ class _MuzzleVelocityAppState extends State { } } - Future _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 Widget build(BuildContext context) { return MaterialApp( @@ -81,425 +66,9 @@ class _MuzzleVelocityAppState extends State { home: NoteListScreen( toggleTheme: _toggleTheme, toggleTags: _toggleTags, - emptyTrash: _emptyTrash, isDarkMode: _themeMode == ThemeMode.dark, 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 { - TextEditingController searchController = TextEditingController(); - List notes = []; - List 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 _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 _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().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 _formatTags(String content) { - final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)'); - final List 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 _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 _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( - 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( - value: 'Toggle Tags', - child: ListTile( - leading: Icon(widget.showTags ? Icons.label_off : Icons.label), - title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'), - ), - ), - PopupMenuItem( - 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( - value: 'Select Folder', - child: ListTile( - leading: Icon(Icons.folder), - title: Text('Set Notes Directory'), - ), - ), - PopupMenuItem( - 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 - }, - ), - ); - }, - ), - ), - ); - } -} diff --git a/lib/note_editor.dart b/lib/note_editor.dart index 44dad8b..b3e9ca5 100644 --- a/lib/note_editor.dart +++ b/lib/note_editor.dart @@ -1,20 +1,23 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_code_editor/flutter_code_editor.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:markdown/markdown.dart' as md; +import 'package:path/path.dart' as path; import 'themes/day.dart'; import 'themes/night.dart'; import 'themes/markdown.dart'; -import 'dart:io'; -import 'package:path/path.dart' as path; class NoteEditorScreen extends StatefulWidget { final File note; 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 _NoteEditorScreenState createState() => _NoteEditorScreenState(); @@ -26,13 +29,6 @@ class _NoteEditorScreenState extends State { late CodeController _controller; double _fontSize = 16.0; // Default font size - void _updateFontSize(double newSize) { - setState(() { - _fontSize = newSize; - }); - } - - @override void initState() { super.initState(); @@ -58,11 +54,12 @@ class _NoteEditorScreenState extends State { }); } - Future _saveNote() async { try { - final notesDirectory = widget.notesDirectoryPath ?? widget.note.parent.path; // āœ… Use correct directory - final newFilePath = path.join(notesDirectory, widget.note.uri.pathSegments.last); + final notesDirectory = widget.notesDirectoryPath ?? + widget.note.parent.path; // āœ… Use correct directory + final newFilePath = + path.join(notesDirectory, widget.note.uri.pathSegments.last); print("šŸ“ Attempting to save note at: $newFilePath"); final newFile = File(newFilePath); @@ -76,6 +73,12 @@ class _NoteEditorScreenState extends State { } } + void _updateFontSize(double newSize) { + setState(() { + _fontSize = newSize; + }); + } + @override void dispose() { _controller.removeListener(_saveNote); @@ -92,10 +95,7 @@ class _NoteEditorScreenState extends State { Padding( padding: EdgeInsets.symmetric(horizontal: 15), child: PopupMenuButton( - icon: Icon( - Icons.radio_button_checked, - color: Colors.orange - ), + icon: Icon(Icons.radio_button_checked, color: Colors.orange), onSelected: (String value) { if (value == 'Toggle Preview') { setState(() { @@ -143,52 +143,75 @@ class _NoteEditorScreenState extends State { ], ), body: isLoading - ? Center(child: CircularProgressIndicator()) // Show loader while initializing - : _controller == null - ? Center(child: CircularProgressIndicator()) - : _isPreviewMode - ? Padding( - padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: MarkdownBody( - builders: { - 'latex': LatexElementBuilder( - textStyle: TextStyle( - color: widget.isDarkMode ? Colors.white : Colors.black + ? Center( + child: + CircularProgressIndicator()) // Show loader while initializing + : _isPreviewMode + ? Padding( + padding: const EdgeInsets.all(16.0), + child: SingleChildScrollView( + child: MarkdownBody( + builders: { + 'latex': LatexElementBuilder( + textStyle: TextStyle( + 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 + ), + ), + ), ) - ), - }, - 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), - child: CodeTheme( - data: CodeThemeData(styles: widget.isDarkMode ? nightTheme : dayTheme), - child: CodeField( - gutterStyle: GutterStyle.none, - controller: _controller, - expands: true, - textStyle: TextStyle( - fontSize: _fontSize, - ), - ), - ), - ), + : 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, + ), + ), + ), + ), ); } } diff --git a/lib/note_list.dart b/lib/note_list.dart new file mode 100644 index 0000000..ca334e0 --- /dev/null +++ b/lib/note_list.dart @@ -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 { + TextEditingController searchController = TextEditingController(); + List notes = []; + List 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 _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 _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 _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() + .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 _formatTags(String content) { + final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)'); + final List 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 _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( + 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( + value: 'Toggle Tags', + child: ListTile( + leading: + Icon(widget.showTags ? Icons.label_off : Icons.label), + title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'), + ), + ), + PopupMenuItem( + 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( + value: 'Select Folder', + child: ListTile( + leading: Icon(Icons.folder), + title: Text('Set Notes Directory'), + ), + ), + PopupMenuItem( + 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 + }, + ), + ); + }, + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index af38ab8..533ba42 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -289,7 +289,7 @@ packages: source: hosted version: "5.1.1" markdown: - dependency: transitive + dependency: "direct main" description: name: markdown sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" @@ -337,7 +337,7 @@ packages: source: hosted version: "1.0.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" diff --git a/pubspec.yaml b/pubspec.yaml index 3d2f2d7..c27327f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,15 +34,17 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - google_fonts: ^6.2.1 - path_provider: ^2.1.5 + file_picker: ^9.0.0 flutter_highlight: ^0.7.0 flutter_code_editor: ^0.3.2 flutter_markdown: ^0.7.6+2 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 shared_preferences: ^2.5.2 - file_picker: ^9.0.0 dev_dependencies: flutter_test: