diff --git a/.gitignore b/.gitignore index d64610c..cfb2fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ .aider* .build-container-cache /coverage/ +/.agent-shell/ +/ios/ +/macos/ diff --git a/CLAUDE.md b/CLAUDE.md index fb77280..9d17b44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ - `flutter pub get` --> `./flutterw pub get` - `flutter run -d linux` --> `./flutterw run -d linux` - See `./Makefile` for more examples. +- If you are an agent, you may be also containerized. Try `distrobox-host-exec $(pwd)/flutterw` # MANDATORY WORKFLOW diff --git a/Makefile b/Makefile index bae5bf4..add553f 100644 --- a/Makefile +++ b/Makefile @@ -1,43 +1,45 @@ all: build-container clean-container build-builders build-linux-debug-container -DB=~/.local/share/com.example.d4rt_formulas/d4rt_formulas/formulas.sqlite +DATABASEFILE=~/.local/share/com.example.d4rt_formulas/d4rt_formulas/formulas.sqlite + +FLUTTERW := $(shell if [ "$$CONTAINER_ID" = "" ]; then echo "./flutterw"; else echo "distrobox-host-exec $(CURDIR)/flutterw"; fi) build-container: - ./flutterw --build-container + $(FLUTTERW) --build-container clean: flutter clean - [ -f $(DB) ] && rm $(DB) + [ -f $(DATABASEFILE) ] && rm $(DATABASEFILE) clean-container: rm -r .build-container-cache - ./flutterw clean + $(FLUTTERW) clean pub-get-container: - ./flutterw pub get + $(FLUTTERW) pub get -test: - ./flutterw test +test: + $(FLUTTERW) test build-builders: - ./flutterw pub run build_runner build --delete-conflicting-outputs + $(FLUTTERW) pub run build_runner build --delete-conflicting-outputs -build-android-release-container: - ./flutterw build apk --release +build-android-release-container: + $(FLUTTERW) build apk --release -build-linux-debug-container: - ./flutterw build linux --debug +build-linux-debug-container: + $(FLUTTERW) build linux --debug -build-web-debug-container: - ./flutterw build web --debug +build-web-debug-container: + $(FLUTTERW) build web --debug -run-linux-debug-container: - ./flutterw run -d linux +run-linux-debug-container: + $(FLUTTERW) run -d linux -run-web-debug-container: - ./flutterw run --web-port $${WEB_PORT:-8081} -d web-server +run-web-debug-container: + $(FLUTTERW) run --web-port $${WEB_PORT:-8081} -d web-server run-linux-debug-native: flutter run -d linux @@ -50,4 +52,4 @@ ai: run-emulator: flutter emulators --launch Medium_Phone - flutter run -d emulator-5554 \ No newline at end of file + flutter run -d emulator-5554 diff --git a/TODO.md b/TODO.md index 1d86856..b86211d 100644 --- a/TODO.md +++ b/TODO.md @@ -67,17 +67,18 @@ - [R] When a formula is derived in FormulaScreen, the new FormulaScreen is not pushed in navigator, it replacles the current FormulaScreen - [R] In FormulaScreen, a Formula cant be derived if DerivedFormula.isDerivable() returns false - [R] The algorithm of formulaSolver should be https://en.wikipedia.org/wiki/Newton%27s_method -- [ ] Use receive_sharing_intent package to implement import of files in linux and android. +- [R] Use receive_sharing_intent package to implement import of files in linux and android. - The application will accept *.d4rtf files with the same format of files in ./assets . - The application will accept also shared text, with same format as files in ./assets. - - The loaded formulaelemets will be added to the GetIt.instance.get() +- [R] Preview of imported formulalements - The screen will receive a list of FormulaElements to import - The formulas will have a "edit" button to show a FormulaEditor with the formula - The screen will have an "import all" button to import all the FormulaElements in the list. This will call Corpus.addFormulaElement() for each element, and then pop the screen. -- [ ] In FormulaList, add a button next to "export" to import FormulaElements. +- [R] In FormulaList, add a button next to "export" to import FormulaElements. - It will show a screen with a text editor with dart syntax and a button "paste". - The "paste" button will copy the clipboard into the text editor. - A second button "import" will use the import preview screen -- Make formulaSolver() asyncronous, and show a CircularProgressIndicator while the formula is being solved. Honor a new optinal parameter "timeout" in formulaSolver, that will throw a TimeoutException. - [ ] Add a uuid column to the table or FormulaElements, so it is not necessary to load all the formulas to find a formula by uuid. This will improve performance when updating and deleting. +- [ ] Make formulaSolver() asyncronous, and show a CircularProgressIndicator while the formula is being solved. Honor a new optinal parameter "timeout" in formulaSolver, that will throw a TimeoutException. +- [ ] When importing FormulaElements, save the FormulaElements in the database (currently, they are only added to the Corpus in memory). diff --git a/android/d4rt_formulas_android.iml b/android/d4rt_formulas_android.iml new file mode 100644 index 0000000..3bc4b3b --- /dev/null +++ b/android/d4rt_formulas_android.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/ai/formula_editor.dart b/lib/ai/formula_editor.dart index 8020496..50f980b 100644 --- a/lib/ai/formula_editor.dart +++ b/lib/ai/formula_editor.dart @@ -9,6 +9,9 @@ import '../database/database_service.dart'; import '../service_locator.dart'; import 'formula_screen.dart'; import 'unit_dropdown.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_highlight/themes/monokai-sublime.dart'; +import 'package:highlight/languages/dart.dart'; /// A screen for editing a Formula's properties including name, description, /// input/output variables, and d4rt code. @@ -17,12 +20,7 @@ class FormulaEditor extends StatefulWidget { final Corpus corpus; final Function(Formula)? onSave; - const FormulaEditor({ - super.key, - required this.formula, - required this.corpus, - this.onSave, - }); + const FormulaEditor({super.key, required this.formula, required this.corpus, this.onSave}); @override State createState() => _FormulaEditorState(); @@ -32,14 +30,14 @@ class _FormulaEditorState extends State { final _formKey = GlobalKey(); late TextEditingController _nameController; late TextEditingController _descriptionController; - late TextEditingController _d4rtCodeController; - + late CodeController _d4rtCodeController; + // Track input variables final List<_InputVariableRowData> _inputVariables = []; - + // Output variable late _OutputVariableRowData _outputVariable; - + bool _isPreviewVisible = false; @override @@ -47,17 +45,19 @@ class _FormulaEditorState extends State { super.initState(); _nameController = TextEditingController(text: widget.formula.name); _descriptionController = TextEditingController(text: widget.formula.description ?? ''); - _d4rtCodeController = TextEditingController(text: widget.formula.d4rtCode); - + _d4rtCodeController = CodeController(language: dart, text: widget.formula.d4rtCode ?? ''); + // Initialize input variables for (final input in widget.formula.input) { - _inputVariables.add(_InputVariableRowData( - nameController: TextEditingController(text: input.name), - unit: input.unit, - values: input.values != null ? List.from(input.values!) : null, - )); + _inputVariables.add( + _InputVariableRowData( + nameController: TextEditingController(text: input.name), + unit: input.unit, + values: input.values != null ? List.from(input.values!) : null, + ), + ); } - + // Initialize output variable _outputVariable = _OutputVariableRowData( nameController: TextEditingController(text: widget.formula.output.name), @@ -79,11 +79,13 @@ class _FormulaEditorState extends State { void _addInputVariable() { setState(() { - _inputVariables.add(_InputVariableRowData( - nameController: TextEditingController(text: 'var${_inputVariables.length + 1}'), - unit: null, - values: null, - )); + _inputVariables.add( + _InputVariableRowData( + nameController: TextEditingController(text: 'var${_inputVariables.length + 1}'), + unit: null, + values: null, + ), + ); }); } @@ -117,10 +119,7 @@ class _FormulaEditorState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => FormulaScreen( - formula: formula, - corpus: widget.corpus, - ), + builder: (context) => FormulaScreen(formula: formula, corpus: widget.corpus), ), ); } @@ -159,17 +158,12 @@ class _FormulaEditorState extends State { try { final input = []; for (final variable in _inputVariables) { - input.add(VariableSpec( - name: variable.nameController.text.trim(), - unit: variable.unit, - values: variable.values, - )); + input.add( + VariableSpec(name: variable.nameController.text.trim(), unit: variable.unit, values: variable.values), + ); } - final output = VariableSpec( - name: _outputVariable.nameController.text.trim(), - unit: _outputVariable.unit, - ); + final output = VariableSpec(name: _outputVariable.nameController.text.trim(), unit: _outputVariable.unit); return Formula( uuid: widget.formula.uuid, @@ -177,7 +171,7 @@ class _FormulaEditorState extends State { description: _descriptionController.text.isEmpty ? null : _descriptionController.text, input: input, output: output, - d4rtCode: _d4rtCodeController.text, + d4rtCode: _d4rtCodeController.fullText, tags: widget.formula.tags, // Preserve existing tags ); } catch (e) { @@ -296,21 +290,9 @@ class _FormulaEditorState extends State { appBar: AppBar( title: const Text('Edit Formula'), actions: [ - 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', - ), + 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'), ], ), body: Form( @@ -363,13 +345,7 @@ class _FormulaEditorState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( - 'Description (Markdown)', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), + const Text('Description (Markdown)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), Row( mainAxisSize: MainAxisSize.min, children: [ @@ -400,13 +376,8 @@ class _FormulaEditorState extends State { child: Markdown( data: _descriptionController.text, shrinkWrap: true, - builders: { - 'latex': LatexElementBuilder(), - }, - extensionSet: markdown.ExtensionSet( - [LatexBlockSyntax()], - [LatexInlineSyntax()], - ), + builders: {'latex': LatexElementBuilder()}, + extensionSet: markdown.ExtensionSet([LatexBlockSyntax()], [LatexInlineSyntax()]), ), ), const SizedBox(height: 16), @@ -432,13 +403,7 @@ class _FormulaEditorState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Input Variables', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), + const Text('Input Variables', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 8), ..._inputVariables.asMap().entries.map((entry) { final index = entry.key; @@ -470,10 +435,7 @@ class _FormulaEditorState extends State { flex: 2, child: TextFormField( controller: variable.nameController, - decoration: const InputDecoration( - labelText: 'Name', - border: OutlineInputBorder(), - ), + decoration: const InputDecoration(labelText: 'Name', border: OutlineInputBorder()), ), ), const SizedBox(width: 8), @@ -530,7 +492,8 @@ class _FormulaEditorState extends State { final unitSpec = widget.corpus.getUnit(unit); return DropdownMenuItem( value: unit, - child: Text('${unitSpec.symbol} - ${unit}', + child: Text( + '${unitSpec.symbol} - ${unit}', style: const TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis, ), @@ -567,13 +530,7 @@ class _FormulaEditorState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Output Variable', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), + const Text('Output Variable', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 8), Row( children: [ @@ -581,10 +538,7 @@ class _FormulaEditorState extends State { flex: 2, child: TextFormField( controller: _outputVariable.nameController, - decoration: const InputDecoration( - labelText: 'Name', - border: OutlineInputBorder(), - ), + decoration: const InputDecoration(labelText: 'Name', border: OutlineInputBorder()), ), ), const SizedBox(width: 8), @@ -641,7 +595,8 @@ class _FormulaEditorState extends State { final unitSpec = widget.corpus.getUnit(unit); return DropdownMenuItem( value: unit, - child: Text('${unitSpec.symbol} - ${unit}', + child: Text( + '${unitSpec.symbol} - ${unit}', style: const TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis, ), @@ -667,67 +622,18 @@ class _FormulaEditorState extends State { Widget _buildD4rtCodeSection() { return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'D4RT Code', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + child: Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: CodeTheme( + data: CodeThemeData(styles: monokaiSublimeTheme), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SingleChildScrollView( + child: CodeField(controller: _d4rtCodeController), ), ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - border: Border.all(color: Theme.of(context).dividerColor), - borderRadius: BorderRadius.circular(4), - ), - child: Column( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), - ), - child: Row( - children: [ - Icon(Icons.code, size: 16, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 8), - Text( - 'Dart Syntax', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - ), - TextFormField( - controller: _d4rtCodeController, - decoration: const InputDecoration( - hintText: 'Enter D4RT/Dart code here', - border: InputBorder.none, - contentPadding: EdgeInsets.all(12), - ), - maxLines: 10, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 14, - ), - ), - ], - ), - ), - ], + ), ), ), ); @@ -763,19 +669,12 @@ class _InputVariableRowData { String? unit; List? values; - _InputVariableRowData({ - required this.nameController, - this.unit, - this.values, - }); + _InputVariableRowData({required this.nameController, this.unit, this.values}); } class _OutputVariableRowData { final TextEditingController nameController; String? unit; - _OutputVariableRowData({ - required this.nameController, - this.unit, - }); + _OutputVariableRowData({required this.nameController, this.unit}); } diff --git a/lib/ai/formula_list.dart b/lib/ai/formula_list.dart index 0974078..bc4aa37 100644 --- a/lib/ai/formula_list.dart +++ b/lib/ai/formula_list.dart @@ -7,13 +7,17 @@ import 'formula_screen.dart'; import 'package:share_plus/share_plus.dart' as share_plus; import 'formula_editor.dart'; import 'package:share_plus/share_plus.dart'; +import 'import_preview_screen.dart'; +import '../services/import_service.dart'; class FormulaList extends StatefulWidget { final Corpus corpus; + final VoidCallback? onImport; const FormulaList({ super.key, required this.corpus, + this.onImport, }); @override @@ -77,23 +81,6 @@ class _FormulaListState extends State { } } - void _editFormula(Formula formula) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => FormulaEditor( - formula: formula, - corpus: widget.corpus, - onSave: (updatedFormula){ - setState((){ - // THIS UPDATES THE FORMULA LIST - }); - } - ), - ), - ); - } - void _copyFormula(Formula formula) async { try { final exportString = _formulaAndDependenciesToExportStringLiteral(formula); @@ -133,6 +120,7 @@ class _FormulaListState extends State { ); } + @override Widget build(BuildContext context) { return Column( @@ -162,13 +150,9 @@ class _FormulaListState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () => _editFormula(formula), - tooltip: 'Edit Formula', - ), PopupMenuButton( icon: const Icon(Icons.share), + tooltip: 'Share or copy to clipboard', onSelected: (value) { if (value == 'share') { _shareFormula(formula); diff --git a/lib/ai/formula_screen.dart b/lib/ai/formula_screen.dart index b982666..672d82c 100644 --- a/lib/ai/formula_screen.dart +++ b/lib/ai/formula_screen.dart @@ -121,14 +121,13 @@ class _FormulaScreenState extends State { } late final dynamic result; - //if( formula is DerivedFormula) { + if( formula is DerivedFormula) { result = formulaSolver(formula, formula.output.name, inputValues,); - //} - //else { - // TODO: MAYBE ONLY FORMULASOLVER IS NECCESSARY" - //final evaluator = FormulaEvaluator(); - //result = evaluator.evaluate(formula as Formula, inputValues); - //} + } + else { + final evaluator = FormulaEvaluator(); + result = evaluator.evaluate(formula as Formula, inputValues); + } // Convert output to selected unit if needed String? unit = formula.output.unit; @@ -342,7 +341,7 @@ class _FormulaScreenState extends State { children: [ // Fixed width for field name SizedBox( - width: 150, + width: 50, child: Text( formula.output.name, overflow: TextOverflow.ellipsis, @@ -353,7 +352,7 @@ class _FormulaScreenState extends State { Expanded( child: TextFormField( readOnly: true, - enabled: false, + enabled: true, controller: TextEditingController(text: _result), decoration: const InputDecoration( border: UnderlineInputBorder(), @@ -388,10 +387,10 @@ class _FormulaScreenState extends State { children: [ // Fixed width for field name SizedBox( - width: 150, + width: 50, child: Text( variable.name, - overflow: TextOverflow.ellipsis, + overflow: TextOverflow.fade ), ), const SizedBox(width: 8), // Add some spacing diff --git a/lib/ai/import_from_text_screen.dart b/lib/ai/import_from_text_screen.dart new file mode 100644 index 0000000..8219eb2 --- /dev/null +++ b/lib/ai/import_from_text_screen.dart @@ -0,0 +1,140 @@ +import 'package:d4rt_formulas/d4rt_formulas.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:get_it/get_it.dart'; +import 'package:flutter_highlight/themes/monokai-sublime.dart'; +import 'package:highlight/languages/dart.dart'; + +import '../database/database_service.dart'; +import '../service_locator.dart'; + +import '../services/import_service.dart'; +import 'formula_list.dart'; +import '../corpus.dart'; +import '../defaults/default_corpus.dart'; +import '../formula_models.dart' as models; +import 'import_preview_screen.dart'; + + +/// Screen to import formula elements from text +class ImportFromTextScreen extends StatefulWidget { + final Corpus corpus; + + const ImportFromTextScreen({super.key, required this.corpus}); + + @override + State createState() => _ImportFromTextScreenState(); +} + +class _ImportFromTextScreenState extends State { + final CodeController _codeController = CodeController(language: dart, text: "// Insert code here..."); + bool _isLoading = false; + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + Future _pasteFromClipboard() async { + setState(() => _isLoading = true); + + try { + final clipboardData = await Clipboard.getData('text/plain'); + if (clipboardData?.text != null) { + _codeController.text = clipboardData!.text!; + } else { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Clipboard is empty'), backgroundColor: Colors.orange)); + } + } catch (e) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error pasting from clipboard: $e'), backgroundColor: Colors.red)); + } finally { + setState(() => _isLoading = false); + } + } + + Future _import() async { + final text = _codeController.fullText; + if (text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter or paste formula text'), backgroundColor: Colors.orange), + ); + return; + } + + setState(() => _isLoading = true); + + try { + final importService = ImportService(); + final elements = importService.parseSharedText(text); + + if (!mounted) return; + + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ImportPreviewScreen(elements: elements, corpus: widget.corpus), + ), + ); + + if (result == true) { + Navigator.pop(context, true); + } + } catch (e) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error parsing text: $e'), backgroundColor: Colors.red)); + } finally { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Import from Text')), + body: Column( + children: [ + Expanded( + child: CodeTheme( + data: CodeThemeData(styles: monokaiSublimeTheme), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SingleChildScrollView(child: CodeField(controller: _codeController)), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _pasteFromClipboard, + icon: _isLoading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.content_paste), + label: const Text('Paste'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _import, + icon: const Icon(Icons.library_add), + label: const Text('Import'), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/ai/import_preview_screen.dart b/lib/ai/import_preview_screen.dart new file mode 100644 index 0000000..62ce1f4 --- /dev/null +++ b/lib/ai/import_preview_screen.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:d4rt_formulas/formula_models.dart'; +import 'package:d4rt_formulas/corpus.dart'; +import 'package:d4rt_formulas/ai/formula_editor.dart'; +import 'package:d4rt_formulas/services/import_service.dart'; + +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_highlight/themes/monokai-sublime.dart'; +import 'package:highlight/languages/dart.dart'; + +/// Screen to preview and import formula elements +class ImportPreviewScreen extends StatefulWidget { + final List elements; + final Corpus corpus; + + const ImportPreviewScreen({super.key, required this.elements, required this.corpus}); + + @override + State createState() => _ImportPreviewScreenState(); +} + +class _ImportPreviewScreenState extends State { + final Set _selectedUuids = {}; + + @override + void initState() { + super.initState(); + // Select all by default + for (final element in widget.elements) { + if (element is Formula) { + _selectedUuids.add(element.uuid); + } else if (element is UnitSpec) { + _selectedUuids.add(element.name); + } + } + } + + void _editFormulaElement(FormulaElement element) { + if (element is Formula) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => FormulaEditor( + formula: element, + corpus: widget.corpus, + onSave: (updatedFormula) { + // Update the element in the list + setState(() { + final index = widget.elements.indexWhere((e) => e is Formula && e.uuid == updatedFormula.uuid); + if (index != -1) { + widget.elements[index] = updatedFormula; + } + }); + }, + ), + ), + ); + } + } + + void _importSelected() { + final selectedElements = widget.elements.where((element) { + if (element is Formula) { + return _selectedUuids.contains(element.uuid); + } else if (element is UnitSpec) { + return _selectedUuids.contains(element.name); + } + return false; + }).toList(); + + if (selectedElements.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('No elements selected to import'), backgroundColor: Colors.orange)); + return; + } + + try { + widget.corpus.loadFormulaElements(selectedElements, true); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Imported ${selectedElements.length} element(s) successfully'), + backgroundColor: Colors.green, + ), + ); + + Navigator.pop(context, true); + } catch (e) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error importing: $e'), backgroundColor: Colors.red)); + } + } + + @override + Widget build(BuildContext context) { + final formulas = widget.elements.whereType().toList(); + final units = widget.elements.whereType().toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('Import Preview'), + actions: [IconButton(icon: const Icon(Icons.check), tooltip: 'Import Selected', onPressed: _importSelected)], + ), + body: Column( + children: [ + if (formulas.isEmpty && units.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text('No formula elements found in the shared content', style: TextStyle(fontSize: 16)), + ) + else + Expanded( + child: ListView( + children: [ + if (formulas.isNotEmpty) ...[ + const ListTile( + title: Text('Formulas', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ), + ...formulas.map((formula) => _buildFormulaTile(formula)), + ], + if (units.isNotEmpty) ...[ + const ListTile( + title: Text('Units', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ), + ...units.map((unit) => _buildUnitTile(unit)), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildFormulaTile(Formula formula) { + final isSelected = _selectedUuids.contains(formula.uuid); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: ListTile( + leading: Checkbox( + value: isSelected, + onChanged: (value) { + setState(() { + if (value == true) { + _selectedUuids.add(formula.uuid); + } else { + _selectedUuids.remove(formula.uuid); + } + }); + }, + ), + title: Text(formula.name), + subtitle: Text( + formula.description?.isNotEmpty == true ? formula.description!.split('\n').first : 'No description', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (formula.tags.isNotEmpty) + SingleChildScrollView( + child: SizedBox( + width: 150, + child: Wrap( + spacing: 4, + children: formula.tags.take(10).map((tag) { + return Chip( + label: Text(tag, style: const TextStyle(fontSize: 10)), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + }).toList(), + ), + ), + ), + IconButton(icon: const Icon(Icons.edit), tooltip: 'Edit', onPressed: () => _editFormulaElement(formula)), + ], + ), + ), + ); + } + + Widget _buildUnitTile(UnitSpec unit) { + final isSelected = _selectedUuids.contains(unit.name); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: ListTile( + leading: Checkbox( + value: isSelected, + onChanged: (value) { + setState(() { + if (value == true) { + _selectedUuids.add(unit.name); + } else { + _selectedUuids.remove(unit.name); + } + }); + }, + ), + title: Text(unit.name), + subtitle: Text('Base: ${unit.baseUnit} • Symbol: ${unit.symbol}'), + ), + ); + } +} + diff --git a/lib/ai/unit_dropdown.dart b/lib/ai/unit_dropdown.dart index 830b250..b7a608d 100644 --- a/lib/ai/unit_dropdown.dart +++ b/lib/ai/unit_dropdown.dart @@ -22,34 +22,33 @@ class UnitDropdown extends StatelessWidget { final availableUnits = unitNames.map((name) => corpus.getUnit(name)).toList(); return SizedBox( - width: 200, // Constrain dropdown width + width: 50, // Constrain dropdown width child: DropdownButton( value: selectedUnit ?? variable.unit, - selectedItemBuilder: (context) => availableUnits.map((unit) => - SizedBox( - width: 200, - child: Text(unit.symbol, overflow: TextOverflow.ellipsis), - ) - ).toList(), - icon: const Icon(Icons.arrow_drop_down), - elevation: 16, - style: TextStyle(color: Theme.of(context).colorScheme.primary, fontSize: 14), - underline: Container(height: 1, color: Theme.of(context).dividerColor), - onChanged: onUnitChanged, - items: availableUnits.map>((UnitSpec unit) { - return DropdownMenuItem( - value: unit.name, - child: SizedBox( - width: 200, // Fixed width for all items - child: Text("${unit.symbol} - ${unit.name}", - style: const TextStyle(fontSize: 14), - overflow: TextOverflow.ellipsis, + selectedItemBuilder: (context) => availableUnits + .map((unit) => SizedBox(width: 50, child: Text(unit.symbol, overflow: TextOverflow.ellipsis))) + .toList(), + icon: const Icon(Icons.arrow_drop_down), + elevation: 16, + style: TextStyle(color: Theme.of(context).colorScheme.primary, fontSize: 14), + underline: Container(height: 1, color: Theme.of(context).dividerColor), + onChanged: onUnitChanged, + items: availableUnits.map>((UnitSpec unit) { + return DropdownMenuItem( + value: unit.name, + child: SizedBox( + width: 300, // Fixed width for all items + child: Text( + "${unit.symbol} - ${unit.name}", + style: const TextStyle(fontSize: 14), + overflow: TextOverflow.ellipsis, + ), ), - ), - ); - }).toList(), - menuMaxHeight: 400, - isExpanded: true, + ); + }).toList(), + menuWidth: 300, + menuMaxHeight: 400, + isExpanded: true, ), ); } diff --git a/lib/corpus.dart b/lib/corpus.dart index a3691c1..28c7cd1 100644 --- a/lib/corpus.dart +++ b/lib/corpus.dart @@ -223,7 +223,7 @@ class Corpus{ /// Loads formula elements, making sure to load units first, then formulas /// to avoid dependency issues. - void loadFormulaElements(List elements) { + void loadFormulaElements(List elements, [bool replaceOnDuplicates = false]) { List units = []; List formulas = []; @@ -239,10 +239,10 @@ class Corpus{ } // Load units first to satisfy dependencies - loadUnits(units); + loadUnits(units, replaceOnDuplicates); // Then load formulas - loadFormulas(formulas); + loadFormulas(formulas, replaceOnDuplicates: replaceOnDuplicates, checkUnits: true); } /// Loads corpus from database elements diff --git a/lib/database/database_service.dart b/lib/database/database_service.dart index 532a38e..1767518 100644 --- a/lib/database/database_service.dart +++ b/lib/database/database_service.dart @@ -14,7 +14,6 @@ extension CorpusDatabaseExtension on FormulasDatabase { for (final element in elements) { try { final parsed = SetUtils.parseCorpusElements('[${element.elementText}]'); - print("PARSED:$element"); parsedElements.addAll(parsed); } catch (e) { print('Error parsing database element: $e'); diff --git a/lib/main.dart b/lib/main.dart index 38b389e..e1da391 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:d4rt_formulas/d4rt_formulas.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'ai/import_from_text_screen.dart'; import 'database/database_service.dart'; import 'service_locator.dart'; @@ -18,9 +19,13 @@ void main() async { runApp(const MyApp()); } +final GlobalKey<_CorpusLoaderState> corpusLoaderKey = GlobalKey<_CorpusLoaderState>(); + class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); + get corpusFuture => corpusLoaderKey.currentState?._corpusFuture; + @override Widget build(BuildContext context) { return MaterialApp( @@ -30,19 +35,41 @@ class MyApp extends StatelessWidget { } class CorpusLoader extends StatefulWidget { + CorpusLoader({Key? key}) : super(key: corpusLoaderKey); + @override - _CorpusLoaderState createState() => _CorpusLoaderState(); + State createState() => _CorpusLoaderState(); } class _CorpusLoaderState extends State { late Future _corpusFuture; + @override void initState() { super.initState(); _corpusFuture = loadCorpusFromDatabaseOrAssets(); } + void _handleImport() { + _corpusFuture.then((corpus) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ImportFromTextScreen( + corpus: corpus, + ), + ), + ).then((result) { + if (result) { + setState(() { + // Refresh the list when returning from import + }); + } + }); + }); + } + @override Widget build(BuildContext context) { return FutureBuilder( @@ -59,9 +86,19 @@ class _CorpusLoaderState extends State { // If the corpus is empty (user chose not to load default), we could handle that here // For now, just display the formula list return Scaffold( - appBar: AppBar(title: const Text('Formulas')), + appBar: AppBar( + title: const Text('Formulas'), + actions: [ + IconButton( + icon: const Icon(Icons.library_add), + tooltip: 'Import formulas', + onPressed: _handleImport, + ), + ], + ), body: FormulaList( corpus: snapshot.data!, + onImport: _handleImport, ), ); } diff --git a/lib/services/import_service.dart b/lib/services/import_service.dart new file mode 100644 index 0000000..6a425dd --- /dev/null +++ b/lib/services/import_service.dart @@ -0,0 +1,99 @@ +import 'dart:io'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:d4rt_formulas/formula_models.dart'; +import 'package:d4rt_formulas/set_utils.dart'; +import 'package:d4rt_formulas/error_handler.dart'; + +/// Service to handle import of formula elements from shared files or text +class ImportService { + static final ImportService _instance = ImportService._internal(); + factory ImportService() => _instance; + ImportService._internal(); + + /// Parses shared text content as formula elements + /// The text should be in the same format as files in ./assets/formulas + List parseSharedText(String text) { + try { + final List list = SetUtils.parseD4rtLiteral(text); + + final elements = []; + for (final item in list) { + if (item is Map) { + // Try to parse as Formula first (has 'd4rtCode' field) + if (item.containsKey('d4rtCode')) { + elements.add(Formula.fromSet(item)); + } + // Try to parse as UnitSpec (has 'name' and 'baseUnit' or 'isBase') + else if (item.containsKey('name')) { + elements.add(UnitSpec.fromSet(item)); + } + else { + throw ArgumentError('Unknown element type: $item'); + } + } + } + + return elements; + } catch (e, stack) { + errorHandler.notify(e, stack); + throw FormatException('Failed to parse shared text as formula elements: $e'); + } + } + + /// Parses a .d4rtf file content as formula elements + List parseD4rtfFile(String filePath) { + try { + final file = File(filePath); + if (!file.existsSync()) { + throw FileSystemException('File not found', filePath); + } + + final content = file.readAsStringSync(); + return parseSharedText(content); + } catch (e, stack) { + errorHandler.notify(e, stack); + throw FormatException('Failed to parse .d4rtf file: $e'); + } + } + + /// Listens for shared files (Android only for now) + Stream> get sharedFilesStream { + return ReceiveSharingIntent.instance.getMediaStream(); + } + + /// Gets initial shared media (for when app is launched via share) + Future> getInitialSharedMedia() async { + try { + return await ReceiveSharingIntent.instance.getInitialMedia(); + } catch (e, stack) { + errorHandler.notify(e, stack); + return []; + } + } + + /// Gets shared text (for when app receives text via share) + Future getSharedText() async { + try { + final media = await ReceiveSharingIntent.instance.getInitialMedia(); + // Note: In newer versions of receive_sharing_intent, TEXT type may not be available + // We check if media exists and try to get the path + if (media.isNotEmpty) { + return media.first.path; + } + return null; + } catch (e, stack) { + errorHandler.notify(e, stack); + return null; + } + } + + /// Clears the initial shared media after processing + Future clearInitialSharedMedia() async { + try { + // Note: resetInitialMedia() was removed in newer versions + // The media is automatically cleared after being read + } catch (e, stack) { + errorHandler.notify(e, stack); + } + } +} diff --git a/lib/set_utils.dart b/lib/set_utils.dart index 2c2ca0b..26140e0 100644 --- a/lib/set_utils.dart +++ b/lib/set_utils.dart @@ -37,7 +37,6 @@ abstract class SetUtils { } /// Escapes special characters in a string for use in D4RT literals - @deprecated static String escapeD4rtString(String input) { return input .replaceAll(r'\\', r'\\\\') // escape backslashes first @@ -75,9 +74,9 @@ abstract class SetUtils { /// Uses JSON-like formatting but for Dart language, with proper indentation. static String prettyPrint(dynamic value, {int indent = 0}) { if (value is String) { - return _prettyPrintString(value, indent); + return _prettyPrintString(value); } else if (value is num) { - return _prettyPrintNumber(value, indent); + return _prettyPrintNumber(value); } else if (value is Set) { return _prettyPrintSet(value, indent); } else if (value is List) { @@ -90,15 +89,15 @@ abstract class SetUtils { } /// Pretty prints a simple string, escaping special characters if needed. - static String _prettyPrintString(String s, int indent) { + static String _prettyPrintString(String s) { // Check if the string needs raw string formatting (newlines, $, backslashes, quotes) final needsRawString = s.contains('\n') || s.contains(r'$') || s.contains(r'\\') || s.contains('"'); - if (needsRawString) { - return _prettyPrintRawString(s, indent); + if (needsRawString && s != '"' ) { + return _prettyPrintRawString(s); } // Simple string with escaped quotes @@ -107,7 +106,7 @@ abstract class SetUtils { } /// Pretty prints a number. - static String _prettyPrintNumber(num n, int indent) { + static String _prettyPrintNumber(num n) { return n.toString(); } @@ -157,9 +156,16 @@ abstract class SetUtils { /// Pretty prints a raw string (for strings containing newlines, $, backslashes, etc.) /// Uses Dart's raw string syntax r"""...""" - static String _prettyPrintRawString(String s, int indent) { - // Escape triple quotes by replacing """ with ""\" - final escaped = s.replaceAll('"""', r'""\\"'); - return 'r"""$escaped"""'; + static String _prettyPrintRawString(String s) { + if( s == '"'){ + return "'\""; + } + if( s.contains('"""') && s.contains("'''") ){ + return escapeD4rtString(s); + } + if( s.contains('"""') ){ + return "r'''$s'''"; + } + return 'r"""$s"""'; } } \ No newline at end of file