Merge branch 'docker' into apgar-test
This commit is contained in:
commit
7ed9529f7a
30 changed files with 247 additions and 151 deletions
21
Dockerfile
Normal file
21
Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Use the official Flutter SDK image
|
||||||
|
FROM ghcr.io/cirruslabs/flutter:stable
|
||||||
|
|
||||||
|
# Install cmake, ninja, clang, pkg-config for flutter linux
|
||||||
|
RUN apt-get update && apt-get install -y cmake ninja-build clang pkg-config libgtk-3-dev liblzma-dev
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Configure cache directories
|
||||||
|
ENV PUB_CACHE=/cache/pub-cache
|
||||||
|
ENV GRADLE_USER_HOME=/cache/gradle-cache
|
||||||
|
RUN mkdir -p $PUB_CACHE $GRADLE_USER_HOME
|
||||||
|
|
||||||
|
# Copy pubspec files and get dependencies
|
||||||
|
# COPY pubspec.yaml pubspec.lock ./
|
||||||
|
# RUN flutter pub get
|
||||||
|
|
||||||
|
# Copy the rest of the application code and build
|
||||||
|
# Commented out to avoid building the app during image creation, this will be handled externally by makefile
|
||||||
|
# COPY . .
|
||||||
|
# RUN flutter build apk --release
|
||||||
25
Makefile
Normal file
25
Makefile
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
|
||||||
|
flutter-container-exec = podman-compose run --entrypoint "$(1)" flutter
|
||||||
|
|
||||||
|
all: clean-podman build-linux-debug-podman build-linux-debug-podman
|
||||||
|
|
||||||
|
build-podman:
|
||||||
|
podman-compose build
|
||||||
|
|
||||||
|
clean-podman: build-podman
|
||||||
|
$(call flutter-container-exec, flutter clean)
|
||||||
|
|
||||||
|
pub-get-podman: build-podman
|
||||||
|
$(call flutter-container-exec, flutter pub get)
|
||||||
|
|
||||||
|
build-android-release-podman: pub-get-podman
|
||||||
|
$(call flutter-container-exec, flutter build apk --release)
|
||||||
|
|
||||||
|
build-linux-debug-podman: pub-get-podman
|
||||||
|
$(call flutter-container-exec, flutter build linux --debug)
|
||||||
|
|
||||||
|
run-linux-debug: build-linux-debug-podman
|
||||||
|
build/linux/x64/debug/bundle/d4rt_formulas
|
||||||
|
|
||||||
|
run-web-release-podman: build-web-release-podman
|
||||||
|
cd build/web && python3 -m http.server 8080
|
||||||
12
README.md
12
README.md
|
|
@ -1,3 +1,5 @@
|
||||||
|
https://github.com/Shahxad-Akram/flutter_tex/blob/master/example/lib/tex_view_markdown_example.dart
|
||||||
|
|
||||||
# Math Formulae Manager
|
# Math Formulae Manager
|
||||||
|
|
||||||
A comprehensive command-line application for managing and computing mathematical formulas across various disciplines including mathematics, physics, medicine, and engineering.
|
A comprehensive command-line application for managing and computing mathematical formulas across various disciplines including mathematics, physics, medicine, and engineering.
|
||||||
|
|
@ -123,15 +125,11 @@ Each formula includes:
|
||||||
- **Images** - Visual diagrams, graphs, or illustrations
|
- **Images** - Visual diagrams, graphs, or illustrations
|
||||||
- **Examples** - Sample calculations and use cases
|
- **Examples** - Sample calculations and use cases
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
- `bin/` - Main executable and entry point
|
|
||||||
- `lib/` - Core library code and formula engine
|
|
||||||
- `test/` - Unit tests and formula validation tests
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
[Installation and usage instructions to be added]
|
This project uses `flutter`, so a valid installation is needed in order to build it.
|
||||||
|
|
||||||
|
For convenience, a containerized build is provided. It is based on `podman` and `podman-compose`. See [Makefile](Makefile) for details.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,20 +126,25 @@ Where:
|
||||||
"name": "Apgar Score",
|
"name": "Apgar Score",
|
||||||
"description": "Newborn health assessment scoring system\n\nScores 0-2 for:\n1. Heart rate\n2. Breathing\n3. Muscle tone\n4. Reflexes\n5. Skin color\nTotal score 0-10",
|
"description": "Newborn health assessment scoring system\n\nScores 0-2 for:\n1. Heart rate\n2. Breathing\n3. Muscle tone\n4. Reflexes\n5. Skin color\nTotal score 0-10",
|
||||||
"input": [
|
"input": [
|
||||||
{"name": "HeartRate", "values": ["hr1", "hr2", "hr3"] },
|
{"name": "HeartRate", "values": ["Absent", "< 100 bpm>", "> 100 bpm"] },
|
||||||
{"name": "Breathing", "values": ["hr1", "hr2", "hr3"] },
|
{"name": "Breathing", "values": ["Absent", "Weak, irregular", "Strong, robust cry"] },
|
||||||
{"name": "MuscleTone", "values": ["hr1", "hr2", "hr3"] },
|
{"name": "MuscleTone", "values": ["None", "Some", "Flexed arms/leg, resists extension"] },
|
||||||
{"name": "Reflexes", "values": ["hr1", "hr2", "hr3"] },
|
{"name": "Reflexes", "values": ["No response", "Grimace on aggressive stimulation", "Cry on stimulation"] },
|
||||||
{"name": "SkinColor", "values": ["hr1", "hr2", "hr3"] }
|
{"name": "SkinColor", "values": ["Blue or pale", "Blue extremities, pink body", "Pink"] }
|
||||||
],
|
],
|
||||||
"output": {"name": "Result", "unit": "scalar"},
|
"output": {"name": "Result", "unit": "scalar"},
|
||||||
"d4rtCode": """
|
"d4rtCode": """
|
||||||
var total = HeartRate + Breathing + MuscleTone + Reflexes + SkinColor;
|
var total = HeartRate + Breathing + MuscleTone + Reflexes + SkinColor;
|
||||||
var interpretation = switch (total) {
|
late var interpretation;
|
||||||
>= 7 => 'Normal',
|
if( total < 4 ) {
|
||||||
4-6 => 'Requires attention',
|
interpretation = 'Critical condition';
|
||||||
_ => 'Emergency care needed'
|
}
|
||||||
};
|
else if( total < 7 ){
|
||||||
|
interpretation = 'Needs assistance';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
interpretation = 'Normal';
|
||||||
|
}
|
||||||
Result = 'Score: \$total - \$interpretation';
|
Result = 'Score: \$total - \$interpretation';
|
||||||
""",
|
""",
|
||||||
"tags": ["medical", "pediatrics", "assessment"]
|
"tags": ["medical", "pediatrics", "assessment"]
|
||||||
3
assets/units/scalar.d4rt.units
Normal file
3
assets/units/scalar.d4rt.units
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[
|
||||||
|
{"name": "scalar", "symbol": "", "isBase": true},
|
||||||
|
]
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
flutter:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: d4rt-formulas-builder
|
||||||
|
volumes:
|
||||||
|
- ./.build-container-cache:/cache:z
|
||||||
|
- .:/app:z # Link the current directory to /app in the container
|
||||||
|
environment:
|
||||||
|
- FLUTTER_FLAVOR=prod # Example environment variable, adjust as needed
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# launch as: firejail --profile=firejail-warp-terminal.profile /opt/warpdotdev/warp-terminal/warp
|
|
||||||
private /home/alvaro/repos/d4rt_formulas/
|
|
||||||
blacklist /datos-1T
|
|
||||||
blacklist /datos-luks/
|
|
||||||
blacklist /media
|
|
||||||
|
|
||||||
# net none # disable network
|
|
||||||
# noroot # don't run as root
|
|
||||||
caps.drop all # drop Linux capabilities
|
|
||||||
# seccomp # enable syscall filtering
|
|
||||||
First version of a formula widget
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import '../formula_models.dart';
|
import '../formula_models.dart';
|
||||||
import '../formula_evaluator.dart';
|
import '../formula_evaluator.dart';
|
||||||
|
|
@ -11,24 +9,22 @@ class FormulaScreen extends StatefulWidget {
|
||||||
final Formula formula;
|
final Formula formula;
|
||||||
final Corpus corpus;
|
final Corpus corpus;
|
||||||
|
|
||||||
const FormulaScreen({
|
const FormulaScreen({super.key, required this.formula, required this.corpus});
|
||||||
super.key,
|
|
||||||
required this.formula,
|
|
||||||
required this.corpus,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FormulaScreen> createState() => _FormulaScreenState();
|
State<FormulaScreen> createState() => _FormulaScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Create VariableWidget. Depending on VariableSpec.values, it can be a ValueDropdown or a D4rtEditingController
|
||||||
|
// The d4rtValue will be FormulaResult?
|
||||||
|
|
||||||
//// Start of D4rtEditingController class ////
|
//// Start of D4rtEditingController class ////
|
||||||
class D4rtEditingController extends TextEditingController {
|
class D4rtEditingController extends TextEditingController {
|
||||||
String? _lastError;
|
String? _lastError;
|
||||||
|
|
||||||
String? get lastError => _lastError;
|
String? get lastError => _lastError;
|
||||||
FormulaResult? _lastValue;
|
FormulaResult? _lastValue;
|
||||||
|
|
||||||
D4rtEditingController({String? text}) : super(text: text);
|
D4rtEditingController({super.text});
|
||||||
|
|
||||||
bool validate() {
|
bool validate() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -44,9 +40,8 @@ class D4rtEditingController extends TextEditingController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get d4rtValue => _lastValue;
|
FormulaResult? get d4rtValue => _lastValue;
|
||||||
|
|
||||||
@override
|
|
||||||
set text(String newText) {
|
set text(String newText) {
|
||||||
super.text = newText;
|
super.text = newText;
|
||||||
validate();
|
validate();
|
||||||
|
|
@ -97,27 +92,37 @@ class _FormulaScreenState extends State<FormulaScreen> {
|
||||||
final inputValues = <String, dynamic>{};
|
final inputValues = <String, dynamic>{};
|
||||||
for (final input in widget.formula.input) {
|
for (final input in widget.formula.input) {
|
||||||
final controller = _inputControllers[input.name]!;
|
final controller = _inputControllers[input.name]!;
|
||||||
if( controller.d4rtValue == null ){
|
if (controller.d4rtValue == null) {
|
||||||
throw FormulaEvaluationException( "Field ${input.name} is invalid" );
|
//throw FormulaEvaluationException("Field ${input.name} is invalid");
|
||||||
|
_result = "";
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
final value = controller.d4rtValue.value;
|
|
||||||
|
|
||||||
|
late final dynamic convertedValue;
|
||||||
|
|
||||||
|
switch (controller.d4rtValue) {
|
||||||
|
case NumberResult nr:
|
||||||
// Convert input to base unit if needed
|
// Convert input to base unit if needed
|
||||||
// Always convert from dropdown unit to variable's base unit
|
// Always convert from dropdown unit to variable's base unit
|
||||||
late final convertedValue;
|
if (input.unit != null) {
|
||||||
if( value is Number && input.unit != null ) {
|
|
||||||
convertedValue = widget.corpus.convert(
|
convertedValue = widget.corpus.convert(
|
||||||
value,
|
nr.value,
|
||||||
_selectedUnits[input.name]!,
|
_selectedUnits[input.name]!,
|
||||||
input.unit as String,
|
input.unit as String,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
convertedValue = nr.value;
|
||||||
}
|
}
|
||||||
else{
|
|
||||||
convertedValue = value;
|
case StringResult sr:
|
||||||
|
convertedValue = sr.value;
|
||||||
|
default:
|
||||||
|
throw FormulaEvaluationException(
|
||||||
|
"Field ${input.name} has unsupported type ${controller.d4rtValue!.runtimeType}",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
inputValues[input.name] = convertedValue;
|
inputValues[input.name] = convertedValue;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final evaluator = FormulaEvaluator();
|
final evaluator = FormulaEvaluator();
|
||||||
|
|
@ -125,14 +130,11 @@ class _FormulaScreenState extends State<FormulaScreen> {
|
||||||
|
|
||||||
// Convert output to selected unit if needed
|
// Convert output to selected unit if needed
|
||||||
String? unit = widget.formula.output.unit;
|
String? unit = widget.formula.output.unit;
|
||||||
if( unit != null ) {
|
if (unit != null) {
|
||||||
_result = widget.corpus.convert(
|
_result = widget.corpus
|
||||||
result,
|
.convert(result, unit, _selectedOutputUnit!)
|
||||||
unit,
|
.toStringAsFixed(2);
|
||||||
_selectedOutputUnit!,
|
} else {
|
||||||
).toStringAsFixed(2);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
_result = result;
|
_result = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,9 +157,7 @@ class _FormulaScreenState extends State<FormulaScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: Text(widget.formula.name)),
|
||||||
title: Text(widget.formula.name),
|
|
||||||
),
|
|
||||||
body: Form(
|
body: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
@ -186,9 +186,9 @@ class _FormulaScreenState extends State<FormulaScreen> {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Description',
|
'Description',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -213,9 +213,9 @@ class _FormulaScreenState extends State<FormulaScreen> {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Input Variables',
|
'Input Variables',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
...widget.formula.input.map((variable) => _buildVariableRow(variable)),
|
...widget.formula.input.map((variable) => _buildVariableRow(variable)),
|
||||||
|
|
@ -229,9 +229,9 @@ class _FormulaScreenState extends State<FormulaScreen> {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Result',
|
'Result',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -286,6 +286,7 @@ class _FormulaScreenState extends State<FormulaScreen> {
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
border: UnderlineInputBorder(),
|
border: UnderlineInputBorder(),
|
||||||
),
|
),
|
||||||
|
autovalidateMode: AutovalidateMode.always,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return 'Required';
|
return 'Required';
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ class Corpus{
|
||||||
return _allFormulas.values.toList(growable:false);
|
return _allFormulas.values.toList(growable:false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Formula? getFormula(String name) {
|
||||||
|
return _allFormulas.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
final Multimap<String, String> _baseToUnits = Multimap.create();
|
final Multimap<String, String> _baseToUnits = Multimap.create();
|
||||||
final Map<String, UnitSpec> _allUnits = {};
|
final Map<String, UnitSpec> _allUnits = {};
|
||||||
|
|
||||||
|
|
@ -73,7 +77,7 @@ class Corpus{
|
||||||
|
|
||||||
List<String> unitsOfSameMagnitude(String? unit){
|
List<String> unitsOfSameMagnitude(String? unit){
|
||||||
if( unit == null ){
|
if( unit == null ){
|
||||||
return ["unitless"];
|
return ["scalar"];
|
||||||
}
|
}
|
||||||
final base = getUnit(unit).baseUnit;
|
final base = getUnit(unit).baseUnit;
|
||||||
return _baseToUnits[base] as List<String>;
|
return _baseToUnits[base] as List<String>;
|
||||||
|
|
@ -90,7 +94,7 @@ class Corpus{
|
||||||
|
|
||||||
String _converterFromCodeStringAsExpression(Number x, String codeString) {
|
String _converterFromCodeStringAsExpression(Number x, String codeString) {
|
||||||
final buffer = StringBuffer();
|
final buffer = StringBuffer();
|
||||||
buffer.writeln("final x = ${x};");
|
buffer.writeln("final x = $x;");
|
||||||
buffer.writeln("main(){return $codeString;}");
|
buffer.writeln("main(){return $codeString;}");
|
||||||
final code = buffer.toString();
|
final code = buffer.toString();
|
||||||
return code;
|
return code;
|
||||||
|
|
@ -98,7 +102,7 @@ class Corpus{
|
||||||
|
|
||||||
String _converterFromCodeStringAsStatement(Number x, String codeString) {
|
String _converterFromCodeStringAsStatement(Number x, String codeString) {
|
||||||
final buffer = StringBuffer();
|
final buffer = StringBuffer();
|
||||||
buffer.writeln("final x = ${x};");
|
buffer.writeln("final x = $x;");
|
||||||
buffer.writeln("main(){ $codeString; return x; }");
|
buffer.writeln("main(){ $codeString; return x; }");
|
||||||
final code = buffer.toString();
|
final code = buffer.toString();
|
||||||
return code;
|
return code;
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,48 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert' show utf8;
|
import 'dart:convert' show utf8;
|
||||||
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
|
||||||
import 'package:resource_portable/resource_portable.dart' show Resource;
|
import 'package:resource_portable/resource_portable.dart' show Resource;
|
||||||
|
|
||||||
import '../corpus.dart';
|
import '../corpus.dart';
|
||||||
import '../formula_models.dart';
|
import '../formula_models.dart';
|
||||||
|
|
||||||
|
|
||||||
Future<Corpus> createDefaultCorpus() async{
|
Future<Corpus> createDefaultCorpus() async{
|
||||||
final corpus = Corpus();
|
final corpus = Corpus();
|
||||||
|
|
||||||
|
Future<String> loadResourceAsString(String path) async {
|
||||||
|
return await rootBundle.loadString(path, cache: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> loadUnits() async {
|
Future<void> loadUnits() async {
|
||||||
final unitResources = [
|
final unitResources = [
|
||||||
"lib/defaults/units/angle.d4rt.units",
|
"assets/units/angle.d4rt.units",
|
||||||
"lib/defaults/units/area.d4rt.units",
|
"assets/units/area.d4rt.units",
|
||||||
"lib/defaults/units/distance.d4rt.units",
|
"assets/units/distance.d4rt.units",
|
||||||
"lib/defaults/units/energy.d4rt.units",
|
"assets/units/energy.d4rt.units",
|
||||||
"lib/defaults/units/force.d4rt.units",
|
"assets/units/force.d4rt.units",
|
||||||
"lib/defaults/units/mass.d4rt.units",
|
"assets/units/mass.d4rt.units",
|
||||||
"lib/defaults/units/pressure.d4rt.units",
|
"assets/units/pressure.d4rt.units",
|
||||||
"lib/defaults/units/scalar.d4rt.units",
|
"assets/units/scalar.d4rt.units",
|
||||||
"lib/defaults/units/temperature.d4rt.units",
|
"assets/units/temperature.d4rt.units",
|
||||||
"lib/defaults/units/time.d4rt.units",
|
"assets/units/time.d4rt.units",
|
||||||
"lib/defaults/units/velocity.d4rt.units",
|
"assets/units/velocity.d4rt.units",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (final unitRes in unitResources) {
|
for (final unitRes in unitResources) {
|
||||||
final resource = Resource(unitRes);
|
final literal = await loadResourceAsString(unitRes);
|
||||||
final literal = await resource.readAsString(encoding: utf8);
|
|
||||||
final units = UnitSpec.fromArrayStringLiteral(literal);
|
final units = UnitSpec.fromArrayStringLiteral(literal);
|
||||||
corpus.loadUnits(units);
|
corpus.loadUnits(units);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadFormulas() async {
|
Future<void> loadFormulas() async {
|
||||||
final formulaResources = ["lib/defaults/formulas.d4rt"];
|
final formulaResources = ["assets/formulas/formulas.d4rt"];
|
||||||
|
|
||||||
for (final formRes in formulaResources) {
|
for (final formRes in formulaResources) {
|
||||||
final resource = Resource(formRes);
|
final literal = await loadResourceAsString(formRes);
|
||||||
final literal = await resource.readAsString(encoding: utf8);
|
|
||||||
final formulas = Formula.fromArrayStringLiteral(literal);
|
final formulas = Formula.fromArrayStringLiteral(literal);
|
||||||
corpus.loadFormulas(formulas);
|
corpus.loadFormulas(formulas);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
[
|
|
||||||
{"name": "scalar", "symbol": "", "isBase": true}
|
|
||||||
]
|
|
||||||
|
|
@ -76,14 +76,14 @@ class FormulaEvaluator {
|
||||||
final d4rtInterpreter = interpreter ?? createDefaultInterpreter();
|
final d4rtInterpreter = interpreter ?? createDefaultInterpreter();
|
||||||
prepareInterpreter(d4rtInterpreter);
|
prepareInterpreter(d4rtInterpreter);
|
||||||
final d4rtCode = """
|
final d4rtCode = """
|
||||||
${d4rtImports}
|
$d4rtImports
|
||||||
main()
|
main()
|
||||||
{
|
{
|
||||||
late var result;
|
late var result;
|
||||||
result = $code;
|
result = $code;
|
||||||
return result;
|
return result;
|
||||||
}""";
|
}""";
|
||||||
//print("evaluateExpression:\n$d4rtCode");
|
print("evaluateExpression:\n$d4rtCode");
|
||||||
final result = d4rtInterpreter.execute(source: d4rtCode);
|
final result = d4rtInterpreter.execute(source: d4rtCode);
|
||||||
switch ( result ){
|
switch ( result ){
|
||||||
case int value:
|
case int value:
|
||||||
|
|
@ -100,9 +100,18 @@ class FormulaEvaluator {
|
||||||
dynamic evaluate(Formula formula, Map<String, dynamic> inputValues) {
|
dynamic evaluate(Formula formula, Map<String, dynamic> inputValues) {
|
||||||
_validateInputValues(formula, inputValues);
|
_validateInputValues(formula, inputValues);
|
||||||
final completeSource = _buildCompleteSource(formula, inputValues);
|
final completeSource = _buildCompleteSource(formula, inputValues);
|
||||||
|
try {
|
||||||
final result = _interpreter.execute(source: completeSource);
|
final result = _interpreter.execute(source: completeSource);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
catch (e) {
|
||||||
|
print( "Error evaluating formula source:\n$completeSource" );
|
||||||
|
throw FormulaEvaluationException(
|
||||||
|
'Error evaluating formula "${formula.name}": $e',
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _validateInputValues(Formula formula, Map<String, dynamic> inputValues) {
|
void _validateInputValues(Formula formula, Map<String, dynamic> inputValues) {
|
||||||
final missingVars = <String>[];
|
final missingVars = <String>[];
|
||||||
|
|
|
||||||
|
|
@ -102,17 +102,17 @@ class UnitSpec {
|
||||||
class VariableSpec {
|
class VariableSpec {
|
||||||
final String name;
|
final String name;
|
||||||
final String? unit;
|
final String? unit;
|
||||||
final List<dynamic>? allowedValues;
|
final List<dynamic>? values;
|
||||||
|
|
||||||
VariableSpec({required this.name, this.unit, this.allowedValues}){
|
VariableSpec({required this.name, this.unit, this.values}){
|
||||||
final valuesValid = allowedValues != null && allowedValues?.isNotEmpty == true;
|
final valuesValid = values != null && values?.isNotEmpty == true;
|
||||||
if( unit == null && !valuesValid ){
|
if( unit == null && !valuesValid ){
|
||||||
throw new ArgumentError("$name: at least unit or allowedValues should be valid");
|
throw ArgumentError("$name: at least unit or allowedValues should be valid");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'var($name: $unit${allowedValues != null ? ' allowed: $allowedValues' : ''})';
|
String toString() => 'var($name: $unit${values != null ? ' allowed: $values' : ''})';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
|
|
@ -121,10 +121,10 @@ class VariableSpec {
|
||||||
runtimeType == other.runtimeType &&
|
runtimeType == other.runtimeType &&
|
||||||
unit == other.unit &&
|
unit == other.unit &&
|
||||||
name == other.name &&
|
name == other.name &&
|
||||||
const DeepCollectionEquality().equals(allowedValues, other.allowedValues);
|
const DeepCollectionEquality().equals(values, other.values);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(unit, name, allowedValues != null ? const DeepCollectionEquality().hash(allowedValues!) : 0);
|
int get hashCode => Object.hash(unit, name, values != null ? const DeepCollectionEquality().hash(values!) : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
class Formula {
|
class Formula {
|
||||||
|
|
@ -146,7 +146,7 @@ class Formula {
|
||||||
validate();
|
validate();
|
||||||
}
|
}
|
||||||
|
|
||||||
validate() {
|
void validate() {
|
||||||
if (name.trim().isEmpty) {
|
if (name.trim().isEmpty) {
|
||||||
throw ArgumentError('Formula name cannot be empty');
|
throw ArgumentError('Formula name cannot be empty');
|
||||||
}
|
}
|
||||||
|
|
@ -220,7 +220,7 @@ class Formula {
|
||||||
return VariableSpec(
|
return VariableSpec(
|
||||||
name: name,
|
name: name,
|
||||||
unit: unit,
|
unit: unit,
|
||||||
allowedValues: allowed?.toList(growable: false),
|
values: allowed?.toList(growable: false),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'corpus.dart';
|
||||||
import 'defaults/default_corpus.dart';
|
import 'defaults/default_corpus.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
runApp(MaterialApp(
|
runApp(MaterialApp(
|
||||||
home: FutureBuilder<Corpus>(
|
home: FutureBuilder<Corpus>(
|
||||||
future: createDefaultCorpus(),
|
future: createDefaultCorpus(),
|
||||||
|
|
|
||||||
52
pubspec.lock
52
pubspec.lock
|
|
@ -109,10 +109,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.6"
|
version: "3.0.7"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -125,18 +125,18 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: d4rt
|
name: d4rt
|
||||||
sha256: "1eb626145e2ed97332f9e6e842e626f973d3969ce30e2794efb4744bd8aeba63"
|
sha256: eff6a10f31e9e5b60b99146a33204c5f2d74e20ac3eeb14132d8a8ed0921c6e1
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.7"
|
version: "0.1.9"
|
||||||
equatable:
|
equatable:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: equatable
|
name: equatable
|
||||||
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
|
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.7"
|
version: "2.0.8"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -244,10 +244,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
version: "1.6.0"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -356,10 +356,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -585,34 +585,34 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.24"
|
version: "6.3.28"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_ios
|
name: url_launcher_ios
|
||||||
sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
|
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.4"
|
version: "6.3.6"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_linux
|
name: url_launcher_linux
|
||||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
version: "3.2.2"
|
||||||
url_launcher_macos:
|
url_launcher_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_macos
|
name: url_launcher_macos
|
||||||
sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
|
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.3"
|
version: "3.2.5"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -625,18 +625,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_web
|
name: url_launcher_web
|
||||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.2"
|
||||||
url_launcher_windows:
|
url_launcher_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_windows
|
name: url_launcher_windows
|
||||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.4"
|
version: "3.1.5"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -657,10 +657,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: watcher
|
name: watcher
|
||||||
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a"
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.4"
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -702,5 +702,5 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.9.0 <4.0.0"
|
dart: ">=3.10.7 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.38.0"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.10.7
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
|
|
@ -50,8 +50,8 @@ dev_dependencies:
|
||||||
# activated in the `analysis_options.yaml` file located at the root of your
|
# activated in the `analysis_options.yaml` file located at the root of your
|
||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints:
|
||||||
test: ^1.26.3
|
test:
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
@ -68,6 +68,9 @@ flutter:
|
||||||
# assets:
|
# assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
# - images/a_dot_ham.jpeg
|
# - images/a_dot_ham.jpeg
|
||||||
|
assets:
|
||||||
|
- assets/units/
|
||||||
|
- assets/formulas/
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:d4rt/d4rt.dart';
|
||||||
import 'dart:math' as Math;
|
import 'dart:math' as Math;
|
||||||
|
|
||||||
|
|
||||||
main(){
|
void main(){
|
||||||
test('Access to Math', () {
|
test('Access to Math', () {
|
||||||
|
|
||||||
final completeSource = """
|
final completeSource = """
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:d4rt/d4rt.dart';
|
||||||
import 'dart:math' as Math;
|
import 'dart:math' as Math;
|
||||||
|
|
||||||
|
|
||||||
main(){
|
void main(){
|
||||||
test('for dart grammar tests', () {
|
test('for dart grammar tests', () {
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
import 'package:d4rt_formulas/corpus.dart';
|
import 'package:d4rt_formulas/corpus.dart';
|
||||||
import 'package:d4rt_formulas/defaults/default_corpus.dart';
|
import 'package:d4rt_formulas/defaults/default_corpus.dart';
|
||||||
import 'package:d4rt_formulas/formula_evaluator.dart';
|
import 'package:d4rt_formulas/formula_evaluator.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:d4rt_formulas/formula_models.dart';
|
import 'package:d4rt_formulas/formula_models.dart';
|
||||||
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
Future<Corpus> createTestCorpus() async {
|
Future<Corpus> createTestCorpus() async {
|
||||||
return createDefaultCorpus();
|
return createDefaultCorpus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Corpus> testCorpus = createTestCorpus();
|
||||||
|
|
||||||
|
|
||||||
test("Parses unit", () {
|
test("Parses unit", () {
|
||||||
final setLiteral = {"name": "kilometer", "symbol": "km", "baseUnit": "meter", "factor": 1000};
|
final setLiteral = {"name": "kilometer", "symbol": "km", "baseUnit": "meter", "factor": 1000};
|
||||||
final unit = UnitSpec.fromSet(setLiteral);
|
final unit = UnitSpec.fromSet(setLiteral);
|
||||||
|
|
@ -23,37 +28,37 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("From km to in", () async {
|
test("From km to in", () async {
|
||||||
final corpus = await createTestCorpus();
|
final corpus = await testCorpus;
|
||||||
final inches = corpus.convert(1, "kilometer", "inch");
|
final inches = corpus.convert(1, "kilometer", "inch");
|
||||||
expect( inches, closeTo(39370.078,0.001) );
|
expect( inches, closeTo(39370.078,0.001) );
|
||||||
});
|
});
|
||||||
|
|
||||||
test("From furlong to base", () async {
|
test("From furlong to base", () async {
|
||||||
final corpus = await createTestCorpus();
|
final corpus = await testCorpus;
|
||||||
final m = corpus.convert(1, "furlong", "meter");
|
final m = corpus.convert(1, "furlong", "meter");
|
||||||
expect(m,closeTo(201.168,0.001));
|
expect(m,closeTo(201.168,0.001));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("From base to furlong", () async {
|
test("From base to furlong", () async {
|
||||||
final corpus = await createTestCorpus();
|
final corpus = await testCorpus;
|
||||||
final m = corpus.convert(201.168, "meter", "furlong");
|
final m = corpus.convert(201.168, "meter", "furlong");
|
||||||
expect(m,closeTo(1,0.001));
|
expect(m,closeTo(1,0.001));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("From C to F", () async {
|
test("From C to F", () async {
|
||||||
final corpus = await createTestCorpus();
|
final corpus = await testCorpus;
|
||||||
final m = corpus.convert(37, "Celsius", "Fahrenheit");
|
final m = corpus.convert(37, "Celsius", "Fahrenheit");
|
||||||
expect(m,closeTo(98.6,0.001));
|
expect(m,closeTo(98.6,0.001));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("From K to F", () async {
|
test("From K to F", () async {
|
||||||
final corpus = await createTestCorpus();
|
final corpus = await testCorpus;
|
||||||
final m = corpus.convert(37, "Kelvin", "Fahrenheit");
|
final m = corpus.convert(37, "Kelvin", "Fahrenheit");
|
||||||
expect(m,closeTo(-393.07,0.001));
|
expect(m,closeTo(-393.07,0.001));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("From C to K", () async {
|
test("From C to K", () async {
|
||||||
final corpus = await createTestCorpus();
|
final corpus = await testCorpus;
|
||||||
final m = corpus.convert(100, "Celsius", "Kelvin");
|
final m = corpus.convert(100, "Celsius", "Kelvin");
|
||||||
expect(m,closeTo(373.15,0.001));
|
expect(m,closeTo(373.15,0.001));
|
||||||
});
|
});
|
||||||
|
|
@ -108,5 +113,22 @@ void main() {
|
||||||
expect(result, 98.0); // F = m * a = 10 * 9.8 = 98 N
|
expect(result, 98.0); // F = m * a = 10 * 9.8 = 98 N
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('APGAR Score', () {
|
||||||
|
test('evaluates APGAR score formula - Normal case', () async {
|
||||||
|
final corpus = await testCorpus;
|
||||||
|
final formula = corpus.getFormula("Apgar Score")!;
|
||||||
|
final evaluator = FormulaEvaluator();
|
||||||
|
|
||||||
|
final result = evaluator.evaluate(formula, {
|
||||||
|
'HeartRate': 2,
|
||||||
|
'Breathing': 2,
|
||||||
|
'MuscleTone': 2,
|
||||||
|
'Reflexes': 2,
|
||||||
|
'SkinColor': 2
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result, 'Score: 10 - Normal');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
firejail --profile=firejail-warp-terminal.profile warp-terminal
|
|
||||||
Loading…
Reference in a new issue