Long-pressing a note in the list opens it in view mode with the scroll position at the bottom of the file. Switching to edit mode the first time positions the cursor at the end of the text and scrolls there too, so the user can immediately append content. Implementation: - NoteEditorScreen gains a scrollToEnd parameter (default false) - A ScrollController is attached to the view/edit SingleChildScrollView - After content loads with scrollToEnd=true, two post-frame callbacks fire (first renders the content, second has a stable maxScrollExtent) then jumpTo(maxScrollExtent) - _cursorAtEnd flag is consumed on the first Mode Edit switch: sets selection to text.length, requests focus, and scrolls to bottom - NoteListScreen ListTile gets onLongPress that navigates with scrollToEnd: true and reloads on return Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
406 lines
13 KiB
Dart
Executable file
406 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;
|
|
bool _cursorAtEnd = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_mode = widget.initialMode;
|
|
_cursorAtEnd = widget.scrollToEnd;
|
|
_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) {
|
|
debugPrint("❌ Failed to read file: ${widget.note.path}");
|
|
_initialContent = "";
|
|
}
|
|
|
|
setState(() {
|
|
_controller = CodeController(
|
|
text: _initialContent,
|
|
language: markdown,
|
|
);
|
|
_controller.addListener(_onTextChanged);
|
|
_isLoading = false;
|
|
});
|
|
|
|
if (widget.initialMode == NoteMode.edit) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_controller.selection =
|
|
TextSelection.collapsed(offset: _controller.text.length);
|
|
_editorFocusNode.requestFocus();
|
|
});
|
|
} else if (widget.scrollToEnd) {
|
|
// Two frames: first renders the content, second has a stable maxScrollExtent.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
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;
|
|
});
|
|
}
|
|
|
|
@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; });
|
|
if (_cursorAtEnd) {
|
|
_cursorAtEnd = false; // consume once
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|