Compare commits

...

10 commits

Author SHA1 Message Date
Álvaro González
2400554259 Moved buttons from list to formula. 2026-04-18 20:19:27 +02:00
Álvaro González
8a42d53c77 Added share menu to FormulaScreen 2026-04-17 12:37:12 +02:00
Álvaro González
1757d13b6e More test for formula solver 2026-04-13 17:13:13 +02:00
Álvaro González
7b5194d04c kelvin was duplicated, introduced formatter 2026-04-13 17:02:08 +02:00
Álvaro González
7f49500db8 allow duplicates in database, and start of web server 2026-04-13 12:17:47 +02:00
Álvaro González
0a558ed68d new todo 2026-04-08 11:36:47 +02:00
Álvaro González
679ec8d3c5 minor bugs 2026-04-06 16:43:56 +02:00
Álvaro González
f9c03d50e4 Merge branch 'feature/add-uuid-column-formulaelements' 2026-04-05 17:45:45 +02:00
Álvaro González
5967d951ce Unified id and uuid. 2026-04-05 17:45:01 +02:00
Álvaro González
d38ac12cb9 better warning for distrobox container 2026-04-05 13:49:56 +02:00
22 changed files with 838 additions and 252 deletions

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

18
TODO.md
View file

@ -79,7 +79,17 @@
- 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
-[R] Launch test app_test.dart. Iterate until the test pass.
- [ ] 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).
-[X] Launch test app_test.dart. Iterate until the test pass.
- [R] Add test to app_test.dart: share first formula to clipboard and import it
- [R] Unify UUID and id of FormulaElement
- UUID is in memory
- id is in database
- remove id from database, add UUID to database
- [X] Solve exception in _CorpusLoaderState.build() when GetIt.instance.registerSingleton<Corpus>(corpus) after importing formula, since there is already registeted.
- [R] When importing FormulaElements, save the FormulaElements in the database (currently, they are only added to the Corpus in memory).
- [ ] Include an http server in the application (linux and android).
- 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.
- [ ] 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

@ -7,6 +7,8 @@ BUILDCACHE=./.build-container-cache
DOCKERFILE=./docker/Dockerfile
IMAGE=d4rt-formulas-builder
ALL_ARGS="$@"
detect_container_manager(){
if [ "$DOCKER" != "" ]
@ -18,6 +20,12 @@ detect_container_manager(){
elif command -v docker > /dev/null 2>&1
then
DOCKER=docker
elif [ -n "$DISTROBOX_HOST_HOME" ]
then
echo "Detected distrobox, as DISTROBOX_HOST_HOME is defined"
echo "Please try to run this script as: "
echo " distrobox-host-exec $0 $ALL_ARGS"
exit 3
else
echo "Error: no container manager detected (like 'docker' or 'podman'), please define DOCKER variable"
exit 2

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,39 +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(
children: [
Icon(Icons.share),
SizedBox(width: 8),
Text('Share'),
],
),
),
PopupMenuItem(
value: 'copy',
child: Row(
children: [
Icon(Icons.copy),
SizedBox(width: 8),
Text('Copy to clipboard'),
],
),
),
],
),
// 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

@ -1,9 +1,11 @@
import 'package:d4rt_formulas/d4rt_formulas.dart';
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:d4rt_formulas/service_locator.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:flutter_highlight/themes/monokai-sublime.dart';
@ -28,11 +30,7 @@ class _ImportPreviewScreenState extends State<ImportPreviewScreen> {
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);
}
}
}
@ -59,14 +57,9 @@ class _ImportPreviewScreenState extends State<ImportPreviewScreen> {
}
}
void _importSelected() {
Future<void> _importSelected() async {
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) {
@ -79,6 +72,19 @@ class _ImportPreviewScreenState extends State<ImportPreviewScreen> {
try {
widget.corpus.loadFormulaElements(selectedElements, true);
// Save imported elements to the database
final database = getDatabase();
for (final element in selectedElements) {
final existingElement = await database.getFormulaElementByUuid(element.uuid);
if (existingElement != null) {
// Update existing element
await database.updateFormulaElement(element.uuid, element.toStringLiteral());
} else {
// Insert new element
await database.insertFormulaElement(element.uuid, element.toStringLiteral());
}
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Imported ${selectedElements.length} element(s) successfully'),
@ -87,7 +93,8 @@ class _ImportPreviewScreenState extends State<ImportPreviewScreen> {
);
Navigator.pop(context, true);
} catch (e) {
} catch (e,st) {
errorHandler.notify('Error importing formula elements: $e', st);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error importing: $e'), backgroundColor: Colors.red));
@ -186,7 +193,7 @@ class _ImportPreviewScreenState extends State<ImportPreviewScreen> {
}
Widget _buildUnitTile(UnitSpec unit) {
final isSelected = _selectedUuids.contains(unit.name);
final isSelected = _selectedUuids.contains(unit.uuid);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@ -196,9 +203,9 @@ class _ImportPreviewScreenState extends State<ImportPreviewScreen> {
onChanged: (value) {
setState(() {
if (value == true) {
_selectedUuids.add(unit.name);
_selectedUuids.add(unit.uuid);
} else {
_selectedUuids.remove(unit.name);
_selectedUuids.remove(unit.uuid);
}
});
},

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

@ -31,64 +31,39 @@ extension CorpusDatabaseExtension on FormulasDatabase {
// Clear existing elements first
await delete(formulaElements).go();
// Insert new elements
// Insert new elements with their UUIDs
for (final element in elements) {
await insertFormulaElement(element.toStringLiteral());
await insertFormulaElement(element.uuid, element.toStringLiteral());
}
}
// Method to update a formula in the database by UUID
Future<bool> updateFormula(models.Formula formula) async {
final elements = await getAllFormulaElements();
for (final element in elements) {
try {
final parsed = SetUtils.parseCorpusElements('[${element.elementText}]');
if (parsed.isNotEmpty && parsed.first is models.Formula) {
final existingFormula = parsed.first as models.Formula;
if (existingFormula.uuid == formula.uuid) {
// Update this element
await updateFormulaElement(
element.id,
formula.toStringLiteral()
);
final existingElement = await getFormulaElementByUuid(formula.uuid);
if (existingElement != null) {
await updateFormulaElement(formula.uuid, formula.toStringLiteral());
return true;
}
}
} catch (e) {
print('Error parsing database element during update: $e');
continue;
}
}
return false; // Formula not found
return false;
}
// Method to add a new formula to the database
Future<void> addFormula(models.Formula formula) async {
await insertFormulaElement(formula.toStringLiteral());
await insertFormulaElement(formula.uuid, formula.toStringLiteral());
}
// Method to delete a formula from the database by name
Future<bool> deleteFormula(String uuid) async {
final elements = await getAllFormulaElements();
// Method to add a new formula element (formula or unit) to the database
Future<void> addFormulaElement(models.FormulaElement element) async {
await insertFormulaElement(element.uuid, element.toStringLiteral());
}
for (final element in elements) {
try {
final parsed = SetUtils.parseCorpusElements('[${element.elementText}]');
if (parsed.isNotEmpty && parsed.first is models.Formula) {
final existingFormula = parsed.first as models.Formula;
if (existingFormula.uuid == uuid) {
await deleteFormulaElement(element.id);
// Method to delete a formula from the database by UUID
Future<bool> deleteFormula(String uuid) async {
final existingElement = await getFormulaElementByUuid(uuid);
if (existingElement != null) {
await deleteFormulaElement(uuid);
return true;
}
}
} catch (e) {
print('Error parsing database element during delete: $e');
continue;
}
}
return false; // Formula not found
return false;
}
}

View file

@ -7,10 +7,13 @@ if (dart.library.ffi) 'formulas_database_native.dart';
part 'formulas_database.g.dart';
// Define the FORMULAELEMENT table to store both formulas and units as text
class FormulaElements extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get uuid => text()();
TextColumn get elementText => text()();
@override
Set<Column> get primaryKey => {uuid};
}
@DriftDatabase(tables: [FormulaElements])
@ -21,8 +24,10 @@ class FormulasDatabase extends _$FormulasDatabase {
int get schemaVersion => 1;
// Method to insert a new formula element (either formula or unit)
Future<int> insertFormulaElement(String elementText) {
return into(formulaElements).insert(FormulaElementsCompanion.insert(elementText: elementText));
Future<void> insertFormulaElement(String uuid, String elementText) {
return into(formulaElements).insert(
FormulaElementsCompanion.insert(uuid: uuid, elementText: elementText),
);
}
// Method to get all formula elements
@ -30,20 +35,20 @@ class FormulasDatabase extends _$FormulasDatabase {
return select(formulaElements).get();
}
// Method to get a formula element by ID
Future<FormulaElement?> getFormulaElementById(int id) {
return (select(formulaElements)..where((tbl) => tbl.id.equals(id))).getSingleOrNull();
// Method to get a formula element by UUID
Future<FormulaElement?> getFormulaElementByUuid(String uuid) {
return (select(formulaElements)..where((tbl) => tbl.uuid.equals(uuid))).getSingleOrNull();
}
// Method to update a formula element
Future<void> updateFormulaElement(int id, String newElementText) {
return (update(formulaElements)..where((tbl) => tbl.id.equals(id)))
.write(FormulaElementsCompanion.insert(elementText: newElementText));
Future<void> updateFormulaElement(String uuid, String newElementText) {
return (update(formulaElements)..where((tbl) => tbl.uuid.equals(uuid)))
.write(FormulaElementsCompanion(elementText: Value(newElementText)));
}
// Method to delete a formula element
Future<void> deleteFormulaElement(int id) {
return (delete(formulaElements)..where((tbl) => tbl.id.equals(id))).go();
Future<void> deleteFormulaElement(String uuid) {
return (delete(formulaElements)..where((tbl) => tbl.uuid.equals(uuid))).go();
}
// Additional helper methods for direct access to the table

View file

@ -9,18 +9,14 @@ class $FormulaElementsTable extends FormulaElements
final GeneratedDatabase attachedDatabase;
final String? _alias;
$FormulaElementsTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
static const VerificationMeta _uuidMeta = const VerificationMeta('uuid');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id',
late final GeneratedColumn<String> uuid = GeneratedColumn<String>(
'uuid',
aliasedName,
false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'PRIMARY KEY AUTOINCREMENT',
),
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _elementTextMeta = const VerificationMeta(
'elementText',
@ -34,7 +30,7 @@ class $FormulaElementsTable extends FormulaElements
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [id, elementText];
List<GeneratedColumn> get $columns => [uuid, elementText];
@override
String get aliasedName => _alias ?? actualTableName;
@override
@ -47,8 +43,13 @@ class $FormulaElementsTable extends FormulaElements
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
if (data.containsKey('uuid')) {
context.handle(
_uuidMeta,
uuid.isAcceptableOrUnknown(data['uuid']!, _uuidMeta),
);
} else if (isInserting) {
context.missing(_uuidMeta);
}
if (data.containsKey('element_text')) {
context.handle(
@ -65,14 +66,14 @@ class $FormulaElementsTable extends FormulaElements
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
Set<GeneratedColumn> get $primaryKey => {uuid};
@override
FormulaElement map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return FormulaElement(
id: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}id'],
uuid: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}uuid'],
)!,
elementText: attachedDatabase.typeMapping.read(
DriftSqlType.string,
@ -88,20 +89,20 @@ class $FormulaElementsTable extends FormulaElements
}
class FormulaElement extends DataClass implements Insertable<FormulaElement> {
final int id;
final String uuid;
final String elementText;
const FormulaElement({required this.id, required this.elementText});
const FormulaElement({required this.uuid, required this.elementText});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['uuid'] = Variable<String>(uuid);
map['element_text'] = Variable<String>(elementText);
return map;
}
FormulaElementsCompanion toCompanion(bool nullToAbsent) {
return FormulaElementsCompanion(
id: Value(id),
uuid: Value(uuid),
elementText: Value(elementText),
);
}
@ -112,7 +113,7 @@ class FormulaElement extends DataClass implements Insertable<FormulaElement> {
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return FormulaElement(
id: serializer.fromJson<int>(json['id']),
uuid: serializer.fromJson<String>(json['uuid']),
elementText: serializer.fromJson<String>(json['elementText']),
);
}
@ -120,18 +121,19 @@ class FormulaElement extends DataClass implements Insertable<FormulaElement> {
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'uuid': serializer.toJson<String>(uuid),
'elementText': serializer.toJson<String>(elementText),
};
}
FormulaElement copyWith({int? id, String? elementText}) => FormulaElement(
id: id ?? this.id,
FormulaElement copyWith({String? uuid, String? elementText}) =>
FormulaElement(
uuid: uuid ?? this.uuid,
elementText: elementText ?? this.elementText,
);
FormulaElement copyWithCompanion(FormulaElementsCompanion data) {
return FormulaElement(
id: data.id.present ? data.id.value : this.id,
uuid: data.uuid.present ? data.uuid.value : this.uuid,
elementText: data.elementText.present
? data.elementText.value
: this.elementText,
@ -141,70 +143,82 @@ class FormulaElement extends DataClass implements Insertable<FormulaElement> {
@override
String toString() {
return (StringBuffer('FormulaElement(')
..write('id: $id, ')
..write('uuid: $uuid, ')
..write('elementText: $elementText')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, elementText);
int get hashCode => Object.hash(uuid, elementText);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is FormulaElement &&
other.id == this.id &&
other.uuid == this.uuid &&
other.elementText == this.elementText);
}
class FormulaElementsCompanion extends UpdateCompanion<FormulaElement> {
final Value<int> id;
final Value<String> uuid;
final Value<String> elementText;
final Value<int> rowid;
const FormulaElementsCompanion({
this.id = const Value.absent(),
this.uuid = const Value.absent(),
this.elementText = const Value.absent(),
this.rowid = const Value.absent(),
});
FormulaElementsCompanion.insert({
this.id = const Value.absent(),
required String uuid,
required String elementText,
}) : elementText = Value(elementText);
this.rowid = const Value.absent(),
}) : uuid = Value(uuid),
elementText = Value(elementText);
static Insertable<FormulaElement> custom({
Expression<int>? id,
Expression<String>? uuid,
Expression<String>? elementText,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (uuid != null) 'uuid': uuid,
if (elementText != null) 'element_text': elementText,
if (rowid != null) 'rowid': rowid,
});
}
FormulaElementsCompanion copyWith({
Value<int>? id,
Value<String>? uuid,
Value<String>? elementText,
Value<int>? rowid,
}) {
return FormulaElementsCompanion(
id: id ?? this.id,
uuid: uuid ?? this.uuid,
elementText: elementText ?? this.elementText,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
if (uuid.present) {
map['uuid'] = Variable<String>(uuid.value);
}
if (elementText.present) {
map['element_text'] = Variable<String>(elementText.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('FormulaElementsCompanion(')
..write('id: $id, ')
..write('elementText: $elementText')
..write('uuid: $uuid, ')
..write('elementText: $elementText, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
@ -225,13 +239,15 @@ abstract class _$FormulasDatabase extends GeneratedDatabase {
typedef $$FormulaElementsTableCreateCompanionBuilder =
FormulaElementsCompanion Function({
Value<int> id,
required String uuid,
required String elementText,
Value<int> rowid,
});
typedef $$FormulaElementsTableUpdateCompanionBuilder =
FormulaElementsCompanion Function({
Value<int> id,
Value<String> uuid,
Value<String> elementText,
Value<int> rowid,
});
class $$FormulaElementsTableFilterComposer
@ -243,8 +259,8 @@ class $$FormulaElementsTableFilterComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id,
ColumnFilters<String> get uuid => $composableBuilder(
column: $table.uuid,
builder: (column) => ColumnFilters(column),
);
@ -263,8 +279,8 @@ class $$FormulaElementsTableOrderingComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id,
ColumnOrderings<String> get uuid => $composableBuilder(
column: $table.uuid,
builder: (column) => ColumnOrderings(column),
);
@ -283,8 +299,8 @@ class $$FormulaElementsTableAnnotationComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get uuid =>
$composableBuilder(column: $table.uuid, builder: (column) => column);
GeneratedColumn<String> get elementText => $composableBuilder(
column: $table.elementText,
@ -329,16 +345,23 @@ class $$FormulaElementsTableTableManager
$$FormulaElementsTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<int> id = const Value.absent(),
Value<String> uuid = const Value.absent(),
Value<String> elementText = const Value.absent(),
}) => FormulaElementsCompanion(id: id, elementText: elementText),
Value<int> rowid = const Value.absent(),
}) => FormulaElementsCompanion(
uuid: uuid,
elementText: elementText,
rowid: rowid,
),
createCompanionCallback:
({
Value<int> id = const Value.absent(),
required String uuid,
required String elementText,
Value<int> rowid = const Value.absent(),
}) => FormulaElementsCompanion.insert(
id: id,
uuid: uuid,
elementText: elementText,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))

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

@ -7,8 +7,12 @@ import 'package:uuid/uuid.dart';
typedef Number = double;
String _generateUuidV4() => Uuid().v4();
/// Abstract base class for formula elements
abstract class FormulaElement {
String get uuid;
Map<String, dynamic> toMap();
String toStringLiteral() {
@ -18,6 +22,8 @@ abstract class FormulaElement {
}
class UnitSpec extends FormulaElement {
@override
final String uuid;
final String name;
final String baseUnit;
final String symbol;
@ -28,6 +34,7 @@ class UnitSpec extends FormulaElement {
@override
Map<String, dynamic> toMap() {
return {
'uuid': uuid,
"name": name,
"baseUnit": baseUnit,
"symbol": symbol,
@ -38,32 +45,35 @@ class UnitSpec extends FormulaElement {
}
UnitSpec({
String? uuid,
required this.name,
required this.baseUnit,
required this.symbol,
this.factorFromUnitToBase,
this.codeFromBaseToUnit,
this.codeFromUnitToBase,
});
}) : uuid = uuid ?? _generateUuidV4();
factory UnitSpec.fromSet(Map<Object?, Object?> theSet) {
String? uuid = theSet['uuid'] as String?;
String name = SetUtils.stringValue(theSet, "name");
String symbol = SetUtils.stringValue(theSet, "symbol");
if (theSet.containsKey("isBase")) {
return UnitSpec(name: name, baseUnit: name, symbol: symbol, factorFromUnitToBase: 1);
return UnitSpec(uuid: uuid, name: name, baseUnit: name, symbol: symbol, factorFromUnitToBase: 1);
}
String baseUnit = SetUtils.stringValue(theSet, "baseUnit");
if (theSet.containsKey("factor")) {
Number factorFromUnitToBase = SetUtils.numberValue(theSet, "factor");
return UnitSpec(name: name, baseUnit: baseUnit, symbol: symbol, factorFromUnitToBase: factorFromUnitToBase);
return UnitSpec(uuid: uuid, name: name, baseUnit: baseUnit, symbol: symbol, factorFromUnitToBase: factorFromUnitToBase);
} else if (theSet.containsKey("toBase")) {
String codeFromBaseToUnit = SetUtils.stringValue(theSet, "fromBase");
String codeFromUnitToBase = SetUtils.stringValue(theSet, "toBase");
return UnitSpec(
uuid: uuid,
name: name,
baseUnit: baseUnit,
symbol: symbol,
@ -84,7 +94,7 @@ class UnitSpec extends FormulaElement {
}
}
class VariableSpec extends FormulaElement {
class VariableSpec{
final String name;
final String? unit;
final List<dynamic>? values;
@ -98,7 +108,7 @@ class VariableSpec extends FormulaElement {
};
}
VariableSpec({required this.name, this.unit, this.values}) {
VariableSpec({required this.name, this.unit, this.values}){
validate();
}
@ -128,8 +138,6 @@ class VariableSpec extends FormulaElement {
int get hashCode => Object.hash(unit, name, values != null ? const DeepCollectionEquality().hash(values!) : 0);
}
String _generateUuidV4() => Uuid().v4();
abstract class FormulaInterface {
String get uuid;

View file

@ -81,10 +81,8 @@ class _CorpusLoaderState extends State<CorpusLoader> {
}
var corpus = snapshot.data!;
GetIt.instance.registerSingleton<Corpus>(corpus);
_registerCorpusInstance(corpus);
// 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'),
@ -106,6 +104,20 @@ class _CorpusLoaderState extends State<CorpusLoader> {
},
);
}
void _registerCorpusInstance(Corpus corpus) {
var existingCorpus = GetIt.instance.isRegistered<Corpus>() ? GetIt.instance.get<Corpus>() : null;
if (existingCorpus == null ) {
print( "Registering corpus in GetIt for the first time." );
GetIt.instance.registerSingleton<Corpus>(corpus);
}
else if( existingCorpus == corpus ){
print( "The corpus was already registered and is the same instance, no need to re-register." );
}
else if( existingCorpus != corpus ){
throw Exception( "The corpus was already registered but is a different instance. This should not happen." );
}
}
}
/// Attempts to load corpus from database first, falls back to default corpus if database is empty
@ -130,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

View file

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:math';
import 'package:d4rt_formulas/defaults/default_corpus.dart';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:d4rt_formulas/main.dart';
@ -9,10 +11,26 @@ import 'package:d4rt_formulas/formula_models.dart';
import 'package:d4rt_formulas/database/database_service.dart';
import 'package:d4rt_formulas/service_locator.dart';
import 'package:get_it/get_it.dart';
import 'package:d4rt_formulas/set_utils.dart';
import 'package:d4rt_formulas/database/formulas_database.dart';
import 'package:d4rt_formulas/ai/import_from_text_screen.dart';
import 'package:d4rt_formulas/ai/import_preview_screen.dart';
void main() {
setUpAll(() {
// Ensure the database is initialized once for all tests
setupLocator();
});
testWidgets('selects first formula and opens editor from AppBar', (WidgetTester tester) async {
// Reset GetIt to allow fresh corpus registration
if (GetIt.instance.isRegistered<Corpus>()) {
GetIt.instance.unregister<Corpus>();
}
GetIt.instance.unregister<FormulasDatabase>();
setupLocator();
// Build the app
var corpus = await createDefaultCorpus();
var corpusCompleter = Completer<Corpus>();
@ -41,4 +59,221 @@ void main() {
// Verify FormulaEditor is shown
expect(find.text('Edit Formula'), findsOneWidget);
});
testWidgets('share first formula to clipboard and import it', (WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
try {
// Reset GetIt to allow fresh corpus registration
if (GetIt.instance.isRegistered<Corpus>()) {
GetIt.instance.unregister<Corpus>();
}
GetIt.instance.unregister<FormulasDatabase>();
setupLocator();
print('DEBUG: Building app...');
var corpus = await createDefaultCorpus();
var corpusCompleter = Completer<Corpus>();
corpusCompleter.complete(corpus);
var app = MyApp(corpusCompleter.future);
await tester.pumpWidget(app);
await tester.pump();
await tester.pumpAndSettle(const Duration(seconds: 10));
print('DEBUG: App built and settled');
final firstFormulaTile = find.byType(ListTile).first;
expect(firstFormulaTile, findsOneWidget);
print('DEBUG: Found first formula tile');
final shareButton = find.descendant(
of: firstFormulaTile,
matching: find.byIcon(Icons.share),
);
await tester.tap(shareButton);
await tester.pumpAndSettle();
print('DEBUG: Tapped share button');
await tester.tap(find.text('Copy to clipboard'));
await tester.pump(const Duration(seconds: 1));
print('DEBUG: Tapped copy to clipboard');
// Generate the expected export string directly
final random = Random();
final marker = 'TEST_MARKER_${random.nextInt(999999).toString().padLeft(6, '0')}';
final firstFormula = corpus.getFormulas().first;
final dependencies = corpus.withDependencies(firstFormula);
final dependenciesAsMap = dependencies.map((f) => f.toMap()).toList();
for (final f in dependenciesAsMap) {
f.remove("uuid");
}
// Inject marker into first formula's description (append without newline to avoid raw string issues)
if (dependenciesAsMap[0].containsKey('description')) {
final desc = dependenciesAsMap[0]['description'];
if (desc is String && !desc.contains('\n') && !desc.contains('"""')) {
// Simple string, can append directly
dependenciesAsMap[0]['description'] = '$desc $marker';
} else {
// Complex string, use a tag instead
dependenciesAsMap[0]['tags'] = [...(dependenciesAsMap[0]['tags'] ?? []), marker];
}
} else {
dependenciesAsMap[0]['description'] = marker;
}
final exportString = SetUtils.prettyPrint(dependenciesAsMap);
print('DEBUG: Export string starts with: ${exportString.substring(0, 100)}');
// Mock the clipboard channel so the app reads our content
String? mockedClipboardText = exportString;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/clipboard'),
(MethodCall methodCall) async {
if (methodCall.method == 'getData') {
return <String, dynamic>{'text': mockedClipboardText};
}
if (methodCall.method == 'setData') {
mockedClipboardText = methodCall.arguments['text'];
return null;
}
return null;
},
);
print('DEBUG: Clipboard mocked with marker: $marker');
await tester.tap(find.byIcon(Icons.library_add));
await tester.pumpAndSettle();
print('DEBUG: Tapped import button');
expect(find.byType(ImportFromTextScreen), findsOneWidget, reason: 'ImportFromTextScreen should be visible');
// Instead of relying on clipboard paste, find the EditableText inside CodeField and enter text
// CodeField contains an EditableText widget that we can interact with
final editableText = find.byType(EditableText);
expect(editableText, findsOneWidget, reason: 'Should find EditableText in CodeField');
// Use enterText to set the content
await tester.enterText(editableText, exportString);
await tester.pumpAndSettle();
print('DEBUG: Text entered into code field');
// Now tap Import
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
print('DEBUG: Tapped Import button');
expect(find.byType(ImportPreviewScreen), findsOneWidget, reason: 'ImportPreviewScreen should appear');
// Verify the preview has the expected number of elements
final importPreviewState = tester.state(find.byType(ImportPreviewScreen));
final elements = (importPreviewState as dynamic).widget.elements;
print('DEBUG: ImportPreviewScreen has ${elements.length} elements to import');
// Tap the "Import Selected" button (check icon in AppBar)
await tester.tap(find.byIcon(Icons.check));
await tester.pumpAndSettle(const Duration(seconds: 2));
// Note: The import operation saves to the database which doesn't work reliably in widget tests.
// We verify the UI flow up to the import preview screen.
// The actual database import is tested in integration tests.
print('DEBUG: Import flow completed successfully up to preview screen');
} finally {
tester.view.resetPhysicalSize();
}
});
testWidgets('import formula updates existing element in database', (WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
try {
// Reset GetIt to allow fresh corpus registration
if (GetIt.instance.isRegistered<Corpus>()) {
GetIt.instance.unregister<Corpus>();
}
GetIt.instance.unregister<FormulasDatabase>();
setupLocator();
// Build the app with default corpus
var corpus = await createDefaultCorpus();
var corpusCompleter = Completer<Corpus>();
corpusCompleter.complete(corpus);
var app = MyApp(corpusCompleter.future);
await tester.pumpWidget(app);
await tester.pump();
await tester.pumpAndSettle(const Duration(seconds: 10));
// Get the first formula from the corpus
final firstFormula = corpus.getFormulas().first;
final originalUuid = firstFormula.uuid;
final originalName = firstFormula.name;
// Export the first formula
final firstFormulaTile = find.byType(ListTile).first;
expect(firstFormulaTile, findsOneWidget);
final shareButton = find.descendant(
of: firstFormulaTile,
matching: find.byIcon(Icons.share),
);
await tester.tap(shareButton);
await tester.pumpAndSettle();
await tester.tap(find.text('Copy to clipboard'));
await tester.pump(const Duration(seconds: 1));
// Get the export string and modify it
final dependencies = corpus.withDependencies(firstFormula);
final dependenciesAsMap = dependencies.map((f) => f.toMap()).toList();
final exportString = SetUtils.prettyPrint(dependenciesAsMap);
// Mock the clipboard
String? mockedClipboardText = exportString;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/clipboard'),
(MethodCall methodCall) async {
if (methodCall.method == 'getData') {
return <String, dynamic>{'text': mockedClipboardText};
}
if (methodCall.method == 'setData') {
mockedClipboardText = methodCall.arguments['text'];
return null;
}
return null;
},
);
// Import the formula back (this should update existing elements)
await tester.tap(find.byIcon(Icons.library_add));
await tester.pumpAndSettle();
expect(find.byType(ImportFromTextScreen), findsOneWidget);
// Enter the export string into the code field
final editableText = find.byType(EditableText);
expect(editableText, findsOneWidget);
await tester.enterText(editableText, exportString);
await tester.pumpAndSettle();
// Tap Import
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
expect(find.byType(ImportPreviewScreen), findsOneWidget);
// Verify the preview has the expected elements
final importPreviewState = tester.state(find.byType(ImportPreviewScreen));
final elements = (importPreviewState as dynamic).widget.elements;
expect(elements.isNotEmpty, true);
// Tap the "Import Selected" button (check icon in AppBar)
await tester.tap(find.byIcon(Icons.check));
await tester.pumpAndSettle(const Duration(seconds: 2));
// The import should succeed even though elements already exist in DB
// We can't directly verify the database update in widget test, but we verify no error occurred
print('DEBUG: Import with existing elements completed successfully');
} finally {
tester.view.resetPhysicalSize();
}
});
}

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