import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown_latex/flutter_markdown_latex.dart'; import 'package:flutter_svg/svg.dart'; import 'package:markdown/markdown.dart' as md; import 'package:shared_preferences/shared_preferences.dart'; import 'package:share_plus/share_plus.dart'; import 'themes/day.dart'; import 'themes/night.dart'; import 'markdown_editing.dart'; import 'markdown_view.dart'; import 'shortcuts.dart'; class NoteEditorScreen extends StatefulWidget { final File note; final String? notesDirectoryPath; // ✅ Add this final List searchTerms; final Future Function() onQuit; NoteEditorScreen( {required this.note, required this.notesDirectoryPath, this.searchTerms = const [], this.onQuit = quitApp}); @override _NoteEditorScreenState createState() => _NoteEditorScreenState(); } class _NoteEditorScreenState extends State { late MarkdownController _controller; late String _initialContent; final _viewKey = GlobalKey(); final _editorFocus = FocusNode(); final _screenFocus = FocusNode(); final _sourceScroll = ScrollController(); final _previewScroll = ScrollController(); Timer? _centerHold; final _menuKey = GlobalKey>(); bool _isEditing = false; bool _isPreviewMode = false; bool _isLoading = true; bool _autoSaveEnabled = false; // Controlled by user toggle double _fontSize = 16.0; @override void initState() { super.initState(); _loadPreferences(); _loadNoteContent(); } Future _loadPreferences() async { final prefs = await SharedPreferences.getInstance(); setState(() { _autoSaveEnabled = prefs.getBool('auto_save_enabled') ?? false; }); } Future _setAutoSaveEnabled(bool value) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool('auto_save_enabled', value); setState(() { _autoSaveEnabled = value; }); } Future _loadNoteContent() async { try { _initialContent = await widget.note.readAsString(); } catch (e) { print("❌ Failed to read file: ${widget.note.path}"); _initialContent = ""; } setState(() { _controller = MarkdownController( text: _initialContent, theme: _styles, )..search(widget.searchTerms); _controller.addListener(_onTextChanged); _isLoading = false; }); WidgetsBinding.instance.addPostFrameCallback((_) => _showMatch(0)); } void _showMatch(int index) { final matches = _controller.matches; if (matches.isEmpty) return; final current = index % matches.length; _controller.selectMatch(current); _viewKey.currentState?.revealOffset(matches[current].start); } void _toggleEditing() { setState(() { _isEditing = !_isEditing; _isPreviewMode = false; }); if (_isEditing) { // Without a caret, focusing would put it at the end and scroll there. if (!_controller.selection.isValid) { final offset = _viewKey.currentState?.firstVisibleOffset() ?? 0; _controller.selection = TextSelection.collapsed(offset: offset); } WidgetsBinding.instance .addPostFrameCallback((_) => _editorFocus.requestFocus()); } else { // Keep focus inside the screen so its shortcuts stay active. _screenFocus.requestFocus(); } } void _togglePreview() { setState(() { _isPreviewMode = !_isPreviewMode; }); if (_isPreviewMode) _screenFocus.requestFocus(); } void _stepFontSize(double step) { _updateFontSize((_fontSize + step).clamp(12.0, 24.0)); } void _toggleEmphasis(String marker) { if (_isEditing && !_isPreviewMode) { _controller.value = toggleEmphasis(_controller.value, marker); } } ScrollController get _activeScroll => _isPreviewMode ? _previewScroll : _sourceScroll; void _scroll(double lines) => _scrollBy(lines * _fontSize * 1.5); void _scrollPage(int direction) { if (!_activeScroll.hasClients) return; // Keep a little of the previous page in view for context. _scrollBy(direction * _activeScroll.position.viewportDimension * 0.9); } void _scrollBy(double pixels) { if (!_activeScroll.hasClients) return; final position = _activeScroll.position; _activeScroll.jumpTo((position.pixels + pixels) .clamp(position.minScrollExtent, position.maxScrollExtent)); } bool get _isDark => Theme.of(context).brightness == Brightness.dark; Map get _styles => _isDark ? nightTheme : dayTheme; Future _backToList() async { if (await _onWillPop() && mounted) Navigator.pop(context); } /// Holding the D-pad center button goes back to the list. KeyEventResult _onCenterKey(FocusNode node, KeyEvent event) { if (event.logicalKey != LogicalKeyboardKey.select) { return KeyEventResult.ignored; } if (event is KeyDownEvent) { _centerHold = Timer(const Duration(milliseconds: 500), _backToList); } else if (event is KeyUpEvent) { _centerHold?.cancel(); } return KeyEventResult.handled; } Future _quit() async { if (await _onWillPop()) await widget.onQuit(); } Future _escape() async { if (_isPreviewMode) { _togglePreview(); } else if (_isEditing) { _toggleEditing(); } else { await _backToList(); } } Map get _shortcuts { const primary = primaryShortcut; void previous() => _showMatch(_controller.currentMatch - 1); void next() => _showMatch(_controller.currentMatch + 1); return { quitShortcut: _quit, primary(LogicalKeyboardKey.keyL): _backToList, primary(LogicalKeyboardKey.keyE): _toggleEditing, primary(LogicalKeyboardKey.keyS): _saveNote, primary(LogicalKeyboardKey.keyP): _togglePreview, const SingleActivator(LogicalKeyboardKey.escape): _escape, primary(LogicalKeyboardKey.keyB): () => _toggleEmphasis('**'), primary(LogicalKeyboardKey.keyI): () => _toggleEmphasis('*'), const SingleActivator(LogicalKeyboardKey.f3): next, const SingleActivator(LogicalKeyboardKey.f3, shift: true): previous, primary(LogicalKeyboardKey.keyG): next, primary(LogicalKeyboardKey.keyG, shift: true): previous, primary(LogicalKeyboardKey.equal): () => _stepFontSize(2), primary(LogicalKeyboardKey.equal, shift: true): () => _stepFontSize(2), primary(LogicalKeyboardKey.numpadAdd): () => _stepFontSize(2), primary(LogicalKeyboardKey.minus): () => _stepFontSize(-2), primary(LogicalKeyboardKey.numpadSubtract): () => _stepFontSize(-2), primary(LogicalKeyboardKey.digit0): () => _updateFontSize(16), // In edit mode the arrows move the caret instead. if ((_isPreviewMode || !_isEditing) && dpadNavigation) ...{ const SingleActivator(LogicalKeyboardKey.arrowLeft): _backToList, const SingleActivator(LogicalKeyboardKey.arrowRight): () => openMenuWithFocus(_menuKey), }, if (_isPreviewMode || !_isEditing) ...{ const SingleActivator(LogicalKeyboardKey.arrowDown): () => _scroll(3), const SingleActivator(LogicalKeyboardKey.arrowUp): () => _scroll(-3), const SingleActivator(LogicalKeyboardKey.pageDown): () => _scrollPage(1), const SingleActivator(LogicalKeyboardKey.pageUp): () => _scrollPage(-1), }, }; } void _onTextChanged() { if (_autoSaveEnabled && _controller.text != _initialContent) { _saveNote(); } } Future _saveNote() async { try { if (_controller.text == _initialContent) return; // No change, skip saving await widget.note.writeAsString(_controller.text); _initialContent = _controller.text; // Update initial state print("✅ Note saved successfully"); } catch (e) { print("❌ Error saving note: ${widget.note.path}"); } } void _shareNote() { if (_controller.text.isNotEmpty) { Share.share(_controller.text, subject: widget.note.uri.pathSegments.last); } } Future _onWillPop() async { if (_controller.text == _initialContent) return true; // No changes, exit return await showDialog( context: context, builder: (context) => AlertDialog( title: Text("Save Changes?"), content: Text("You have unsaved changes. Save before exiting?"), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(true), // Discard and exit child: Text("Discard"), ), TextButton( onPressed: () async { await _saveNote(); Navigator.of(context).pop(true); // Save and exit }, child: Text("Save"), ), TextButton( onPressed: () => Navigator.of(context).pop(false), // Stay on page child: Text("Cancel"), ), ], ), ) ?? false; } void _updateFontSize(double newSize) { setState(() { _fontSize = newSize; }); } @override void dispose() { _controller.removeListener(_onTextChanged); _controller.dispose(); _editorFocus.dispose(); _screenFocus.dispose(); _sourceScroll.dispose(); _previewScroll.dispose(); _centerHold?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { // Follow theme switches made while the note is open. if (!_isLoading) _controller.theme = _styles; return WillPopScope( onWillPop: _onWillPop, child: CallbackShortcuts( bindings: _isLoading ? const {} : _shortcuts, child: Focus( focusNode: _screenFocus, autofocus: true, onKeyEvent: _onCenterKey, child: Scaffold( appBar: AppBar( title: Text(widget.note.uri.pathSegments.last.replaceAll('.md', '')), leading: IconButton( icon: Icon(Icons.arrow_back), onPressed: () async { if (await _onWillPop()) { Navigator.pop(context); } }, ), actions: [ IconButton( icon: Icon( _isEditing ? Icons.edit_off_outlined : Icons.edit_outlined), onPressed: _isLoading ? null : _toggleEditing, ), Padding( padding: EdgeInsets.symmetric(horizontal: 15), child: PopupMenuButton( key: _menuKey, icon: SvgPicture.asset( "assets/blaster.svg", width: 30, color: Colors.orange, ), onSelected: (String value) { switch (value) { case 'Share': // ✅ New case for sharing _shareNote(); break; case 'Save': _saveNote(); break; case 'Toggle Autosave': _setAutoSaveEnabled(!_autoSaveEnabled); break; case 'Toggle Preview': _togglePreview(); break; } }, itemBuilder: (BuildContext context) => [ PopupMenuItem( // ✅ Share button value: 'Share', child: ListTile( leading: Icon(Icons.share_outlined), title: Text("Share"), ), ), PopupMenuDivider(), PopupMenuItem( value: 'Save', child: ListTile( leading: Icon(Icons.save_outlined), title: Text("Save"), ), ), PopupMenuItem( value: 'Toggle Autosave', child: ListTile( leading: Icon(_autoSaveEnabled ? Icons.alarm_off : Icons.alarm_on), title: Text(_autoSaveEnabled ? "Disable Autosave" : "Enable Autosave"), ), ), PopupMenuDivider(), PopupMenuItem( enabled: false, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Font Size'), StatefulBuilder( builder: (context, setState) { // Directional mode leaves Up/Down to the D-pad, so // focus can move past the slider; Left/Right adjust it. return MediaQuery( data: MediaQuery.of(context).copyWith( navigationMode: dpadNavigation ? NavigationMode.directional : NavigationMode.traditional), child: Slider( value: _fontSize, min: 12.0, max: 24.0, divisions: 6, label: _fontSize.toString(), onChanged: (newSize) { setState(() { _fontSize = newSize; }); _updateFontSize(newSize); }, ), ); }, ), ], ), ), PopupMenuDivider(), PopupMenuItem( value: 'Toggle Preview', child: ListTile( leading: Icon(_isPreviewMode ? Icons.edit_note_outlined : Icons.visibility_outlined), title: Text(_isPreviewMode ? "Edit Mode" : "Preview Mode"), ), ), ], ), ), ], ), body: _isLoading ? Center(child: CircularProgressIndicator()) : _isPreviewMode ? Padding( padding: const EdgeInsets.all(16.0), child: SingleChildScrollView( controller: _previewScroll, child: MarkdownBody( builders: { 'latex': LatexElementBuilder( textStyle: TextStyle( color: _isDark ? Colors.white : Colors.black)), }, extensionSet: md.ExtensionSet( [LatexBlockSyntax()], [LatexInlineSyntax()], ), data: _controller.text, selectable: true, styleSheet: MarkdownStyleSheet( p: TextStyle( fontSize: _fontSize, color: _isDark ? Colors.white : Colors.black), h1: TextStyle( fontSize: _fontSize + 8, color: _isDark ? Colors.white : Colors.black, fontWeight: FontWeight.bold), h2: TextStyle( fontSize: _fontSize + 6, color: _isDark ? Colors.white : Colors.black, fontWeight: FontWeight.bold), h3: TextStyle( fontSize: _fontSize + 4, color: _isDark ? Colors.white : Colors.black, fontWeight: FontWeight.bold), code: TextStyle( fontSize: _fontSize, backgroundColor: Colors.transparent), codeblockDecoration: BoxDecoration( color: Colors.transparent), ), ), ), ) : Padding( padding: const EdgeInsets.all(16.0), child: MarkdownView( key: _viewKey, controller: _controller, readOnly: !_isEditing, fontSize: _fontSize, focusNode: _editorFocus, scrollController: _sourceScroll, ), ), floatingActionButton: _isLoading || _isPreviewMode ? null : ListenableBuilder( listenable: _controller, builder: (context, _) => _controller.matches.length > 1 ? Column( mainAxisSize: MainAxisSize.min, children: [ FloatingActionButton.small( heroTag: 'previous-match', onPressed: () => _showMatch(_controller.currentMatch - 1), child: Icon(Icons.keyboard_arrow_up), ), SizedBox(height: 8), FloatingActionButton.small( heroTag: 'next-match', onPressed: () => _showMatch(_controller.currentMatch + 1), child: Icon(Icons.keyboard_arrow_down), ), ], ) : SizedBox.shrink(), ), ), ), ), ); } }