d4t_formulas/lib/ai/formula_editor.dart

688 lines
22 KiB
Dart
Raw Permalink Normal View History

2026-02-25 08:08:06 +00:00
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown_plus_latex/flutter_markdown_plus_latex.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:markdown/markdown.dart' as markdown;
import '../formula_models.dart';
import '../corpus.dart';
2026-02-26 20:49:32 +00:00
import '../database/database_service.dart';
import '../service_locator.dart';
2026-02-25 08:08:06 +00:00
import 'formula_screen.dart';
import 'unit_dropdown.dart';
2026-03-21 12:54:24 +00:00
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:flutter_highlight/themes/monokai-sublime.dart';
import 'package:highlight/languages/dart.dart';
2026-02-25 08:08:06 +00:00
/// A screen for editing a Formula's properties including name, description,
/// input/output variables, and d4rt code.
class FormulaEditor extends StatefulWidget {
final Formula formula;
final Corpus corpus;
final Function(Formula)? onSave;
2026-02-25 08:08:06 +00:00
2026-03-21 12:54:24 +00:00
const FormulaEditor({super.key, required this.formula, required this.corpus, this.onSave});
2026-02-25 08:08:06 +00:00
@override
State<FormulaEditor> createState() => _FormulaEditorState();
}
class _FormulaEditorState extends State<FormulaEditor> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameController;
late TextEditingController _descriptionController;
2026-03-21 12:54:24 +00:00
late CodeController _d4rtCodeController;
2026-02-25 08:08:06 +00:00
// Track input variables
final List<_InputVariableRowData> _inputVariables = [];
2026-03-21 12:54:24 +00:00
2026-02-25 08:08:06 +00:00
// Output variable
late _OutputVariableRowData _outputVariable;
2026-03-21 12:54:24 +00:00
2026-02-25 08:08:06 +00:00
bool _isPreviewVisible = false;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.formula.name);
_descriptionController = TextEditingController(text: widget.formula.description ?? '');
2026-03-21 12:54:24 +00:00
_d4rtCodeController = CodeController(language: dart, text: widget.formula.d4rtCode ?? '');
2026-02-25 08:08:06 +00:00
// Initialize input variables
for (final input in widget.formula.input) {
2026-03-21 12:54:24 +00:00
_inputVariables.add(
_InputVariableRowData(
nameController: TextEditingController(text: input.name),
unit: input.unit,
values: input.values != null ? List.from(input.values!) : null,
),
);
2026-02-25 08:08:06 +00:00
}
2026-03-21 12:54:24 +00:00
2026-02-25 08:08:06 +00:00
// Initialize output variable
_outputVariable = _OutputVariableRowData(
nameController: TextEditingController(text: widget.formula.output.name),
unit: widget.formula.output.unit,
);
}
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_d4rtCodeController.dispose();
for (final variable in _inputVariables) {
variable.nameController.dispose();
}
_outputVariable.nameController.dispose();
super.dispose();
}
void _addInputVariable() {
setState(() {
2026-03-21 12:54:24 +00:00
_inputVariables.add(
_InputVariableRowData(
nameController: TextEditingController(text: 'var${_inputVariables.length + 1}'),
unit: null,
values: null,
),
);
2026-02-25 08:08:06 +00:00
});
}
void _removeInputVariable(int index) {
setState(() {
_inputVariables.removeAt(index);
});
}
void _showPreview() {
setState(() {
_isPreviewVisible = true;
});
}
void _hidePreview() {
setState(() {
_isPreviewVisible = false;
});
}
void _testFormula() {
// Validate the formula before testing
if (!_validateFormula()) {
return;
}
final formula = _buildFormula();
if (formula == null) return;
Navigator.push(
context,
MaterialPageRoute(
2026-03-21 12:54:24 +00:00
builder: (context) => FormulaScreen(formula: formula, corpus: widget.corpus),
2026-02-25 08:08:06 +00:00
),
);
}
bool _validateFormula() {
// Validate name
if (_nameController.text.trim().isEmpty) {
_showErrorDialog('Formula name cannot be empty');
return false;
}
// Validate output name
if (_outputVariable.nameController.text.trim().isEmpty) {
_showErrorDialog('Output variable name cannot be empty');
return false;
}
// Validate input variable names
for (final variable in _inputVariables) {
if (variable.nameController.text.trim().isEmpty) {
_showErrorDialog('Input variable names cannot be empty');
return false;
}
}
// Validate d4rt code
if (_d4rtCodeController.text.trim().isEmpty) {
_showErrorDialog('D4RT code cannot be empty');
return false;
}
return true;
}
Formula? _buildFormula() {
try {
final input = <VariableSpec>[];
for (final variable in _inputVariables) {
2026-03-21 12:54:24 +00:00
input.add(
VariableSpec(name: variable.nameController.text.trim(), unit: variable.unit, values: variable.values),
);
2026-02-25 08:08:06 +00:00
}
2026-03-21 12:54:24 +00:00
final output = VariableSpec(name: _outputVariable.nameController.text.trim(), unit: _outputVariable.unit);
2026-02-25 08:08:06 +00:00
return Formula(
uuid: widget.formula.uuid,
2026-02-25 08:08:06 +00:00
name: _nameController.text.trim(),
description: _descriptionController.text.isEmpty ? null : _descriptionController.text,
input: input,
output: output,
2026-03-21 12:54:24 +00:00
d4rtCode: _d4rtCodeController.fullText,
2026-02-25 08:08:06 +00:00
tags: widget.formula.tags, // Preserve existing tags
);
} catch (e) {
_showErrorDialog('Error building formula: $e');
return null;
}
}
2026-02-26 20:49:32 +00:00
Future<void> _saveFormula() async {
2026-02-25 08:08:06 +00:00
if (!_validateFormula()) {
return;
}
final formula = _buildFormula();
if (formula == null) return;
2026-02-26 20:49:32 +00:00
try {
final database = getDatabase();
2026-03-08 19:17:49 +00:00
2026-02-26 20:49:32 +00:00
// Update corpus in memory
widget.corpus.updateFormula(formula);
2026-03-08 19:17:49 +00:00
2026-02-26 20:49:32 +00:00
// Update database
final updated = await database.updateFormula(formula);
2026-03-08 19:17:49 +00:00
2026-02-26 20:49:32 +00:00
if (!updated) {
// If formula wasn't found (e.g., name changed), add it as new
await database.addFormula(formula);
}
2026-03-08 19:17:49 +00:00
2026-02-26 20:49:32 +00:00
// Call the onSave callback if provided
widget.onSave?.call(formula);
2026-03-08 19:17:49 +00:00
2026-02-26 20:49:32 +00:00
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Formula "${formula.name}" saved successfully!'),
backgroundColor: Theme.of(context).colorScheme.primary,
),
);
} catch (e, stack) {
print('Error saving formula: $e\n$stack');
_showErrorDialog('Error saving formula: $e');
}
2026-02-25 08:08:06 +00:00
}
2026-03-08 19:17:49 +00:00
Future<void> _saveFormulaAsCopy() async {
if (!_validateFormula()) {
return;
}
final formula = _buildFormula();
if (formula == null) return;
try {
final database = getDatabase();
// Create a copy with a new UUID
final formulaCopy = Formula(
name: '${formula.name} (Copy)',
description: formula.description,
input: formula.input,
output: formula.output,
d4rtCode: formula.d4rtCode,
tags: formula.tags,
);
// Add to corpus
widget.corpus.addFormula(formulaCopy);
// Add to database
await database.addFormula(formulaCopy);
// Call the onSave callback if provided
widget.onSave?.call(formulaCopy);
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Formula "${formulaCopy.name}" saved successfully!'),
backgroundColor: Theme.of(context).colorScheme.primary,
),
);
// Navigate back to the formula list with the new formula
Navigator.pop(context, formulaCopy);
} catch (e, stack) {
print('Error saving formula copy: $e\n$stack');
_showErrorDialog('Error saving formula copy: $e');
}
}
2026-02-25 08:08:06 +00:00
void _showErrorDialog(String message) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Error'),
content: Text(message),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('OK'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Edit Formula'),
actions: [
2026-03-21 12:54:24 +00:00
IconButton(icon: const Icon(Icons.play_arrow), onPressed: _testFormula, tooltip: 'Test Formula'),
IconButton(icon: const Icon(Icons.copy), onPressed: _saveFormulaAsCopy, tooltip: 'Save as copy'),
IconButton(icon: const Icon(Icons.save), onPressed: _saveFormula, tooltip: 'Save'),
2026-02-25 08:08:06 +00:00
],
),
body: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: ListView(
children: [
_buildNameSection(),
const SizedBox(height: 16),
_buildDescriptionSection(),
const SizedBox(height: 16),
_buildInputVariablesSection(),
const SizedBox(height: 16),
_buildOutputVariableSection(),
const SizedBox(height: 16),
_buildD4rtCodeSection(),
const SizedBox(height: 32),
],
),
),
),
);
}
Widget _buildNameSection() {
return TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Formula Name',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.title),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
}
return null;
},
);
}
Widget _buildDescriptionSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
2026-03-21 12:54:24 +00:00
const Text('Description (Markdown)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
2026-02-25 08:08:06 +00:00
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isPreviewVisible)
TextButton.icon(
icon: const Icon(Icons.visibility_off),
label: const Text('Hide Preview'),
onPressed: _hidePreview,
)
else
TextButton.icon(
icon: const Icon(Icons.visibility),
label: const Text('Preview'),
onPressed: _showPreview,
),
],
),
],
),
const SizedBox(height: 8),
if (_isPreviewVisible) ...[
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Markdown(
data: _descriptionController.text,
shrinkWrap: true,
2026-03-21 12:54:24 +00:00
builders: {'latex': LatexElementBuilder()},
extensionSet: markdown.ExtensionSet([LatexBlockSyntax()], [LatexInlineSyntax()]),
2026-02-25 08:08:06 +00:00
),
),
const SizedBox(height: 16),
],
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
hintText: 'Enter formula description (supports Markdown and LaTeX)',
border: OutlineInputBorder(),
),
maxLines: 5,
),
],
),
),
);
}
Widget _buildInputVariablesSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
2026-03-21 12:54:24 +00:00
const Text('Input Variables', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
2026-02-25 08:08:06 +00:00
const SizedBox(height: 8),
..._inputVariables.asMap().entries.map((entry) {
final index = entry.key;
final variable = entry.value;
return _buildInputVariableRow(index, variable);
}).toList(),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Add Input Variable'),
onPressed: _addInputVariable,
),
],
),
),
);
}
Widget _buildInputVariableRow(int index, _InputVariableRowData variable) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: variable.nameController,
2026-03-21 12:54:24 +00:00
decoration: const InputDecoration(labelText: 'Name', border: OutlineInputBorder()),
2026-02-25 08:08:06 +00:00
),
),
const SizedBox(width: 8),
2026-04-02 19:01:22 +00:00
Flexible(
flex: 1,
child: DropdownButtonFormField<String?>(
isDense: true,
isExpanded: true,
value: _getBaseUnit(variable.unit),
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: "Base unit",
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 0),
),
dropdownColor: Theme.of(context).colorScheme.surface,
menuMaxHeight: 300,
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('None', style: TextStyle(fontSize: 11)),
2026-02-25 08:08:06 +00:00
),
2026-04-02 19:01:22 +00:00
..._getAllBaseUnits().map((baseUnit) {
return DropdownMenuItem<String?>(
value: baseUnit,
child: Text(baseUnit, style: const TextStyle(fontSize: 11)),
);
}).toList(),
2026-02-25 08:08:06 +00:00
],
2026-04-02 19:01:22 +00:00
onChanged: (baseUnit) {
setState(() {
variable.unit = baseUnit;
});
},
2026-02-25 08:08:06 +00:00
),
),
const SizedBox(width: 8),
2026-04-02 19:01:22 +00:00
Flexible(
flex: 1,
child: DropdownButtonFormField<String?>(
isDense: true,
isExpanded: true,
value: variable.unit,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: "Unit",
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 0),
),
dropdownColor: Theme.of(context).colorScheme.surface,
menuMaxHeight: 300,
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('None', style: TextStyle(fontSize: 10)),
2026-02-25 08:08:06 +00:00
),
2026-04-02 19:01:22 +00:00
..._getDerivedUnits(variable.unit).map((unit) {
final unitSpec = widget.corpus.getUnit(unit);
return DropdownMenuItem<String?>(
value: unit,
child: Text(
'${unitSpec.symbol}',
style: const TextStyle(fontSize: 10),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
2026-02-25 08:08:06 +00:00
],
2026-04-02 19:01:22 +00:00
onChanged: (unit) {
setState(() {
variable.unit = unit;
});
},
2026-02-25 08:08:06 +00:00
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _removeInputVariable(index),
tooltip: 'Delete variable',
2026-04-02 19:01:22 +00:00
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
2026-02-25 08:08:06 +00:00
),
],
),
],
),
),
);
}
Widget _buildOutputVariableSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
2026-03-21 12:54:24 +00:00
const Text('Output Variable', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
2026-02-25 08:08:06 +00:00
const SizedBox(height: 8),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: _outputVariable.nameController,
2026-03-21 12:54:24 +00:00
decoration: const InputDecoration(labelText: 'Name', border: OutlineInputBorder()),
2026-02-25 08:08:06 +00:00
),
),
const SizedBox(width: 8),
2026-04-02 19:01:22 +00:00
Flexible(
flex: 1,
child: DropdownButtonFormField<String?>(
isDense: true,
isExpanded: true,
value: _getBaseUnit(_outputVariable.unit),
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: "Base unit",
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 0),
),
dropdownColor: Theme.of(context).colorScheme.surface,
menuMaxHeight: 300,
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('None', style: TextStyle(fontSize: 11)),
2026-02-25 08:08:06 +00:00
),
2026-04-02 19:01:22 +00:00
..._getAllBaseUnits().map((baseUnit) {
return DropdownMenuItem<String?>(
value: baseUnit,
child: Text(baseUnit, style: const TextStyle(fontSize: 11)),
);
}).toList(),
2026-02-25 08:08:06 +00:00
],
2026-04-02 19:01:22 +00:00
onChanged: (baseUnit) {
setState(() {
_outputVariable.unit = baseUnit;
});
},
2026-02-25 08:08:06 +00:00
),
),
const SizedBox(width: 8),
2026-04-02 19:01:22 +00:00
Flexible(
flex: 1,
child: DropdownButtonFormField<String?>(
isDense: true,
isExpanded: true,
value: _outputVariable.unit,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: "Unit",
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 0),
),
dropdownColor: Theme.of(context).colorScheme.surface,
menuMaxHeight: 300,
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('None', style: TextStyle(fontSize: 10)),
2026-02-25 08:08:06 +00:00
),
2026-04-02 19:01:22 +00:00
..._getDerivedUnits(_outputVariable.unit).map((unit) {
final unitSpec = widget.corpus.getUnit(unit);
return DropdownMenuItem<String?>(
value: unit,
child: Text(
'${unitSpec.symbol}',
style: const TextStyle(fontSize: 10),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
2026-02-25 08:08:06 +00:00
],
2026-04-02 19:01:22 +00:00
onChanged: (unit) {
setState(() {
_outputVariable.unit = unit;
});
},
2026-02-25 08:08:06 +00:00
),
),
],
),
],
),
),
);
}
Widget _buildD4rtCodeSection() {
return Card(
2026-04-02 19:01:22 +00:00
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('D4RT Code (Dart syntax)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(minHeight: 200),
child: CodeTheme(
data: CodeThemeData(styles: monokaiSublimeTheme),
child: SingleChildScrollView(
child: CodeField(controller: _d4rtCodeController),
),
2026-02-25 08:08:06 +00:00
),
),
2026-04-02 19:01:22 +00:00
],
2026-02-25 08:08:06 +00:00
),
),
);
}
// Helper methods for unit management
String? _getBaseUnit(String? unit) {
if (unit == null) return null;
try {
return widget.corpus.getUnit(unit).baseUnit;
} catch (e) {
return null;
}
}
List<String> _getAllBaseUnits() {
final baseUnits = <String>{};
for (final unit in widget.corpus.allUnits()) {
baseUnits.add(unit.baseUnit);
}
return baseUnits.toList()..sort();
}
List<String> _getDerivedUnits(String? baseUnit) {
if (baseUnit == null) return [];
return widget.corpus.unitsOfSameMagnitude(baseUnit)..sort();
}
}
// Data classes to track variable state
class _InputVariableRowData {
final TextEditingController nameController;
String? unit;
List<dynamic>? values;
2026-03-21 12:54:24 +00:00
_InputVariableRowData({required this.nameController, this.unit, this.values});
2026-02-25 08:08:06 +00:00
}
class _OutputVariableRowData {
final TextEditingController nameController;
String? unit;
2026-03-21 12:54:24 +00:00
_OutputVariableRowData({required this.nameController, this.unit});
2026-02-25 08:08:06 +00:00
}