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; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Permission.storage.request(); runApp(MuzzleVelocityApp()); } class MuzzleVelocityApp extends StatefulWidget { @override _MuzzleVelocityAppState createState() => _MuzzleVelocityAppState(); } class _MuzzleVelocityAppState extends State { ThemeMode _themeMode = ThemeMode.system; bool showTags = true; @override void initState() { super.initState(); // Ensure the correct theme mode is set on startup _themeMode = WidgetsBinding.instance.window.platformBrightness == Brightness.dark ? ThemeMode.dark : ThemeMode.light; } void _toggleTheme(bool darkMode) { if (mounted) { setState(() { _themeMode = darkMode ? ThemeMode.dark : ThemeMode.light; }); } } void _toggleTags() { if (mounted) { setState(() { showTags = !showTags; }); } } 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( title: 'Muzzle Velocity', theme: ThemeData( brightness: Brightness.light, scaffoldBackgroundColor: Color(0xffF6F4F1), appBarTheme: AppBarTheme(backgroundColor: Color(0xffF6F4F1)), textTheme: GoogleFonts.ibmPlexMonoTextTheme(dayText), ), darkTheme: ThemeData( brightness: Brightness.dark, scaffoldBackgroundColor: Color(0xFF121212), appBarTheme: AppBarTheme(backgroundColor: Color(0xFF121212)), textTheme: GoogleFonts.ibmPlexMonoTextTheme(nightText), ), themeMode: _themeMode, 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 }, ), ); }, ), ), ); } }