diff --git a/android/app/build.gradle b/android/app/build.gradle index 42606da..8e29607 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -9,6 +9,7 @@ android { namespace = "com.example.muzzlevelocity" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion + compileSdkVersion 34 compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 @@ -28,6 +29,7 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + targetSdkVersion 34 } buildTypes { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2c16406..ba0b42b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,14 @@ + + + + + + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true"> + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/muzzlevelocity/MainActivity.kt b/android/app/src/main/kotlin/com/example/muzzlevelocity/MainActivity.kt index 46cc26b..ce0d7fc 100644 --- a/android/app/src/main/kotlin/com/example/muzzlevelocity/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/muzzlevelocity/MainActivity.kt @@ -1,5 +1,11 @@ package com.example.muzzlevelocity -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugins.GeneratedPluginRegistrant -class MainActivity: FlutterActivity() +class MainActivity: FlutterFragmentActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + GeneratedPluginRegistrant.registerWith(flutterEngine) + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 324f718..e168e24 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,19 @@ 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 'search_service.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() { + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Permission.storage.request(); runApp(MuzzleVelocityApp()); } @@ -44,8 +52,9 @@ class _MuzzleVelocityAppState extends State { } Future _emptyTrash() async { - final dir = await getApplicationDocumentsDirectory(); - final trashDir = Directory('${dir.path}/notes/trash'); + 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(); @@ -60,13 +69,13 @@ class _MuzzleVelocityAppState extends State { brightness: Brightness.light, scaffoldBackgroundColor: Color(0xffF6F4F1), appBarTheme: AppBarTheme(backgroundColor: Color(0xffF6F4F1)), - textTheme: GoogleFonts.ibmPlexMonoTextTheme(), + textTheme: GoogleFonts.ibmPlexMonoTextTheme(dayText), ), darkTheme: ThemeData( brightness: Brightness.dark, scaffoldBackgroundColor: Color(0xFF121212), appBarTheme: AppBarTheme(backgroundColor: Color(0xFF121212)), - textTheme: GoogleFonts.ibmPlexMonoTextTheme(), + textTheme: GoogleFonts.ibmPlexMonoTextTheme(nightText), ), themeMode: _themeMode, home: NoteListScreen( @@ -103,52 +112,62 @@ class _NoteListScreenState extends State { TextEditingController searchController = TextEditingController(); List notes = []; List filteredNotes = []; + String? notesDirectoryPath; + String? defaultDir; @override void initState() { super.initState(); - _loadNotes(); + _initializeApp(); // Call an async method to handle initialization searchController.addListener(() { final text = searchController.text.trim(); - if (text.startsWith(':')) { _handleCommand(text); } else { - _filterNotes(); // Only filter, do NOT create a note here! + _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'); } - Future _createNewNote(String title) async { - final dir = await getApplicationDocumentsDirectory(); - final notesDir = Directory('${dir.path}/notes'); + void _createNewNote(String title) async { + try { + final safeTitle = _sanitizeFilename(title); + final newNote = File(path.join(notesDirectoryPath!, '$safeTitle.md')); - 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, + 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'), ), ); } @@ -210,20 +229,57 @@ class _NoteListScreenState extends State { Future _loadNotes() async { - final dir = await getApplicationDocumentsDirectory(); - final notesDir = Directory('${dir.path}/notes'); + 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(); - if (mounted) { - setState(() { - notes = files; - filteredNotes = files; - }); - } + 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(() { @@ -279,6 +335,38 @@ class _NoteListScreenState extends State { 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( @@ -322,6 +410,9 @@ class _NoteListScreenState extends State { widget.toggleTheme(!widget.isDarkMode); }); break; + case 'Select Folder': + _showDirectoryChoiceDialog(notesDirectoryPath ?? ''); // Re-trigger popup + break; case 'Empty Trash': _confirmEmptyTrash(); break; @@ -346,6 +437,13 @@ class _NoteListScreenState extends State { ), ), ), + PopupMenuItem( + value: 'Select Folder', + child: ListTile( + leading: Icon(Icons.folder), + title: Text('Set Notes Directory'), + ), + ), PopupMenuItem( value: 'Empty Trash', child: ListTile( @@ -386,14 +484,16 @@ class _NoteListScreenState extends State { await Navigator.push( context, MaterialPageRoute( - builder: (context) => NoteEditorScreen( - note: noteFile, - isDarkMode: widget.isDarkMode, - ), + 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 + _loadNotes(); // Reload notes after returning + setState(() {}); // Force UI refresh }, ), ); diff --git a/lib/note_editor.dart b/lib/note_editor.dart index 03652ef..44dad8b 100644 --- a/lib/note_editor.dart +++ b/lib/note_editor.dart @@ -7,18 +7,21 @@ 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; - NoteEditorScreen({required this.note, required this.isDarkMode}); + NoteEditorScreen({required this.note, required this.isDarkMode, required this.notesDirectoryPath}); @override _NoteEditorScreenState createState() => _NoteEditorScreenState(); } class _NoteEditorScreenState extends State { + bool isLoading = true; bool _isPreviewMode = false; // Default: Editing mode late CodeController _controller; double _fontSize = 16.0; // Default font size @@ -37,7 +40,13 @@ class _NoteEditorScreenState extends State { } Future _loadNoteContent() async { - final content = await widget.note.readAsString(); + String content = ""; + try { + content = await widget.note.readAsString(); + } catch (e) { + print("āŒ Failed to read file: ${widget.note.path}"); + } + setState(() { _controller = CodeController( text: content, @@ -45,11 +54,26 @@ class _NoteEditorScreenState extends State { ); _controller.addListener(_saveNote); _controller.popupController.enabled = false; + isLoading = false; }); } + Future _saveNote() async { - await widget.note.writeAsString(_controller.text); + try { + 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); + await newFile.writeAsString(_controller.text); + + print("āœ… Successfully saved note at: $newFilePath"); + } catch (e, stackTrace) { + print("āŒ Error saving note: ${widget.note.path}"); + print("āš ļø Exception: $e"); + print("šŸ›  Stack trace: $stackTrace"); + } } @override @@ -118,10 +142,12 @@ class _NoteEditorScreenState extends State { ), ], ), - body: _controller == null - ? Center(child: CircularProgressIndicator()) - : _isPreviewMode - ? Padding( + 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( diff --git a/lib/themes/day.dart b/lib/themes/day.dart index b3398c0..7033e5f 100644 --- a/lib/themes/day.dart +++ b/lib/themes/day.dart @@ -3,6 +3,24 @@ import 'package:flutter/material.dart'; import 'package:flutter/painting.dart'; +const TextTheme dayText = TextTheme( + titleLarge: TextStyle(color: Color(0xff333333)), + bodyLarge: TextStyle(color: Color(0xff333333)), + bodyMedium: TextStyle(color: Color(0xff333333)), + bodySmall: TextStyle(color: Color(0xff333333)), + displayLarge: TextStyle(color: Color(0xff333333)), + displayMedium: TextStyle(color: Color(0xff333333)), + displaySmall: TextStyle(color: Color(0xff333333)), + headlineLarge: TextStyle(color: Color(0xff333333)), + headlineMedium: TextStyle(color: Color(0xff333333)), + headlineSmall: TextStyle(color: Color(0xff333333)), + labelLarge: TextStyle(color: Color(0xff333333)), + labelMedium: TextStyle(color: Color(0xff333333)), + labelSmall: TextStyle(color: Color(0xff333333)), + titleMedium: TextStyle(color: Color(0xff333333)), + titleSmall: TextStyle(color: Color(0xff333333)), +); + const dayTheme = { 'root': TextStyle(backgroundColor: Color(0xffF6F4F1), color: Color(0xff333333)), diff --git a/lib/themes/night.dart b/lib/themes/night.dart index d1ac776..8f6bc79 100644 --- a/lib/themes/night.dart +++ b/lib/themes/night.dart @@ -3,6 +3,24 @@ import 'package:flutter/material.dart'; import 'package:flutter/painting.dart'; +const TextTheme nightText = TextTheme( + titleLarge: TextStyle(color: Color(0xffc5c8c6)), + bodyLarge: TextStyle(color: Color(0xffc5c8c6)), + bodyMedium: TextStyle(color: Color(0xffc5c8c6)), + bodySmall: TextStyle(color: Color(0xffc5c8c6)), + displayLarge: TextStyle(color: Color(0xffc5c8c6)), + displayMedium: TextStyle(color: Color(0xffc5c8c6)), + displaySmall: TextStyle(color: Color(0xffc5c8c6)), + headlineLarge: TextStyle(color: Color(0xffc5c8c6)), + headlineMedium: TextStyle(color: Color(0xffc5c8c6)), + headlineSmall: TextStyle(color: Color(0xffc5c8c6)), + labelLarge: TextStyle(color: Color(0xffc5c8c6)), + labelMedium: TextStyle(color: Color(0xffc5c8c6)), + labelSmall: TextStyle(color: Color(0xffc5c8c6)), + titleMedium: TextStyle(color: Color(0xffc5c8c6)), + titleSmall: TextStyle(color: Color(0xffc5c8c6)), +); + const nightTheme = { 'comment': TextStyle(color: Color(0xff969896)), 'quote': TextStyle(color: Color(0xff969896)), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a1cdfd0..b832e00 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,14 @@ import FlutterMacOS import Foundation +import file_picker import path_provider_foundation +import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 263d3dc..af38ab8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: dependency: transitive description: @@ -105,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "6f6bfa8797f296965bdc3e1f702574ab49a540c19b9237b401e7c2b25dfe594c" + url: "https://pub.dev" + source: hosted + version: "9.0.0" flutter: dependency: "direct main" description: flutter @@ -158,6 +182,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.3" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" + url: "https://pub.dev" + source: hosted + version: "2.0.24" flutter_svg: dependency: transitive description: @@ -368,6 +400,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98 + url: "https://pub.dev" + source: hosted + version: "9.4.6" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -408,6 +488,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "846849e3e9b68f3ef4b60c60cf4b3e02e9321bc7f4d8c4692cf87ffa82fc8a3a" + url: "https://pub.dev" + source: hosted + version: "2.5.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: a768fc8ede5f0c8e6150476e14f38e2417c0864ca36bb4582be8e21925a03c22 + url: "https://pub.dev" + source: hosted + version: "2.4.6" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -589,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.dev" + source: hosted + version: "5.10.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b1db1d0..3d2f2d7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,9 @@ dependencies: flutter_code_editor: ^0.3.2 flutter_markdown: ^0.7.6+2 flutter_markdown_latex: ^0.3.4 + permission_handler: ^11.4.0 + shared_preferences: ^2.5.2 + file_picker: ^9.0.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4f78848..a0d0bbe 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 88b22e5..c20a586 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + permission_handler_windows url_launcher_windows )