diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..c7f7e314 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.rs] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitconfig.example b/.gitconfig.example new file mode 100644 index 00000000..b7425a0a --- /dev/null +++ b/.gitconfig.example @@ -0,0 +1,7 @@ +[user] + name = Sanskar + email = sanskarin@outlook.in + +# Apply locally with: +# git config user.name "Sanskar" +# git config user.email "sanskarin@outlook.in" diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..9cda2b44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,56 @@ +name: Bug report +description: Report a reproducible UnitFlow problem +title: "bug: " +labels: [bug] +body: + - type: markdown + attributes: + value: Thanks for helping improve UnitFlow. Remove private information before submitting logs or screenshots. + - type: textarea + id: summary + attributes: + label: What happened? + description: Describe the problem and what you expected instead. + validations: + required: true + - type: input + id: version + attributes: + label: UnitFlow version or commit + validations: + required: true + - type: input + id: platform + attributes: + label: Platform and OS + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Select ... + 2. Enter ... + 3. Observe ... + validations: + required: true + - type: textarea + id: conversion + attributes: + label: Conversion details + description: Include source unit, destination unit, input, actual output, and expected output when relevant. + - type: textarea + id: logs + attributes: + label: Logs or screenshots + description: Optional. Remove secrets and personal information. + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I searched existing issues for duplicates. + required: true + - label: I removed secrets and private information. + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..4be21714 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,45 @@ +name: Feature request +description: Suggest an improvement for UnitFlow +title: "feat: " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem or opportunity + description: What user need would this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the behavior, UI, units, or workflow you would like. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Optional alternatives or tradeoffs. + - type: dropdown + id: area + attributes: + label: Area + options: + - Rust core + - Flutter UI + - Unit catalog + - Accessibility + - Documentation + - Build and release + - Other + validations: + required: true + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I searched existing issues for similar requests. + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..fc077559 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + +Describe the problem and the focused change that solves it. + +## Validation + +- [ ] Rust checks/tests pass when Rust code changed. +- [ ] Flutter analyze/tests pass when Flutter code changed. +- [ ] Conversion constants/formulas have regression coverage when changed. +- [ ] UI changes were checked on narrow and wide layouts. +- [ ] Accessibility implications were reviewed. +- [ ] Documentation/changelog were updated when needed. +- [ ] No secrets or private information are included. + +## Screenshots + +Add screenshots for meaningful UI changes when useful. + +## Notes / limitations + +Call out migrations, compatibility concerns, follow-up work, or known limitations. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..fd41cae0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + rust: + name: Rust core + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cargo check + run: cargo check --manifest-path rust/Cargo.toml --all-targets + + - name: Cargo test + run: cargo test --manifest-path rust/Cargo.toml + + flutter: + name: Flutter app + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Resolve packages + working-directory: app + run: flutter pub get + + - name: Analyze + working-directory: app + run: flutter analyze + + - name: Test + working-directory: app + run: flutter test diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0465c13c --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Rust +/target/ +rust/target/ +Cargo.lock + +# Flutter / Dart +.dart_tool/ +.packages +.pub-cache/ +.pub/ +build/ +app/.flutter-plugins +app/.flutter-plugins-dependencies +app/.metadata + +# IDE +.idea/ +.vscode/ +*.iml + +# OS +.DS_Store +Thumbs.db + +# Local files +.env +.env.* +!.env.example +coverage/ +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..5da0557a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable UnitFlow changes are documented here. + +## [Unreleased] + +### Added + +- Rust high-precision conversion core using decimal arithmetic. +- Searchable catalog covering length, mass, temperature, time, area, volume, speed, data, pressure, energy, power, and angle. +- Explicit rounding options in the Rust engine. +- Validated affine custom-unit formulas. +- Rust command-line converter and unit catalog listing. +- Flutter Material 3 frontend with responsive layout. +- Searchable unit picker, category navigation, swap, copy, favorites, history, batch conversion, scientific notation, and precision controls. +- Educational conversion explanations and accessibility semantics. +- Cross-platform Flutter bootstrap scripts. +- Rust and Flutter test suites. +- CI, security, contribution, support, release, accessibility, performance, and architecture documentation. + +### Notes + +The Rust crate is the authoritative precision-focused engine. The current Flutter UI uses its dependency-free Dart adapter for immediate cross-platform execution while the documented native/WASM bridge remains a future integration milestone. + +## [0.1.0] - 2026-08-19 + +Initial public development baseline. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..f150ec23 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,19 @@ +# Code of Conduct + +UnitFlow welcomes respectful, constructive collaboration. + +## Our standards + +Expected behavior includes: + +- Be respectful and patient in technical discussions. +- Give actionable feedback focused on code, design, documentation, and outcomes. +- Welcome contributors with different experience levels and backgrounds. +- Respect privacy and do not publish personal information without permission. +- Credit prior work and sources appropriately. + +Unacceptable behavior includes harassment, discrimination, threats, deliberate disruption, impersonation, publishing private information, or repeated hostile conduct. + +## Enforcement + +Report conduct concerns privately to `sanskarin@outlook.in`. Maintainers may remove content, restrict participation, close discussions, or take other proportionate moderation actions to protect the project community. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..3d438eb4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,63 @@ +# Contributing to UnitFlow + +Thanks for improving UnitFlow. + +## Development setup + +1. Install a current stable Rust toolchain and Flutter SDK. +2. Clone the repository. +3. Configure the preferred commit identity when appropriate: + +```bash +git config user.name "Sanskar" +git config user.email "sanskarin@outlook.in" +``` + +4. Bootstrap Flutter platform folders when needed: + +```bash +./tool/bootstrap_platforms.sh +``` + +On Windows PowerShell: + +```powershell +./tool/bootstrap_platforms.ps1 +``` + +## Before opening a pull request + +Run the complete quality suite: + +```bash +cargo fmt --manifest-path rust/Cargo.toml --check +cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings +cargo test --manifest-path rust/Cargo.toml + +cd app +flutter analyze +flutter test +``` + +## Change expectations + +- Keep conversion factors traceable and documented. +- Add regression tests for conversion changes. +- Avoid network requirements for static unit conversions. +- Preserve accessibility labels and keyboard-friendly flows. +- Keep UI logic separate from conversion logic. +- Update `CHANGELOG.md` and `what_changed.md` for meaningful user-facing changes. +- Never commit secrets, private keys, tokens, or credentials. + +## Commit style + +Use focused commits with descriptive Conventional Commit-style subjects, for example: + +- `feat(core): add pressure conversions` +- `fix(app): preserve input after unit swap` +- `test(core): cover affine temperature conversions` +- `docs: explain release checklist` + +## Pull requests + +Explain the problem, implementation, validation performed, screenshots for UI changes when useful, and any remaining limitations. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..6625fb60 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: rust-check rust-test flutter-get flutter-analyze flutter-test test + +rust-check: + cargo check --manifest-path rust/Cargo.toml --all-targets + +rust-test: + cargo test --manifest-path rust/Cargo.toml + +flutter-get: + cd app && flutter pub get + +flutter-analyze: + cd app && flutter analyze + +flutter-test: + cd app && flutter test + +test: rust-check rust-test flutter-get flutter-analyze flutter-test diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..e085fdee --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,42 @@ +# UnitFlow Roadmap + +## 0.1 — Foundation + +- Rust decimal conversion core +- Broad offline catalog +- Flutter Material 3 interface +- Search, swap, batch, favorites, history, precision, scientific notation +- Tests, CI, docs, contribution and security policies + +## 0.2 — Native bridge + +- Connect Flutter to the Rust core through a generated, audited native bridge +- Add Android/iOS native library packaging +- Add desktop native library packaging +- Add WebAssembly bridge for web builds +- Golden cross-engine regression tests so Dart adapter and Rust output stay aligned + +## 0.3 — Personalization + +- Persist favorites and recent conversions locally +- Pinned conversion pairs +- User-defined affine units with validation UI +- Import/export local settings +- Locale-aware number formatting and input normalization + +## 0.4 — Advanced workflows + +- CSV batch import/export +- Engineering notation and significant-figure modes +- Formula explanations with traceable conversion constants +- Expanded scientific catalog +- Keyboard shortcuts and command palette on desktop/web + +## 1.0 — Stable release + +- Stable cross-platform Rust bridge +- Completed accessibility audit +- Performance budgets enforced in CI +- Full release automation and signed artifacts +- Conversion constant provenance audit +- Versioned migration policy for local settings diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..95c9d16d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +## Supported versions + +Security fixes are applied to the latest release line and the default development branch. + +## Reporting a vulnerability + +Please do not publish exploitable details in a public issue before a fix is available. Report security concerns privately to: + +- `sanskarin@outlook.in` +- `sanskarin.business@gmail.com` + +Include the affected component, reproduction conditions, impact, and any safe proof-of-concept details needed to understand the issue. + +## Security model + +UnitFlow is designed to keep static conversions offline. The conversion core does not require remote services, analytics, accounts, or cloud storage. This reduces data exposure and allows the default conversion workflow to operate without network permissions. + +## Secrets + +Never commit API keys, access tokens, signing credentials, keystores, private certificates, or production environment files. + +## Dependency hygiene + +CI checks formatting, static analysis, tests, and Rust advisory/security tooling where available. Dependency changes should be reviewed for maintenance status, licensing, and necessity before merge. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000..00c6a09b --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,23 @@ +# UnitFlow Support + +## Help and bug reports + +For reproducible bugs, open a GitHub issue with: + +- UnitFlow version or commit +- Platform and OS version +- Flutter/Rust versions when relevant +- Exact source and destination units +- Input value and expected result +- Steps to reproduce +- Logs or screenshots with private information removed + +## Contact + +- Business: `sanskarin@outlook.in` +- Business: `sanskarin.business@gmail.com` +- Support: `supportramsandesh@gmail.com` + +## Scope + +Support covers UnitFlow installation, conversion behavior, accessibility, build problems, documentation, and contribution questions. Security concerns should follow `SECURITY.md` rather than a public issue. diff --git a/app/README.md b/app/README.md new file mode 100644 index 00000000..e7305ade --- /dev/null +++ b/app/README.md @@ -0,0 +1,45 @@ +# UnitFlow Flutter Frontend + +This directory contains the Flutter presentation layer for UnitFlow. + +## Features + +- Material 3 responsive UI +- 12 conversion categories +- Searchable unit selection +- Unit swap +- Copy result +- Session favorites +- Recent conversion history +- Batch conversion +- Precision controls +- Scientific notation +- Light/dark system theme support +- Accessibility semantics on key controls + +## Run + +If platform runner folders are not present, generate them from the repository root: + +```bash +./tool/bootstrap_platforms.sh +``` + +Windows PowerShell: + +```powershell +./tool/bootstrap_platforms.ps1 +``` + +Then: + +```bash +flutter pub get +flutter analyze +flutter test +flutter run +``` + +## Architecture note + +The Flutter layer currently includes a small dependency-free Dart conversion adapter so the app can run immediately across Flutter targets. The precision-focused Rust crate under `../rust` is the authoritative core and is prepared for a future native/WebAssembly bridge described in `../ROADMAP.md` and `../docs/architecture.md`. diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 00000000..775de7e4 --- /dev/null +++ b/app/analysis_options.yaml @@ -0,0 +1,20 @@ +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + dead_code: error + unused_import: error + unused_local_variable: error + invalid_assignment: error + missing_return: error + +linter: + rules: + - always_declare_return_types + - avoid_print + - avoid_relative_lib_imports + - cancel_subscriptions + - directives_ordering + - use_build_context_synchronously diff --git a/app/lib/core/converter.dart b/app/lib/core/converter.dart new file mode 100644 index 00000000..ff8f5f99 --- /dev/null +++ b/app/lib/core/converter.dart @@ -0,0 +1,92 @@ +import 'dart:math' as math; + +import 'unit_model.dart'; + +class ConversionException implements Exception { + const ConversionException(this.message); + + final String message; + + @override + String toString() => message; +} + +class Converter { + const Converter(); + + double convert({ + required double value, + required ConversionUnit from, + required ConversionUnit to, + }) { + if (from.category != to.category) { + throw const ConversionException('Units must belong to the same category.'); + } + + if (from.category == UnitCategory.temperature) { + return _convertTemperature(value, from.id, to.id); + } + + if (to.factorToBase == 0) { + throw const ConversionException('Destination unit has an invalid zero factor.'); + } + + return value * from.factorToBase / to.factorToBase; + } + + List batchConvert({ + required Iterable values, + required ConversionUnit from, + required ConversionUnit to, + }) { + return values + .map((double value) => convert(value: value, from: from, to: to)) + .toList(growable: false); + } + + String format( + double value, { + int decimalPlaces = 8, + bool scientific = false, + }) { + final int safePlaces = decimalPlaces.clamp(0, 15).toInt(); + if (value.isNaN || value.isInfinite) { + return value.toString(); + } + if (scientific) { + return value.toStringAsExponential(safePlaces); + } + + final String fixed = value.toStringAsFixed(safePlaces); + if (!fixed.contains('.')) { + return fixed; + } + return fixed.replaceFirst(RegExp(r'\.?0+$'), ''); + } + + double round(double value, int decimalPlaces) { + final int safePlaces = decimalPlaces.clamp(0, 15).toInt(); + final double scale = math.pow(10, safePlaces).toDouble(); + return (value * scale).roundToDouble() / scale; + } + + double _convertTemperature(double value, String from, String to) { + if (from == to) { + return value; + } + + final double kelvin = switch (from) { + 'kelvin' => value, + 'celsius' => value + 273.15, + 'fahrenheit' => (value + 459.67) * 5 / 9, + _ => throw ConversionException('Unknown temperature unit: $from'), + }; + + return switch (to) { + 'kelvin' => kelvin, + 'celsius' => kelvin - 273.15, + 'fahrenheit' => kelvin * 9 / 5 - 459.67, + _ => throw ConversionException('Unknown temperature unit: $to'), + }; + } +} diff --git a/app/lib/core/unit_catalog.dart b/app/lib/core/unit_catalog.dart new file mode 100644 index 00000000..3413ca6b --- /dev/null +++ b/app/lib/core/unit_catalog.dart @@ -0,0 +1,93 @@ +import 'unit_model.dart'; + +const List unitCatalog = [ + ConversionUnit(id: 'meter', category: UnitCategory.length, name: 'Meter', symbol: 'm', factorToBase: 1, aliases: ['metre'], description: 'SI base unit of length.'), + ConversionUnit(id: 'kilometer', category: UnitCategory.length, name: 'Kilometer', symbol: 'km', factorToBase: 1000, aliases: ['kilometre'], description: 'One thousand meters.'), + ConversionUnit(id: 'centimeter', category: UnitCategory.length, name: 'Centimeter', symbol: 'cm', factorToBase: 0.01, description: 'One hundredth of a meter.'), + ConversionUnit(id: 'millimeter', category: UnitCategory.length, name: 'Millimeter', symbol: 'mm', factorToBase: 0.001, description: 'One thousandth of a meter.'), + ConversionUnit(id: 'inch', category: UnitCategory.length, name: 'Inch', symbol: 'in', factorToBase: 0.0254, aliases: ['inches'], description: 'Exactly 2.54 centimeters.'), + ConversionUnit(id: 'foot', category: UnitCategory.length, name: 'Foot', symbol: 'ft', factorToBase: 0.3048, aliases: ['feet'], description: 'Twelve inches.'), + ConversionUnit(id: 'yard', category: UnitCategory.length, name: 'Yard', symbol: 'yd', factorToBase: 0.9144, description: 'Three feet.'), + ConversionUnit(id: 'mile', category: UnitCategory.length, name: 'Mile', symbol: 'mi', factorToBase: 1609.344, description: '5,280 feet.'), + ConversionUnit(id: 'nautical_mile', category: UnitCategory.length, name: 'Nautical mile', symbol: 'nmi', factorToBase: 1852, description: 'International nautical mile.'), + + ConversionUnit(id: 'kilogram', category: UnitCategory.mass, name: 'Kilogram', symbol: 'kg', factorToBase: 1, description: 'SI base unit of mass.'), + ConversionUnit(id: 'gram', category: UnitCategory.mass, name: 'Gram', symbol: 'g', factorToBase: 0.001, description: 'One thousandth of a kilogram.'), + ConversionUnit(id: 'milligram', category: UnitCategory.mass, name: 'Milligram', symbol: 'mg', factorToBase: 0.000001, description: 'One millionth of a kilogram.'), + ConversionUnit(id: 'tonne', category: UnitCategory.mass, name: 'Metric tonne', symbol: 't', factorToBase: 1000, aliases: ['metric ton'], description: 'One thousand kilograms.'), + ConversionUnit(id: 'ounce', category: UnitCategory.mass, name: 'Ounce', symbol: 'oz', factorToBase: 0.028349523125, description: 'International avoirdupois ounce.'), + ConversionUnit(id: 'pound', category: UnitCategory.mass, name: 'Pound', symbol: 'lb', factorToBase: 0.45359237, aliases: ['lbs'], description: 'International avoirdupois pound.'), + + ConversionUnit(id: 'kelvin', category: UnitCategory.temperature, name: 'Kelvin', symbol: 'K', factorToBase: 1, description: 'SI base unit of thermodynamic temperature.'), + ConversionUnit(id: 'celsius', category: UnitCategory.temperature, name: 'Celsius', symbol: '°C', factorToBase: 1, aliases: ['centigrade'], description: 'Temperature scale offset from kelvin by 273.15.'), + ConversionUnit(id: 'fahrenheit', category: UnitCategory.temperature, name: 'Fahrenheit', symbol: '°F', factorToBase: 1, description: 'Fahrenheit temperature scale.'), + + ConversionUnit(id: 'second', category: UnitCategory.time, name: 'Second', symbol: 's', factorToBase: 1, description: 'SI base unit of time.'), + ConversionUnit(id: 'millisecond', category: UnitCategory.time, name: 'Millisecond', symbol: 'ms', factorToBase: 0.001, description: 'One thousandth of a second.'), + ConversionUnit(id: 'minute', category: UnitCategory.time, name: 'Minute', symbol: 'min', factorToBase: 60, description: 'Sixty seconds.'), + ConversionUnit(id: 'hour', category: UnitCategory.time, name: 'Hour', symbol: 'h', factorToBase: 3600, description: 'Sixty minutes.'), + ConversionUnit(id: 'day', category: UnitCategory.time, name: 'Day', symbol: 'd', factorToBase: 86400, description: 'Twenty-four hours.'), + ConversionUnit(id: 'week', category: UnitCategory.time, name: 'Week', symbol: 'wk', factorToBase: 604800, description: 'Seven days.'), + + ConversionUnit(id: 'square_meter', category: UnitCategory.area, name: 'Square meter', symbol: 'm²', factorToBase: 1, aliases: ['sqm'], description: 'Area of a one-meter square.'), + ConversionUnit(id: 'square_kilometer', category: UnitCategory.area, name: 'Square kilometer', symbol: 'km²', factorToBase: 1000000, description: 'One million square meters.'), + ConversionUnit(id: 'square_foot', category: UnitCategory.area, name: 'Square foot', symbol: 'ft²', factorToBase: 0.09290304, aliases: ['sq ft'], description: 'Area of a one-foot square.'), + ConversionUnit(id: 'acre', category: UnitCategory.area, name: 'Acre', symbol: 'ac', factorToBase: 4046.8564224, description: '43,560 square feet.'), + ConversionUnit(id: 'hectare', category: UnitCategory.area, name: 'Hectare', symbol: 'ha', factorToBase: 10000, description: 'Ten thousand square meters.'), + + ConversionUnit(id: 'liter', category: UnitCategory.volume, name: 'Liter', symbol: 'L', factorToBase: 1, aliases: ['litre'], description: 'Metric unit of volume.'), + ConversionUnit(id: 'milliliter', category: UnitCategory.volume, name: 'Milliliter', symbol: 'mL', factorToBase: 0.001, aliases: ['ml'], description: 'One thousandth of a liter.'), + ConversionUnit(id: 'cubic_meter', category: UnitCategory.volume, name: 'Cubic meter', symbol: 'm³', factorToBase: 1000, description: 'One thousand liters.'), + ConversionUnit(id: 'us_teaspoon', category: UnitCategory.volume, name: 'US teaspoon', symbol: 'tsp', factorToBase: 0.00492892159375, description: 'US customary teaspoon.'), + ConversionUnit(id: 'us_tablespoon', category: UnitCategory.volume, name: 'US tablespoon', symbol: 'tbsp', factorToBase: 0.01478676478125, description: 'US customary tablespoon.'), + ConversionUnit(id: 'us_cup', category: UnitCategory.volume, name: 'US cup', symbol: 'cup', factorToBase: 0.2365882365, description: 'US customary cup.'), + ConversionUnit(id: 'us_gallon', category: UnitCategory.volume, name: 'US gallon', symbol: 'gal', factorToBase: 3.785411784, description: 'US customary liquid gallon.'), + + ConversionUnit(id: 'meter_per_second', category: UnitCategory.speed, name: 'Meter per second', symbol: 'm/s', factorToBase: 1, aliases: ['mps'], description: 'SI derived unit of speed.'), + ConversionUnit(id: 'kilometer_per_hour', category: UnitCategory.speed, name: 'Kilometer per hour', symbol: 'km/h', factorToBase: 1 / 3.6, aliases: ['kph', 'kmph'], description: 'Kilometers traveled in one hour.'), + ConversionUnit(id: 'mile_per_hour', category: UnitCategory.speed, name: 'Mile per hour', symbol: 'mph', factorToBase: 0.44704, description: 'Miles traveled in one hour.'), + ConversionUnit(id: 'knot', category: UnitCategory.speed, name: 'Knot', symbol: 'kn', factorToBase: 1852 / 3600, aliases: ['kt'], description: 'One nautical mile per hour.'), + + ConversionUnit(id: 'byte', category: UnitCategory.data, name: 'Byte', symbol: 'B', factorToBase: 1, description: 'Eight bits.'), + ConversionUnit(id: 'bit', category: UnitCategory.data, name: 'Bit', symbol: 'bit', factorToBase: 0.125, description: 'One eighth of a byte.'), + ConversionUnit(id: 'kilobyte', category: UnitCategory.data, name: 'Kilobyte', symbol: 'kB', factorToBase: 1000, description: 'One thousand bytes.'), + ConversionUnit(id: 'megabyte', category: UnitCategory.data, name: 'Megabyte', symbol: 'MB', factorToBase: 1000000, description: 'One million bytes.'), + ConversionUnit(id: 'gigabyte', category: UnitCategory.data, name: 'Gigabyte', symbol: 'GB', factorToBase: 1000000000, description: 'One billion bytes.'), + ConversionUnit(id: 'kibibyte', category: UnitCategory.data, name: 'Kibibyte', symbol: 'KiB', factorToBase: 1024, description: '1,024 bytes.'), + ConversionUnit(id: 'mebibyte', category: UnitCategory.data, name: 'Mebibyte', symbol: 'MiB', factorToBase: 1048576, description: '1,048,576 bytes.'), + + ConversionUnit(id: 'pascal', category: UnitCategory.pressure, name: 'Pascal', symbol: 'Pa', factorToBase: 1, description: 'SI derived unit of pressure.'), + ConversionUnit(id: 'kilopascal', category: UnitCategory.pressure, name: 'Kilopascal', symbol: 'kPa', factorToBase: 1000, description: 'One thousand pascals.'), + ConversionUnit(id: 'bar', category: UnitCategory.pressure, name: 'Bar', symbol: 'bar', factorToBase: 100000, description: 'One hundred kilopascals.'), + ConversionUnit(id: 'atmosphere', category: UnitCategory.pressure, name: 'Standard atmosphere', symbol: 'atm', factorToBase: 101325, description: 'Standard atmosphere.'), + ConversionUnit(id: 'psi', category: UnitCategory.pressure, name: 'Pound per square inch', symbol: 'psi', factorToBase: 6894.757293168, description: 'Pressure in pounds-force per square inch.'), + + ConversionUnit(id: 'joule', category: UnitCategory.energy, name: 'Joule', symbol: 'J', factorToBase: 1, description: 'SI derived unit of energy.'), + ConversionUnit(id: 'kilojoule', category: UnitCategory.energy, name: 'Kilojoule', symbol: 'kJ', factorToBase: 1000, description: 'One thousand joules.'), + ConversionUnit(id: 'calorie', category: UnitCategory.energy, name: 'Calorie', symbol: 'cal', factorToBase: 4.184, description: 'Thermochemical calorie.'), + ConversionUnit(id: 'kilocalorie', category: UnitCategory.energy, name: 'Kilocalorie', symbol: 'kcal', factorToBase: 4184, aliases: ['food calorie'], description: 'One thousand thermochemical calories.'), + ConversionUnit(id: 'watt_hour', category: UnitCategory.energy, name: 'Watt-hour', symbol: 'Wh', factorToBase: 3600, description: 'Energy from one watt over one hour.'), + ConversionUnit(id: 'kilowatt_hour', category: UnitCategory.energy, name: 'Kilowatt-hour', symbol: 'kWh', factorToBase: 3600000, description: 'Energy from one kilowatt over one hour.'), + + ConversionUnit(id: 'watt', category: UnitCategory.power, name: 'Watt', symbol: 'W', factorToBase: 1, description: 'SI derived unit of power.'), + ConversionUnit(id: 'kilowatt', category: UnitCategory.power, name: 'Kilowatt', symbol: 'kW', factorToBase: 1000, description: 'One thousand watts.'), + ConversionUnit(id: 'megawatt', category: UnitCategory.power, name: 'Megawatt', symbol: 'MW', factorToBase: 1000000, description: 'One million watts.'), + ConversionUnit(id: 'horsepower', category: UnitCategory.power, name: 'Mechanical horsepower', symbol: 'hp', factorToBase: 745.69987158227022, description: 'Mechanical horsepower.'), + + ConversionUnit(id: 'radian', category: UnitCategory.angle, name: 'Radian', symbol: 'rad', factorToBase: 1, description: 'SI derived unit of plane angle.'), + ConversionUnit(id: 'degree', category: UnitCategory.angle, name: 'Degree', symbol: '°', factorToBase: 0.017453292519943295, aliases: ['deg'], description: 'One 360th of a full turn.'), + ConversionUnit(id: 'gradian', category: UnitCategory.angle, name: 'Gradian', symbol: 'gon', factorToBase: 0.015707963267948967, aliases: ['grad'], description: 'One 400th of a full turn.'), + ConversionUnit(id: 'turn', category: UnitCategory.angle, name: 'Turn', symbol: 'turn', factorToBase: 6.283185307179586, aliases: ['revolution', 'rev'], description: 'One full revolution.'), +]; + +List unitsForCategory(UnitCategory category) { + return unitCatalog + .where((ConversionUnit unit) => unit.category == category) + .toList(growable: false); +} + +List searchCatalog(String query) { + return unitCatalog + .where((ConversionUnit unit) => unit.matches(query)) + .toList(growable: false); +} diff --git a/app/lib/core/unit_model.dart b/app/lib/core/unit_model.dart new file mode 100644 index 00000000..220afa7c --- /dev/null +++ b/app/lib/core/unit_model.dart @@ -0,0 +1,79 @@ +enum UnitCategory { + length, + mass, + temperature, + time, + area, + volume, + speed, + data, + pressure, + energy, + power, + angle, +} + +extension UnitCategoryLabel on UnitCategory { + String get label { + switch (this) { + case UnitCategory.length: + return 'Length'; + case UnitCategory.mass: + return 'Mass'; + case UnitCategory.temperature: + return 'Temperature'; + case UnitCategory.time: + return 'Time'; + case UnitCategory.area: + return 'Area'; + case UnitCategory.volume: + return 'Volume'; + case UnitCategory.speed: + return 'Speed'; + case UnitCategory.data: + return 'Data'; + case UnitCategory.pressure: + return 'Pressure'; + case UnitCategory.energy: + return 'Energy'; + case UnitCategory.power: + return 'Power'; + case UnitCategory.angle: + return 'Angle'; + } + } +} + +class ConversionUnit { + const ConversionUnit({ + required this.id, + required this.category, + required this.name, + required this.symbol, + required this.factorToBase, + required this.description, + this.aliases = const [], + }); + + final String id; + final UnitCategory category; + final String name; + final String symbol; + final double factorToBase; + final String description; + final List aliases; + + String get displayName => '$name ($symbol)'; + + bool matches(String query) { + final String needle = query.trim().toLowerCase(); + if (needle.isEmpty) { + return true; + } + return id.toLowerCase().contains(needle) || + name.toLowerCase().contains(needle) || + symbol.toLowerCase().contains(needle) || + description.toLowerCase().contains(needle) || + aliases.any((String value) => value.toLowerCase().contains(needle)); + } +} diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 00000000..9e836029 --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'screens/home_screen.dart'; + +void main() { + runApp(const UnitFlowApp()); +} + +class UnitFlowApp extends StatelessWidget { + const UnitFlowApp({super.key}); + + @override + Widget build(BuildContext context) { + final ColorScheme lightScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF3657C8), + brightness: Brightness.light, + ); + final ColorScheme darkScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF9EB0FF), + brightness: Brightness.dark, + ); + + return MaterialApp( + title: 'UnitFlow', + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.system, + theme: ThemeData( + useMaterial3: true, + colorScheme: lightScheme, + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + cardTheme: const CardThemeData( + margin: EdgeInsets.zero, + ), + ), + darkTheme: ThemeData( + useMaterial3: true, + colorScheme: darkScheme, + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + cardTheme: const CardThemeData( + margin: EdgeInsets.zero, + ), + ), + home: const HomeScreen(), + ); + } +} diff --git a/app/lib/screens/home_screen.dart b/app/lib/screens/home_screen.dart new file mode 100644 index 00000000..498e4dda --- /dev/null +++ b/app/lib/screens/home_screen.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; + +import '../core/unit_model.dart'; +import '../state/app_state.dart'; +import '../widgets/converter_card.dart'; +import '../widgets/history_section.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + late final AppState _state; + late final TextEditingController _inputController; + + @override + void initState() { + super.initState(); + _state = AppState(); + _inputController = TextEditingController(text: _state.input); + } + + @override + void dispose() { + _inputController.dispose(); + _state.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _state, + builder: (BuildContext context, Widget? child) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.swap_horiz_rounded), + SizedBox(width: 10), + Text('UnitFlow'), + ], + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: Text( + 'Made by the Sanskar', + style: Theme.of(context).textTheme.labelMedium, + ), + ), + ), + ], + ), + body: SafeArea( + child: SelectionArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 980), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _HeroHeader(state: _state), + const SizedBox(height: 16), + _CategorySelector(state: _state), + const SizedBox(height: 16), + ConverterCard( + state: _state, + inputController: _inputController, + ), + const SizedBox(height: 16), + HistorySection(state: _state), + const SizedBox(height: 28), + const Center( + child: Text( + 'Offline-first • Open source • MIT License', + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class _HeroHeader extends StatelessWidget { + const _HeroHeader({required this.state}); + + final AppState state; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: [ + theme.colorScheme.primaryContainer, + theme.colorScheme.secondaryContainer, + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Convert with confidence.', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Fast offline conversions, searchable units, batch tools, favorites, recent history, and educational context.', + style: theme.textTheme.bodyLarge, + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip( + avatar: const Icon(Icons.offline_bolt_outlined, size: 18), + label: const Text('Offline core'), + ), + Chip( + avatar: const Icon(Icons.category_outlined, size: 18), + label: Text('${UnitCategory.values.length} categories'), + ), + Chip( + avatar: const Icon(Icons.science_outlined, size: 18), + label: Text(state.category.label), + ), + ], + ), + ], + ), + ); + } +} + +class _CategorySelector extends StatelessWidget { + const _CategorySelector({required this.state}); + + final AppState state; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Category', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: UnitCategory.values.map((UnitCategory category) { + return ChoiceChip( + label: Text(category.label), + selected: state.category == category, + onSelected: (bool selected) { + if (selected) { + state.setCategory(category); + } + }, + ); + }).toList(growable: false), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/state/app_state.dart b/app/lib/state/app_state.dart new file mode 100644 index 00000000..96561a81 --- /dev/null +++ b/app/lib/state/app_state.dart @@ -0,0 +1,222 @@ +import 'package:flutter/foundation.dart'; + +import '../core/converter.dart'; +import '../core/unit_catalog.dart'; +import '../core/unit_model.dart'; + +@immutable +class UnitPair { + const UnitPair(this.fromId, this.toId); + + final String fromId; + final String toId; + + @override + bool operator ==(Object other) { + return other is UnitPair && other.fromId == fromId && other.toId == toId; + } + + @override + int get hashCode => Object.hash(fromId, toId); +} + +@immutable +class ConversionRecord { + const ConversionRecord({ + required this.input, + required this.output, + required this.from, + required this.to, + }); + + final String input; + final String output; + final ConversionUnit from; + final ConversionUnit to; +} + +class AppState extends ChangeNotifier { + AppState({Converter converter = const Converter()}) : _converter = converter { + _selectDefaultsForCategory(); + _recalculate(addToHistory: false); + } + + final Converter _converter; + UnitCategory _category = UnitCategory.length; + late ConversionUnit _from; + late ConversionUnit _to; + String _input = '1'; + String _output = ''; + String? _error; + String _search = ''; + int _decimalPlaces = 8; + bool _scientific = false; + final Set _favorites = {}; + final List _recent = []; + + UnitCategory get category => _category; + ConversionUnit get from => _from; + ConversionUnit get to => _to; + String get input => _input; + String get output => _output; + String? get error => _error; + String get search => _search; + int get decimalPlaces => _decimalPlaces; + bool get scientific => _scientific; + List get recent => List.unmodifiable(_recent); + Set get favorites => Set.unmodifiable(_favorites); + List get availableUnits => unitsForCategory(_category); + List get filteredUnits => availableUnits + .where((ConversionUnit unit) => unit.matches(_search)) + .toList(growable: false); + + bool get isCurrentPairFavorite => _favorites.contains(UnitPair(_from.id, _to.id)); + + void setCategory(UnitCategory value) { + if (_category == value) { + return; + } + _category = value; + _search = ''; + _selectDefaultsForCategory(); + _recalculate(addToHistory: false); + notifyListeners(); + } + + void setFrom(ConversionUnit value) { + if (value.category != _category || value.id == _from.id) { + return; + } + _from = value; + _recalculate(); + notifyListeners(); + } + + void setTo(ConversionUnit value) { + if (value.category != _category || value.id == _to.id) { + return; + } + _to = value; + _recalculate(); + notifyListeners(); + } + + void swapUnits() { + final ConversionUnit previousFrom = _from; + _from = _to; + _to = previousFrom; + if (_output.isNotEmpty && _error == null) { + _input = _output; + } + _recalculate(); + notifyListeners(); + } + + void setInput(String value) { + _input = value; + _recalculate(addToHistory: false); + notifyListeners(); + } + + void commitInput() { + _recalculate(addToHistory: true); + notifyListeners(); + } + + void setSearch(String value) { + _search = value; + notifyListeners(); + } + + void setDecimalPlaces(int value) { + final int clamped = value.clamp(0, 15).toInt(); + if (_decimalPlaces == clamped) { + return; + } + _decimalPlaces = clamped; + _recalculate(addToHistory: false); + notifyListeners(); + } + + void setScientific(bool value) { + if (_scientific == value) { + return; + } + _scientific = value; + _recalculate(addToHistory: false); + notifyListeners(); + } + + void toggleCurrentFavorite() { + final UnitPair pair = UnitPair(_from.id, _to.id); + if (!_favorites.remove(pair)) { + _favorites.add(pair); + } + notifyListeners(); + } + + List batchConvert(Iterable lines) { + final List output = []; + for (final String line in lines) { + final String trimmed = line.trim(); + if (trimmed.isEmpty) { + continue; + } + final double? value = double.tryParse(trimmed); + if (value == null) { + output.add('$trimmed → invalid number'); + continue; + } + final double converted = _converter.convert(value: value, from: _from, to: _to); + output.add( + '$trimmed ${_from.symbol} → ${_converter.format(converted, decimalPlaces: _decimalPlaces, scientific: _scientific)} ${_to.symbol}', + ); + } + return output; + } + + void clearHistory() { + if (_recent.isEmpty) { + return; + } + _recent.clear(); + notifyListeners(); + } + + void _selectDefaultsForCategory() { + final List units = unitsForCategory(_category); + _from = units.first; + _to = units.length > 1 ? units[1] : units.first; + } + + void _recalculate({bool addToHistory = true}) { + final double? value = double.tryParse(_input.trim()); + if (value == null) { + _output = ''; + _error = _input.trim().isEmpty ? null : 'Enter a valid number.'; + return; + } + + try { + final double converted = _converter.convert(value: value, from: _from, to: _to); + _output = _converter.format( + converted, + decimalPlaces: _decimalPlaces, + scientific: _scientific, + ); + _error = null; + if (addToHistory) { + _recent.insert( + 0, + ConversionRecord(input: _input, output: _output, from: _from, to: _to), + ); + if (_recent.length > 12) { + _recent.removeRange(12, _recent.length); + } + } + } on ConversionException catch (error) { + _output = ''; + _error = error.message; + } + } +} diff --git a/app/lib/widgets/batch_conversion_dialog.dart b/app/lib/widgets/batch_conversion_dialog.dart new file mode 100644 index 00000000..bb7c8f41 --- /dev/null +++ b/app/lib/widgets/batch_conversion_dialog.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../state/app_state.dart'; + +Future showBatchConversionDialog({ + required BuildContext context, + required AppState state, +}) async { + final TextEditingController controller = TextEditingController(); + List results = const []; + + await showDialog( + context: context, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return AlertDialog( + title: const Text('Batch conversion'), + content: SizedBox( + width: 620, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Convert multiple values from ${state.from.symbol} to ${state.to.symbol}. Enter one value per line.', + ), + const SizedBox(height: 12), + TextField( + controller: controller, + minLines: 6, + maxLines: 10, + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: '1\n2.5\n100', + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () { + setState(() { + results = state.batchConvert(controller.text.split('\n')); + }); + }, + icon: const Icon(Icons.calculate_outlined), + label: const Text('Convert batch'), + ), + if (results.isNotEmpty) ...[ + const SizedBox(height: 16), + SelectableText(results.join('\n')), + ], + ], + ), + ), + ), + actions: [ + if (results.isNotEmpty) + TextButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: results.join('\n'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Batch results copied.')), + ); + }, + icon: const Icon(Icons.copy_outlined), + label: const Text('Copy'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + }, + ); + + controller.dispose(); +} diff --git a/app/lib/widgets/converter_card.dart b/app/lib/widgets/converter_card.dart new file mode 100644 index 00000000..a906852f --- /dev/null +++ b/app/lib/widgets/converter_card.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../core/unit_model.dart'; +import '../state/app_state.dart'; +import 'batch_conversion_dialog.dart'; +import 'settings_sheet.dart'; +import 'unit_picker_dialog.dart'; + +class ConverterCard extends StatelessWidget { + const ConverterCard({ + required this.state, + required this.inputController, + super.key, + }); + + final AppState state; + final TextEditingController inputController; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final List units = state.availableUnits; + + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text('Converter', style: theme.textTheme.headlineSmall), + ), + IconButton( + tooltip: 'Display settings', + onPressed: () => showSettingsSheet(context: context, state: state), + icon: const Icon(Icons.tune), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: inputController, + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Value', + errorText: state.error, + prefixIcon: const Icon(Icons.pin_outlined), + suffixText: state.from.symbol, + ), + onChanged: state.setInput, + onSubmitted: (_) => state.commitInput(), + ), + const SizedBox(height: 16), + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool narrow = constraints.maxWidth < 620; + final Widget fromButton = _UnitButton( + label: 'From', + unit: state.from, + onPressed: () => _pickUnit(context, units, true), + ); + final Widget toButton = _UnitButton( + label: 'To', + unit: state.to, + onPressed: () => _pickUnit(context, units, false), + ); + final Widget swap = Semantics( + button: true, + label: 'Swap source and destination units', + child: IconButton.filledTonal( + tooltip: 'Swap units', + onPressed: () { + state.swapUnits(); + inputController.text = state.input; + inputController.selection = TextSelection.collapsed( + offset: inputController.text.length, + ); + }, + icon: Icon(narrow ? Icons.swap_vert : Icons.swap_horiz), + ), + ); + + if (narrow) { + return Column( + children: [ + fromButton, + Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: swap), + toButton, + ], + ); + } + + return Row( + children: [ + Expanded(child: fromButton), + Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: swap), + Expanded(child: toButton), + ], + ); + }, + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: theme.colorScheme.primaryContainer, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Result', style: theme.textTheme.labelLarge), + const SizedBox(height: 6), + SelectableText( + state.output.isEmpty ? '—' : '${state.output} ${state.to.symbol}', + style: theme.textTheme.headlineMedium?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.tonalIcon( + onPressed: state.output.isEmpty ? null : () => _copyResult(context), + icon: const Icon(Icons.copy_outlined), + label: const Text('Copy'), + ), + FilledButton.tonalIcon( + onPressed: state.toggleCurrentFavorite, + icon: Icon( + state.isCurrentPairFavorite ? Icons.star : Icons.star_border, + ), + label: Text(state.isCurrentPairFavorite ? 'Favorited' : 'Favorite pair'), + ), + FilledButton.tonalIcon( + onPressed: () => showBatchConversionDialog(context: context, state: state), + icon: const Icon(Icons.table_rows_outlined), + label: const Text('Batch'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + _Explanation(state: state), + ], + ), + ), + ); + } + + Future _pickUnit( + BuildContext context, + List units, + bool pickingFrom, + ) async { + final ConversionUnit? selected = await showUnitPickerDialog( + context: context, + title: pickingFrom ? 'Choose source unit' : 'Choose destination unit', + units: units, + selected: pickingFrom ? state.from : state.to, + ); + if (selected == null) { + return; + } + if (pickingFrom) { + state.setFrom(selected); + } else { + state.setTo(selected); + } + } + + void _copyResult(BuildContext context) { + final String text = '${state.output} ${state.to.symbol}'; + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Result copied.')), + ); + } +} + +class _UnitButton extends StatelessWidget { + const _UnitButton({ + required this.label, + required this.unit, + required this.onPressed, + }); + + final String label; + final ConversionUnit unit; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: '$label unit: ${unit.name}', + child: OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + alignment: Alignment.centerLeft, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(unit.displayName, overflow: TextOverflow.ellipsis), + ], + ), + ), + const Icon(Icons.expand_more), + ], + ), + ), + ); + } +} + +class _Explanation extends StatelessWidget { + const _Explanation({required this.state}); + + final AppState state; + + @override + Widget build(BuildContext context) { + return ExpansionTile( + tilePadding: EdgeInsets.zero, + title: const Text('How this conversion works'), + subtitle: Text('${state.from.name} → ${state.to.name}'), + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text(state.from.description), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text(state.to.description), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text( + state.category == UnitCategory.temperature + ? 'Temperature conversions use an affine scale transformation through kelvin.' + : 'Linear conversions pass through the category base unit using each unit factor.', + ), + ), + ], + ); + } +} diff --git a/app/lib/widgets/history_section.dart b/app/lib/widgets/history_section.dart new file mode 100644 index 00000000..1a7e08f0 --- /dev/null +++ b/app/lib/widgets/history_section.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../state/app_state.dart'; + +class HistorySection extends StatelessWidget { + const HistorySection({required this.state, super.key}); + + final AppState state; + + @override + Widget build(BuildContext context) { + final List recent = state.recent; + if (recent.isEmpty) { + return const SizedBox.shrink(); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text('Recent conversions', style: Theme.of(context).textTheme.titleMedium), + const Spacer(), + TextButton.icon( + onPressed: state.clearHistory, + icon: const Icon(Icons.delete_sweep_outlined), + label: const Text('Clear'), + ), + ], + ), + const SizedBox(height: 8), + for (final ConversionRecord record in recent.take(6)) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.history), + title: Text('${record.input} ${record.from.symbol} → ${record.output} ${record.to.symbol}'), + subtitle: Text('${record.from.name} to ${record.to.name}'), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/settings_sheet.dart b/app/lib/widgets/settings_sheet.dart new file mode 100644 index 00000000..fa90083f --- /dev/null +++ b/app/lib/widgets/settings_sheet.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import '../state/app_state.dart'; + +Future showSettingsSheet({ + required BuildContext context, + required AppState state, +}) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (BuildContext context) { + return AnimatedBuilder( + animation: state, + builder: (BuildContext context, Widget? child) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Display settings', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 20), + Text('Decimal places: ${state.decimalPlaces}'), + Slider( + min: 0, + max: 15, + divisions: 15, + value: state.decimalPlaces.toDouble(), + label: '${state.decimalPlaces}', + onChanged: (double value) => state.setDecimalPlaces(value.round()), + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Scientific notation'), + subtitle: const Text('Show results using exponential notation.'), + value: state.scientific, + onChanged: state.setScientific, + ), + ], + ), + ), + ); + }, + ); + }, + ); +} diff --git a/app/lib/widgets/unit_picker_dialog.dart b/app/lib/widgets/unit_picker_dialog.dart new file mode 100644 index 00000000..e05a4d82 --- /dev/null +++ b/app/lib/widgets/unit_picker_dialog.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../core/unit_model.dart'; + +Future showUnitPickerDialog({ + required BuildContext context, + required String title, + required List units, + required ConversionUnit selected, +}) { + String query = ''; + + return showDialog( + context: context, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + final List filtered = units + .where((ConversionUnit unit) => unit.matches(query)) + .toList(growable: false); + + return AlertDialog( + title: Text(title), + content: SizedBox( + width: 520, + height: 520, + child: Column( + children: [ + TextField( + autofocus: true, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + labelText: 'Search units', + hintText: 'Name, symbol, alias, or description', + ), + onChanged: (String value) { + setState(() { + query = value; + }); + }, + ), + const SizedBox(height: 12), + Expanded( + child: filtered.isEmpty + ? const Center(child: Text('No matching units.')) + : ListView.builder( + itemCount: filtered.length, + itemBuilder: (BuildContext context, int index) { + final ConversionUnit unit = filtered[index]; + return ListTile( + selected: unit.id == selected.id, + leading: CircleAvatar(child: Text(unit.symbol)), + title: Text(unit.name), + subtitle: Text(unit.description), + trailing: unit.id == selected.id + ? const Icon(Icons.check_circle) + : null, + onTap: () => Navigator.of(context).pop(unit), + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + }, + ); +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 00000000..06289c39 --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,20 @@ +name: unitflow +version: 0.1.0+1 +publish_to: "none" +description: Offline-first Flutter frontend for the UnitFlow converter. + +repository: https://github.com/sanskarIN/unitflow + +environment: + sdk: ">=3.8.0 <4.0.0" + +flutter: + uses-material-design: true + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/app/test/converter_test.dart b/app/test/converter_test.dart new file mode 100644 index 00000000..3a6080b9 --- /dev/null +++ b/app/test/converter_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/core/converter.dart'; +import 'package:unitflow/core/unit_catalog.dart'; +import 'package:unitflow/core/unit_model.dart'; + +ConversionUnit unit(String id) { + return unitCatalog.firstWhere((ConversionUnit value) => value.id == id); +} + +void main() { + const Converter converter = Converter(); + + test('converts kilometers to meters', () { + final double result = converter.convert( + value: 1.25, + from: unit('kilometer'), + to: unit('meter'), + ); + expect(result, closeTo(1250, 1e-12)); + }); + + test('converts Celsius to Fahrenheit', () { + final double result = converter.convert( + value: 100, + from: unit('celsius'), + to: unit('fahrenheit'), + ); + expect(result, closeTo(212, 1e-10)); + }); + + test('keeps decimal and binary data units distinct', () { + final double decimal = converter.convert( + value: 1, + from: unit('megabyte'), + to: unit('byte'), + ); + final double binary = converter.convert( + value: 1, + from: unit('mebibyte'), + to: unit('byte'), + ); + expect(decimal, 1000000); + expect(binary, 1048576); + }); + + test('rejects mismatched categories', () { + expect( + () => converter.convert( + value: 1, + from: unit('meter'), + to: unit('kilogram'), + ), + throwsA(isA()), + ); + }); + + test('formats trailing zeros cleanly', () { + expect(converter.format(12.5, decimalPlaces: 6), '12.5'); + expect(converter.format(12, decimalPlaces: 6), '12'); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart new file mode 100644 index 00000000..f198eaa6 --- /dev/null +++ b/app/test/widget_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/main.dart'; + +void main() { + testWidgets('renders UnitFlow converter and computes a default result', (WidgetTester tester) async { + await tester.pumpWidget(const UnitFlowApp()); + await tester.pumpAndSettle(); + + expect(find.text('UnitFlow'), findsOneWidget); + expect(find.text('Converter'), findsOneWidget); + expect(find.text('Convert with confidence.'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + expect(find.textContaining('km'), findsWidgets); + }); +} diff --git a/docs/accessibility.md b/docs/accessibility.md new file mode 100644 index 00000000..f5e53fe2 --- /dev/null +++ b/docs/accessibility.md @@ -0,0 +1,30 @@ +# Accessibility + +UnitFlow should remain usable with touch, keyboard, screen readers, zoom, and high-contrast system settings. + +## Current practices + +- Material controls with visible labels rather than icon-only meaning where practical. +- `Semantics` labels on swap and unit-selection actions. +- Responsive layouts that move from horizontal to vertical controls on narrow screens. +- Selectable conversion output. +- System light/dark theme support. +- Error text attached to the numeric input field. +- Touch targets provided by standard Material components. + +## Review checklist + +For user-facing changes verify: + +- meaningful focus order +- controls usable without a pointer +- screen-reader labels describe purpose, not appearance +- text remains readable at increased text scale +- no information conveyed by color alone +- error states are textual and discoverable +- dialogs have clear titles and close/cancel actions +- result updates remain understandable without animation + +## Target + +Before 1.0, perform a dedicated audit against current WCAG guidance and Flutter accessibility recommendations, then track any deviations as issues with reproducible steps. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..df26fab6 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,42 @@ +# Architecture + +## Overview + +UnitFlow is a small monorepo with two deliberately separated layers: + +- `rust/` — precision-focused conversion domain engine. +- `app/` — Flutter presentation layer and dependency-free Dart adapter. + +The separation keeps conversion rules independent from UI code and makes it possible to replace the temporary Dart adapter with a native/WASM Rust bridge without redesigning the screens. + +## Rust core + +The Rust crate contains: + +- `catalog.rs` — unit/category metadata and search. +- `converter.rs` — decimal conversion, affine temperature handling, rounding, validation. +- `custom.rs` — validated affine custom units. +- `error.rs` — typed error model. +- `src/bin/unitflow.rs` — command-line interface. +- `tests/` — regression coverage. + +Built-in linear units convert through a category base unit. Temperature uses explicit affine transformations. The Rust core uses `rust_decimal` to avoid ordinary binary floating-point drift for stored decimal constants and supported arithmetic. + +## Flutter app + +The frontend contains: + +- `core/` — lightweight unit model, local catalog, and conversion adapter. +- `state/` — application state, recent history, favorites, precision settings, batch operations. +- `screens/` — page-level layout. +- `widgets/` — searchable unit picker, converter card, history, batch and settings UI. + +No account, backend, analytics service, or network API is required for static conversions. + +## Bridge direction + +The stable bridge milestone will expose Rust conversion functions through generated native bindings on mobile/desktop and WebAssembly on web. Golden fixtures will compare Rust and Dart adapter results until the adapter can be removed or retained only as an emergency development fallback. + +## Data policy + +Current favorites and history are session-local only. Future persistence must remain local by default, have an explicit schema version, and avoid collecting personal information. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 00000000..d66e88da --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,33 @@ +# Performance + +UnitFlow's default conversion path is intentionally local and lightweight. + +## Performance goals + +- Single conversions should feel immediate on supported devices. +- Unit search should remain responsive for the complete built-in catalog. +- Batch conversion should avoid blocking work proportional to UI rendering. +- App startup should not require network access. +- The Rust core should avoid unnecessary allocation in hot conversion paths. + +## Measurement + +For Rust, use release builds when comparing performance: + +```bash +cargo build --manifest-path rust/Cargo.toml --release +``` + +For Flutter, use profile/release modes and the Flutter performance tooling appropriate to the target platform. + +## Budgets for 1.0 + +The project intends to establish reproducible device-class benchmarks before 1.0 covering: + +- cold startup +- first conversion latency +- 1,000-value batch conversion +- full-catalog search latency +- memory footprint after repeated conversions + +Budgets should be measured on documented hardware rather than guessed, then enforced where CI runners can produce stable results. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 00000000..986b1a16 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,40 @@ +# Release Checklist + +## Before tagging + +1. Update version numbers in `rust/Cargo.toml` and `app/pubspec.yaml`. +2. Update `CHANGELOG.md` and `what_changed.md`. +3. Run Rust format, Clippy, and tests. +4. Bootstrap Flutter platform folders using the target stable SDK. +5. Run Flutter analyze and tests. +6. Review conversion constants changed since the previous release. +7. Perform accessibility and responsive-layout checks. +8. Confirm no secrets, signing keys, generated credentials, or local environment files are tracked. + +## Build checks + +At minimum, produce a web build plus one native target available on the release host: + +```bash +cd app +flutter build web +``` + +Platform-specific release builds should be produced only on appropriately configured hosts. + +## Versioning + +UnitFlow follows semantic versioning: + +- patch: fixes without intended API breakage +- minor: backward-compatible features and new units +- major: incompatible API/data-format changes + +## Tagging + +```bash +git tag -a v0.1.0 -m "UnitFlow 0.1.0" +git push origin v0.1.0 +``` + +Release notes should include user-visible changes, migration notes, validation performed, known limitations, and links to security information when relevant. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 00000000..23279007 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,72 @@ +# Development Setup + +## Prerequisites + +- Git +- Rust stable toolchain with Cargo +- Flutter stable SDK +- Platform toolchains required by the Flutter target you plan to build + +## Clone + +```bash +git clone https://github.com/sanskarIN/unitflow.git +cd unitflow +``` + +Preferred local commit identity: + +```bash +git config user.name "Sanskar" +git config user.email "sanskarin@outlook.in" +``` + +## Rust core + +```bash +cd rust +cargo test +cargo run -- 1 km m 8 +cargo run -- --list +``` + +## Flutter frontend + +Platform runner folders are intentionally bootstrap-generated so they match the installed Flutter SDK. + +Unix-like systems: + +```bash +./tool/bootstrap_platforms.sh +``` + +Windows PowerShell: + +```powershell +./tool/bootstrap_platforms.ps1 +``` + +Then: + +```bash +cd app +flutter analyze +flutter test +flutter run +``` + +## Common Flutter targets + +```bash +flutter run -d chrome +flutter run -d windows +flutter run -d linux +flutter run -d macos +flutter run -d android +``` + +Only targets supported by the current host/toolchain will be available. + +## Repository hygiene + +Generated build output, platform metadata caches, IDE state, environment files, and credentials are ignored. Do not commit secrets or signing material. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 00000000..307a842d --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,54 @@ +# Testing Strategy + +UnitFlow treats conversion correctness as the primary quality requirement. + +## Rust core + +Run: + +```bash +cargo fmt --manifest-path rust/Cargo.toml --check +cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings +cargo test --manifest-path rust/Cargo.toml +``` + +Core regression tests cover: + +- exact decimal linear conversions +- affine temperature conversions +- decimal versus binary data units +- category mismatch rejection +- precision validation +- alias/description search +- custom affine-unit validation and round trips + +Every new unit or formula change should include at least one known-value regression test. + +## Flutter app + +After bootstrapping platform folders: + +```bash +cd app +flutter analyze +flutter test +``` + +Flutter tests cover adapter conversion behavior and a UI smoke test. + +## Cross-engine parity + +Until the Flutter frontend is directly connected to the Rust core, any conversion constant changed in one engine must be changed in the other. The native-bridge milestone will add shared golden fixtures so parity is mechanically verified. + +## Manual checks + +Before release, verify: + +- keyboard and touch navigation +- light and dark system themes +- narrow phone and wide desktop layouts +- copy and batch conversion flows +- invalid numeric input +- unit search by name, symbol, and alias +- swap behavior +- precision/scientific notation controls diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 00000000..4372551f --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "unitflow-core" +version = "0.1.0" +edition = "2021" +rust-version = "1.82" +description = "High-precision offline unit conversion core for UnitFlow" +license = "MIT" +repository = "https://github.com/sanskarIN/unitflow" +readme = "../README.md" + +[lib] +name = "unitflow_core" +crate-type = ["rlib", "cdylib", "staticlib"] + +[dependencies] +rust_decimal = { version = "1", default-features = false, features = ["std"] } + +[[bin]] +name = "unitflow" +path = "src/bin/unitflow.rs" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 00000000..1d824571 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,43 @@ +# UnitFlow Rust Core + +The Rust crate provides UnitFlow's precision-focused conversion engine. + +## Run tests + +```bash +cargo test +``` + +## CLI + +Convert a value: + +```bash +cargo run -- 1 km m 8 +``` + +List built-in units: + +```bash +cargo run -- --list +``` + +## Library example + +```rust +use unitflow_core::{convert_str, ConversionOptions}; + +let result = convert_str("1.25", "km", "m", ConversionOptions::default())?; +assert_eq!(result.value.to_string(), "1250.00"); +# Ok::<(), unitflow_core::ConversionError>(()) +``` + +## Design + +Linear units convert through a base unit using decimal factors. Temperature uses explicit affine transformations. Custom units use validated formulas of the form: + +```text +base = value * factor + offset +``` + +The crate avoids network access and forbids unsafe Rust through crate lint configuration. diff --git a/rust/src/bin/unitflow.rs b/rust/src/bin/unitflow.rs new file mode 100644 index 00000000..7a721ca8 --- /dev/null +++ b/rust/src/bin/unitflow.rs @@ -0,0 +1,41 @@ +use std::{env, process}; + +use unitflow_core::{convert_str, ConversionOptions, RoundingMode, UNITS}; + +fn main() { + let args: Vec = env::args().skip(1).collect(); + + if args.first().is_some_and(|arg| arg == "--list") { + for unit in UNITS { + println!("{}\t{}\t{}\t{}", unit.category.label(), unit.id, unit.symbol, unit.name); + } + return; + } + + if args.len() < 3 || args.len() > 4 { + eprintln!("Usage: unitflow [decimal-places]"); + eprintln!(" unitflow --list"); + process::exit(2); + } + + let decimal_places = args + .get(3) + .map_or(Ok(12), |value| value.parse::()) + .unwrap_or_else(|_| { + eprintln!("decimal-places must be a non-negative integer"); + process::exit(2); + }); + + let options = ConversionOptions { + decimal_places, + rounding: RoundingMode::HalfEven, + }; + + match convert_str(&args[0], &args[1], &args[2], options) { + Ok(result) => println!("{}", result.value.normalize()), + Err(error) => { + eprintln!("{error}"); + process::exit(1); + } + } +} diff --git a/rust/src/catalog.rs b/rust/src/catalog.rs new file mode 100644 index 00000000..dfd5daa5 --- /dev/null +++ b/rust/src/catalog.rs @@ -0,0 +1,174 @@ +/// A conversion category supported by UnitFlow. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Category { + Length, + Mass, + Temperature, + Time, + Area, + Volume, + Speed, + Data, + Pressure, + Energy, + Power, + Angle, +} + +impl Category { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Length => "Length", + Self::Mass => "Mass", + Self::Temperature => "Temperature", + Self::Time => "Time", + Self::Area => "Area", + Self::Volume => "Volume", + Self::Speed => "Speed", + Self::Data => "Data", + Self::Pressure => "Pressure", + Self::Energy => "Energy", + Self::Power => "Power", + Self::Angle => "Angle", + } + } +} + +/// Metadata plus a linear factor to the category base unit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnitDef { + pub id: &'static str, + pub category: Category, + pub name: &'static str, + pub symbol: &'static str, + pub factor_to_base: &'static str, + pub aliases: &'static [&'static str], + pub description: &'static str, +} + +pub const UNITS: &[UnitDef] = &[ + UnitDef { id: "meter", category: Category::Length, name: "Meter", symbol: "m", factor_to_base: "1", aliases: &["metre", "meters", "metres"], description: "SI base unit of length." }, + UnitDef { id: "kilometer", category: Category::Length, name: "Kilometer", symbol: "km", factor_to_base: "1000", aliases: &["kilometre", "kilometers", "kilometres"], description: "One thousand meters." }, + UnitDef { id: "centimeter", category: Category::Length, name: "Centimeter", symbol: "cm", factor_to_base: "0.01", aliases: &["centimetre"], description: "One hundredth of a meter." }, + UnitDef { id: "millimeter", category: Category::Length, name: "Millimeter", symbol: "mm", factor_to_base: "0.001", aliases: &["millimetre"], description: "One thousandth of a meter." }, + UnitDef { id: "micrometer", category: Category::Length, name: "Micrometer", symbol: "µm", factor_to_base: "0.000001", aliases: &["micrometre", "micron"], description: "One millionth of a meter." }, + UnitDef { id: "nanometer", category: Category::Length, name: "Nanometer", symbol: "nm", factor_to_base: "0.000000001", aliases: &["nanometre"], description: "One billionth of a meter." }, + UnitDef { id: "inch", category: Category::Length, name: "Inch", symbol: "in", factor_to_base: "0.0254", aliases: &["inches"], description: "Exactly 2.54 centimeters." }, + UnitDef { id: "foot", category: Category::Length, name: "Foot", symbol: "ft", factor_to_base: "0.3048", aliases: &["feet"], description: "Twelve inches." }, + UnitDef { id: "yard", category: Category::Length, name: "Yard", symbol: "yd", factor_to_base: "0.9144", aliases: &["yards"], description: "Three feet." }, + UnitDef { id: "mile", category: Category::Length, name: "Mile", symbol: "mi", factor_to_base: "1609.344", aliases: &["miles"], description: "5,280 feet." }, + UnitDef { id: "nautical_mile", category: Category::Length, name: "Nautical mile", symbol: "nmi", factor_to_base: "1852", aliases: &["nautical miles"], description: "International nautical mile." }, + + UnitDef { id: "kilogram", category: Category::Mass, name: "Kilogram", symbol: "kg", factor_to_base: "1", aliases: &["kilograms"], description: "SI base unit of mass." }, + UnitDef { id: "gram", category: Category::Mass, name: "Gram", symbol: "g", factor_to_base: "0.001", aliases: &["grams"], description: "One thousandth of a kilogram." }, + UnitDef { id: "milligram", category: Category::Mass, name: "Milligram", symbol: "mg", factor_to_base: "0.000001", aliases: &["milligrams"], description: "One millionth of a kilogram." }, + UnitDef { id: "microgram", category: Category::Mass, name: "Microgram", symbol: "µg", factor_to_base: "0.000000001", aliases: &["micrograms"], description: "One billionth of a kilogram." }, + UnitDef { id: "tonne", category: Category::Mass, name: "Metric tonne", symbol: "t", factor_to_base: "1000", aliases: &["metric ton", "tonnes"], description: "One thousand kilograms." }, + UnitDef { id: "ounce", category: Category::Mass, name: "Ounce", symbol: "oz", factor_to_base: "0.028349523125", aliases: &["ounces"], description: "International avoirdupois ounce." }, + UnitDef { id: "pound", category: Category::Mass, name: "Pound", symbol: "lb", factor_to_base: "0.45359237", aliases: &["pounds", "lbs"], description: "International avoirdupois pound." }, + UnitDef { id: "stone", category: Category::Mass, name: "Stone", symbol: "st", factor_to_base: "6.35029318", aliases: &["stones"], description: "Fourteen pounds." }, + + UnitDef { id: "kelvin", category: Category::Temperature, name: "Kelvin", symbol: "K", factor_to_base: "1", aliases: &["kelvins"], description: "SI base unit of thermodynamic temperature." }, + UnitDef { id: "celsius", category: Category::Temperature, name: "Celsius", symbol: "°C", factor_to_base: "1", aliases: &["centigrade"], description: "Temperature scale offset from kelvin by 273.15." }, + UnitDef { id: "fahrenheit", category: Category::Temperature, name: "Fahrenheit", symbol: "°F", factor_to_base: "1", aliases: &["fahrenheits"], description: "Fahrenheit temperature scale." }, + + UnitDef { id: "second", category: Category::Time, name: "Second", symbol: "s", factor_to_base: "1", aliases: &["seconds", "sec"], description: "SI base unit of time." }, + UnitDef { id: "millisecond", category: Category::Time, name: "Millisecond", symbol: "ms", factor_to_base: "0.001", aliases: &["milliseconds"], description: "One thousandth of a second." }, + UnitDef { id: "microsecond", category: Category::Time, name: "Microsecond", symbol: "µs", factor_to_base: "0.000001", aliases: &["microseconds"], description: "One millionth of a second." }, + UnitDef { id: "minute", category: Category::Time, name: "Minute", symbol: "min", factor_to_base: "60", aliases: &["minutes"], description: "Sixty seconds." }, + UnitDef { id: "hour", category: Category::Time, name: "Hour", symbol: "h", factor_to_base: "3600", aliases: &["hours", "hr"], description: "Sixty minutes." }, + UnitDef { id: "day", category: Category::Time, name: "Day", symbol: "d", factor_to_base: "86400", aliases: &["days"], description: "Twenty-four hours." }, + UnitDef { id: "week", category: Category::Time, name: "Week", symbol: "wk", factor_to_base: "604800", aliases: &["weeks"], description: "Seven days." }, + + UnitDef { id: "square_meter", category: Category::Area, name: "Square meter", symbol: "m²", factor_to_base: "1", aliases: &["square metre", "sqm"], description: "Area of a one-meter square." }, + UnitDef { id: "square_kilometer", category: Category::Area, name: "Square kilometer", symbol: "km²", factor_to_base: "1000000", aliases: &["square kilometre", "sq km"], description: "One million square meters." }, + UnitDef { id: "square_centimeter", category: Category::Area, name: "Square centimeter", symbol: "cm²", factor_to_base: "0.0001", aliases: &["sq cm"], description: "One ten-thousandth of a square meter." }, + UnitDef { id: "square_foot", category: Category::Area, name: "Square foot", symbol: "ft²", factor_to_base: "0.09290304", aliases: &["sq ft"], description: "Area of a one-foot square." }, + UnitDef { id: "square_yard", category: Category::Area, name: "Square yard", symbol: "yd²", factor_to_base: "0.83612736", aliases: &["sq yd"], description: "Nine square feet." }, + UnitDef { id: "acre", category: Category::Area, name: "Acre", symbol: "ac", factor_to_base: "4046.8564224", aliases: &["acres"], description: "43,560 square feet." }, + UnitDef { id: "hectare", category: Category::Area, name: "Hectare", symbol: "ha", factor_to_base: "10000", aliases: &["hectares"], description: "Ten thousand square meters." }, + + UnitDef { id: "liter", category: Category::Volume, name: "Liter", symbol: "L", factor_to_base: "1", aliases: &["litre", "liters", "litres"], description: "Metric unit of volume." }, + UnitDef { id: "milliliter", category: Category::Volume, name: "Milliliter", symbol: "mL", factor_to_base: "0.001", aliases: &["millilitre", "ml"], description: "One thousandth of a liter." }, + UnitDef { id: "cubic_meter", category: Category::Volume, name: "Cubic meter", symbol: "m³", factor_to_base: "1000", aliases: &["cubic metre"], description: "One thousand liters." }, + UnitDef { id: "cubic_centimeter", category: Category::Volume, name: "Cubic centimeter", symbol: "cm³", factor_to_base: "0.001", aliases: &["cc"], description: "Equivalent to one milliliter." }, + UnitDef { id: "us_teaspoon", category: Category::Volume, name: "US teaspoon", symbol: "tsp", factor_to_base: "0.00492892159375", aliases: &["teaspoon"], description: "US customary teaspoon." }, + UnitDef { id: "us_tablespoon", category: Category::Volume, name: "US tablespoon", symbol: "tbsp", factor_to_base: "0.01478676478125", aliases: &["tablespoon"], description: "US customary tablespoon." }, + UnitDef { id: "us_fluid_ounce", category: Category::Volume, name: "US fluid ounce", symbol: "fl oz", factor_to_base: "0.0295735295625", aliases: &["fluid ounce"], description: "US customary fluid ounce." }, + UnitDef { id: "us_cup", category: Category::Volume, name: "US cup", symbol: "cup", factor_to_base: "0.2365882365", aliases: &["cups"], description: "US customary cup." }, + UnitDef { id: "us_pint", category: Category::Volume, name: "US pint", symbol: "pt", factor_to_base: "0.473176473", aliases: &["pint"], description: "US customary liquid pint." }, + UnitDef { id: "us_gallon", category: Category::Volume, name: "US gallon", symbol: "gal", factor_to_base: "3.785411784", aliases: &["gallon"], description: "US customary liquid gallon." }, + + UnitDef { id: "meter_per_second", category: Category::Speed, name: "Meter per second", symbol: "m/s", factor_to_base: "1", aliases: &["mps"], description: "SI derived unit of speed." }, + UnitDef { id: "kilometer_per_hour", category: Category::Speed, name: "Kilometer per hour", symbol: "km/h", factor_to_base: "0.2777777777777777777777777778", aliases: &["kph", "kmph"], description: "Kilometers traveled in one hour." }, + UnitDef { id: "mile_per_hour", category: Category::Speed, name: "Mile per hour", symbol: "mph", factor_to_base: "0.44704", aliases: &["miles per hour"], description: "Miles traveled in one hour." }, + UnitDef { id: "knot", category: Category::Speed, name: "Knot", symbol: "kn", factor_to_base: "0.5144444444444444444444444444", aliases: &["knots", "kt"], description: "One nautical mile per hour." }, + + UnitDef { id: "byte", category: Category::Data, name: "Byte", symbol: "B", factor_to_base: "1", aliases: &["bytes"], description: "Eight bits." }, + UnitDef { id: "bit", category: Category::Data, name: "Bit", symbol: "bit", factor_to_base: "0.125", aliases: &["bits"], description: "One eighth of a byte." }, + UnitDef { id: "kilobyte", category: Category::Data, name: "Kilobyte", symbol: "kB", factor_to_base: "1000", aliases: &["KB"], description: "One thousand bytes." }, + UnitDef { id: "megabyte", category: Category::Data, name: "Megabyte", symbol: "MB", factor_to_base: "1000000", aliases: &["megabytes"], description: "One million bytes." }, + UnitDef { id: "gigabyte", category: Category::Data, name: "Gigabyte", symbol: "GB", factor_to_base: "1000000000", aliases: &["gigabytes"], description: "One billion bytes." }, + UnitDef { id: "terabyte", category: Category::Data, name: "Terabyte", symbol: "TB", factor_to_base: "1000000000000", aliases: &["terabytes"], description: "One trillion bytes." }, + UnitDef { id: "kibibyte", category: Category::Data, name: "Kibibyte", symbol: "KiB", factor_to_base: "1024", aliases: &["kibibytes"], description: "1,024 bytes." }, + UnitDef { id: "mebibyte", category: Category::Data, name: "Mebibyte", symbol: "MiB", factor_to_base: "1048576", aliases: &["mebibytes"], description: "1,048,576 bytes." }, + UnitDef { id: "gibibyte", category: Category::Data, name: "Gibibyte", symbol: "GiB", factor_to_base: "1073741824", aliases: &["gibibytes"], description: "1,073,741,824 bytes." }, + + UnitDef { id: "pascal", category: Category::Pressure, name: "Pascal", symbol: "Pa", factor_to_base: "1", aliases: &["pascals"], description: "SI derived unit of pressure." }, + UnitDef { id: "kilopascal", category: Category::Pressure, name: "Kilopascal", symbol: "kPa", factor_to_base: "1000", aliases: &["kilopascals"], description: "One thousand pascals." }, + UnitDef { id: "bar", category: Category::Pressure, name: "Bar", symbol: "bar", factor_to_base: "100000", aliases: &["bars"], description: "One hundred kilopascals." }, + UnitDef { id: "atmosphere", category: Category::Pressure, name: "Standard atmosphere", symbol: "atm", factor_to_base: "101325", aliases: &["atmospheres"], description: "Standard atmosphere." }, + UnitDef { id: "psi", category: Category::Pressure, name: "Pound per square inch", symbol: "psi", factor_to_base: "6894.757293168", aliases: &["pounds per square inch"], description: "Pressure in pounds-force per square inch." }, + + UnitDef { id: "joule", category: Category::Energy, name: "Joule", symbol: "J", factor_to_base: "1", aliases: &["joules"], description: "SI derived unit of energy." }, + UnitDef { id: "kilojoule", category: Category::Energy, name: "Kilojoule", symbol: "kJ", factor_to_base: "1000", aliases: &["kilojoules"], description: "One thousand joules." }, + UnitDef { id: "calorie", category: Category::Energy, name: "Calorie", symbol: "cal", factor_to_base: "4.184", aliases: &["calories"], description: "Thermochemical calorie." }, + UnitDef { id: "kilocalorie", category: Category::Energy, name: "Kilocalorie", symbol: "kcal", factor_to_base: "4184", aliases: &["food calorie"], description: "One thousand thermochemical calories." }, + UnitDef { id: "watt_hour", category: Category::Energy, name: "Watt-hour", symbol: "Wh", factor_to_base: "3600", aliases: &["watt hour"], description: "Energy from one watt over one hour." }, + UnitDef { id: "kilowatt_hour", category: Category::Energy, name: "Kilowatt-hour", symbol: "kWh", factor_to_base: "3600000", aliases: &["kilowatt hour"], description: "Energy from one kilowatt over one hour." }, + + UnitDef { id: "watt", category: Category::Power, name: "Watt", symbol: "W", factor_to_base: "1", aliases: &["watts"], description: "SI derived unit of power." }, + UnitDef { id: "kilowatt", category: Category::Power, name: "Kilowatt", symbol: "kW", factor_to_base: "1000", aliases: &["kilowatts"], description: "One thousand watts." }, + UnitDef { id: "megawatt", category: Category::Power, name: "Megawatt", symbol: "MW", factor_to_base: "1000000", aliases: &["megawatts"], description: "One million watts." }, + UnitDef { id: "horsepower", category: Category::Power, name: "Mechanical horsepower", symbol: "hp", factor_to_base: "745.69987158227022", aliases: &["horse power"], description: "Mechanical horsepower." }, + + UnitDef { id: "radian", category: Category::Angle, name: "Radian", symbol: "rad", factor_to_base: "1", aliases: &["radians"], description: "SI derived unit of plane angle." }, + UnitDef { id: "degree", category: Category::Angle, name: "Degree", symbol: "°", factor_to_base: "0.0174532925199432957692369077", aliases: &["degrees", "deg"], description: "One 360th of a full turn." }, + UnitDef { id: "gradian", category: Category::Angle, name: "Gradian", symbol: "gon", factor_to_base: "0.0157079632679489661923132169", aliases: &["gradians", "grad"], description: "One 400th of a full turn." }, + UnitDef { id: "turn", category: Category::Angle, name: "Turn", symbol: "turn", factor_to_base: "6.2831853071795864769252867666", aliases: &["revolution", "rev"], description: "One full revolution." }, +]; + +#[must_use] +pub fn find_unit(query: &str) -> Option<&'static UnitDef> { + let needle = query.trim(); + UNITS.iter().find(|unit| { + unit.id.eq_ignore_ascii_case(needle) + || unit.name.eq_ignore_ascii_case(needle) + || unit.symbol.eq_ignore_ascii_case(needle) + || unit.aliases.iter().any(|alias| alias.eq_ignore_ascii_case(needle)) + }) +} + +#[must_use] +pub fn units_for_category(category: Category) -> Vec<&'static UnitDef> { + UNITS.iter().filter(|unit| unit.category == category).collect() +} + +#[must_use] +pub fn search_units(query: &str) -> Vec<&'static UnitDef> { + let needle = query.trim().to_ascii_lowercase(); + if needle.is_empty() { + return UNITS.iter().collect(); + } + + UNITS.iter() + .filter(|unit| { + unit.id.to_ascii_lowercase().contains(&needle) + || unit.name.to_ascii_lowercase().contains(&needle) + || unit.symbol.to_ascii_lowercase().contains(&needle) + || unit.description.to_ascii_lowercase().contains(&needle) + || unit.aliases.iter().any(|alias| alias.to_ascii_lowercase().contains(&needle)) + }) + .collect() +} diff --git a/rust/src/converter.rs b/rust/src/converter.rs new file mode 100644 index 00000000..07f68cac --- /dev/null +++ b/rust/src/converter.rs @@ -0,0 +1,145 @@ +use std::str::FromStr; + +use rust_decimal::{Decimal, RoundingStrategy}; + +use crate::{catalog::find_unit, ConversionError}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RoundingMode { + HalfEven, + HalfAwayFromZero, + TowardZero, + AwayFromZero, + Floor, + Ceiling, +} + +impl RoundingMode { + const fn strategy(self) -> RoundingStrategy { + match self { + Self::HalfEven => RoundingStrategy::MidpointNearestEven, + Self::HalfAwayFromZero => RoundingStrategy::MidpointAwayFromZero, + Self::TowardZero => RoundingStrategy::ToZero, + Self::AwayFromZero => RoundingStrategy::AwayFromZero, + Self::Floor => RoundingStrategy::ToNegativeInfinity, + Self::Ceiling => RoundingStrategy::ToPositiveInfinity, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversionResult { + pub value: Decimal, + pub from_id: &'static str, + pub to_id: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConversionOptions { + pub decimal_places: u32, + pub rounding: RoundingMode, +} + +impl Default for ConversionOptions { + fn default() -> Self { + Self { + decimal_places: 12, + rounding: RoundingMode::HalfEven, + } + } +} + +pub fn convert( + value: Decimal, + from: &str, + to: &str, + options: ConversionOptions, +) -> Result { + if options.decimal_places > 28 { + return Err(ConversionError::InvalidPrecision(options.decimal_places)); + } + + let from_unit = find_unit(from).ok_or_else(|| ConversionError::UnknownUnit(from.to_owned()))?; + let to_unit = find_unit(to).ok_or_else(|| ConversionError::UnknownUnit(to.to_owned()))?; + + if from_unit.category != to_unit.category { + return Err(ConversionError::CategoryMismatch { + from: from_unit.category.label().to_owned(), + to: to_unit.category.label().to_owned(), + }); + } + + let raw = if from_unit.category == crate::catalog::Category::Temperature { + convert_temperature(value, from_unit.id, to_unit.id)? + } else { + let from_factor = parse_factor(from_unit.factor_to_base)?; + let to_factor = parse_factor(to_unit.factor_to_base)?; + let base = value + .checked_mul(from_factor) + .ok_or_else(|| ConversionError::Arithmetic("overflow converting to base unit".into()))?; + base.checked_div(to_factor) + .ok_or_else(|| ConversionError::Arithmetic("division failed converting from base unit".into()))? + }; + + Ok(ConversionResult { + value: raw.round_dp_with_strategy(options.decimal_places, options.rounding.strategy()), + from_id: from_unit.id, + to_id: to_unit.id, + }) +} + +pub fn convert_str( + value: &str, + from: &str, + to: &str, + options: ConversionOptions, +) -> Result { + let decimal = Decimal::from_str(value.trim()) + .map_err(|_| ConversionError::InvalidNumber(value.to_owned()))?; + convert(decimal, from, to, options) +} + +fn parse_factor(value: &str) -> Result { + Decimal::from_str(value) + .map_err(|_| ConversionError::Arithmetic(format!("invalid catalog factor: {value}"))) +} + +fn convert_temperature(value: Decimal, from: &str, to: &str) -> Result { + if from == to { + return Ok(value); + } + + let kelvin = match from { + "kelvin" => value, + "celsius" => value + .checked_add(Decimal::new(27_315, 2)) + .ok_or_else(|| ConversionError::Arithmetic("temperature overflow".into()))?, + "fahrenheit" => { + let shifted = value + .checked_add(Decimal::new(45_967, 2)) + .ok_or_else(|| ConversionError::Arithmetic("temperature overflow".into()))?; + shifted + .checked_mul(Decimal::from(5u32)) + .and_then(|v| v.checked_div(Decimal::from(9u32))) + .ok_or_else(|| ConversionError::Arithmetic("temperature conversion failed".into()))? + } + _ => return Err(ConversionError::UnknownUnit(from.to_owned())), + }; + + match to { + "kelvin" => Ok(kelvin), + "celsius" => kelvin + .checked_sub(Decimal::new(27_315, 2)) + .ok_or_else(|| ConversionError::Arithmetic("temperature overflow".into())), + "fahrenheit" => { + let scaled = kelvin + .checked_mul(Decimal::from(9u32)) + .and_then(|v| v.checked_div(Decimal::from(5u32))) + .ok_or_else(|| ConversionError::Arithmetic("temperature conversion failed".into()))?; + scaled + .checked_sub(Decimal::new(45_967, 2)) + .ok_or_else(|| ConversionError::Arithmetic("temperature overflow".into())) + } + _ => Err(ConversionError::UnknownUnit(to.to_owned())), + } +} diff --git a/rust/src/custom.rs b/rust/src/custom.rs new file mode 100644 index 00000000..c48c6b48 --- /dev/null +++ b/rust/src/custom.rs @@ -0,0 +1,65 @@ +use rust_decimal::Decimal; + +use crate::ConversionError; + +/// A validated affine custom unit formula: `base = value * factor + offset`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomUnit { + pub name: String, + pub symbol: String, + pub factor_to_base: Decimal, + pub offset_to_base: Decimal, +} + +impl CustomUnit { + pub fn new( + name: impl Into, + symbol: impl Into, + factor_to_base: Decimal, + offset_to_base: Decimal, + ) -> Result { + let name = name.into().trim().to_owned(); + let symbol = symbol.into().trim().to_owned(); + + if name.is_empty() { + return Err(ConversionError::InvalidCustomUnit("name cannot be empty".into())); + } + if symbol.is_empty() { + return Err(ConversionError::InvalidCustomUnit("symbol cannot be empty".into())); + } + if factor_to_base.is_zero() { + return Err(ConversionError::InvalidCustomUnit( + "factor_to_base cannot be zero".into(), + )); + } + + Ok(Self { + name, + symbol, + factor_to_base, + offset_to_base, + }) + } + + pub fn to_base(&self, value: Decimal) -> Result { + value + .checked_mul(self.factor_to_base) + .and_then(|v| v.checked_add(self.offset_to_base)) + .ok_or_else(|| ConversionError::Arithmetic("custom unit overflow".into())) + } + + pub fn from_base(&self, value: Decimal) -> Result { + value + .checked_sub(self.offset_to_base) + .and_then(|v| v.checked_div(self.factor_to_base)) + .ok_or_else(|| ConversionError::Arithmetic("custom unit conversion failed".into())) + } +} + +pub fn convert_custom( + value: Decimal, + from: &CustomUnit, + to: &CustomUnit, +) -> Result { + to.from_base(from.to_base(value)?) +} diff --git a/rust/src/error.rs b/rust/src/error.rs new file mode 100644 index 00000000..947a467f --- /dev/null +++ b/rust/src/error.rs @@ -0,0 +1,31 @@ +use core::fmt; + +/// Errors returned by the UnitFlow conversion engine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConversionError { + UnknownUnit(String), + CategoryMismatch { from: String, to: String }, + InvalidNumber(String), + InvalidPrecision(u32), + InvalidCustomUnit(String), + Arithmetic(String), +} + +impl fmt::Display for ConversionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownUnit(unit) => write!(f, "unknown unit: {unit}"), + Self::CategoryMismatch { from, to } => { + write!(f, "cannot convert between different categories: {from} -> {to}") + } + Self::InvalidNumber(value) => write!(f, "invalid decimal value: {value}"), + Self::InvalidPrecision(value) => { + write!(f, "precision must be between 0 and 28, got {value}") + } + Self::InvalidCustomUnit(message) => write!(f, "invalid custom unit: {message}"), + Self::Arithmetic(message) => write!(f, "arithmetic error: {message}"), + } + } +} + +impl std::error::Error for ConversionError {} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 00000000..3c4773f4 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,14 @@ +//! UnitFlow high-precision conversion core. +//! +//! The crate provides a searchable built-in catalog, decimal conversion, +//! explicit rounding, and validated affine custom units. + +pub mod catalog; +pub mod converter; +pub mod custom; +pub mod error; + +pub use catalog::{find_unit, search_units, units_for_category, Category, UnitDef, UNITS}; +pub use converter::{convert, convert_str, ConversionOptions, ConversionResult, RoundingMode}; +pub use custom::{convert_custom, CustomUnit}; +pub use error::ConversionError; diff --git a/rust/tests/conversion_tests.rs b/rust/tests/conversion_tests.rs new file mode 100644 index 00000000..e0806e12 --- /dev/null +++ b/rust/tests/conversion_tests.rs @@ -0,0 +1,99 @@ +use std::str::FromStr; + +use rust_decimal::Decimal; +use unitflow_core::{ + convert, convert_custom, convert_str, search_units, ConversionError, ConversionOptions, + CustomUnit, RoundingMode, +}; + +fn options(dp: u32) -> ConversionOptions { + ConversionOptions { + decimal_places: dp, + rounding: RoundingMode::HalfEven, + } +} + +#[test] +fn kilometer_to_meter_is_exact() { + let result = convert_str("1.25", "km", "m", options(6)).expect("conversion should succeed"); + assert_eq!(result.value, Decimal::from_str("1250").unwrap()); +} + +#[test] +fn pounds_to_kilograms_matches_exact_definition() { + let result = convert_str("2", "lb", "kg", options(12)).expect("conversion should succeed"); + assert_eq!(result.value, Decimal::from_str("0.90718474").unwrap()); +} + +#[test] +fn celsius_to_fahrenheit_handles_affine_transform() { + let result = convert_str("100", "celsius", "fahrenheit", options(8)) + .expect("conversion should succeed"); + assert_eq!(result.value, Decimal::from(212u32)); +} + +#[test] +fn fahrenheit_to_celsius_handles_affine_transform() { + let result = convert_str("32", "fahrenheit", "celsius", options(8)) + .expect("conversion should succeed"); + assert_eq!(result.value, Decimal::ZERO); +} + +#[test] +fn decimal_and_binary_data_units_are_distinct() { + let decimal = convert_str("1", "MB", "B", options(0)).unwrap(); + let binary = convert_str("1", "MiB", "B", options(0)).unwrap(); + assert_eq!(decimal.value, Decimal::from(1_000_000u32)); + assert_eq!(binary.value, Decimal::from(1_048_576u32)); +} + +#[test] +fn category_mismatch_is_rejected() { + let error = convert_str("10", "m", "kg", options(4)).expect_err("must reject mismatch"); + assert!(matches!(error, ConversionError::CategoryMismatch { .. })); +} + +#[test] +fn invalid_precision_is_rejected() { + let error = convert( + Decimal::ONE, + "m", + "cm", + ConversionOptions { + decimal_places: 29, + rounding: RoundingMode::HalfEven, + }, + ) + .expect_err("must reject unsupported precision"); + assert_eq!(error, ConversionError::InvalidPrecision(29)); +} + +#[test] +fn catalog_search_matches_aliases_and_descriptions() { + assert!(search_units("metre").iter().any(|unit| unit.id == "meter")); + assert!(search_units("thermodynamic") + .iter() + .any(|unit| unit.id == "kelvin")); +} + +#[test] +fn custom_affine_units_round_trip() { + let from = CustomUnit::new( + "Double plus three", + "d3", + Decimal::from(2u32), + Decimal::from(3u32), + ) + .unwrap(); + let to = CustomUnit::new("Identity", "i", Decimal::ONE, Decimal::ZERO).unwrap(); + + let output = convert_custom(Decimal::from(5u32), &from, &to).unwrap(); + assert_eq!(output, Decimal::from(13u32)); +} + +#[test] +fn zero_custom_factor_is_rejected() { + let error = CustomUnit::new("Broken", "x", Decimal::ZERO, Decimal::ZERO) + .expect_err("zero factor must be invalid"); + assert!(matches!(error, ConversionError::InvalidCustomUnit(_))); +} diff --git a/tool/bootstrap_platforms.ps1 b/tool/bootstrap_platforms.ps1 new file mode 100644 index 00000000..ced52ea0 --- /dev/null +++ b/tool/bootstrap_platforms.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = "Stop" + +$RootDir = Split-Path -Parent $PSScriptRoot +Set-Location (Join-Path $RootDir "app") + +flutter create . ` + --project-name unitflow ` + --org com.sanskarin ` + --platforms android,ios,web,windows,linux,macos + +flutter pub get + +Write-Host "UnitFlow Flutter platform folders are ready." diff --git a/tool/bootstrap_platforms.sh b/tool/bootstrap_platforms.sh new file mode 100644 index 00000000..fc2217ce --- /dev/null +++ b/tool/bootstrap_platforms.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR/app" + +flutter create . \ + --project-name unitflow \ + --org com.sanskarin \ + --platforms android,ios,web,windows,linux,macos + +flutter pub get + +echo "UnitFlow Flutter platform folders are ready." diff --git a/what_changed.md b/what_changed.md new file mode 100644 index 00000000..23313b2a --- /dev/null +++ b/what_changed.md @@ -0,0 +1,77 @@ +# What Changed + +## Current milestone + +**UnitFlow 0.1 foundation — implementation and validation** + +Date: 2026-08-19 +Working branch: `chatgpt/unitflow-complete-20260819` +Target branch: `main` + +## Implemented + +### Rust core + +- Added `unitflow-core` Rust crate. +- Added high-precision decimal conversion using `rust_decimal`. +- Added explicit rounding modes. +- Added affine temperature conversion. +- Added validated affine custom units. +- Added searchable built-in catalog with aliases and descriptions. +- Added 12 conversion categories: length, mass, temperature, time, area, volume, speed, data, pressure, energy, power, angle. +- Added command-line conversion interface and unit listing. +- Added Rust regression tests. + +### Flutter frontend + +- Added dependency-light Flutter application. +- Added Material 3 responsive UI. +- Added category selector. +- Added searchable unit picker. +- Added value input, live conversion, swap, copy result, precision controls, scientific notation. +- Added session favorite conversion pairs. +- Added recent conversion history. +- Added batch conversion and copy workflow. +- Added educational unit descriptions and conversion explanation. +- Added accessibility semantics and system light/dark theme support. +- Added Flutter conversion tests and UI smoke test. + +### Build and tooling + +- Added Flutter platform bootstrap scripts for Unix-like shells and Windows PowerShell. +- Added `Makefile` quality commands. +- Added `.gitignore`, `.editorconfig`, and `.gitconfig.example`. +- Added GitHub Actions CI for Rust check/tests and Flutter analyze/tests. +- Added bug report, feature request, and pull request templates. + +### Documentation + +- Added contribution, security, support, code of conduct, changelog, and roadmap documents. +- Added architecture, setup, testing, release, accessibility, and performance guides. +- Added app-specific and Rust-core-specific README files. + +## Important architecture note + +The Rust crate is the precision-focused authoritative conversion engine. The current Flutter UI uses a dependency-free Dart conversion adapter so the application can execute immediately without a generated native binding. Direct Rust-to-Flutter native/WebAssembly bridge integration is explicitly scheduled in `ROADMAP.md`; parity must be maintained until that bridge replaces the adapter. + +## Validation status + +- Static code review: in progress. +- GitHub Actions validation: pending pull request run. +- No validation result is recorded as passing until the workflow completes successfully. + +## Commit identity note + +The requested preferred commit email is `sanskarin@outlook.in`. The GitHub connector used for repository writes does not expose an author/committer email field, so connector-created commits use the identity GitHub assigns to the authenticated operation. `.gitconfig.example` and `CONTRIBUTING.md` preserve the requested email for normal local Git commits. + +## Known limitations / next tasks + +1. Run CI and correct any compile, analyzer, or test failures. +2. Merge the validated branch to `main`. +3. Add direct Rust native/WASM bridge in the next milestone. +4. Add local persistence for favorites/history/pinned pairs in a later milestone. +5. Add shared golden fixtures for Rust/Dart parity before expanding conversion constants further. + +## Recent commit themes + +The branch intentionally uses many focused commits for repository scaffolding, Rust core features, Flutter features, tests, documentation, CI, templates, fixes, and tooling.