Merge branch 'feature/embed-http-server'

This commit is contained in:
Álvaro González 2026-05-10 13:00:43 +02:00
commit 0c8bc9bd03
17 changed files with 448 additions and 115 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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"},

View file

@ -1,6 +1,5 @@
[
{"name": "Kelvin", "symbol": "K", "isBase": true},
{"name": "kelvin", "symbol": "K", "baseUnit": "Kelvin", "factor": 1},
{
"name": "Celsius",
"symbol": "°C",

View file

@ -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<FormulaList> createState() => _FormulaListState();
static String _formulaAndDependenciesToExportStringLiteral(Formula formula) {
final corpus = GetIt.instance.get<Corpus>();
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<FormulaList> {
@ -56,49 +105,6 @@ class _FormulaListState extends State<FormulaList> {
}).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<FormulaList> {
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: () {

View file

@ -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<FormulaScreen> {
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,6 +160,83 @@ class _FormulaScreenState extends State<FormulaScreen> {
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
@ -353,7 +434,7 @@ class _FormulaScreenState extends State<FormulaScreen> {
child: TextFormField(
readOnly: true,
enabled: true,
controller: TextEditingController(text: _result),
controller: TextEditingController(text: formatOutput(_result)),
decoration: const InputDecoration(
border: UnderlineInputBorder(),
filled: true,

View file

@ -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<Corpus> fromDatabaseElements(List<FormulaElement> 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<FormulaElement> withDependencies(Formula formula) {
final result = <FormulaElement>{};
@ -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);
}
}

View file

@ -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) {
} 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");
}
}
}

View file

@ -142,7 +142,9 @@ Future<Corpus> 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

View file

@ -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<String, List<int>> _extractedFiles = {};
WebAppServer({this.port = 8080});
/// Start the HTTP server
Future<void> 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<void> stop() async {
await _server?.close(force: true);
_server = null;
print('WebAppServer stopped');
}
/// Extract webapp.zip from assets into memory
Future<void> _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<int>;
}
}
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<int> 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,String>
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}' : '';
}

26
lib/value_formatter.dart Normal file
View file

@ -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();
}

View file

@ -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:

View file

@ -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

4
test.sh Executable file
View file

@ -0,0 +1,4 @@
#|/bin/bash
echo Es un test de CI/CD
echo date
uname -a

View file

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

View file

@ -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");
});
});
}