muzzle-velocity/lib/main.dart

348 lines
9.8 KiB
Dart
Raw Normal View History

2025-02-20 22:40:41 +02:00
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'note_editor.dart';
import 'search_service.dart';
void main() {
runApp(MuzzleVelocityApp());
}
class MuzzleVelocityApp extends StatefulWidget {
@override
_MuzzleVelocityAppState createState() => _MuzzleVelocityAppState();
}
class _MuzzleVelocityAppState extends State<MuzzleVelocityApp> {
ThemeMode _themeMode = ThemeMode.system;
bool showTags = true;
void _toggleTheme(bool darkMode) {
if (mounted) {
setState(() {
_themeMode = darkMode ? ThemeMode.dark : ThemeMode.light;
});
}
}
void _toggleTags() {
if (mounted) {
setState(() {
showTags = !showTags;
});
}
}
Future<void> _emptyTrash() async {
final dir = await getApplicationDocumentsDirectory();
final trashDir = Directory('${dir.path}/notes/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,
textTheme: GoogleFonts.ibmPlexMonoTextTheme(),
),
darkTheme: ThemeData(
brightness: Brightness.dark,
textTheme: GoogleFonts.ibmPlexMonoTextTheme(),
),
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<NoteListScreen> {
TextEditingController searchController = TextEditingController();
List<File> notes = [];
List<File> filteredNotes = [];
@override
void initState() {
super.initState();
_loadNotes();
searchController.addListener(() {
final text = searchController.text.trim();
2025-02-21 00:29:41 +02:00
2025-02-20 22:40:41 +02:00
if (text.startsWith(':')) {
_handleCommand(text);
} else {
2025-02-21 00:29:41 +02:00
_filterNotes(); // Only filter, do NOT create a note here!
2025-02-20 22:40:41 +02:00
}
});
}
2025-02-21 00:29:41 +02:00
bool _isNewNote(String title) {
final safeTitle = _sanitizeFilename(title);
return !notes.any((note) => note.uri.pathSegments.last == '$safeTitle.md');
}
Future<void> _createNewNote(String title) async {
final dir = await getApplicationDocumentsDirectory();
final notesDir = Directory('${dir.path}/notes');
if (!notesDir.existsSync()) {
notesDir.createSync(recursive: true);
}
final safeTitle = _sanitizeFilename(title);
final newNote = File('${notesDir.path}/$safeTitle.md');
if (!newNote.existsSync()) {
newNote.writeAsStringSync('# $title\n\n'); // Pre-fill with title
setState(() {
notes.add(newNote);
filteredNotes.add(newNote);
});
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NoteEditorScreen(
note: newNote,
isDarkMode: widget.isDarkMode,
),
),
);
}
}
String _sanitizeFilename(String title) {
return title
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '') // Remove invalid characters
.trim();
}
2025-02-20 22:40:41 +02:00
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 dir = await getApplicationDocumentsDirectory();
final notesDir = Directory('${dir.path}/notes');
if (!notesDir.existsSync()) {
notesDir.createSync(recursive: true);
}
final files = notesDir.listSync().whereType<File>().where((file) => file.path.endsWith('.md')).toList();
if (mounted) {
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();
});
}
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.yellow;
spans.add(TextSpan(text: tag + ' ', style: TextStyle(color: tagColor, fontWeight: FontWeight.bold)));
}
return spans;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
2025-02-21 00:29:41 +02:00
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
}
},
2025-02-20 22:40:41 +02:00
),
),
actions: [
IconButton(
2025-02-21 00:29:41 +02:00
icon: Icon(!widget.showTags ? Icons.tag : Icons.horizontal_rule),
2025-02-20 22:40:41 +02:00
onPressed: widget.toggleTags,
),
],
),
2025-02-21 00:29:41 +02:00
floatingActionButton: IconButton(
icon: Icon(!widget.isDarkMode ? Icons.dark_mode : Icons.light_mode),
onPressed: () => widget.toggleTheme(!widget.isDarkMode),
),
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: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NoteEditorScreen(
note: noteFile,
isDarkMode: widget.isDarkMode,
),
2025-02-20 22:40:41 +02:00
),
2025-02-21 00:29:41 +02:00
);
},
),
);
},
),
2025-02-20 22:40:41 +02:00
),
);
}
}