527 lines
18 KiB
Dart
527 lines
18 KiB
Dart
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<String> searchTerms;
|
|
final Future<void> Function() onQuit;
|
|
|
|
NoteEditorScreen(
|
|
{required this.note,
|
|
required this.notesDirectoryPath,
|
|
this.searchTerms = const [],
|
|
this.onQuit = quitApp});
|
|
|
|
@override
|
|
_NoteEditorScreenState createState() => _NoteEditorScreenState();
|
|
}
|
|
|
|
class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|
late MarkdownController _controller;
|
|
late String _initialContent;
|
|
final _viewKey = GlobalKey<MarkdownViewState>();
|
|
final _editorFocus = FocusNode();
|
|
final _screenFocus = FocusNode();
|
|
final _sourceScroll = ScrollController();
|
|
final _previewScroll = ScrollController();
|
|
Timer? _centerHold;
|
|
final _menuKey = GlobalKey<PopupMenuButtonState<String>>();
|
|
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<void> _loadPreferences() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
setState(() {
|
|
_autoSaveEnabled = prefs.getBool('auto_save_enabled') ?? false;
|
|
});
|
|
}
|
|
|
|
Future<void> _setAutoSaveEnabled(bool value) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setBool('auto_save_enabled', value);
|
|
setState(() {
|
|
_autoSaveEnabled = value;
|
|
});
|
|
}
|
|
|
|
Future<void> _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<String, TextStyle> get _styles => _isDark ? nightTheme : dayTheme;
|
|
|
|
Future<void> _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<void> _quit() async {
|
|
if (await _onWillPop()) await widget.onQuit();
|
|
}
|
|
|
|
Future<void> _escape() async {
|
|
if (_isPreviewMode) {
|
|
_togglePreview();
|
|
} else if (_isEditing) {
|
|
_toggleEditing();
|
|
} else {
|
|
await _backToList();
|
|
}
|
|
}
|
|
|
|
Map<ShortcutActivator, VoidCallback> 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<void> _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<bool> _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<String>(
|
|
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<String>( // ✅ Share button
|
|
value: 'Share',
|
|
child: ListTile(
|
|
leading: Icon(Icons.share_outlined),
|
|
title: Text("Share"),
|
|
),
|
|
),
|
|
PopupMenuDivider(),
|
|
PopupMenuItem<String>(
|
|
value: 'Save',
|
|
child: ListTile(
|
|
leading: Icon(Icons.save_outlined),
|
|
title: Text("Save"),
|
|
),
|
|
),
|
|
PopupMenuItem<String>(
|
|
value: 'Toggle Autosave',
|
|
child: ListTile(
|
|
leading: Icon(_autoSaveEnabled
|
|
? Icons.alarm_off
|
|
: Icons.alarm_on),
|
|
title: Text(_autoSaveEnabled
|
|
? "Disable Autosave"
|
|
: "Enable Autosave"),
|
|
),
|
|
),
|
|
PopupMenuDivider(),
|
|
PopupMenuItem<String>(
|
|
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<String>(
|
|
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(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|