- note_editor: persist font size to SharedPreferences on change and restore it on load - note_list: add :fav (toggle favorites filter), :sub (toggle subdirectory search), :content (toggle file content search) commands - texts: document new commands in help content Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
401 lines
13 KiB
Dart
Executable file
401 lines
13 KiB
Dart
Executable file
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_code_editor/flutter_code_editor.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 'themes/markdown.dart';
|
|
import 'codefield.dart';
|
|
|
|
class NoteEditorScreen extends StatefulWidget {
|
|
final File note;
|
|
final String? notesDirectoryPath;
|
|
final bool isDarkMode;
|
|
final NoteMode initialMode;
|
|
final bool scrollToEnd;
|
|
|
|
const NoteEditorScreen({
|
|
super.key,
|
|
required this.note,
|
|
required this.isDarkMode,
|
|
required this.notesDirectoryPath,
|
|
this.initialMode = NoteMode.view,
|
|
this.scrollToEnd = false,
|
|
});
|
|
|
|
@override
|
|
State<NoteEditorScreen> createState() => _NoteEditorScreenState();
|
|
}
|
|
|
|
enum NoteMode { view, edit, preview }
|
|
|
|
class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|
late CodeController _controller;
|
|
late String _initialContent;
|
|
late NoteMode _mode;
|
|
final FocusNode _editorFocusNode = FocusNode();
|
|
final ScrollController _scrollController = ScrollController();
|
|
bool _isLoading = true;
|
|
bool _autoSaveEnabled = false;
|
|
double _fontSize = 16.0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_mode = widget.initialMode;
|
|
_loadPreferences();
|
|
_loadNoteContent();
|
|
}
|
|
|
|
Future<void> _loadPreferences() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
setState(() {
|
|
_autoSaveEnabled = prefs.getBool('auto_save_enabled') ?? false;
|
|
_fontSize = prefs.getDouble('font_size') ?? 16.0;
|
|
});
|
|
}
|
|
|
|
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) {
|
|
debugPrint("❌ Failed to read file: ${widget.note.path}");
|
|
_initialContent = "";
|
|
}
|
|
|
|
setState(() {
|
|
_controller = CodeController(
|
|
text: _initialContent,
|
|
language: markdown,
|
|
);
|
|
_controller.addListener(_onTextChanged);
|
|
_isLoading = false;
|
|
});
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (widget.initialMode == NoteMode.edit) {
|
|
_controller.selection =
|
|
TextSelection.collapsed(offset: _controller.text.length);
|
|
_editorFocusNode.requestFocus();
|
|
} else if (widget.scrollToEnd) {
|
|
// Two frames: first renders content, second has a stable maxScrollExtent.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
|
|
}
|
|
});
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
|
}
|
|
}
|
|
|
|
void _onTextChanged() {
|
|
if (_mode == NoteMode.edit && _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
|
|
|
|
debugPrint("✅ Note saved successfully");
|
|
} catch (e) {
|
|
debugPrint("❌ Error saving note: ${widget.note.path}");
|
|
}
|
|
}
|
|
|
|
void _shareNote() {
|
|
if (_controller.text.isNotEmpty) {
|
|
SharePlus.instance.share(ShareParams(
|
|
text: _controller.text,
|
|
subject: widget.note.uri.pathSegments.last,
|
|
));
|
|
}
|
|
}
|
|
|
|
Future<bool> _onWillPop() async {
|
|
if (_mode != NoteMode.edit || _controller.text == _initialContent) return true;
|
|
|
|
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();
|
|
if (context.mounted) Navigator.of(context).pop(true);
|
|
},
|
|
child: Text("Save"),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false), // Stay on page
|
|
child: Text("Cancel"),
|
|
),
|
|
],
|
|
),
|
|
) ??
|
|
false;
|
|
}
|
|
|
|
void _updateFontSize(double newSize) {
|
|
setState(() {
|
|
_fontSize = newSize;
|
|
});
|
|
SharedPreferences.getInstance().then((prefs) => prefs.setDouble('font_size', newSize));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.removeListener(_onTextChanged);
|
|
_controller.dispose();
|
|
_editorFocusNode.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (bool didPop, Object? result) async {
|
|
if (didPop) return;
|
|
final bool shouldPop = await _onWillPop();
|
|
if (shouldPop && context.mounted) Navigator.of(context).pop();
|
|
},
|
|
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()) {
|
|
if (context.mounted) Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
actions: [
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 15),
|
|
child: PopupMenuButton<String>(
|
|
icon: SvgPicture.asset(
|
|
"assets/blaster.svg",
|
|
width: 30,
|
|
colorFilter: const ColorFilter.mode(Colors.orange, BlendMode.srcIn),
|
|
),
|
|
onSelected: (String value) {
|
|
switch (value) {
|
|
case 'Share': // ✅ New case for sharing
|
|
_shareNote();
|
|
break;
|
|
case 'Save':
|
|
_saveNote();
|
|
break;
|
|
case 'Toggle Autosave':
|
|
_setAutoSaveEnabled(!_autoSaveEnabled);
|
|
break;
|
|
case 'Mode View':
|
|
setState(() { _mode = NoteMode.view; });
|
|
break;
|
|
case 'Mode Edit':
|
|
setState(() { _mode = NoteMode.edit; });
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_controller.selection = TextSelection.collapsed(
|
|
offset: _controller.text.length);
|
|
_editorFocusNode.requestFocus();
|
|
_scrollToBottom();
|
|
});
|
|
break;
|
|
case 'Mode Preview':
|
|
setState(() { _mode = NoteMode.preview; });
|
|
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',
|
|
enabled: _mode == NoteMode.edit,
|
|
child: ListTile(
|
|
leading: Icon(Icons.save_outlined),
|
|
title: Text("Save"),
|
|
),
|
|
),
|
|
PopupMenuItem<String>(
|
|
value: 'Toggle Autosave',
|
|
enabled: _mode == NoteMode.edit,
|
|
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) {
|
|
return 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: 'Mode View',
|
|
child: ListTile(
|
|
leading: Icon(_mode == NoteMode.view
|
|
? Icons.check
|
|
: Icons.book_outlined),
|
|
title: Text("View Mode"),
|
|
),
|
|
),
|
|
PopupMenuItem<String>(
|
|
value: 'Mode Edit',
|
|
child: ListTile(
|
|
leading: Icon(_mode == NoteMode.edit
|
|
? Icons.check
|
|
: Icons.edit_note_outlined),
|
|
title: Text("Edit Mode"),
|
|
),
|
|
),
|
|
PopupMenuItem<String>(
|
|
value: 'Mode Preview',
|
|
child: ListTile(
|
|
leading: Icon(_mode == NoteMode.preview
|
|
? Icons.check
|
|
: Icons.visibility_outlined),
|
|
title: Text("Preview Mode"),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: _isLoading
|
|
? Center(child: CircularProgressIndicator())
|
|
: _mode == NoteMode.preview
|
|
? Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: SingleChildScrollView(
|
|
child: MarkdownBody(
|
|
builders: {
|
|
'latex': LatexElementBuilder(
|
|
textStyle: TextStyle(
|
|
color: widget.isDarkMode
|
|
? Colors.white
|
|
: Colors.black)),
|
|
},
|
|
extensionSet: md.ExtensionSet(
|
|
[LatexBlockSyntax()],
|
|
[LatexInlineSyntax()],
|
|
),
|
|
data: _controller.text,
|
|
selectable: true,
|
|
styleSheet: MarkdownStyleSheet(
|
|
p: TextStyle(
|
|
fontSize: _fontSize,
|
|
color: widget.isDarkMode
|
|
? Colors.white
|
|
: Colors.black),
|
|
h1: TextStyle(
|
|
fontSize: _fontSize + 8,
|
|
color: widget.isDarkMode
|
|
? Colors.white
|
|
: Colors.black,
|
|
fontWeight: FontWeight.bold),
|
|
h2: TextStyle(
|
|
fontSize: _fontSize + 6,
|
|
color: widget.isDarkMode
|
|
? Colors.white
|
|
: Colors.black,
|
|
fontWeight: FontWeight.bold),
|
|
h3: TextStyle(
|
|
fontSize: _fontSize + 4,
|
|
color: widget.isDarkMode
|
|
? 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: CodeTheme(
|
|
data: CodeThemeData(
|
|
styles: widget.isDarkMode ? nightTheme : dayTheme),
|
|
child: SingleChildScrollView(
|
|
controller: _scrollController,
|
|
child: WrappedCodeField(
|
|
wrap: true,
|
|
controller: _controller,
|
|
readOnly: _mode == NoteMode.view,
|
|
focusNode: _editorFocusNode,
|
|
textStyle: TextStyle(
|
|
fontSize: _fontSize,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|