muzzle-velocity/lib/note_editor.dart
randogoth db13702e54 add long-press gesture to open note scrolled to end
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>
2026-06-19 17:50:56 +03:00

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,
),
),
),
),
),
),
);
}
}