import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../theme.dart'; /// Click-to-edit cell. Shows [displayText] normally; on click, shows a /// TextField pre-filled with [editText]. Enter/blur saves, Escape reverts. /// Green border when editing, red border on validation error. /// /// Set [autoEdit] to true to start in edit mode on first build (for newly /// created items). class InlineEditCell extends StatefulWidget { final String displayText; final String editText; final ValueChanged onSaved; final String? Function(String)? validator; final TextStyle? style; final TextAlign textAlign; final TextInputType keyboardType; final bool autoEdit; const InlineEditCell({ super.key, required this.displayText, required this.editText, required this.onSaved, this.validator, this.style, this.textAlign = TextAlign.left, this.keyboardType = TextInputType.text, this.autoEdit = false, }); /// Convenience for editing cent amounts. Displays formatted, edits as decimal. factory InlineEditCell.cents({ Key? key, required int cents, required ValueChanged onSaved, TextStyle? style, TextAlign textAlign = TextAlign.right, bool autoEdit = false, }) { return InlineEditCell( key: key, displayText: formatCents(cents), editText: (cents / 100).toStringAsFixed(2), onSaved: (text) { final parsed = double.tryParse(text); if (parsed != null) onSaved((parsed * 100).round()); }, validator: (text) => double.tryParse(text) == null ? 'Invalid' : null, style: style, textAlign: textAlign, keyboardType: const TextInputType.numberWithOptions(decimal: true), autoEdit: autoEdit, ); } @override State createState() => _InlineEditCellState(); } class _InlineEditCellState extends State { bool _editing = false; late TextEditingController _controller; late FocusNode _focusNode; bool _hasError = false; @override void initState() { super.initState(); _controller = TextEditingController(); _focusNode = FocusNode(onKeyEvent: _onKey); _focusNode.addListener(_onFocusChange); if (widget.autoEdit) { // Start in edit mode immediately _editing = true; _controller.text = widget.editText; _controller.selection = TextSelection(baseOffset: 0, extentOffset: widget.editText.length); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _focusNode.requestFocus(); }); } } @override void didUpdateWidget(InlineEditCell oldWidget) { super.didUpdateWidget(oldWidget); // Handle autoEdit transitioning from false → true on an existing widget if (widget.autoEdit && !oldWidget.autoEdit && !_editing) { _editing = true; _controller.text = widget.editText; _controller.selection = TextSelection(baseOffset: 0, extentOffset: widget.editText.length); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _focusNode.requestFocus(); }); } } @override void dispose() { _focusNode.removeListener(_onFocusChange); _focusNode.dispose(); _controller.dispose(); super.dispose(); } KeyEventResult _onKey(FocusNode node, KeyEvent event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { _cancel(); return KeyEventResult.handled; } return KeyEventResult.ignored; } void _onFocusChange() { if (!_focusNode.hasFocus && _editing) { _save(); } } void _startEditing() { _controller.text = widget.editText; _controller.selection = TextSelection(baseOffset: 0, extentOffset: widget.editText.length); _hasError = false; setState(() => _editing = true); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _focusNode.requestFocus(); }); } void _save() { if (!_editing) return; final text = _controller.text.trim(); if (widget.validator != null) { final err = widget.validator!(text); if (err != null) { setState(() => _hasError = true); return; } } setState(() { _editing = false; _hasError = false; }); widget.onSaved(text); } void _cancel() { setState(() { _editing = false; _hasError = false; }); // Explicitly unfocus so HoverBuilder's Focus listener releases _focusNode.unfocus(); } /// Whether this cell is actively being edited (used by HoverBuilder to stay visible). bool get isEditing => _editing; @override Widget build(BuildContext context) { if (!_editing) { return GestureDetector( onTap: _startEditing, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( alignment: widget.textAlign == TextAlign.right ? Alignment.centerRight : Alignment.centerLeft, child: Text( widget.displayText, style: widget.style, textAlign: widget.textAlign, overflow: TextOverflow.ellipsis, ), ), ), ); } final borderColor = _hasError ? accentRed : accentGreen; final fontSize = widget.style?.fontSize ?? bodySize; // Cursor height matches font size to avoid overflowing the compact field final cursorHeight = fontSize + 2; return TextField( controller: _controller, focusNode: _focusNode, style: (widget.style ?? const TextStyle(fontSize: bodySize, color: textPrimary)).copyWith(color: textPrimary), textAlign: widget.textAlign, keyboardType: widget.keyboardType, cursorHeight: cursorHeight, decoration: InputDecoration( isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), filled: true, fillColor: bgInput, enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(3), borderSide: BorderSide(color: borderColor, width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(3), borderSide: BorderSide(color: borderColor, width: 1.5), ), ), onSubmitted: (_) => _save(), ); } } /// Small 20x20 remove button. Dim normally, red on hover. class RemoveButton extends StatefulWidget { final VoidCallback onPressed; const RemoveButton({super.key, required this.onPressed}); @override State createState() => _RemoveButtonState(); } class _RemoveButtonState extends State { bool _hovering = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _hovering = true), onExit: (_) => setState(() => _hovering = false), cursor: SystemMouseCursors.click, child: GestureDetector( onTap: widget.onPressed, child: SizedBox( width: 20, height: 20, child: Icon( Icons.close, size: 14, color: _hovering ? accentRed : textDim, ), ), ), ); } } /// Small "+" add row button for bottom of sections. class AddRowButton extends StatefulWidget { final String label; final VoidCallback onPressed; const AddRowButton({super.key, required this.label, required this.onPressed}); @override State createState() => _AddRowButtonState(); } class _AddRowButtonState extends State { bool _hovering = false; @override Widget build(BuildContext context) { return GestureDetector( onTap: widget.onPressed, child: MouseRegion( onEnter: (_) => setState(() => _hovering = true), onExit: (_) => setState(() => _hovering = false), cursor: SystemMouseCursors.click, child: Container( height: 28, padding: const EdgeInsets.symmetric(horizontal: 10), child: Row( children: [ Icon(Icons.add, size: 14, color: _hovering ? accentGreen : textDim), const SizedBox(width: 4), Text( widget.label, style: TextStyle(fontSize: 11, color: _hovering ? accentGreen : textDim), ), ], ), ), ), ); } } /// Builder that provides hover state to children. /// Stays "active" when any descendant has focus (prevents hiding editable /// fields when the mouse moves away during editing). class HoverBuilder extends StatefulWidget { final Widget Function(BuildContext context, bool hovering) builder; const HoverBuilder({super.key, required this.builder}); @override State createState() => _HoverBuilderState(); } class _HoverBuilderState extends State { bool _hovering = false; bool _childHasFocus = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _hovering = true), onExit: (_) => setState(() => _hovering = false), child: Focus( onFocusChange: (focused) => setState(() => _childHasFocus = focused), skipTraversal: true, canRequestFocus: false, child: widget.builder(context, _hovering || _childHasFocus), ), ); } }