diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..83bb04b --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,26 @@ +name: run-on-push +on: + push: + branches: + - master + # - feature/embed-http-server + +jobs: + run-script: + runs-on: ubuntu-latest + steps: + - name: Run script 1 + run: uname -a + - name: Run script 2 + run: date + - name: Run script 3 + run: whoami + - name: Run script 4 + run: pwd + - name: Run script 5 + run: hostname -I + - name: Run script 6 + run: hostname + - uses: actions/checkout@v4 + - name: Run script 1 + run: ./test.sh diff --git a/Makefile b/Makefile index add553f..f4a2cb5 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ build-container: clean: flutter clean - [ -f $(DATABASEFILE) ] && rm $(DATABASEFILE) + [ -f $(DATABASEFILE) ] && rm $(DATABASEFILE) || true clean-container: rm -r .build-container-cache @@ -35,6 +35,13 @@ build-linux-debug-container: build-web-debug-container: $(FLUTTERW) build web --debug +# Zip web build for embedding as asset +assets/generated/webapp.zip: build/web + mkdir -p assets/generated + cd build/web && zip -r ../../assets/generated/webapp.zip . + +build-webapp-zip: assets/generated/webapp.zip + run-linux-debug-container: $(FLUTTERW) run -d linux diff --git a/TODO.md b/TODO.md index 4105a84..3b706fb 100644 --- a/TODO.md +++ b/TODO.md @@ -91,5 +91,7 @@ - Add a rule in Makefile to create a zip file with the contents of ./build/web in the ./assets/generated directory -> ./assets/generated/webapp.zip - Add webapp.zip as a flutter asset - In the /static path, serve the files contained in webapp.zip -- [ ] Ensure database is loaded if the file exist, and not use default corpus allways. +- [X] Ensure database is loaded if the file exist, and not use default corpus allways. +- [ ] Ensure more room for formula title in FormulaScreen. Maybe a marquee or another row for buttons or both. +- [ ] In android, images in description are not shown. - [ ] Make formulaSolver() asyncronous, and show a CircularProgressIndicator inside the output variable while the formula is being solved. Honor a new optinal parameter "timeout" in formulaSolver, that will throw a TimeoutException. diff --git a/assets/formulas/thermodynamics.d4rt b/assets/formulas/thermodynamics.d4rt index 7248302..f5246aa 100644 --- a/assets/formulas/thermodynamics.d4rt +++ b/assets/formulas/thermodynamics.d4rt @@ -19,7 +19,7 @@ This law combines Boyle's, Charles's and Avogadro's laws. """, "input": [ {"name": "n", "unit": "mole"}, - {"name": "T", "unit": "kelvin"}, + {"name": "T", "unit": "Kelvin"}, {"name": "V", "unit": "cubic meter"} ], "output": {"name": "P", "unit": "pascal"}, diff --git a/assets/units/temperature.d4rt.units b/assets/units/temperature.d4rt.units index 61c23c6..5c27bae 100644 --- a/assets/units/temperature.d4rt.units +++ b/assets/units/temperature.d4rt.units @@ -1,6 +1,5 @@ [ {"name": "Kelvin", "symbol": "K", "isBase": true}, - {"name": "kelvin", "symbol": "K", "baseUnit": "Kelvin", "factor": 1}, { "name": "Celsius", "symbol": "°C", diff --git a/lib/ai/formula_list.dart b/lib/ai/formula_list.dart index 198477d..a4fb90b 100644 --- a/lib/ai/formula_list.dart +++ b/lib/ai/formula_list.dart @@ -1,6 +1,8 @@ +import 'package:d4rt_formulas/error_handler.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; // For Clipboard import 'package:d4rt_formulas/formula_models.dart'; +import 'package:get_it/get_it.dart'; import '../corpus.dart'; import '../set_utils.dart'; import 'formula_screen.dart'; @@ -22,6 +24,53 @@ class FormulaList extends StatefulWidget { @override State createState() => _FormulaListState(); + + static String _formulaAndDependenciesToExportStringLiteral(Formula formula) { + final corpus = GetIt.instance.get(); + final dependencies = corpus.withDependencies(formula); + final dependenciesAsMap = dependencies.map((f) => f.toMap()).toList(); + for( final f in dependenciesAsMap ){ + f.remove("uuid"); + } + return SetUtils.prettyPrint(dependenciesAsMap); + } + + + static void shareFormula(Formula formula) async { + try { + final exportString = _formulaAndDependenciesToExportStringLiteral(formula); + + // Share the string + await share_plus.SharePlus.instance.share( + share_plus.ShareParams( + text: exportString, + subject: 'Sharing formula: ${formula.name}', + ), + ); + } catch (e, st) { + errorHandler.notify(e, st); + } + } + + static void copyFormula(BuildContext context, Formula formula) async { + try { + final exportString = _formulaAndDependenciesToExportStringLiteral(formula); + + // Copy to clipboard + await Clipboard.setData(ClipboardData(text: exportString)); + + // Show a snackbar to confirm + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Formula and dependencies copied to clipboard!'), + duration: Duration(seconds: 2), + ), + ); + } catch (e, st) { + errorHandler.notify(e, st); + } + } + } class _FormulaListState extends State { @@ -56,49 +105,6 @@ class _FormulaListState extends State { }).toList(); } - String _formulaAndDependenciesToExportStringLiteral(Formula formula) { - final dependencies = widget.corpus.withDependencies(formula); - final dependenciesAsMap = dependencies.map((f) => f.toMap()).toList(); - for( final f in dependenciesAsMap ){ - f.remove("uuid"); - } - return SetUtils.prettyPrint(dependenciesAsMap); - } - - void _shareFormula(Formula formula) async { - try { - final exportString = _formulaAndDependenciesToExportStringLiteral(formula); - - // Share the string - await share_plus.SharePlus.instance.share( - share_plus.ShareParams( - text: exportString, - subject: 'Sharing formula: ${formula.name}', - ), - ); - } catch (e) { - _showErrorDialog('Error sharing formula: $e'); - } - } - - void _copyFormula(Formula formula) async { - try { - final exportString = _formulaAndDependenciesToExportStringLiteral(formula); - - // Copy to clipboard - await Clipboard.setData(ClipboardData(text: exportString)); - - // Show a snackbar to confirm - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Formula and dependencies copied to clipboard!'), - duration: Duration(seconds: 2), - ), - ); - } catch (e) { - _showErrorDialog('Error copying formula: $e'); - } - } void _showErrorDialog(String message) { showDialog( @@ -150,41 +156,7 @@ class _FormulaListState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - PopupMenuButton( - icon: const Icon(Icons.share), - tooltip: 'Share or copy to clipboard', - onSelected: (value) { - if (value == 'share') { - _shareFormula(formula); - } else if (value == 'copy') { - _copyFormula(formula); - } - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'share', - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.share, size: 20), - const SizedBox(width: 8), - Flexible(child: Text('Share', softWrap: false)), - ], - ), - ), - PopupMenuItem( - value: 'copy', - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.copy, size: 20), - const SizedBox(width: 8), - Flexible(child: Text('Copy to clipboard', softWrap: false)), - ], - ), - ), - ], - ), + // TOTHINK: Add buttons here, but I don't know which ones ], ), onTap: () { diff --git a/lib/ai/formula_screen.dart b/lib/ai/formula_screen.dart index 672d82c..ffcc368 100644 --- a/lib/ai/formula_screen.dart +++ b/lib/ai/formula_screen.dart @@ -1,4 +1,5 @@ // dart +import 'package:d4rt_formulas/database/database_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus_latex/flutter_markdown_plus_latex.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; @@ -7,7 +8,10 @@ import '../formula_models.dart'; import '../formula_evaluator.dart'; import '../corpus.dart'; import '../error_handler.dart'; +import '../service_locator.dart'; +import '../value_formatter.dart'; import 'd4rt_editing_controller.dart'; +import 'formula_list.dart'; import 'unit_dropdown.dart'; import 'formula_editor.dart'; @@ -133,7 +137,7 @@ class _FormulaScreenState extends State { String? unit = formula.output.unit; if (unit != null && result is Number) { final converted = widget.corpus.convert(result, unit, _selectedOutputUnit!); - _result = converted.toStringAsFixed(2); + _result = formatOutput(converted); } else { _result = result?.toString(); } @@ -156,28 +160,105 @@ class _FormulaScreenState extends State { appBar: AppBar( title: Text(formula.name), actions: [ + PopupMenuButton( + icon: const Icon(Icons.share), + tooltip: 'Share or copy to clipboard', + onSelected: (value) { + if (value == 'share') { + FormulaList.shareFormula(formula.originalFormula); + } else if (value == 'copy') { + FormulaList.copyFormula(context, formula.originalFormula); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'share', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.share, size: 20), + const SizedBox(width: 8), + Flexible(child: Text('Share', softWrap: false)), + ], + ), + ), + PopupMenuItem( + value: 'copy', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.copy, size: 20), + const SizedBox(width: 8), + Flexible(child: Text('Copy to clipboard', softWrap: false)), + ], + ), + ), + ], + ), + + IconButton( + icon: const Icon(Icons.delete_forever), + onPressed: () { + print( "Borrando"); + showAlertDialog(BuildContext context) { + // set up the buttons + Widget cancelButton = TextButton( + child: Text("Cancel"), + onPressed: () { + Navigator.of(context).pop(); + }, + ); + Widget deleteButton = TextButton( + child: Text("Delete"), + onPressed: () { + widget.corpus.forgetFormula(formula.originalFormula); + getDatabase().deleteFormula(formula.originalFormula.uuid); + Navigator.of(context) + ..pop()..pop(); + }, + ); + + // set up the AlertDialog + AlertDialog alert = AlertDialog( + title: Text("Delete Formula"), + content: Text("Please confirm deletion of formula ${formula.name}"), + actions: [ + cancelButton, + deleteButton, + ], + ); + return alert; + } + // show the dialog + showDialog( + context: context, + builder: showAlertDialog + ); + }, + tooltip: "Delete formula" + ), IconButton( icon: const Icon(Icons.edit), onPressed: formula is DerivedFormula ? null : () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - FormulaEditor( - formula: formula as Formula, - corpus: widget.corpus, - onSave: (updatedFormula) { - widget.onSave?.call(updatedFormula); - setState(() { - formula = updatedFormula; - }); - }, - ), + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + FormulaEditor( + formula: formula as Formula, + corpus: widget.corpus, + onSave: (updatedFormula) { + widget.onSave?.call(updatedFormula); + setState(() { + formula = updatedFormula; + }); + }, ), - ); - }, + ), + ); + }, tooltip: formula is DerivedFormula ? 'Cannot edit derived formula' : 'Edit Formula', @@ -353,7 +434,7 @@ class _FormulaScreenState extends State { child: TextFormField( readOnly: true, enabled: true, - controller: TextEditingController(text: _result), + controller: TextEditingController(text: formatOutput(_result)), decoration: const InputDecoration( border: UnderlineInputBorder(), filled: true, diff --git a/lib/corpus.dart b/lib/corpus.dart index 28c7cd1..568815c 100644 --- a/lib/corpus.dart +++ b/lib/corpus.dart @@ -112,6 +112,7 @@ class Corpus{ throw ArgumentError("Duplicate unit:$unit"); } _allUnits[unit.name] = unit; + _baseToUnits[unit.baseUnit]?.remove(unit.name); _baseToUnits[unit.baseUnit]?.add(unit.name); } } @@ -245,13 +246,6 @@ class Corpus{ loadFormulas(formulas, replaceOnDuplicates: replaceOnDuplicates, checkUnits: true); } - /// Loads corpus from database elements - static Future fromDatabaseElements(List elements) async { - final corpus = Corpus(); - corpus.loadFormulaElements(elements); - return corpus; - } - /// Returns the formula, the units of the formula, and all the units from the corpus with the same base unit. List withDependencies(Formula formula) { final result = {}; @@ -281,4 +275,11 @@ class Corpus{ return result.toList(); } + void forgetFormula(Formula formula) { + for (final tag in formula.tags) { + _tags[tag]?.remove(formula); + } + _allFormulas.remove(formula.uuid); + } + } diff --git a/lib/formula_evaluator.dart b/lib/formula_evaluator.dart index e59e823..09385c1 100644 --- a/lib/formula_evaluator.dart +++ b/lib/formula_evaluator.dart @@ -415,6 +415,7 @@ Number functionSolver( while (iter < maxNewtonIters) { final Number y = f(x); + print( "iter: $iter x: $x y: $y"); if (y == 0 || y.abs() <= maxDelta) { return x; } @@ -422,7 +423,7 @@ Number functionSolver( final Number dy = numericalDerivative(x); if (dy == 0 || dy.abs() < 1e-12) { - throw NoSolutionException("Derivative is zero or too small, cannot continue Newton-Raphson."); + throw NoSolutionException("Derivative is zero or too small, cannot continue Newton-Raphson: $dy"); } final Number delta = y / dy; @@ -435,7 +436,7 @@ Number functionSolver( // If step exploded, cap the step to a reasonable multiple of `step` final Number maxStepAllowed = step * 1e6; if ((xNew - x).abs() > maxStepAllowed) { - xNew = x + (delta.isNegative ? -maxStepAllowed : maxStepAllowed); + xNew = x - (delta.isNegative ? -maxStepAllowed : maxStepAllowed); } x = xNew; @@ -446,9 +447,16 @@ Number functionSolver( try { return searchNewton(); - } catch (e) { - var approx = searchApproximately(hint, hint + step); - return binarySearch(approx[0], approx[1]); + } catch (e1) { + try { + var approx = searchApproximately(hint, hint + step); + return binarySearch(approx[0], approx[1]); + } + catch( e2 ){ + errorHandler.notify(e1); + errorHandler.notify(e2); + throw NoSolutionException("Failed to find a root using both Newton-Raphson and approximate search: $e1 -- $e2"); + } } } diff --git a/lib/main.dart b/lib/main.dart index 963180c..d2cdd97 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -142,7 +142,9 @@ Future loadCorpusFromDatabaseOrAssets() async { return defaultCorpus; } else { // Load corpus from database elements - return await Corpus.fromDatabaseElements(dbElements); + final corpus = Corpus(); + corpus.loadFormulaElements(dbElements, true); + return corpus; } } catch (e, st) { // If there's an error loading from database, fall back to default corpus diff --git a/lib/services/web_app_server.dart b/lib/services/web_app_server.dart new file mode 100644 index 0000000..ebc7da5 --- /dev/null +++ b/lib/services/web_app_server.dart @@ -0,0 +1,158 @@ +import 'dart:io'; +import 'package:archive/archive_io.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:path/path.dart' as path; + +/// HTTP server that serves webapp.zip contents at /static path +class WebAppServer { + HttpServer? _server; + final int port; + final Map> _extractedFiles = {}; + + WebAppServer({this.port = 8080}); + + /// Start the HTTP server + Future start() async { + if (_server != null) { + print('WebAppServer already running on port $_server!.port'); + return; + } + + await _extractWebAppZip(); + + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + print('WebAppServer started on http://localhost:${_server!.port}'); + + _server!.listen(_handleRequest); + } + + /// Stop the HTTP server + Future stop() async { + await _server?.close(force: true); + _server = null; + print('WebAppServer stopped'); + } + + /// Extract webapp.zip from assets into memory + Future _extractWebAppZip() async { + try { + // Load the zip file from assets + final zipData = await rootBundle.load('assets/generated/webapp.zip'); + final bytes = zipData.buffer.asUint8List(zipData.offsetInBytes, zipData.lengthInBytes); + + // Decode the ZIP archive + final archive = ZipDecoder().decodeBytes(bytes); + + // Extract all files into memory + _extractedFiles.clear(); + for (final file in archive) { + if (file.isFile && file.size > 0) { + _extractedFiles[file.name] = file.content as List; + } + } + + print('Extracted ${_extractedFiles.length} files from webapp.zip'); + } catch (e, st) { + print('Error extracting webapp.zip: $e'); + print(st); + rethrow; + } + } + + /// Handle incoming HTTP requests + void _handleRequest(HttpRequest request) { + try { + String uriPath = request.uri.path; + + // Only handle /static/* paths + if (uriPath.startsWith('/static')) { + String filePath = uriPath.substring('/static'.length); + if (filePath.startsWith('/')) { + filePath = filePath.substring(1); + } + + // Default to index.html if no file specified or path is /static/ + if (filePath.isEmpty) { + filePath = 'index.html'; + } + + _serveFile(request, filePath); + } else { + _sendNotFound(request, 'Not found'); + } + } catch (e, st) { + print('Error handling request: $e'); + print(st); + _sendError(request, 'Internal server error: $e'); + } + } + + /// Serve a file from the extracted zip + void _serveFile(HttpRequest request, String filePath) { + if (!_extractedFiles.containsKey(filePath)) { + _sendNotFound(request, 'File not found: $filePath'); + return; + } + + List fileData = _extractedFiles[filePath]!; + String contentType = _getContentType(filePath); + + request.response.headers.set('Content-Type', contentType); + request.response.headers.set('Content-Length', fileData.length.toString()); + request.response.add(fileData); + request.response.close(); + } + + /// Get MIME type based on file extension + String _getContentType(String filePath) { + // TODO: CHANGE TO A Map + String ext = path.extension(filePath).toLowerCase(); + switch (ext) { + case '.html': + return 'text/html; charset=utf-8'; + case '.js': + return 'application/javascript; charset=utf-8'; + case '.css': + return 'text/css; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.wasm': + return 'application/wasm'; + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.svg': + return 'image/svg+xml'; + case '.ico': + return 'image/x-icon'; + case '.txt': + return 'text/plain; charset=utf-8'; + default: + return 'application/octet-stream'; + } + } + + void _sendNotFound(HttpRequest request, String message) { + request.response.statusCode = HttpStatus.notFound; + request.response.headers.set('Content-Type', 'text/plain'); + request.response.write(message); + request.response.close(); + } + + void _sendError(HttpRequest request, String message) { + request.response.statusCode = HttpStatus.internalServerError; + request.response.headers.set('Content-Type', 'text/plain'); + request.response.write(message); + request.response.close(); + } + + /// Check if server is running + bool get isRunning => _server != null; + + /// Get server URL + String get url => _server != null ? 'http://localhost:${_server!.port}' : ''; +} diff --git a/lib/value_formatter.dart b/lib/value_formatter.dart new file mode 100644 index 0000000..1387bf2 --- /dev/null +++ b/lib/value_formatter.dart @@ -0,0 +1,26 @@ + + +import 'package:flutter/cupertino.dart'; + +String? formatOutput(dynamic result) { + if (result == null) return null; + return result.toString(); + + // Try to parse as number to format with commas + if (result is num) { + var tooMuchPrecision = result.toStringAsPrecision(21); + var parts = tooMuchPrecision.split("e"); + var exponent = parts.length > 1 ? "e${parts[1]}" : ""; + var endingWithZeroes = parts[0]; + while (endingWithZeroes.endsWith('0') && endingWithZeroes.contains('.')) { + endingWithZeroes = endingWithZeroes.substring(0, endingWithZeroes.length - 1); + } + if( endingWithZeroes.endsWith(".") ){ + endingWithZeroes = endingWithZeroes.substring(0, endingWithZeroes.length -1 ); + } + return endingWithZeroes + exponent; + } + + // Otherwise return raw string + return result.toString(); +} diff --git a/pubspec.lock b/pubspec.lock index cfbcee2..1c604da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.4.1" + archive: + dependency: "direct main" + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -688,6 +696,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" provider: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index edeadaa..ec95943 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: collection: share_plus: receive_sharing_intent: + archive: ^4.0.9 dev_dependencies: flutter_test: @@ -88,6 +89,7 @@ flutter: assets: - assets/units/ - assets/formulas/ + - assets/generated/webapp.zip # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..81f3763 --- /dev/null +++ b/test.sh @@ -0,0 +1,4 @@ +#|/bin/bash +echo Es un test de CI/CD +echo date +uname -a diff --git a/test/formula_solver_test.dart b/test/formula_solver_test.dart index b0407ae..d223127 100644 --- a/test/formula_solver_test.dart +++ b/test/formula_solver_test.dart @@ -1,7 +1,8 @@ +import 'dart:math' as Math; + import 'package:d4rt_formulas/formula_evaluator.dart'; import 'package:d4rt_formulas/formula_models.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'dart:math' as Math; void main() { @@ -22,6 +23,20 @@ void main() { expect( solution, closeTo(5, 1e-10)); }); + + test("Solve x formula", () { + final formula = Formula( + name: 'Test x', + input: [ + VariableSpec(name: 'x', unit: 'scalar'), + ], + output: VariableSpec(name: 'y', unit: 'scalar'), + d4rtCode: 'y = x;', + ); + + var solution = formulaSolver(formula, "x", {"y": 123456789}, maxDelta: 1e-10); + expect(solution, closeTo(123456789, 1e-10)); + }); }); group('Native functions', () { @@ -75,5 +90,4 @@ void main() { expect(root, closeTo(Math.log(2), 0.01)); }); }); - } diff --git a/test/value_formatter_test.dart b/test/value_formatter_test.dart new file mode 100644 index 0000000..534f711 --- /dev/null +++ b/test/value_formatter_test.dart @@ -0,0 +1,15 @@ +import 'package:d4rt_formulas/corpus.dart'; +import 'package:d4rt_formulas/defaults/default_corpus.dart'; +import 'package:d4rt_formulas/formula_evaluator.dart'; +import 'package:d4rt_formulas/value_formatter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Format', () { + test('1 is 1', () { + var s = formatOutput(1.0); + expect(s, "1"); + }); + }); +} +