devbox setup, dep upgrades, lint fixes, Android build fixes

- Add devbox.json pinning Flutter 3.35.7, Android SDK (Nix flake),
  openjdk17, just, openssl
- Add android/nix-android-sdk/ Nix flake + flake.lock + Android Studio
  config script
- Add justfile with build/get/upgrade/icons/analyze/rebuild recipes
- flutter pub upgrade --major-versions: file_picker ^11, google_fonts ^8,
  permission_handler ^12, share_plus ^12, flutter_lints ^6 (+71 transitive)
- Fix file_picker v11 API: FilePicker.platform → FilePicker.getDirectoryPath()
- Fix all 37 analyzer issues: deprecated APIs (window, WillPopScope, Share,
  SvgPicture color), async BuildContext guards, key constructors, private
  createState() return types, unused locals, print → debugPrint, etc.
- Hook _handleCommand into onSubmitted for : prefix commands
- Android: Gradle 8.3→8.11.1, AGP 8.2.2→8.9.1, KGP 1.8.22→2.2.20,
  compileSdk/targetSdk 34→36, namespace auto-derivation for library modules,
  kotlin.compiler.execution.strategy=in-process

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-06-19 17:08:29 +03:00
parent 9dbd638fa8
commit fc6c69db24
19 changed files with 1287 additions and 383 deletions

375
lib/note_list.dart Normal file → Executable file
View file

@ -19,7 +19,8 @@ class NoteListScreen extends StatefulWidget {
final bool isDarkMode;
final bool showTags;
NoteListScreen({
const NoteListScreen({
super.key,
required this.toggleTheme,
required this.toggleTags,
required this.isDarkMode,
@ -27,11 +28,12 @@ class NoteListScreen extends StatefulWidget {
});
@override
_NoteListScreenState createState() => _NoteListScreenState();
State<NoteListScreen> createState() => _NoteListScreenState();
}
class _NoteListScreenState extends State<NoteListScreen> {
TextEditingController searchController = TextEditingController();
final FocusNode searchFocusNode = FocusNode();
List<File> notes = [];
List<File> filteredNotes = [];
List<File> _visibleNotes = [];
@ -52,6 +54,9 @@ class _NoteListScreenState extends State<NoteListScreen> {
searchController.addListener(() {
_debouncedFilterNotes();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
FocusScope.of(context).requestFocus(searchFocusNode);
});
}
void _debouncedFilterNotes() {
@ -95,16 +100,16 @@ class _NoteListScreenState extends State<NoteListScreen> {
Future<void> _requestStoragePermission() async {
if (Platform.isAndroid) {
if (await Permission.storage.request().isGranted) {
print("✅ Basic storage permission granted.");
debugPrint("✅ Basic storage permission granted.");
return;
}
if (await Permission.manageExternalStorage.request().isGranted) {
print("✅ Full file access granted.");
debugPrint("✅ Full file access granted.");
return;
}
print("❌ Storage permission denied. Opening settings...");
debugPrint("❌ Storage permission denied. Opening settings...");
await openAppSettings(); // Open settings if denied
}
}
@ -116,12 +121,46 @@ class _NoteListScreenState extends State<NoteListScreen> {
setState(() {
defaultDir = dir.path;
_useExternalEditor = prefs.getBool('use_external_editor') ?? false;
_includeSubdirectories = prefs.getBool('include_subdirectories') ?? true; // Load setting
_includeSubdirectories = prefs.getBool('include_subdirectories') ?? true;
});
notesDirectoryPath = prefs.getString('notes_directory') ?? defaultDir;
// 🚀 Check if it's the first start OR if the directory is empty
final directory = Directory(notesDirectoryPath!);
if (prefs.getBool('initialized') == null ||
!directory.listSync().any((entity) => entity is File && entity.path.endsWith('.md'))) {
await _createDefaultNotes();
await prefs.setBool('initialized', true);
}
await _loadNotes();
}
Future<void> _createDefaultNotes() async {
final quickStartNote = File(path.join(notesDirectoryPath!, "QuickStart.md"));
if (!quickStartNote.existsSync()) {
quickStartNote.writeAsStringSync("""
# Quick Start Guide
Welcome to *Muzzle Velocity*.
This is your fast and minimal note-taking app.
1. Create Notes quickly: just type a title in the search bar and press enter.
2. 🔍 Type keywords to filter notes instantly.
3. 💻 Type commands like :help to get more information.
4. 🏷 Use hashtags like #quickstart, context tags like @help, or project tags like +muzzle in your notes for categorization.
5. 🔫 Tap the blaster at the top right for more settings or to clear the search field.
*Muzzle Velocity* is built for speed and simplicity! 🚀
Happy note taking!
""");
}
}
void _toggleIncludeSubdirectories(bool value) {
setState(() {
_includeSubdirectories = value;
@ -157,7 +196,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
setState(() {
notesDirectoryPath = defaultDir; // Reset to default directory
});
Navigator.pop(context); // Close dialog
if (context.mounted) Navigator.pop(context);
_loadNotes(); // Reload notes from the default directory
},
child: Text("Use Default"),
@ -165,7 +204,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
TextButton(
onPressed: () async {
await _requestStoragePermission();
Navigator.pop(context); // Close the current dialog
if (context.mounted) Navigator.pop(context);
await _selectNotesDirectory(); // Allow user to pick a folder
},
child: Text("Choose Folder"),
@ -177,7 +216,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
}
Future<void> _selectNotesDirectory() async {
String? selectedDirectory = await FilePicker.platform.getDirectoryPath();
String? selectedDirectory = await FilePicker.getDirectoryPath();
if (selectedDirectory != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
@ -206,7 +245,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
final entries = dir.listSync(recursive: _includeSubdirectories);
for (var entry in entries) {
if (entry is File && entry.path.endsWith('.md')) {
if (entry is File && _isValidNoteFile(entry)) {
if (path.basename(path.dirname(entry.path)) == "trash") {
continue;
@ -268,6 +307,11 @@ class _NoteListScreenState extends State<NoteListScreen> {
return sortedTags.toSet(); // Convert back to a set after sorting
}
bool _isValidNoteFile(File file) {
final validExtensions = ['.md', '.txt', '.markdown'];
return validExtensions.any((ext) => file.path.toLowerCase().endsWith(ext));
}
void _filterNotes() {
final query = searchController.text.trim().toLowerCase();
@ -351,6 +395,15 @@ class _NoteListScreenState extends State<NoteListScreen> {
String notePath = title.trim();
String? subfolder;
String safeTitle;
String extension = '.md'; // Default extension
// Check if user specified an extension manually
if (title.contains('.')) {
final ext = path.extension(title);
if (['.txt', '.markdown', '.md'].contains(ext)) {
extension = ext;
}
}
if (notePath.startsWith('/')) {
final parts = notePath.split('/');
@ -358,21 +411,21 @@ class _NoteListScreenState extends State<NoteListScreen> {
if (parts.length > 1) {
subfolder = parts.sublist(0, parts.length - 1).join('/');
}
safeTitle = _sanitizeFilename(parts.last);
safeTitle = _sanitizeFilename(parts.last.replaceAll(extension, '')); // Remove extension from input
} else {
safeTitle = _sanitizeFilename(title);
safeTitle = _sanitizeFilename(title.replaceAll(extension, ''));
}
// Construct the final file path
String fullPath = subfolder != null
? path.join(notesDirectoryPath!, subfolder, '$safeTitle.md')
: path.join(notesDirectoryPath!, '$safeTitle.md');
? path.join(notesDirectoryPath!, subfolder, '$safeTitle$extension')
: path.join(notesDirectoryPath!, '$safeTitle$extension');
final newNote = File(fullPath);
// Prevent duplicate note creation
if (notes.any((note) => note.path == newNote.path)) {
print("❌ Note already exists at: $fullPath");
debugPrint("❌ Note already exists at: $fullPath");
return;
}
@ -416,7 +469,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
if (notePath.startsWith('/')) {
final parts = notePath.split('/');
parts.removeWhere((element) => element.isEmpty); // Remove empty elements
parts.removeWhere((element) => element.isEmpty);
if (parts.length > 1) {
subfolder = parts.sublist(0, parts.length - 1).join('/');
}
@ -425,12 +478,18 @@ class _NoteListScreenState extends State<NoteListScreen> {
safeTitle = _sanitizeFilename(title);
}
// Construct the expected file path
String fullPath = subfolder != null
? path.join(notesDirectoryPath!, subfolder, '$safeTitle.md')
: path.join(notesDirectoryPath!, '$safeTitle.md');
// Allow multiple file extensions
for (var ext in ['.md', '.txt', '.markdown']) {
String fullPath = subfolder != null
? path.join(notesDirectoryPath!, subfolder, '$safeTitle$ext')
: path.join(notesDirectoryPath!, '$safeTitle$ext');
return !notes.any((note) => note.path == fullPath);
if (notes.any((note) => note.path == fullPath)) {
return false; // A note with this name already exists
}
}
return true;
}
String _sanitizeFilename(String title) {
@ -508,7 +567,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
_deleteEmptyParentFolders(folder.parent); // Recursively check parent
}
} catch (e) {
print("❌ Error deleting empty folder: $e");
debugPrint("❌ Error deleting empty folder: $e");
}
}
@ -577,13 +636,14 @@ class _NoteListScreenState extends State<NoteListScreen> {
break;
case ':help':
_showInfoPage(context, "Help", helpContent);
break;
default:
return; // Do nothing for unrecognized commands
}
searchController.clear();
}
_showInfoPage(BuildContext context, String title, String content) {
void _showInfoPage(BuildContext context, String title, String content) {
Navigator.push(
context,
MaterialPageRoute(
@ -603,6 +663,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
title: Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: TextField(
focusNode: searchFocusNode,
enableInteractiveSelection: true,
autocorrect: false,
textInputAction: TextInputAction.done,
@ -615,10 +676,11 @@ class _NoteListScreenState extends State<NoteListScreen> {
style: GoogleFonts.ibmPlexMono(color: Colors.grey[300]),
onSubmitted: (text) {
final trimmedText = text.trim();
if (trimmedText.isNotEmpty && _isNewNote(trimmedText)) {
if (trimmedText.startsWith(':')) {
_handleCommand(trimmedText);
} else if (trimmedText.isNotEmpty && _isNewNote(trimmedText)) {
_createNewNote(trimmedText);
searchController
.clear(); // Clears the search field after creation
searchController.clear();
}
},
),
@ -626,137 +688,145 @@ class _NoteListScreenState extends State<NoteListScreen> {
actions: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: PopupMenuButton<String>(
icon: SvgPicture.asset(
"assets/blaster.svg",
width: 30,
color: Colors.orange,
),
onSelected: (String value) {
switch (value) {
case 'Toggle Tags':
widget.toggleTags();
break;
case 'Toggle Theme':
setState(() {
widget.toggleTheme(!widget.isDarkMode);
});
break;
case 'External Editor':
_toggleExternalEditor(!_useExternalEditor);
break;
case 'Select Folder':
_showDirectoryChoiceDialog(
notesDirectoryPath ?? ''); // Re-trigger popup
break;
case 'Empty Trash':
_confirmEmptyTrash();
break;
case 'Help':
_showInfoPage(context, "Help", helpContent);
break;
case 'About':
_showInfoPage(context, "About", aboutContent);
break;
}
child: GestureDetector(
onTap: () {
if (searchController.text.isNotEmpty) {
searchController.clear();
}
},
itemBuilder: (BuildContext context) => [
PopupMenuItem<String>(
value: 'Toggle Tags',
child: ListTile(
leading:
Icon(widget.showTags ? Icons.short_text : Icons.tag),
title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'),
),
child: PopupMenuButton<String>(
enabled: searchController.text.isEmpty,
icon: SvgPicture.asset(
"assets/blaster.svg",
width: 30,
colorFilter: const ColorFilter.mode(Colors.orange, BlendMode.srcIn),
),
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',
onSelected: (String value) {
switch (value) {
case 'Toggle Tags':
widget.toggleTags();
break;
case 'Toggle Theme':
setState(() {
widget.toggleTheme(!widget.isDarkMode);
});
break;
case 'External Editor':
_toggleExternalEditor(!_useExternalEditor);
break;
case 'Select Folder':
_showDirectoryChoiceDialog(
notesDirectoryPath ?? ''); // Re-trigger popup
break;
case 'Empty Trash':
_confirmEmptyTrash();
break;
case 'Help':
_showInfoPage(context, "Help", helpContent);
break;
case 'About':
_showInfoPage(context, "About", aboutContent);
break;
}
},
itemBuilder: (BuildContext context) => [
PopupMenuItem<String>(
value: 'Toggle Tags',
child: ListTile(
leading:
Icon(widget.showTags ? Icons.short_text : Icons.tag),
title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'),
),
),
),
PopupMenuItem<String>(
value: 'Include Subdirectories',
child: ListTile(
leading: Icon(_includeSubdirectories ? Icons.file_copy_outlined : Icons.folder_copy_outlined),
title: Text(_includeSubdirectories ? 'Exclude Subdirectories' : 'Include Subdirectories'),
onTap: () {
_toggleIncludeSubdirectories(!_includeSubdirectories);
Navigator.pop(context); // Close menu after toggle
},
),
),
PopupMenuItem<String>(
value: 'Include File Content',
child: ListTile(
leading: Icon(
_includeFileContent ? Icons.highlight_off : Icons.file_open_outlined,
),
title: Text(
_includeFileContent ? 'Exclude File Content' : 'Include File Content',
),
onTap: () {
_toggleIncludeFileContent(!_includeFileContent);
Navigator.pop(context);
},
),
),
PopupMenuItem<String>(
value: 'External Editor',
child: ListTile(
leading: Icon(
_useExternalEditor
? Icons.open_in_new_off
: Icons.open_in_new,
),
title: Text(
!_useExternalEditor
? 'Use External Editor'
: 'Use Internal Editor',
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_shared_outlined),
title: Text('Set Notes Directory'),
PopupMenuItem<String>(
value: 'Include Subdirectories',
child: ListTile(
leading: Icon(_includeSubdirectories ? Icons.file_copy_outlined : Icons.folder_copy_outlined),
title: Text(_includeSubdirectories ? 'Exclude Subdirectories' : 'Search Subdirectories'),
onTap: () {
_toggleIncludeSubdirectories(!_includeSubdirectories);
Navigator.pop(context); // Close menu after toggle
},
),
),
),
PopupMenuDivider(),
PopupMenuItem<String>(
value: 'Empty Trash',
child: ListTile(
leading: Icon(Icons.delete, color: Colors.redAccent),
title: Text('Empty Trash',
style: TextStyle(color: Colors.redAccent)),
PopupMenuItem<String>(
value: 'Include File Content',
child: ListTile(
leading: Icon(
_includeFileContent ? Icons.highlight_off : Icons.file_open_outlined,
),
title: Text(
_includeFileContent ? 'Exclude File Content' : 'Search File Content',
),
onTap: () {
_toggleIncludeFileContent(!_includeFileContent);
Navigator.pop(context);
},
),
),
),
PopupMenuDivider(),
PopupMenuItem<String>(
value: 'Help',
child: ListTile(
leading: Icon(Icons.help_outline),
title: Text('Help'),
PopupMenuItem<String>(
value: 'External Editor',
child: ListTile(
leading: Icon(
_useExternalEditor
? Icons.open_in_new_off
: Icons.open_in_new,
),
title: Text(
!_useExternalEditor
? 'Use External Editor'
: 'Use Internal Editor',
),
),
),
),
PopupMenuItem<String>(
value: 'About',
child: ListTile(
leading: Icon(Icons.info_outline),
title: Text('About'),
PopupMenuItem<String>(
value: 'Select Folder',
child: ListTile(
leading: Icon(Icons.folder_shared_outlined),
title: Text('Set Notes Directory'),
),
),
),
],
PopupMenuDivider(),
PopupMenuItem<String>(
value: 'Empty Trash',
child: ListTile(
leading: Icon(Icons.delete, color: Colors.redAccent),
title: Text('Empty Trash',
style: TextStyle(color: Colors.redAccent)),
),
),
PopupMenuDivider(),
PopupMenuItem<String>(
value: 'Help',
child: ListTile(
leading: Icon(Icons.help_outline),
title: Text('Help'),
),
),
PopupMenuItem<String>(
value: 'About',
child: ListTile(
leading: Icon(Icons.info_outline),
title: Text('About'),
),
),
],
),
),
),
],
@ -783,11 +853,6 @@ class _NoteListScreenState extends State<NoteListScreen> {
}
final noteFile = filteredNotes[index];
final noteContent = _includeFileContent ? noteFile.readAsStringSync() : ""; // Read content only if needed
final tagSpans = _formatTags(noteContent).isNotEmpty
? _formatTags(noteContent)
: [TextSpan(text: '')];
return Dismissible(
key: Key(noteFile.path),
direction: DismissDirection.endToStart,
@ -836,7 +901,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
if (result.stdout.toString().trim().isNotEmpty) {
await Process.run('xdg-open', [noteFile.path]);
} else {
print("❌ `xdg-open` is not available.");
debugPrint("❌ `xdg-open` is not available.");
}
} else if (Platform.isAndroid) {
await _requestStoragePermission();
@ -844,10 +909,10 @@ class _NoteListScreenState extends State<NoteListScreen> {
noteFile.path,
type: "text/markdown",
);
print("✅ OpenFilex result: ${result.type}, message: ${result.message}");
debugPrint("✅ OpenFilex result: ${result.type}, message: ${result.message}");
}
} catch (e) {
print("❌ Failed to open external editor: $e");
debugPrint("❌ Failed to open external editor: $e");
}
} else {
await Navigator.push(