From cf3f5083c5fe73d03bf32afa5c2cadd785f3458a Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:32:28 +0530 Subject: [PATCH 001/241] docs: add reproducible verification record --- docs/verification.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/verification.md diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 00000000..d3b52701 --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,29 @@ +# Verification Record + +This document records reproducible quality checks for milestone audits. It complements `what_changed.md`; it does not replace CI results. + +## Phase 1 audit target + +Branch: `audit/phase-1-quality` + +Required checks: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-features + +cd apps/unitflow_app +flutter pub get +dart format --output=none --set-exit-if-changed lib test +flutter analyze --fatal-infos --fatal-warnings +flutter test +``` + +## Rules + +- A failed command is a failed audit until the defect is fixed and the command is rerun. +- Never convert an unavailable toolchain into a passing result. +- Every behavior bug found by verification should receive regression coverage where practical. +- Build/toolchain limitations belong in `what_changed.md` with exact commands and errors. +- Security and release checks are additive to these core quality gates. From b713c138978209b1e20ed3337313dfc3d40a9082 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:32:52 +0530 Subject: [PATCH 002/241] chore: add structured bug report template --- .github/ISSUE_TEMPLATE/bug_report.yml | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..84cdf425 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,68 @@ +name: Bug report +description: Report a reproducible defect in UnitFlow +title: "bug: " +labels: [bug, triage] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a problem. Remove secrets and personal data from logs before submitting. + - type: input + id: version + attributes: + label: UnitFlow version or commit + placeholder: v0.1.0-alpha.1 or commit SHA + validations: + required: true + - type: dropdown + id: platform + attributes: + label: Platform + options: + - Android + - Windows + - Linux + - macOS + - Web + - iOS + - Rust core only + - Other + validations: + required: true + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the observed behavior and what you expected instead. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + placeholder: | + 1. Open ... + 2. Select ... + 3. Enter ... + 4. Observe ... + validations: + required: true + - type: textarea + id: conversion + attributes: + label: Conversion details + description: For conversion defects, include exact input, source/target units, notation, rounding, and locale. + - type: textarea + id: logs + attributes: + label: Relevant non-sensitive logs + render: shell + - type: checkboxes + id: checks + attributes: + label: Checklist + options: + - label: I searched existing issues for duplicates. + required: true + - label: I removed credentials, tokens, private data, and signing material. + required: true From abcd9c387717dc1a7d07f31191f6980c63e5ef48 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:33:05 +0530 Subject: [PATCH 003/241] chore: add feature request template --- .github/ISSUE_TEMPLATE/feature_request.yml | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..2cddd0db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,53 @@ +name: Feature request +description: Propose a coherent improvement to UnitFlow +title: "feat: " +labels: [enhancement, triage] +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 experience + description: Describe the desired behavior without prescribing unnecessary implementation details. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: dropdown + id: area + attributes: + label: Area + options: + - Conversion engine + - Unit catalog + - Custom units + - Converter UI + - Search/library + - Favorites/history/pins + - Import/export + - Accessibility + - Performance + - Platform integration + - Documentation + - Other + validations: + required: true + - type: checkboxes + id: principles + attributes: + label: Product principles + options: + - label: This keeps core static conversion usable offline. + required: true + - label: This does not require an intrusive donation or sign-in flow. + required: true + - label: I searched existing issues for similar requests. + required: true From b04a9374aa35c81b90682d117f3f696dec56b342 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:33:11 +0530 Subject: [PATCH 004/241] chore: configure issue support links --- .github/ISSUE_TEMPLATE/config.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..e85f90c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Private security report + url: mailto:sanskarin@outlook.in?subject=UnitFlow%20Security + about: Report suspected vulnerabilities privately instead of opening a public issue. + - name: General support + url: mailto:supportramsandesh@gmail.com?subject=UnitFlow%20Support + about: Use private support when an issue would require sharing non-public details. From 0a3b90a244eb0c4161637620f764293afbff33c2 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:33:22 +0530 Subject: [PATCH 005/241] chore: add pull request quality template --- .github/pull_request_template.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..1ed7afe0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +## What changed + +Describe the user-visible or engineering change and why it is needed. + +## Verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-features` +- [ ] `flutter analyze --fatal-infos --fatal-warnings` +- [ ] `flutter test` +- [ ] Relevant manual journey checked where automation is insufficient + +If a check is not applicable or could not run, explain why instead of marking it complete. + +## Quality checklist + +- [ ] Tests cover behavior changes or the reason they are unnecessary is documented. +- [ ] No secrets, credentials, signing keys, private endpoints, or real user data were added. +- [ ] Accessibility and keyboard/touch behavior were considered for UI changes. +- [ ] Static conversion remains offline-capable. +- [ ] Documentation and `what_changed.md` are updated when needed. +- [ ] Backward compatibility/migration impact is documented. + +## Screenshots / recordings + +Add UI evidence for visual changes when useful. Do not include private user data. From ef26c34cbd37089477a85375dc5dc83f020fb49a Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:35:58 +0530 Subject: [PATCH 006/241] ci: automate audit branch formatting --- .github/workflows/format-audit.yml | 55 ++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/format-audit.yml diff --git a/.github/workflows/format-audit.yml b/.github/workflows/format-audit.yml new file mode 100644 index 00000000..72539528 --- /dev/null +++ b/.github/workflows/format-audit.yml @@ -0,0 +1,55 @@ +name: Format audit branch + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: format-audit-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + format: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'audit/') + runs-on: ubuntu-latest + steps: + - name: Checkout pull request branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + + - name: Install Rust formatter + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Format Rust + run: cargo fmt --all + + - name: Format Dart + working-directory: apps/unitflow_app + run: dart format lib test + + - name: Commit formatting when needed + shell: bash + run: | + if git diff --quiet; then + echo "Formatting is already clean." + exit 0 + fi + git config user.name "Sanskar" + git config user.email "sanskarin@outlook.in" + git add crates apps + git commit -m "style: apply automated formatting" + git push origin HEAD:${{ github.event.pull_request.head.ref }} From 7f0136ae1d1310638a02222315ec9c6cf771aa83 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:36:53 +0530 Subject: [PATCH 007/241] ci: add CodeQL security analysis --- .github/workflows/codeql.yml | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..8a78fdc0 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "23 3 * * 1" + workflow_dispatch: + +permissions: + contents: read + security-events: write + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze Rust + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: rust + build-mode: none + + - name: Analyze + uses: github/codeql-action/analyze@v4 + with: + category: "/language:rust" From eb9dd1630c9831b3f4c784b8f45c70301366702f Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:37:06 +0530 Subject: [PATCH 008/241] ci: review dependency changes for vulnerabilities --- .github/workflows/dependency-review.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/dependency-review.yml diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..c1ab2830 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,20 @@ +name: Dependency review + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Review dependency changes + uses: actions/dependency-review-action@v5 + with: + fail-on-severity: moderate From 1122eb62154f47912972d23cab9649ea161d5ef4 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:37:41 +0530 Subject: [PATCH 009/241] feat: add batch conversion CSV export --- .../converter/application/batch_export.dart | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/unitflow_app/lib/features/converter/application/batch_export.dart diff --git a/apps/unitflow_app/lib/features/converter/application/batch_export.dart b/apps/unitflow_app/lib/features/converter/application/batch_export.dart new file mode 100644 index 00000000..1211450f --- /dev/null +++ b/apps/unitflow_app/lib/features/converter/application/batch_export.dart @@ -0,0 +1,30 @@ +import '../domain/unit_models.dart'; + +/// Produces UTF-8 friendly RFC-4180-style CSV text for batch conversion results. +/// Values are already canonical decimal strings, so no locale-dependent separators leak into +/// exported numeric data. +String batchResultsToCsv( + Iterable results, { + required String Function(ConversionResult result) valueFormatter, +}) { + final buffer = StringBuffer('unit_id,unit_name,symbol,value\r\n'); + for (final result in results) { + buffer + ..write(_csv(result.to.id)) + ..write(',') + ..write(_csv(result.to.name)) + ..write(',') + ..write(_csv(result.to.symbol)) + ..write(',') + ..write(_csv(valueFormatter(result))) + ..write('\r\n'); + } + return buffer.toString(); +} + +String _csv(String value) { + if (!value.contains(RegExp('[,\\r\\n"]'))) { + return value; + } + return '"${value.replaceAll('"', '""')}"'; +} From 6d8cdff2a5f4ee57c047b0be21b3a7693ae4eaea Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:38:10 +0530 Subject: [PATCH 010/241] ci: add reproducible tagged release builds --- .github/workflows/release.yml | 172 ++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..78f943b1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,172 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + rust-core: + name: Rust core + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Test release profile + run: cargo test --workspace --all-features --release + - name: Build release profile + run: cargo build --workspace --all-features --release + + flutter-web: + name: Flutter web + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/unitflow_app + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - name: Resolve dependencies + run: flutter pub get + - name: Generate missing platform shell + run: flutter create --platforms=web --project-name unitflow --org in.sanskar.unitflow . + - name: Build web release + run: flutter build web --release + - name: Package web release + run: tar -C build/web -czf ../../unitflow-web-${{ github.ref_name }}.tar.gz . + - name: Upload web artifact + uses: actions/upload-artifact@v4 + with: + name: unitflow-web-${{ github.ref_name }} + path: unitflow-web-${{ github.ref_name }}.tar.gz + if-no-files-found: error + + flutter-android: + name: Flutter Android + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/unitflow_app + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - name: Resolve dependencies + run: flutter pub get + - name: Generate missing platform shell + run: flutter create --platforms=android --project-name unitflow --org in.sanskar.unitflow . + - name: Build unsigned APK + run: flutter build apk --release + - name: Upload Android artifact + uses: actions/upload-artifact@v4 + with: + name: unitflow-android-${{ github.ref_name }} + path: apps/unitflow_app/build/app/outputs/flutter-apk/app-release.apk + if-no-files-found: error + + flutter-linux: + name: Flutter Linux + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/unitflow_app + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Linux prerequisites + run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - name: Resolve dependencies + run: flutter pub get + - name: Generate missing platform shell + run: flutter create --platforms=linux --project-name unitflow --org in.sanskar.unitflow . + - name: Build Linux release + run: flutter build linux --release + - name: Package Linux release + run: tar -C build/linux/x64/release/bundle -czf ../../../../unitflow-linux-${{ github.ref_name }}.tar.gz . + - name: Upload Linux artifact + uses: actions/upload-artifact@v4 + with: + name: unitflow-linux-${{ github.ref_name }} + path: unitflow-linux-${{ github.ref_name }}.tar.gz + if-no-files-found: error + + flutter-windows: + name: Flutter Windows + runs-on: windows-latest + defaults: + run: + working-directory: apps/unitflow_app + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - name: Resolve dependencies + run: flutter pub get + - name: Generate missing platform shell + run: flutter create --platforms=windows --project-name unitflow --org in.sanskar.unitflow . + - name: Build Windows release + run: flutter build windows --release + - name: Package Windows release + shell: pwsh + run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath ../../unitflow-windows-${{ github.ref_name }}.zip + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: unitflow-windows-${{ github.ref_name }} + path: unitflow-windows-${{ github.ref_name }}.zip + if-no-files-found: error + + flutter-macos-ios: + name: Flutter macOS and iOS validation + runs-on: macos-latest + defaults: + run: + working-directory: apps/unitflow_app + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - name: Resolve dependencies + run: flutter pub get + - name: Generate Apple platform shells + run: flutter create --platforms=macos,ios --project-name unitflow --org in.sanskar.unitflow . + - name: Build macOS release + run: flutter build macos --release + - name: Validate iOS no-codesign build + run: flutter build ios --release --no-codesign + - name: Package macOS release + run: ditto -c -k --sequesterRsrc --keepParent build/macos/Build/Products/Release/UnitFlow.app ../../unitflow-macos-${{ github.ref_name }}.zip + - name: Upload macOS artifact + uses: actions/upload-artifact@v4 + with: + name: unitflow-macos-${{ github.ref_name }} + path: unitflow-macos-${{ github.ref_name }}.zip + if-no-files-found: error From d40573b5c12f9f14a1dd65172ae393c60b9b420c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:38:30 +0530 Subject: [PATCH 011/241] docs: add GitHub repository operations guide --- docs/github.md | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/github.md diff --git a/docs/github.md b/docs/github.md new file mode 100644 index 00000000..ae5d4e8e --- /dev/null +++ b/docs/github.md @@ -0,0 +1,82 @@ +# GitHub Repository Operations + +This document records recommended repository settings that cannot be represented reliably as source files alone. + +## Default branch protection + +Protect `main` and require pull requests for routine changes once the initial bootstrap is complete. Recommended rules: + +- require the `Rust quality` and `Flutter quality` CI jobs; +- require CodeQL when the repository's security settings support it; +- require dependency review for pull requests that change dependencies; +- require branches to be up to date before merge when practical; +- block force pushes and branch deletion; +- require conversation resolution before merge; +- permit administrators to intervene only for documented emergencies. + +Do not mark a check as required until the workflow exists and has run successfully at least once. + +## Suggested labels + +Use a small predictable taxonomy: + +- `bug` +- `enhancement` +- `documentation` +- `accessibility` +- `security` +- `performance` +- `dependencies` +- `rust` +- `flutter` +- `android` +- `desktop` +- `web` +- `good first issue` +- `help wanted` +- `triage` +- `blocked` + +Color choice is presentation-only; label meaning should remain understandable without relying on color. + +## Suggested milestones + +- `0.1.0-alpha.1 — Core preview` +- `0.2.0 — Local data and customization` +- `0.3.0 — Platform integration` +- `1.0.0 — Stable release` + +Milestones should track deliverables rather than arbitrary issue counts. + +## Discussions + +If GitHub Discussions is enabled, suggested categories are: + +- Announcements +- Ideas +- Q&A +- Show and tell + +Security reports must not be posted in Discussions; use the private process in `SECURITY.md`. + +## Merge strategy + +Prefer merge commits for multi-commit feature branches when preserving useful atomic history matters. Squash only branches whose intermediate commits are noisy. Do not create empty commits solely to inflate contribution counts. + +## Releases + +Create releases from audited version tags. The release workflow builds reproducible artifacts; store signing credentials outside this repository. Never upload private signing material as a normal workflow artifact. + +## Repository metadata + +Suggested description: + +> Precise, offline-first cross-platform unit converter with a Rust domain core and Flutter UI. + +Suggested topics: + +`unit-converter`, `rust`, `flutter`, `dart`, `android`, `desktop`, `web`, `offline-first`, `accessibility`, `open-source` + +## Funding + +The README and support documentation link to the optional Buy Me a Coffee page at `https://buymeacoffee.com/sanskarIN`. Funding must remain non-intrusive and must never gate features. From 7efa47f8faf36204253fde3b30fd839b49433ff0 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:38:36 +0530 Subject: [PATCH 012/241] chore: document optional environment placeholders --- .env.example | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9f1df145 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# UnitFlow static conversions require no environment variables. +# Add placeholder-only variables here if an optional online integration is introduced. +# Never commit real API keys, tokens, signing secrets, or private endpoints. + +UNITFLOW_LOG_LEVEL=info From 62586894e727cda6bcc11360161e76e88491189c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:38:50 +0530 Subject: [PATCH 013/241] design: add editable UnitFlow logo artwork --- docs/assets/unitflow-logo.svg | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/assets/unitflow-logo.svg diff --git a/docs/assets/unitflow-logo.svg b/docs/assets/unitflow-logo.svg new file mode 100644 index 00000000..7693a3d0 --- /dev/null +++ b/docs/assets/unitflow-logo.svg @@ -0,0 +1,16 @@ + + UnitFlow logo + Rounded square mark with two flowing conversion arrows next to the UnitFlow wordmark. + + + + + + + + + + + UnitFlow + PRECISE • OFFLINE-FIRST • OPEN SOURCE + From e1e1af505f7b831d3df6c8533494bb37d44d0e68 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:39:35 +0530 Subject: [PATCH 014/241] build: add Rust Flutter bridge workspace member --- Cargo.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cf82a5b2..539442fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/unitflow_core"] +members = ["crates/unitflow_core", "crates/unitflow_bridge"] resolver = "2" [workspace.package] @@ -7,10 +7,11 @@ version = "0.1.0-alpha.1" edition = "2021" license = "MIT" repository = "https://github.com/sanskarIN/unitflow" -rust-version = "1.80" +rust-version = "1.82" authors = ["Sanskar "] [workspace.dependencies] +flutter_rust_bridge = "2.12.0" rust_decimal = { version = "1.36", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } thiserror = "2.0" From bee6e521ec8c5581458ac20f176f1a8ed1b057d7 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:39:40 +0530 Subject: [PATCH 015/241] build: add Flutter Rust bridge crate --- crates/unitflow_bridge/Cargo.toml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 crates/unitflow_bridge/Cargo.toml diff --git a/crates/unitflow_bridge/Cargo.toml b/crates/unitflow_bridge/Cargo.toml new file mode 100644 index 00000000..4319c5ae --- /dev/null +++ b/crates/unitflow_bridge/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "unitflow_bridge" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +authors.workspace = true +description = "Flutter bridge API for the UnitFlow Rust conversion core" + +[lib] +crate-type = ["cdylib", "staticlib", "rlib"] + +[dependencies] +flutter_rust_bridge.workspace = true +rust_decimal.workspace = true +unitflow_core = { path = "../unitflow_core" } From 49bc7780cdc9ee1b2853d80d41e20932b8336555 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:39:45 +0530 Subject: [PATCH 016/241] feat: expose Flutter bridge API module --- crates/unitflow_bridge/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 crates/unitflow_bridge/src/lib.rs diff --git a/crates/unitflow_bridge/src/lib.rs b/crates/unitflow_bridge/src/lib.rs new file mode 100644 index 00000000..6a7c2d3f --- /dev/null +++ b/crates/unitflow_bridge/src/lib.rs @@ -0,0 +1,5 @@ +#![forbid(unsafe_code)] + +pub mod api; + +mod frb_generated; From 6e386b0801915585baa9e9816f319beaf309b9dc Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:39:52 +0530 Subject: [PATCH 017/241] feat: define bridge API surface --- crates/unitflow_bridge/src/api/mod.rs | 1 + 1 file changed, 1 insertion(+) create mode 100644 crates/unitflow_bridge/src/api/mod.rs diff --git a/crates/unitflow_bridge/src/api/mod.rs b/crates/unitflow_bridge/src/api/mod.rs new file mode 100644 index 00000000..9c6a24ed --- /dev/null +++ b/crates/unitflow_bridge/src/api/mod.rs @@ -0,0 +1 @@ +pub mod converter; From 8ca897636e95b3c2d68171322633e78176669bb8 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:40:13 +0530 Subject: [PATCH 018/241] feat: add typed Rust bridge conversion endpoints --- crates/unitflow_bridge/src/api/converter.rs | 173 ++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 crates/unitflow_bridge/src/api/converter.rs diff --git a/crates/unitflow_bridge/src/api/converter.rs b/crates/unitflow_bridge/src/api/converter.rs new file mode 100644 index 00000000..64a64486 --- /dev/null +++ b/crates/unitflow_bridge/src/api/converter.rs @@ -0,0 +1,173 @@ +use std::str::FromStr; + +use rust_decimal::Decimal; +use unitflow_core::{ + ConversionRequest, Converter, Notation, RoundMode, UnitCatalog, UnitDefinition, +}; + +#[derive(Debug, Clone)] +pub struct BridgeUnit { + pub id: String, + pub category: String, + pub name: String, + pub symbol: String, + pub aliases: Vec, + pub description: String, + pub scale: String, + pub offset: String, + pub is_builtin: bool, +} + +#[derive(Debug, Clone)] +pub struct BridgeConversionResult { + pub input: String, + pub output: String, + pub from_unit_id: String, + pub to_unit_id: String, + pub category: String, +} + +#[derive(Debug, Clone, Copy)] +pub enum BridgeRoundMode { + NearestEven, + HalfAwayFromZero, + TowardZero, + AwayFromZero, + Floor, + Ceiling, +} + +#[derive(Debug, Clone, Copy)] +pub enum BridgeNotation { + Plain, + Scientific, + Engineering, +} + +impl From for RoundMode { + fn from(value: BridgeRoundMode) -> Self { + match value { + BridgeRoundMode::NearestEven => Self::NearestEven, + BridgeRoundMode::HalfAwayFromZero => Self::HalfAwayFromZero, + BridgeRoundMode::TowardZero => Self::TowardZero, + BridgeRoundMode::AwayFromZero => Self::AwayFromZero, + BridgeRoundMode::Floor => Self::Floor, + BridgeRoundMode::Ceiling => Self::Ceiling, + } + } +} + +impl From for Notation { + fn from(value: BridgeNotation) -> Self { + match value { + BridgeNotation::Plain => Self::Plain, + BridgeNotation::Scientific => Self::Scientific, + BridgeNotation::Engineering => Self::Engineering, + } + } +} + +#[flutter_rust_bridge::frb(sync)] +pub fn bridge_version() -> String { + unitflow_core::VERSION.to_owned() +} + +#[flutter_rust_bridge::frb(sync)] +pub fn list_units() -> Result, String> { + let catalog = UnitCatalog::built_in().map_err(|error| error.to_string())?; + Ok(catalog.all().iter().map(BridgeUnit::from).collect()) +} + +#[flutter_rust_bridge::frb(sync)] +pub fn search_units( + query: String, + category: Option, + limit: u32, +) -> Result, String> { + let catalog = UnitCatalog::built_in().map_err(|error| error.to_string())?; + let category = match category { + Some(value) => Some(parse_category(&value)?), + None => None, + }; + Ok(catalog + .search(&query, category, usize::try_from(limit).unwrap_or(usize::MAX)) + .into_iter() + .map(BridgeUnit::from) + .collect()) +} + +#[flutter_rust_bridge::frb(sync)] +pub fn convert_value( + input: String, + from_unit_id: String, + to_unit_id: String, + decimal_places: Option, + round_mode: BridgeRoundMode, +) -> Result { + let value = Decimal::from_str(input.trim()).map_err(|_| "invalid decimal input".to_owned())?; + let converter = Converter::with_built_in_catalog().map_err(|error| error.to_string())?; + let result = converter + .convert(&ConversionRequest { + value, + from_unit_id, + to_unit_id, + decimal_places, + round_mode: round_mode.into(), + }) + .map_err(|error| error.to_string())?; + + Ok(BridgeConversionResult { + input: result.input.normalize().to_string(), + output: result.output.normalize().to_string(), + from_unit_id: result.from_unit_id, + to_unit_id: result.to_unit_id, + category: result.category.to_string(), + }) +} + +#[flutter_rust_bridge::frb(sync)] +pub fn format_value( + input: String, + notation: BridgeNotation, + decimal_places: Option, + round_mode: BridgeRoundMode, +) -> Result { + let value = Decimal::from_str(input.trim()).map_err(|_| "invalid decimal input".to_owned())?; + unitflow_core::format_decimal(value, notation.into(), decimal_places, round_mode.into()) + .map_err(|error| error.to_string()) +} + +fn parse_category(value: &str) -> Result { + match value.trim().to_ascii_lowercase().replace([' ', '-'], "_").as_str() { + "length" => Ok(unitflow_core::Category::Length), + "area" => Ok(unitflow_core::Category::Area), + "volume" => Ok(unitflow_core::Category::Volume), + "mass" => Ok(unitflow_core::Category::Mass), + "speed" => Ok(unitflow_core::Category::Speed), + "pressure" => Ok(unitflow_core::Category::Pressure), + "energy" => Ok(unitflow_core::Category::Energy), + "power" => Ok(unitflow_core::Category::Power), + "angle" => Ok(unitflow_core::Category::Angle), + "data_size" => Ok(unitflow_core::Category::DataSize), + "frequency" => Ok(unitflow_core::Category::Frequency), + "time" => Ok(unitflow_core::Category::Time), + "temperature" => Ok(unitflow_core::Category::Temperature), + _ => Err(format!("unknown category `{value}`")), + } +} + +impl From<&UnitDefinition> for BridgeUnit { + fn from(unit: &UnitDefinition) -> Self { + Self { + id: unit.id.clone(), + category: unit.category.to_string(), + name: unit.name.clone(), + symbol: unit.symbol.clone(), + aliases: unit.aliases.clone(), + description: unit.description.clone(), + scale: unit.scale.normalize().to_string(), + offset: unit.offset.normalize().to_string(), + is_builtin: unit.is_builtin, + } + } +} From b9718eed2138f0dba112742668b927b62fa6a659 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:40:28 +0530 Subject: [PATCH 019/241] build: allow generated FFI bridge implementation --- crates/unitflow_bridge/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/unitflow_bridge/src/lib.rs b/crates/unitflow_bridge/src/lib.rs index 6a7c2d3f..a804bb52 100644 --- a/crates/unitflow_bridge/src/lib.rs +++ b/crates/unitflow_bridge/src/lib.rs @@ -1,5 +1,5 @@ -#![forbid(unsafe_code)] - pub mod api; +// Generated by flutter_rust_bridge. The domain crate remains `forbid(unsafe_code)`; +// FFI glue is isolated here because generated native bindings necessarily cross an unsafe ABI. mod frb_generated; From 0f58b328f68e29fed605d9fc8440e9a12bea5151 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:40:34 +0530 Subject: [PATCH 020/241] build: add bridge generation placeholder module --- crates/unitflow_bridge/src/frb_generated.rs | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 crates/unitflow_bridge/src/frb_generated.rs diff --git a/crates/unitflow_bridge/src/frb_generated.rs b/crates/unitflow_bridge/src/frb_generated.rs new file mode 100644 index 00000000..5f3f3872 --- /dev/null +++ b/crates/unitflow_bridge/src/frb_generated.rs @@ -0,0 +1,2 @@ +// This module is replaced by `flutter_rust_bridge_codegen generate`. +// Keeping the module present lets the Rust workspace compile before Dart glue generation. From ab5f485bab81dffe485fde6b39abae12dd2fb97f Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:40:51 +0530 Subject: [PATCH 021/241] test: cover Rust Flutter bridge endpoints --- crates/unitflow_bridge/tests/bridge_api.rs | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 crates/unitflow_bridge/tests/bridge_api.rs diff --git a/crates/unitflow_bridge/tests/bridge_api.rs b/crates/unitflow_bridge/tests/bridge_api.rs new file mode 100644 index 00000000..873dd685 --- /dev/null +++ b/crates/unitflow_bridge/tests/bridge_api.rs @@ -0,0 +1,36 @@ +use unitflow_bridge::api::converter::{ + bridge_version, convert_value, list_units, search_units, BridgeRoundMode, +}; + +#[test] +fn exposes_core_version() { + assert!(!bridge_version().is_empty()); +} + +#[test] +fn exposes_catalog_units() { + let units = list_units().expect("catalog should be valid"); + assert!(units.iter().any(|unit| unit.id == "meter")); + assert!(units.iter().any(|unit| unit.id == "fahrenheit")); +} + +#[test] +fn converts_through_bridge_using_decimal_strings() { + let result = convert_value( + "1000".to_owned(), + "meter".to_owned(), + "kilometer".to_owned(), + Some(6), + BridgeRoundMode::NearestEven, + ) + .expect("conversion"); + + assert_eq!(result.output, "1"); +} + +#[test] +fn searches_by_category_string() { + let results = search_units("meter".to_owned(), Some("length".to_owned()), 20) + .expect("search"); + assert!(results.iter().all(|unit| unit.category == "length")); +} From 75e934b629a7c0067732e6549157d47957000dc5 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:41:51 +0530 Subject: [PATCH 022/241] build: add unified quality check script --- tool/check.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tool/check.sh diff --git a/tool/check.sh b/tool/check.sh new file mode 100644 index 00000000..54a70423 --- /dev/null +++ b/tool/check.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-features + +cd "$ROOT/apps/unitflow_app" +flutter pub get +dart format --output=none --set-exit-if-changed lib test +flutter analyze --fatal-infos --fatal-warnings +flutter test From 6979de5441534f5ff7ec86746dfc7879ac7e10be Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:42:04 +0530 Subject: [PATCH 023/241] build: add reproducible Rust bridge generation script --- tool/generate_bridge.sh | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tool/generate_bridge.sh diff --git a/tool/generate_bridge.sh b/tool/generate_bridge.sh new file mode 100644 index 00000000..4e8b337d --- /dev/null +++ b/tool/generate_bridge.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="$ROOT/apps/unitflow_app" +BRIDGE="$ROOT/crates/unitflow_bridge" + +if ! command -v flutter_rust_bridge_codegen >/dev/null 2>&1; then + echo "flutter_rust_bridge_codegen is required (expected 2.12.x)." >&2 + echo "Install it with: cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked" >&2 + exit 1 +fi + +cd "$APP" +flutter_rust_bridge_codegen generate \ + --rust-root "$BRIDGE" \ + --rust-input crate::api \ + --dart-output lib/src/rust + +echo "Rust↔Dart bridge code generated. Run tool/check.sh before committing generated changes." From 2f49ff43528300ed33872e52b60da4c037092d53 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:42:20 +0530 Subject: [PATCH 024/241] ci: generate lockfiles during audit normalization --- .github/workflows/format-audit.yml | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/format-audit.yml b/.github/workflows/format-audit.yml index 72539528..473fda40 100644 --- a/.github/workflows/format-audit.yml +++ b/.github/workflows/format-audit.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout pull request branch - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.ref }} fetch-depth: 0 @@ -34,6 +34,13 @@ jobs: channel: stable cache: true + - name: Generate Rust lockfile + run: cargo generate-lockfile + + - name: Resolve Flutter dependencies + working-directory: apps/unitflow_app + run: flutter pub get + - name: Format Rust run: cargo fmt --all @@ -41,15 +48,19 @@ jobs: working-directory: apps/unitflow_app run: dart format lib test - - name: Commit formatting when needed + - name: Commit normalized sources when needed shell: bash run: | - if git diff --quiet; then - echo "Formatting is already clean." + if git diff --quiet && test -f Cargo.lock && test -f apps/unitflow_app/pubspec.lock; then + echo "Formatting and lockfiles are already clean." exit 0 fi git config user.name "Sanskar" git config user.email "sanskarin@outlook.in" - git add crates apps - git commit -m "style: apply automated formatting" + git add Cargo.lock apps/unitflow_app/pubspec.lock crates apps/unitflow_app/lib apps/unitflow_app/test + if git diff --cached --quiet; then + echo "No tracked normalization changes to commit." + exit 0 + fi + git commit -m "style: normalize sources and lock dependencies" git push origin HEAD:${{ github.event.pull_request.head.ref }} From 020692356d8d41d64c5faa8dbb27642e945fddda Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:43:04 +0530 Subject: [PATCH 025/241] chore: add repository funding metadata --- .github/FUNDING.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..d0a19e34 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +custom: + - "https://buymeacoffee.com/sanskarIN" From afc807ae7f2ea88aee0d43c06ccb535a6bfbf2d8 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:43:10 +0530 Subject: [PATCH 026/241] chore: configure automated dependency updates --- .github/dependabot.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..42f8e9e5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + commit-message: + prefix: "chore(deps)" + + - package-ecosystem: pub + directory: /apps/unitflow_app + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + commit-message: + prefix: "chore(deps)" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + commit-message: + prefix: "chore(ci)" From 199cff21c40f1d4a5d3b778b4a50c4a4ea6de271 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:43:18 +0530 Subject: [PATCH 027/241] test: cover batch CSV escaping --- .../test/features/batch_export_test.dart | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/unitflow_app/test/features/batch_export_test.dart diff --git a/apps/unitflow_app/test/features/batch_export_test.dart b/apps/unitflow_app/test/features/batch_export_test.dart new file mode 100644 index 00000000..1e0084e2 --- /dev/null +++ b/apps/unitflow_app/test/features/batch_export_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/core/math/exact_decimal.dart'; +import 'package:unitflow/features/converter/application/batch_export.dart'; +import 'package:unitflow/features/converter/domain/unit_models.dart'; + +void main() { + test('batch CSV exports deterministic fields and escapes commas', () { + final from = UnitDefinition( + id: 'source', + category: UnitCategory.length, + name: 'Source', + symbol: 's', + scale: ExactDecimal.parse('1'), + ); + final to = UnitDefinition( + id: 'target', + category: UnitCategory.length, + name: 'Target, special', + symbol: 't', + scale: ExactDecimal.parse('1'), + ); + final result = ConversionResult( + input: ExactDecimal.parse('1'), + output: ExactDecimal.parse('2.5'), + from: from, + to: to, + ); + + final csv = batchResultsToCsv( + [result], + valueFormatter: (value) => value.output.toCanonicalString(), + ); + + expect(csv, contains('unit_id,unit_name,symbol,value')); + expect(csv, contains('target,"Target, special",t,2.5')); + }); +} From 74a5a9735bdf5f8dcd2ec1f06271eafae16d284e Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:43:28 +0530 Subject: [PATCH 028/241] feat: add redacting structured diagnostic logger --- .../lib/core/logging/app_log.dart | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 apps/unitflow_app/lib/core/logging/app_log.dart diff --git a/apps/unitflow_app/lib/core/logging/app_log.dart b/apps/unitflow_app/lib/core/logging/app_log.dart new file mode 100644 index 00000000..50a0bd09 --- /dev/null +++ b/apps/unitflow_app/lib/core/logging/app_log.dart @@ -0,0 +1,55 @@ +import 'package:flutter/foundation.dart'; + +enum LogLevel { debug, info, warning, error } + +/// Minimal structured diagnostics with conservative key-based redaction. +/// +/// UnitFlow does not log conversion history, imported backup contents, or clipboard payloads. +abstract final class AppLog { + static const _sensitiveFragments = { + 'password', + 'passwd', + 'token', + 'secret', + 'authorization', + 'cookie', + 'email', + 'backup', + 'clipboard', + 'content', + }; + + static void write( + LogLevel level, + String event, { + Map fields = const {}, + }) { + if (!kDebugMode && level == LogLevel.debug) { + return; + } + final safeFields = {}; + for (final entry in fields.entries) { + safeFields[entry.key] = _shouldRedact(entry.key) ? '' : _bound(entry.value); + } + debugPrint({ + 'level': level.name, + 'event': event, + ...safeFields, + }.toString()); + } + + static bool _shouldRedact(String key) { + final normalized = key.toLowerCase(); + return _sensitiveFragments.any(normalized.contains); + } + + static Object? _bound(Object? value) { + if (value is String && value.length > 200) { + return '${value.substring(0, 197)}...'; + } + if (value is num || value is bool || value == null) { + return value; + } + return value.runtimeType.toString(); + } +} From ceb5cb5a34a2cf916d271f76b883b3bf469a0e78 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:43:43 +0530 Subject: [PATCH 029/241] design: add editable app icon source --- docs/assets/unitflow-app-icon.svg | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/assets/unitflow-app-icon.svg diff --git a/docs/assets/unitflow-app-icon.svg b/docs/assets/unitflow-app-icon.svg new file mode 100644 index 00000000..70f257ad --- /dev/null +++ b/docs/assets/unitflow-app-icon.svg @@ -0,0 +1,13 @@ + + UnitFlow app icon + Two white conversion arrows flowing in opposite directions on a rounded indigo-to-cyan background. + + + + + + + + + + From 0adfc9f8bca770ca41b74bdd147ae424f829d89d Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:43:59 +0530 Subject: [PATCH 030/241] feat: add recent conversions history screen --- .../history/presentation/history_screen.dart | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 apps/unitflow_app/lib/features/history/presentation/history_screen.dart diff --git a/apps/unitflow_app/lib/features/history/presentation/history_screen.dart b/apps/unitflow_app/lib/features/history/presentation/history_screen.dart new file mode 100644 index 00000000..de98c046 --- /dev/null +++ b/apps/unitflow_app/lib/features/history/presentation/history_screen.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../app/app_controller.dart'; +import '../../../app/theme/app_theme.dart'; +import '../../converter/domain/unit_models.dart'; + +final class HistoryScreen extends StatelessWidget { + const HistoryScreen({ + required this.appController, + required this.onOpenPair, + super.key, + }); + + final AppController appController; + final ValueChanged onOpenPair; + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: appController, + builder: (context, _) { + final recents = appController.state.recents; + if (recents.isEmpty) { + return const _EmptyHistory(); + } + return ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 900), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: AppSpacing.md), + Text( + 'Recent conversions', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: AppSpacing.xxs), + Text( + 'Stored locally on this device and limited to the most recent entries.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: AppSpacing.lg), + ...recents.map((recent) { + final from = appController.engine.catalog.byId(recent.fromUnitId); + final to = appController.engine.catalog.byId(recent.toUnitId); + if (from == null || to == null || from.category != to.category) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Card( + child: ListTile( + leading: const CircleAvatar( + child: Icon(Icons.history, size: 20), + ), + title: Text( + '${recent.input} ${from.symbol} → ${to.symbol}', + ), + subtitle: Text( + '${from.name} to ${to.name} • ${DateFormat.yMMMd().add_jm().format(recent.createdAt.toLocal())}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => onOpenPair( + PinnedPair( + category: from.category, + fromUnitId: from.id, + toUnitId: to.id, + ), + ), + ), + ), + ); + }), + const SizedBox(height: AppSpacing.xxl), + ], + ), + ), + ), + ], + ); + }, + ); +} + +final class _EmptyHistory extends StatelessWidget { + const _EmptyHistory(); + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.history_toggle_off, + size: 56, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: AppSpacing.md), + Text( + 'No recent conversions yet', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.xs), + const Text( + 'Conversions appear here after you copy a result, open the batch table, or submit the value field.', + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); +} From 2c887e79124fcd4a5448584def2c33d827523324 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:44:21 +0530 Subject: [PATCH 031/241] feat: integrate recent history into adaptive navigation --- apps/unitflow_app/lib/app/app_shell.dart | 83 +++++++++++++++++------- 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/apps/unitflow_app/lib/app/app_shell.dart b/apps/unitflow_app/lib/app/app_shell.dart index ce384976..dd0cb0f9 100644 --- a/apps/unitflow_app/lib/app/app_shell.dart +++ b/apps/unitflow_app/lib/app/app_shell.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../features/converter/domain/unit_models.dart'; import '../features/converter/presentation/converter_controller.dart'; import '../features/converter/presentation/converter_screen.dart'; +import '../features/history/presentation/history_screen.dart'; import '../features/library/presentation/library_screen.dart'; import '../features/settings/presentation/about_screen.dart'; import '../features/settings/presentation/settings_screen.dart'; @@ -36,12 +37,22 @@ final class _AppShellState extends State { animation: widget.appController, builder: (context, _) => CallbackShortcuts( bindings: { - const SingleActivator(LogicalKeyboardKey.digit1, control: true): () => _select(0), - const SingleActivator(LogicalKeyboardKey.digit2, control: true): () => _select(1), - const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _select(2), - const SingleActivator(LogicalKeyboardKey.digit1, meta: true): () => _select(0), - const SingleActivator(LogicalKeyboardKey.digit2, meta: true): () => _select(1), - const SingleActivator(LogicalKeyboardKey.comma, meta: true): () => _select(2), + const SingleActivator(LogicalKeyboardKey.digit1, control: true): () => + _select(0), + const SingleActivator(LogicalKeyboardKey.digit2, control: true): () => + _select(1), + const SingleActivator(LogicalKeyboardKey.digit3, control: true): () => + _select(2), + const SingleActivator(LogicalKeyboardKey.comma, control: true): () => + _select(3), + const SingleActivator(LogicalKeyboardKey.digit1, meta: true): () => + _select(0), + const SingleActivator(LogicalKeyboardKey.digit2, meta: true): () => + _select(1), + const SingleActivator(LogicalKeyboardKey.digit3, meta: true): () => + _select(2), + const SingleActivator(LogicalKeyboardKey.comma, meta: true): () => + _select(3), }, child: Focus( autofocus: true, @@ -54,7 +65,10 @@ final class _AppShellState extends State { title: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.swap_calls, color: Theme.of(context).colorScheme.primary), + Icon( + Icons.swap_calls, + color: Theme.of(context).colorScheme.primary, + ), const SizedBox(width: AppSpacing.xs), const Text('UnitFlow'), ], @@ -89,23 +103,31 @@ final class _AppShellState extends State { selectedIndex: _selectedIndex, onDestinationSelected: _select, labelType: NavigationRailLabelType.all, - destinations: const [ - NavigationRailDestination( - icon: Icon(Icons.swap_horiz_outlined), - selectedIcon: Icon(Icons.swap_horiz), - label: Text('Convert'), - ), - NavigationRailDestination( - icon: Icon(Icons.library_books_outlined), - selectedIcon: Icon(Icons.library_books), - label: Text('Library'), - ), - NavigationRailDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: Text('Settings'), - ), - ], + destinations: + const [ + NavigationRailDestination( + icon: Icon(Icons.swap_horiz_outlined), + selectedIcon: Icon(Icons.swap_horiz), + label: Text('Convert'), + ), + NavigationRailDestination( + icon: Icon( + Icons.library_books_outlined, + ), + selectedIcon: Icon(Icons.library_books), + label: Text('Library'), + ), + NavigationRailDestination( + icon: Icon(Icons.history_outlined), + selectedIcon: Icon(Icons.history), + label: Text('History'), + ), + NavigationRailDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: Text('Settings'), + ), + ], ), const VerticalDivider(width: 1), Expanded(child: content), @@ -131,6 +153,11 @@ final class _AppShellState extends State { selectedIcon: Icon(Icons.library_books), label: 'Library', ), + NavigationDestination( + icon: Icon(Icons.history_outlined), + selectedIcon: Icon(Icons.history), + label: 'History', + ), NavigationDestination( icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), @@ -153,6 +180,10 @@ final class _AppShellState extends State { appController: widget.appController, onOpenPair: _openPair, ), + HistoryScreen( + appController: widget.appController, + onOpenPair: _openPair, + ), SettingsScreen( appController: widget.appController, onOpenAbout: _openAbout, @@ -173,6 +204,8 @@ final class _AppShellState extends State { } Future _openAbout() => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const Scaffold(body: SafeArea(child: AboutScreen()))), + MaterialPageRoute( + builder: (_) => const Scaffold(body: SafeArea(child: AboutScreen())), + ), ); } From 0b0cef386cffd0df81d856e40b9aab1e2325d60f Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:45:09 +0530 Subject: [PATCH 032/241] fix: make BigInt zero initialization runtime safe --- apps/unitflow_app/lib/core/math/exact_decimal.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/unitflow_app/lib/core/math/exact_decimal.dart b/apps/unitflow_app/lib/core/math/exact_decimal.dart index b8a02c0e..990d023d 100644 --- a/apps/unitflow_app/lib/core/math/exact_decimal.dart +++ b/apps/unitflow_app/lib/core/math/exact_decimal.dart @@ -59,9 +59,9 @@ final class ExactDecimal implements Comparable { return _normalized(coefficient, scale); } - const ExactDecimal._(this.coefficient, this.scale); + ExactDecimal._(this.coefficient, this.scale); - static const zero = ExactDecimal._(BigInt.zero, 0); + static final zero = ExactDecimal._(BigInt.zero, 0); final BigInt coefficient; final int scale; From f6ff44b704194c37df9dbae325f5b34b96d1a076 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:45:35 +0530 Subject: [PATCH 033/241] fix: initialize decimal offsets without const BigInt values --- .../lib/features/converter/domain/unit_models.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/unitflow_app/lib/features/converter/domain/unit_models.dart b/apps/unitflow_app/lib/features/converter/domain/unit_models.dart index 816587f4..55be30b1 100644 --- a/apps/unitflow_app/lib/features/converter/domain/unit_models.dart +++ b/apps/unitflow_app/lib/features/converter/domain/unit_models.dart @@ -96,17 +96,17 @@ extension UnitCategoryInfo on UnitCategory { } final class UnitDefinition { - const UnitDefinition({ + UnitDefinition({ required this.id, required this.category, required this.name, required this.symbol, required this.scale, - this.offset = ExactDecimal.zero, + ExactDecimal? offset, this.aliases = const [], this.description = '', this.isBuiltIn = true, - }); + }) : offset = offset ?? ExactDecimal.zero; final String id; final UnitCategory category; From 8ec028188482087f855a232b43520b3f408223e7 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:48:09 +0530 Subject: [PATCH 034/241] fix: correct cross platform release packaging paths --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78f943b1..09d53d34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,7 @@ jobs: - name: Build Linux release run: flutter build linux --release - name: Package Linux release - run: tar -C build/linux/x64/release/bundle -czf ../../../../unitflow-linux-${{ github.ref_name }}.tar.gz . + run: tar -C build/linux/x64/release/bundle -czf ../../unitflow-linux-${{ github.ref_name }}.tar.gz . - name: Upload Linux artifact uses: actions/upload-artifact@v4 with: @@ -163,10 +163,10 @@ jobs: - name: Validate iOS no-codesign build run: flutter build ios --release --no-codesign - name: Package macOS release - run: ditto -c -k --sequesterRsrc --keepParent build/macos/Build/Products/Release/UnitFlow.app ../../unitflow-macos-${{ github.ref_name }}.zip + run: tar -C build/macos/Build/Products/Release -czf ../../unitflow-macos-${{ github.ref_name }}.tar.gz . - name: Upload macOS artifact uses: actions/upload-artifact@v4 with: name: unitflow-macos-${{ github.ref_name }} - path: unitflow-macos-${{ github.ref_name }}.zip + path: unitflow-macos-${{ github.ref_name }}.tar.gz if-no-files-found: error From 372b1a994138a8cc625e1734e888157700b87c9b Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:48:27 +0530 Subject: [PATCH 035/241] build: configure Flutter localization generation --- apps/unitflow_app/l10n.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 apps/unitflow_app/l10n.yaml diff --git a/apps/unitflow_app/l10n.yaml b/apps/unitflow_app/l10n.yaml new file mode 100644 index 00000000..5a1895fb --- /dev/null +++ b/apps/unitflow_app/l10n.yaml @@ -0,0 +1,6 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations +synthetic-package: false +nullable-getter: false From 218fc9c0e44da0dd77067a0cdad73ffdbb4e7b0c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:48:38 +0530 Subject: [PATCH 036/241] feat: add English localization catalog --- apps/unitflow_app/lib/l10n/app_en.arb | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 apps/unitflow_app/lib/l10n/app_en.arb diff --git a/apps/unitflow_app/lib/l10n/app_en.arb b/apps/unitflow_app/lib/l10n/app_en.arb new file mode 100644 index 00000000..c7b45422 --- /dev/null +++ b/apps/unitflow_app/lib/l10n/app_en.arb @@ -0,0 +1,72 @@ +{ + "@@locale": "en", + "appName": "UnitFlow", + "madeBySanskar": "Made by the Sanskar", + "convert": "Convert", + "library": "Library", + "history": "History", + "settings": "Settings", + "about": "About", + "searchUnitLibrary": "Search unit library", + "convertUnits": "Convert units", + "converterTagline": "Precise, local, and distraction-free.", + "category": "Category", + "value": "Value", + "scientificInputHint": "Scientific notation such as 1.2e6 is supported.", + "from": "From", + "to": "To", + "result": "Result", + "copyResult": "Copy result", + "resultCopied": "Conversion result copied.", + "viewBatchTable": "View batch table", + "batchConversion": "Batch conversion", + "copyCsv": "Copy CSV", + "csvCopied": "Batch conversion CSV copied.", + "learn": "Learn", + "currentPair": "Current pair", + "pinPair": "Pin pair", + "unpinPair": "Unpin pair", + "pinUnitPair": "Pin unit pair", + "unpinUnitPair": "Unpin unit pair", + "swapUnits": "Swap source and target units", + "invalidNumber": "Enter a valid number for the selected locale.", + "noConversionResult": "No conversion result", + "unitLibrary": "Unit library", + "unitLibrarySubtitle": "Search built-in units, favorites, and your own validated custom units.", + "searchUnits": "Search units", + "searchUnitsHint": "Name, symbol, or alias", + "clearSearch": "Clear search", + "all": "All", + "customUnit": "Custom unit", + "pinnedPairs": "Pinned pairs", + "noUnitsMatch": "No units match this search.", + "addFavorite": "Add to favorites", + "removeFavorite": "Remove from favorites", + "removeCustomUnit": "Remove custom unit", + "recentConversions": "Recent conversions", + "recentConversionsSubtitle": "Stored locally on this device and limited to the most recent entries.", + "noRecentConversions": "No recent conversions yet", + "noRecentConversionsSubtitle": "Conversions appear here after you copy a result, open the batch table, or submit the value field.", + "appearance": "Appearance", + "theme": "Theme", + "system": "System", + "light": "Light", + "dark": "Dark", + "conversionFormatting": "Conversion and formatting", + "notation": "Notation", + "plain": "Plain", + "scientific": "Scientific", + "engineering": "Engineering", + "decimalPlaces": "Decimal places", + "digitGrouping": "Digit grouping", + "digitGroupingSubtitle": "Use locale-aware grouping separators in displayed results.", + "privacyLocalData": "Privacy and local data", + "privacyLocalDataSubtitle": "Static conversions require no account. Your preferences, favorites, history, pinned pairs, and custom units are stored locally by default.", + "copyBackupJson": "Copy backup JSON", + "importClipboard": "Import from clipboard", + "clearLocalData": "Clear local data", + "openSourceMit": "Open source under the MIT License.", + "startConverting": "Start converting", + "next": "Next", + "skip": "Skip" +} From cb97ff0baad6ed26ca2d61464546a3d7e4e2dd9d Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:48:52 +0530 Subject: [PATCH 037/241] build: enable generated localizations and Rust bridge runtime --- apps/unitflow_app/pubspec.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/unitflow_app/pubspec.yaml b/apps/unitflow_app/pubspec.yaml index 42d36f93..11045d7a 100644 --- a/apps/unitflow_app/pubspec.yaml +++ b/apps/unitflow_app/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + flutter_rust_bridge: 2.12.0 intl: ^0.20.2 shared_preferences: ^2.5.5 url_launcher: ^6.3.2 @@ -26,4 +27,5 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter: + generate: true uses-material-design: true From a9c379455e27b943f9986f3c9b32d0c251119e72 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:48:58 +0530 Subject: [PATCH 038/241] build: add reproducible Flutter platform bootstrap --- tool/bootstrap_platforms.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tool/bootstrap_platforms.sh diff --git a/tool/bootstrap_platforms.sh b/tool/bootstrap_platforms.sh new file mode 100644 index 00000000..1632c0a5 --- /dev/null +++ b/tool/bootstrap_platforms.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="$ROOT/apps/unitflow_app" + +cd "$APP" +flutter create \ + --platforms=android,web,windows,linux,macos,ios \ + --project-name unitflow \ + --org in.sanskar.unitflow \ + . + +flutter pub get + +echo "Flutter platform shells are ready. Run ../../tool/check.sh before committing generated changes." From 740e067c5502ffe72aafd1db66bf89d6be33616c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:49:12 +0530 Subject: [PATCH 039/241] docs: record Rust Flutter bridge strategy --- docs/adr/0002-flutter-rust-bridge.md | 46 ++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/adr/0002-flutter-rust-bridge.md diff --git a/docs/adr/0002-flutter-rust-bridge.md b/docs/adr/0002-flutter-rust-bridge.md new file mode 100644 index 00000000..ede9a598 --- /dev/null +++ b/docs/adr/0002-flutter-rust-bridge.md @@ -0,0 +1,46 @@ +# ADR-0002: Generated Rust–Flutter bridge with deterministic fallback + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +ADR-0001 establishes Rust as UnitFlow's authoritative conversion domain and Flutter as the presentation layer. The two runtimes need a typed, reproducible boundary that works across supported native targets without moving conversion formulas into widgets. + +Flutter tests and web development also need deterministic behavior when a compiled native Rust library is unavailable. + +## Decision + +Use `flutter_rust_bridge` for generated native bindings around the dedicated `unitflow_bridge` crate. + +The bridge API exchanges decimal values as strings and exposes stable DTOs rather than binary floating-point values. The bridge remains thin: it delegates catalog, conversion, notation, and validation behavior to `unitflow_core`. + +Keep `ExactConversionEngine` in Dart as a deterministic fallback/test engine. It mirrors the released catalog and affine conversion model, avoids binary floating point, and implements the same application-facing `ConversionEngine` contract. + +Generated bridge code is reproducible from checked-in Rust API source and `tool/generate_bridge.sh`. Generated FFI glue is isolated from the `unitflow_core` crate; the domain crate continues to forbid unsafe Rust. + +## Native startup policy + +A production native build should initialize the generated bridge and prefer a Rust-backed implementation of `ConversionEngine`. If bridge initialization fails, the app may fall back to the deterministic Dart engine with a non-blocking diagnostic warning rather than making basic offline conversion unavailable. + +Web may use the deterministic Dart engine until the Rust bridge has a tested WASM path. Static conversion semantics must remain consistent across engines and are protected by mirrored regression tests. + +## Consequences + +### Positive + +- Rust remains the authoritative native domain implementation. +- Flutter presentation stays independent of FFI details. +- Decimal values do not lose precision at the language boundary. +- Widget tests do not need a platform-native dynamic library. +- Bridge generation and platform setup are reproducible scripts rather than undocumented manual steps. + +### Trade-offs + +- Built-in unit metadata is mirrored in the fallback and must be kept in sync until catalog generation is automated. +- Generated bindings add build dependencies and platform-specific packaging work. +- Native and fallback engine parity requires cross-engine regression tests. + +## Follow-up + +Automate bridge code generation in release/audit workflows, add the Rust-backed Flutter adapter once generated APIs are checked in, and add a parity test that compares representative conversion vectors between Rust and the deterministic fallback. From 804ed0e71f6accc599b6eff42cbfad84868447e1 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:49:34 +0530 Subject: [PATCH 040/241] ci: verify generated Rust Flutter bridge --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec5f84e..6bd49b11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -47,7 +47,7 @@ jobs: working-directory: apps/unitflow_app steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Flutter uses: subosito/flutter-action@v2 @@ -58,6 +58,9 @@ jobs: - name: Resolve dependencies run: flutter pub get + - name: Generate localizations + run: flutter gen-l10n + - name: Verify formatting run: dart format --output=none --set-exit-if-changed lib test @@ -66,3 +69,36 @@ jobs: - name: Test run: flutter test + + bridge: + name: Rust Flutter bridge + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Resolve Flutter dependencies + working-directory: apps/unitflow_app + run: flutter pub get + + - name: Install bridge generator + run: cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked + + - name: Generate bridge + run: bash tool/generate_bridge.sh + + - name: Verify generated Rust + run: cargo check --workspace --all-features + + - name: Verify generated Dart + working-directory: apps/unitflow_app + run: flutter analyze --fatal-infos --fatal-warnings From 44e715948af209daa684efba145c93efbfb7066d Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:49:55 +0530 Subject: [PATCH 041/241] feat: wire generated localization delegates into app --- apps/unitflow_app/lib/app/unitflow_app.dart | 61 ++++++++++----------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/apps/unitflow_app/lib/app/unitflow_app.dart b/apps/unitflow_app/lib/app/unitflow_app.dart index e388b519..c78c7a86 100644 --- a/apps/unitflow_app/lib/app/unitflow_app.dart +++ b/apps/unitflow_app/lib/app/unitflow_app.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; import '../core/persistence/user_state.dart'; import '../features/onboarding/presentation/onboarding_screen.dart'; +import '../l10n/app_localizations.dart'; import 'app_controller.dart'; import 'app_shell.dart'; import 'theme/app_theme.dart'; @@ -30,16 +30,12 @@ final class _UnitFlowAppState extends State { animation: widget.appController, builder: (context, _) => MaterialApp( debugShowCheckedModeBanner: false, - title: 'UnitFlow', + onGenerateTitle: (context) => AppLocalizations.of(context).appName, theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: _themeMode(widget.appController.state.theme), - localizationsDelegates: const >[ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: const [Locale('en')], + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: _home(), ), ); @@ -65,28 +61,31 @@ final class _StartupScreen extends StatelessWidget { const _StartupScreen(); @override - Widget build(BuildContext context) => Scaffold( - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.swap_calls, - size: 64, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(height: AppSpacing.lg), - Text('UnitFlow', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: AppSpacing.md), - const SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator(strokeWidth: 3), - ), - const SizedBox(height: AppSpacing.lg), - const Text('Made by the Sanskar'), - ], + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context); + return Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.swap_calls, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: AppSpacing.lg), + Text(strings.appName, style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: AppSpacing.md), + const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 3), + ), + const SizedBox(height: AppSpacing.lg), + Text(strings.madeBySanskar), + ], + ), ), - ), - ); + ); + } } From 20bc70c8e017966a3217645f2ebe7fad4e4d93c0 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:51:16 +0530 Subject: [PATCH 042/241] feat: externalize complete English UI copy --- apps/unitflow_app/lib/l10n/app_en.arb | 45 ++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/l10n/app_en.arb b/apps/unitflow_app/lib/l10n/app_en.arb index c7b45422..8523c120 100644 --- a/apps/unitflow_app/lib/l10n/app_en.arb +++ b/apps/unitflow_app/lib/l10n/app_en.arb @@ -2,6 +2,9 @@ "@@locale": "en", "appName": "UnitFlow", "madeBySanskar": "Made by the Sanskar", + "dismiss": "Dismiss", + "cancel": "Cancel", + "undo": "Undo", "convert": "Convert", "library": "Library", "history": "History", @@ -30,6 +33,7 @@ "unpinUnitPair": "Unpin unit pair", "swapUnits": "Swap source and target units", "invalidNumber": "Enter a valid number for the selected locale.", + "conversionUnavailable": "This value cannot be converted with the current settings.", "noConversionResult": "No conversion result", "unitLibrary": "Unit library", "unitLibrarySubtitle": "Search built-in units, favorites, and your own validated custom units.", @@ -38,6 +42,22 @@ "clearSearch": "Clear search", "all": "All", "customUnit": "Custom unit", + "createCustomUnit": "Create custom unit", + "customUnitFormulaHelp": "Define a safe affine relationship: base = value × scale + offset.", + "stableId": "Stable ID", + "stableIdHint": "my_custom_unit", + "stableIdError": "Use 1–64 lowercase letters, digits, _ or -.", + "name": "Name", + "nameRequired": "Enter a name.", + "symbol": "Symbol", + "symbolRequired": "Enter a symbol.", + "scale": "Scale", + "offset": "Offset", + "required": "Required.", + "aliases": "Aliases", + "aliasesHint": "comma, separated, aliases", + "description": "Description", + "createUnit": "Create unit", "pinnedPairs": "Pinned pairs", "noUnitsMatch": "No units match this search.", "addFavorite": "Add to favorites", @@ -63,10 +83,33 @@ "privacyLocalData": "Privacy and local data", "privacyLocalDataSubtitle": "Static conversions require no account. Your preferences, favorites, history, pinned pairs, and custom units are stored locally by default.", "copyBackupJson": "Copy backup JSON", + "backupCopied": "Backup JSON copied to the clipboard.", "importClipboard": "Import from clipboard", + "clipboardNoBackup": "The clipboard does not contain backup JSON.", + "backupImported": "UnitFlow backup imported.", "clearLocalData": "Clear local data", + "clearLocalDataTitle": "Clear local UnitFlow data?", + "clearLocalDataBody": "This removes preferences, favorites, recents, pinned pairs, and custom units from this device. Export a backup first if you want to restore them later.", + "clearData": "Clear data", + "localDataCleared": "Local UnitFlow data cleared.", + "aboutSubtitle": "License, privacy, support, GitHub, funding, and credits", "openSourceMit": "Open source under the MIT License.", + "projectSupport": "Project and support", + "githubRepository": "GitHub repository", + "buyMeACoffee": "Buy Me a Coffee", + "supportEmail": "Support email", + "businessEmail": "Business email", + "alternateBusinessEmail": "Business email (alternate)", + "privacy": "Privacy", + "aboutPrivacyBody": "Static conversions work offline and do not require an account. Preferences, favorites, history, pinned pairs, and custom units are designed to remain on this device unless you explicitly export them.", + "aboutTagline": "A precise, offline-first unit converter with a Rust domain core and Flutter interface.", "startConverting": "Start converting", "next": "Next", - "skip": "Skip" + "skip": "Skip", + "onboardingConvertTitle": "Convert with confidence", + "onboardingConvertBody": "Explore a broad catalog across length, area, volume, mass, speed, pressure, energy, power, angle, data, frequency, time, temperature, and more.", + "onboardingPrecisionTitle": "Precision by design", + "onboardingPrecisionBody": "UnitFlow keeps decimal calculations deterministic, supports scientific and engineering notation, and makes rounding an explicit setting.", + "onboardingPrivacyTitle": "Offline-first and yours", + "onboardingPrivacyBody": "Static conversions need no account. Favorites, history, pinned pairs, settings, and custom units are designed to stay on your device unless you export them." } From 1f658d5d24ef53d37b3a11d84ae85ccbbef5270e Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:51:36 +0530 Subject: [PATCH 043/241] feat: localize adaptive application shell --- apps/unitflow_app/lib/app/app_shell.dart | 269 ++++++++++++----------- 1 file changed, 139 insertions(+), 130 deletions(-) diff --git a/apps/unitflow_app/lib/app/app_shell.dart b/apps/unitflow_app/lib/app/app_shell.dart index dd0cb0f9..ee6396ec 100644 --- a/apps/unitflow_app/lib/app/app_shell.dart +++ b/apps/unitflow_app/lib/app/app_shell.dart @@ -8,6 +8,7 @@ import '../features/history/presentation/history_screen.dart'; import '../features/library/presentation/library_screen.dart'; import '../features/settings/presentation/about_screen.dart'; import '../features/settings/presentation/settings_screen.dart'; +import '../l10n/app_localizations.dart'; import 'app_controller.dart'; import 'theme/app_theme.dart'; @@ -33,144 +34,140 @@ final class _AppShellState extends State { } @override - Widget build(BuildContext context) => AnimatedBuilder( - animation: widget.appController, - builder: (context, _) => CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.digit1, control: true): () => - _select(0), - const SingleActivator(LogicalKeyboardKey.digit2, control: true): () => - _select(1), - const SingleActivator(LogicalKeyboardKey.digit3, control: true): () => - _select(2), - const SingleActivator(LogicalKeyboardKey.comma, control: true): () => - _select(3), - const SingleActivator(LogicalKeyboardKey.digit1, meta: true): () => - _select(0), - const SingleActivator(LogicalKeyboardKey.digit2, meta: true): () => - _select(1), - const SingleActivator(LogicalKeyboardKey.digit3, meta: true): () => - _select(2), - const SingleActivator(LogicalKeyboardKey.comma, meta: true): () => - _select(3), - }, - child: Focus( - autofocus: true, - child: LayoutBuilder( - builder: (context, constraints) { - final useRail = constraints.maxWidth >= 800; - final content = _content(); - return Scaffold( - appBar: AppBar( - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.swap_calls, - color: Theme.of(context).colorScheme.primary, + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context); + return AnimatedBuilder( + animation: widget.appController, + builder: (context, _) => CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.digit1, control: true): () => + _select(0), + const SingleActivator(LogicalKeyboardKey.digit2, control: true): () => + _select(1), + const SingleActivator(LogicalKeyboardKey.digit3, control: true): () => + _select(2), + const SingleActivator(LogicalKeyboardKey.comma, control: true): () => + _select(3), + const SingleActivator(LogicalKeyboardKey.digit1, meta: true): () => + _select(0), + const SingleActivator(LogicalKeyboardKey.digit2, meta: true): () => + _select(1), + const SingleActivator(LogicalKeyboardKey.digit3, meta: true): () => + _select(2), + const SingleActivator(LogicalKeyboardKey.comma, meta: true): () => + _select(3), + }, + child: Focus( + autofocus: true, + child: LayoutBuilder( + builder: (context, constraints) { + final useRail = constraints.maxWidth >= 800; + final content = _content(); + final destinations = <_NavigationItem>[ + _NavigationItem( + icon: Icons.swap_horiz_outlined, + selectedIcon: Icons.swap_horiz, + label: strings.convert, + ), + _NavigationItem( + icon: Icons.library_books_outlined, + selectedIcon: Icons.library_books, + label: strings.library, + ), + _NavigationItem( + icon: Icons.history_outlined, + selectedIcon: Icons.history, + label: strings.history, + ), + _NavigationItem( + icon: Icons.settings_outlined, + selectedIcon: Icons.settings, + label: strings.settings, + ), + ]; + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.swap_calls, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: AppSpacing.xs), + Text(strings.appName), + ], + ), + actions: [ + IconButton( + tooltip: strings.searchUnitLibrary, + onPressed: () => _select(1), + icon: const Icon(Icons.search), ), const SizedBox(width: AppSpacing.xs), - const Text('UnitFlow'), ], ), - actions: [ - IconButton( - tooltip: 'Search unit library', - onPressed: () => _select(1), - icon: const Icon(Icons.search), - ), - const SizedBox(width: AppSpacing.xs), - ], - ), - body: Column( - children: [ - if (widget.appController.warning != null) - MaterialBanner( - content: Text(widget.appController.warning!), - leading: const Icon(Icons.warning_amber_outlined), - actions: [ - TextButton( - onPressed: widget.appController.clearWarning, - child: const Text('Dismiss'), - ), - ], - ), - Expanded( - child: useRail - ? Row( - children: [ - NavigationRail( - selectedIndex: _selectedIndex, - onDestinationSelected: _select, - labelType: NavigationRailLabelType.all, - destinations: - const [ - NavigationRailDestination( - icon: Icon(Icons.swap_horiz_outlined), - selectedIcon: Icon(Icons.swap_horiz), - label: Text('Convert'), - ), - NavigationRailDestination( - icon: Icon( - Icons.library_books_outlined, + body: Column( + children: [ + if (widget.appController.warning != null) + MaterialBanner( + content: Text(widget.appController.warning!), + leading: const Icon(Icons.warning_amber_outlined), + actions: [ + TextButton( + onPressed: widget.appController.clearWarning, + child: Text(strings.dismiss), + ), + ], + ), + Expanded( + child: useRail + ? Row( + children: [ + NavigationRail( + selectedIndex: _selectedIndex, + onDestinationSelected: _select, + labelType: NavigationRailLabelType.all, + destinations: destinations + .map( + (item) => NavigationRailDestination( + icon: Icon(item.icon), + selectedIcon: Icon(item.selectedIcon), + label: Text(item.label), ), - selectedIcon: Icon(Icons.library_books), - label: Text('Library'), - ), - NavigationRailDestination( - icon: Icon(Icons.history_outlined), - selectedIcon: Icon(Icons.history), - label: Text('History'), - ), - NavigationRailDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: Text('Settings'), - ), - ], - ), - const VerticalDivider(width: 1), - Expanded(child: content), - ], - ) - : content, - ), - ], - ), - bottomNavigationBar: useRail - ? null - : NavigationBar( - selectedIndex: _selectedIndex, - onDestinationSelected: _select, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.swap_horiz_outlined), - selectedIcon: Icon(Icons.swap_horiz), - label: 'Convert', - ), - NavigationDestination( - icon: Icon(Icons.library_books_outlined), - selectedIcon: Icon(Icons.library_books), - label: 'Library', - ), - NavigationDestination( - icon: Icon(Icons.history_outlined), - selectedIcon: Icon(Icons.history), - label: 'History', - ), - NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', - ), - ], + ) + .toList(growable: false), + ), + const VerticalDivider(width: 1), + Expanded(child: content), + ], + ) + : content, ), - ); - }, + ], + ), + bottomNavigationBar: useRail + ? null + : NavigationBar( + selectedIndex: _selectedIndex, + onDestinationSelected: _select, + destinations: destinations + .map( + (item) => NavigationDestination( + icon: Icon(item.icon), + selectedIcon: Icon(item.selectedIcon), + label: item.label, + ), + ) + .toList(growable: false), + ), + ); + }, + ), ), ), - ), - ); + ); + } Widget _content() => IndexedStack( index: _selectedIndex, @@ -209,3 +206,15 @@ final class _AppShellState extends State { ), ); } + +final class _NavigationItem { + const _NavigationItem({ + required this.icon, + required this.selectedIcon, + required this.label, + }); + + final IconData icon; + final IconData selectedIcon; + final String label; +} From 21a4333546da254b7388f952b9bfba7a6c44bfc3 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:52:00 +0530 Subject: [PATCH 044/241] feat: localize onboarding experience --- .../presentation/onboarding_screen.dart | 70 +++++++++++-------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/apps/unitflow_app/lib/features/onboarding/presentation/onboarding_screen.dart b/apps/unitflow_app/lib/features/onboarding/presentation/onboarding_screen.dart index 2c4852e1..6aba18a9 100644 --- a/apps/unitflow_app/lib/features/onboarding/presentation/onboarding_screen.dart +++ b/apps/unitflow_app/lib/features/onboarding/presentation/onboarding_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../app/app_controller.dart'; import '../../../app/theme/app_theme.dart'; +import '../../../l10n/app_localizations.dart'; final class OnboardingScreen extends StatefulWidget { const OnboardingScreen({required this.appController, super.key}); @@ -16,24 +17,6 @@ final class _OnboardingScreenState extends State { final _pageController = PageController(); int _page = 0; - static const _pages = <({IconData icon, String title, String body})>[ - ( - icon: Icons.swap_calls, - title: 'Convert with confidence', - body: 'Explore a broad catalog across length, area, volume, mass, speed, pressure, energy, power, angle, data, frequency, time, temperature, and more.', - ), - ( - icon: Icons.calculate_outlined, - title: 'Precision by design', - body: 'UnitFlow keeps decimal calculations deterministic, supports scientific and engineering notation, and makes rounding an explicit setting.', - ), - ( - icon: Icons.lock_outline, - title: 'Offline-first and yours', - body: 'Static conversions need no account. Favorites, history, pinned pairs, settings, and custom units are designed to stay on your device unless you export them.', - ), - ]; - @override void dispose() { _pageController.dispose(); @@ -43,6 +26,24 @@ final class _OnboardingScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final strings = AppLocalizations.of(context); + final pages = <({IconData icon, String title, String body})>[ + ( + icon: Icons.swap_calls, + title: strings.onboardingConvertTitle, + body: strings.onboardingConvertBody, + ), + ( + icon: Icons.calculate_outlined, + title: strings.onboardingPrecisionTitle, + body: strings.onboardingPrecisionBody, + ), + ( + icon: Icons.lock_outline, + title: strings.onboardingPrivacyTitle, + body: strings.onboardingPrivacyBody, + ), + ]; return Scaffold( body: SafeArea( child: Column( @@ -53,20 +54,22 @@ final class _OnboardingScreenState extends State { padding: const EdgeInsets.all(AppSpacing.md), child: TextButton( onPressed: widget.appController.completeOnboarding, - child: const Text('Skip'), + child: Text(strings.skip), ), ), ), Expanded( child: PageView.builder( controller: _pageController, - itemCount: _pages.length, + itemCount: pages.length, onPageChanged: (value) => setState(() => _page = value), itemBuilder: (context, index) { - final item = _pages[index]; + final item = pages[index]; return Center( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xl, + ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 620), child: Column( @@ -77,7 +80,9 @@ final class _OnboardingScreenState extends State { height: 120, decoration: BoxDecoration( color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(AppRadii.large), + borderRadius: BorderRadius.circular( + AppRadii.large, + ), ), child: Icon( item.icon, @@ -120,10 +125,12 @@ final class _OnboardingScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate( - _pages.length, + pages.length, (index) => AnimatedContainer( duration: const Duration(milliseconds: 180), - margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xxs), + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.xxs, + ), width: index == _page ? 28 : 8, height: 8, decoration: BoxDecoration( @@ -140,14 +147,15 @@ final class _OnboardingScreenState extends State { width: double.infinity, child: FilledButton( onPressed: _next, - child: Text(_page == _pages.length - 1 ? 'Start converting' : 'Next'), + child: Text( + _page == pages.length - 1 + ? strings.startConverting + : strings.next, + ), ), ), const SizedBox(height: AppSpacing.sm), - const Text( - 'Made by the Sanskar', - textAlign: TextAlign.center, - ), + Text(strings.madeBySanskar, textAlign: TextAlign.center), ], ), ), @@ -160,7 +168,7 @@ final class _OnboardingScreenState extends State { } Future _next() async { - if (_page == _pages.length - 1) { + if (_page == 2) { await widget.appController.completeOnboarding(); return; } From b53d1bb148e92dcdd8e18bffcb9202cf1bafe34e Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:52:34 +0530 Subject: [PATCH 045/241] feat: localize settings and data controls --- .../presentation/settings_screen.dart | 296 +++++++++--------- 1 file changed, 151 insertions(+), 145 deletions(-) diff --git a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart index 3105e2cf..44eb82e5 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart @@ -5,6 +5,7 @@ import '../../../app/app_controller.dart'; import '../../../app/theme/app_theme.dart'; import '../../../core/format/decimal_format.dart'; import '../../../core/persistence/user_state.dart'; +import '../../../l10n/app_localizations.dart'; final class SettingsScreen extends StatelessWidget { const SettingsScreen({ @@ -17,155 +18,161 @@ final class SettingsScreen extends StatelessWidget { final VoidCallback onOpenAbout; @override - Widget build(BuildContext context) => AnimatedBuilder( - animation: appController, - builder: (context, _) => ListView( - padding: const EdgeInsets.all(AppSpacing.md), - children: [ - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 860), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: AppSpacing.md), - Text('Settings', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: AppSpacing.lg), - _SectionCard( - title: 'Appearance', - icon: Icons.palette_outlined, - children: [ - _DropdownSetting( - label: 'Theme', - value: appController.state.theme, - values: ThemePreference.values, - labelFor: (value) => switch (value) { - ThemePreference.system => 'System', - ThemePreference.light => 'Light', - ThemePreference.dark => 'Dark', - }, - onChanged: (value) { - if (value != null) { - appController.setTheme(value); - } - }, - ), - ], - ), - const SizedBox(height: AppSpacing.md), - _SectionCard( - title: 'Conversion and formatting', - icon: Icons.tune, - children: [ - _DropdownSetting( - label: 'Notation', - value: appController.state.notation, - values: DecimalNotation.values, - labelFor: (value) => switch (value) { - DecimalNotation.plain => 'Plain', - DecimalNotation.scientific => 'Scientific', - DecimalNotation.engineering => 'Engineering', - }, - onChanged: (value) { - if (value != null) { - appController.setNotation(value); - } - }, - ), - const SizedBox(height: AppSpacing.sm), - _DropdownSetting( - label: 'Decimal places', - value: appController.state.decimalPlaces, - values: List.generate(29, (index) => index), - labelFor: (value) => value.toString(), - onChanged: (value) { - if (value != null) { - appController.setDecimalPlaces(value); - } - }, - ), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('Digit grouping'), - subtitle: const Text('Use locale-aware grouping separators in displayed results.'), - value: appController.state.useGrouping, - onChanged: appController.setUseGrouping, - ), - ], - ), - const SizedBox(height: AppSpacing.md), - _SectionCard( - title: 'Privacy and local data', - icon: Icons.privacy_tip_outlined, - children: [ - const Text( - 'Static conversions require no account. Your preferences, favorites, history, pinned pairs, and custom units are stored locally by default.', - ), - const SizedBox(height: AppSpacing.md), - Wrap( - spacing: AppSpacing.xs, - runSpacing: AppSpacing.xs, - children: [ - OutlinedButton.icon( - onPressed: () => _copyBackup(context), - icon: const Icon(Icons.copy_all_outlined), - label: const Text('Copy backup JSON'), - ), - OutlinedButton.icon( - onPressed: () => _importFromClipboard(context), - icon: const Icon(Icons.content_paste_go_outlined), - label: const Text('Import from clipboard'), - ), - TextButton.icon( - onPressed: () => _confirmReset(context), - icon: const Icon(Icons.delete_sweep_outlined), - label: const Text('Clear local data'), - ), - ], - ), - ], - ), - const SizedBox(height: AppSpacing.md), - _SectionCard( - title: 'About', - icon: Icons.info_outline, - children: [ - ListTile( - contentPadding: EdgeInsets.zero, - title: const Text('UnitFlow'), - subtitle: const Text('License, privacy, support, GitHub, funding, and credits'), - trailing: const Icon(Icons.chevron_right), - onTap: onOpenAbout, - ), - const Divider(), - const ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.verified_user_outlined), - title: Text('Made by the Sanskar'), - subtitle: Text('Open source under the MIT License'), - ), - ], - ), - const SizedBox(height: AppSpacing.xxl), - ], + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context); + return AnimatedBuilder( + animation: appController, + builder: (context, _) => ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 860), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: AppSpacing.md), + Text( + strings.settings, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: AppSpacing.lg), + _SectionCard( + title: strings.appearance, + icon: Icons.palette_outlined, + children: [ + _DropdownSetting( + label: strings.theme, + value: appController.state.theme, + values: ThemePreference.values, + labelFor: (value) => switch (value) { + ThemePreference.system => strings.system, + ThemePreference.light => strings.light, + ThemePreference.dark => strings.dark, + }, + onChanged: (value) { + if (value != null) { + appController.setTheme(value); + } + }, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + _SectionCard( + title: strings.conversionFormatting, + icon: Icons.tune, + children: [ + _DropdownSetting( + label: strings.notation, + value: appController.state.notation, + values: DecimalNotation.values, + labelFor: (value) => switch (value) { + DecimalNotation.plain => strings.plain, + DecimalNotation.scientific => strings.scientific, + DecimalNotation.engineering => strings.engineering, + }, + onChanged: (value) { + if (value != null) { + appController.setNotation(value); + } + }, + ), + const SizedBox(height: AppSpacing.sm), + _DropdownSetting( + label: strings.decimalPlaces, + value: appController.state.decimalPlaces, + values: List.generate(29, (index) => index), + labelFor: (value) => value.toString(), + onChanged: (value) { + if (value != null) { + appController.setDecimalPlaces(value); + } + }, + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: Text(strings.digitGrouping), + subtitle: Text(strings.digitGroupingSubtitle), + value: appController.state.useGrouping, + onChanged: appController.setUseGrouping, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + _SectionCard( + title: strings.privacyLocalData, + icon: Icons.privacy_tip_outlined, + children: [ + Text(strings.privacyLocalDataSubtitle), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.xs, + runSpacing: AppSpacing.xs, + children: [ + OutlinedButton.icon( + onPressed: () => _copyBackup(context), + icon: const Icon(Icons.copy_all_outlined), + label: Text(strings.copyBackupJson), + ), + OutlinedButton.icon( + onPressed: () => _importFromClipboard(context), + icon: const Icon(Icons.content_paste_go_outlined), + label: Text(strings.importClipboard), + ), + TextButton.icon( + onPressed: () => _confirmReset(context), + icon: const Icon(Icons.delete_sweep_outlined), + label: Text(strings.clearLocalData), + ), + ], + ), + ], + ), + const SizedBox(height: AppSpacing.md), + _SectionCard( + title: strings.about, + icon: Icons.info_outline, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(strings.appName), + subtitle: Text(strings.aboutSubtitle), + trailing: const Icon(Icons.chevron_right), + onTap: onOpenAbout, + ), + const Divider(), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.verified_user_outlined), + title: Text(strings.madeBySanskar), + subtitle: Text(strings.openSourceMit), + ), + ], + ), + const SizedBox(height: AppSpacing.xxl), + ], + ), ), ), - ), - ], - ), - ); + ], + ), + ); + } Future _copyBackup(BuildContext context) async { + final strings = AppLocalizations.of(context); await Clipboard.setData(ClipboardData(text: appController.exportState())); if (!context.mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Backup JSON copied to the clipboard.')), + SnackBar(content: Text(strings.backupCopied)), ); } Future _importFromClipboard(BuildContext context) async { + final strings = AppLocalizations.of(context); final data = await Clipboard.getData('text/plain'); final content = data?.text; if (!context.mounted) { @@ -173,7 +180,7 @@ final class SettingsScreen extends StatelessWidget { } if (content == null || content.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('The clipboard does not contain backup JSON.')), + SnackBar(content: Text(strings.clipboardNoBackup)), ); return; } @@ -193,26 +200,25 @@ final class SettingsScreen extends StatelessWidget { return; } ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('UnitFlow backup imported.')), + SnackBar(content: Text(strings.backupImported)), ); } Future _confirmReset(BuildContext context) async { + final strings = AppLocalizations.of(context); final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Clear local UnitFlow data?'), - content: const Text( - 'This removes preferences, favorites, recents, pinned pairs, and custom units from this device. Export a backup first if you want to restore them later.', - ), + title: Text(strings.clearLocalDataTitle), + content: Text(strings.clearLocalDataBody), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + child: Text(strings.cancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), - child: const Text('Clear data'), + child: Text(strings.clearData), ), ], ), @@ -225,7 +231,7 @@ final class SettingsScreen extends StatelessWidget { return; } ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Local UnitFlow data cleared.')), + SnackBar(content: Text(strings.localDataCleared)), ); } } From 5cfb03dd15ed0e4638f6c9c204e01b9422226ac3 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:53:02 +0530 Subject: [PATCH 046/241] feat: localize About support and privacy content --- .../settings/presentation/about_screen.dart | 164 ++++++++++-------- 1 file changed, 90 insertions(+), 74 deletions(-) diff --git a/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart b/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart index 20454511..62bff3c4 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../app/theme/app_theme.dart'; +import '../../../l10n/app_localizations.dart'; final class AboutScreen extends StatelessWidget { const AboutScreen({super.key}); @@ -9,81 +10,94 @@ final class AboutScreen extends StatelessWidget { static const appVersion = '0.1.0-alpha.1'; @override - Widget build(BuildContext context) => ListView( - padding: const EdgeInsets.all(AppSpacing.md), - children: [ - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 820), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: AppSpacing.md), - _IdentityCard(), - const SizedBox(height: AppSpacing.md), - _LinkCard( - title: 'Project and support', - children: [ - _ExternalTile( - icon: Icons.code, - title: 'GitHub repository', - subtitle: 'github.com/sanskarIN/unitflow', - uri: Uri.parse('https://github.com/sanskarIN/unitflow'), - ), - _ExternalTile( - icon: Icons.coffee_outlined, - title: 'Buy Me a Coffee', - subtitle: 'buymeacoffee.com/sanskarIN', - uri: Uri.parse('https://buymeacoffee.com/sanskarIN'), - ), - _ExternalTile( - icon: Icons.support_agent, - title: 'Support email', - subtitle: 'supportramsandesh@gmail.com', - uri: Uri.parse('mailto:supportramsandesh@gmail.com?subject=UnitFlow%20Support'), - ), - _ExternalTile( - icon: Icons.business_outlined, - title: 'Business email', - subtitle: 'sanskarin@outlook.in', - uri: Uri.parse('mailto:sanskarin@outlook.in?subject=UnitFlow'), - ), - _ExternalTile( - icon: Icons.alternate_email, - title: 'Business email (alternate)', - subtitle: 'sanskarin.business@gmail.com', - uri: Uri.parse('mailto:sanskarin.business@gmail.com?subject=UnitFlow'), - ), - ], - ), - const SizedBox(height: AppSpacing.md), - Card( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Privacy', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: AppSpacing.sm), - const Text( - 'Static conversions work offline and do not require an account. Preferences, favorites, history, pinned pairs, and custom units are designed to remain on this device unless you explicitly export them.', + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context); + return ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 820), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: AppSpacing.md), + const _IdentityCard(), + const SizedBox(height: AppSpacing.md), + _LinkCard( + title: strings.projectSupport, + children: [ + _ExternalTile( + icon: Icons.code, + title: strings.githubRepository, + subtitle: 'github.com/sanskarIN/unitflow', + uri: Uri.parse('https://github.com/sanskarIN/unitflow'), + ), + _ExternalTile( + icon: Icons.coffee_outlined, + title: strings.buyMeACoffee, + subtitle: 'buymeacoffee.com/sanskarIN', + uri: Uri.parse('https://buymeacoffee.com/sanskarIN'), + ), + _ExternalTile( + icon: Icons.support_agent, + title: strings.supportEmail, + subtitle: 'supportramsandesh@gmail.com', + uri: Uri.parse( + 'mailto:supportramsandesh@gmail.com?subject=UnitFlow%20Support', + ), + ), + _ExternalTile( + icon: Icons.business_outlined, + title: strings.businessEmail, + subtitle: 'sanskarin@outlook.in', + uri: Uri.parse( + 'mailto:sanskarin@outlook.in?subject=UnitFlow', ), - ], + ), + _ExternalTile( + icon: Icons.alternate_email, + title: strings.alternateBusinessEmail, + subtitle: 'sanskarin.business@gmail.com', + uri: Uri.parse( + 'mailto:sanskarin.business@gmail.com?subject=UnitFlow', + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + strings.privacy, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.sm), + Text(strings.aboutPrivacyBody), + ], + ), ), ), - ), - ], + ], + ), ), ), - ), - ], - ); + ], + ); + } } final class _IdentityCard extends StatelessWidget { + const _IdentityCard(); + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final strings = AppLocalizations.of(context); return Card( child: Padding( padding: const EdgeInsets.all(AppSpacing.xl), @@ -103,21 +117,20 @@ final class _IdentityCard extends StatelessWidget { ), ), const SizedBox(height: AppSpacing.md), - Text('UnitFlow', style: theme.textTheme.headlineMedium), + Text(strings.appName, style: theme.textTheme.headlineMedium), const SizedBox(height: AppSpacing.xs), - Text('Version ${AboutScreen.appVersion}', style: theme.textTheme.bodyMedium), + Text(AboutScreen.appVersion, style: theme.textTheme.bodyMedium), const SizedBox(height: AppSpacing.md), - const Text( - 'A precise, offline-first unit converter with a Rust domain core and Flutter interface.', - textAlign: TextAlign.center, - ), + Text(strings.aboutTagline, textAlign: TextAlign.center), const SizedBox(height: AppSpacing.lg), Text( - 'Made by the Sanskar', - style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + strings.madeBySanskar, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: AppSpacing.xs), - const Text('Open source under the MIT License.'), + Text(strings.openSourceMit), ], ), ), @@ -145,7 +158,10 @@ final class _LinkCard extends StatelessWidget { AppSpacing.lg, AppSpacing.xs, ), - child: Text(title, style: Theme.of(context).textTheme.titleLarge), + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), ), ...children, ], From bab7411941137df80828f24e5f03066f18352140 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:53:21 +0530 Subject: [PATCH 047/241] feat: localize recent conversion history --- .../history/presentation/history_screen.dart | 77 +++++++++++-------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/apps/unitflow_app/lib/features/history/presentation/history_screen.dart b/apps/unitflow_app/lib/features/history/presentation/history_screen.dart index de98c046..29083e45 100644 --- a/apps/unitflow_app/lib/features/history/presentation/history_screen.dart +++ b/apps/unitflow_app/lib/features/history/presentation/history_screen.dart @@ -3,6 +3,7 @@ import 'package:intl/intl.dart'; import '../../../app/app_controller.dart'; import '../../../app/theme/app_theme.dart'; +import '../../../l10n/app_localizations.dart'; import '../../converter/domain/unit_models.dart'; final class HistoryScreen extends StatelessWidget { @@ -19,6 +20,7 @@ final class HistoryScreen extends StatelessWidget { Widget build(BuildContext context) => AnimatedBuilder( animation: appController, builder: (context, _) { + final strings = AppLocalizations.of(context); final recents = appController.state.recents; if (recents.isEmpty) { return const _EmptyHistory(); @@ -34,19 +36,25 @@ final class HistoryScreen extends StatelessWidget { children: [ const SizedBox(height: AppSpacing.md), Text( - 'Recent conversions', + strings.recentConversions, style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: AppSpacing.xxs), Text( - 'Stored locally on this device and limited to the most recent entries.', + strings.recentConversionsSubtitle, style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: AppSpacing.lg), ...recents.map((recent) { - final from = appController.engine.catalog.byId(recent.fromUnitId); - final to = appController.engine.catalog.byId(recent.toUnitId); - if (from == null || to == null || from.category != to.category) { + final from = appController.engine.catalog.byId( + recent.fromUnitId, + ); + final to = appController.engine.catalog.byId( + recent.toUnitId, + ); + if (from == null || + to == null || + from.category != to.category) { return const SizedBox.shrink(); } return Padding( @@ -60,7 +68,7 @@ final class HistoryScreen extends StatelessWidget { '${recent.input} ${from.symbol} → ${to.symbol}', ), subtitle: Text( - '${from.name} to ${to.name} • ${DateFormat.yMMMd().add_jm().format(recent.createdAt.toLocal())}', + '${from.name} → ${to.name} • ${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).add_jm().format(recent.createdAt.toLocal())}', ), trailing: const Icon(Icons.chevron_right), onTap: () => onOpenPair( @@ -89,33 +97,36 @@ final class _EmptyHistory extends StatelessWidget { const _EmptyHistory(); @override - Widget build(BuildContext context) => Center( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.xxl), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.history_toggle_off, - size: 56, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: AppSpacing.md), - Text( - 'No recent conversions yet', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.xs), - const Text( - 'Conversions appear here after you copy a result, open the batch table, or submit the value field.', - textAlign: TextAlign.center, - ), - ], + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.history_toggle_off, + size: 56, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: AppSpacing.md), + Text( + strings.noRecentConversions, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.xs), + Text( + strings.noRecentConversionsSubtitle, + textAlign: TextAlign.center, + ), + ], + ), ), ), - ), - ); + ); + } } From 0fc8a9d6be4be22ac6bb463276c0daf63ec38e3f Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:53:57 +0530 Subject: [PATCH 048/241] refactor: route app diagnostics through redacting logger --- apps/unitflow_app/lib/app/app_controller.dart | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/unitflow_app/lib/app/app_controller.dart b/apps/unitflow_app/lib/app/app_controller.dart index 49f316cf..3ba78287 100644 --- a/apps/unitflow_app/lib/app/app_controller.dart +++ b/apps/unitflow_app/lib/app/app_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; +import '../core/logging/app_log.dart'; import '../core/persistence/user_state.dart'; import '../core/persistence/user_state_repository.dart'; import '../features/converter/data/unit_catalog.dart'; @@ -27,9 +28,15 @@ final class AppController extends ChangeNotifier { final rebuilt = _buildEngine(loaded); _state = loaded; _engine = rebuilt; + AppLog.write(LogLevel.info, 'state_loaded'); } on Object catch (error) { - _warning = 'Saved preferences could not be loaded. Defaults are being used; existing saved data was not overwritten.'; - debugPrint('UnitFlow state load failed: $error'); + _warning = + 'Saved preferences could not be loaded. Defaults are being used; existing saved data was not overwritten.'; + AppLog.write( + LogLevel.error, + 'state_load_failed', + fields: {'error_type': error.runtimeType.toString()}, + ); _state = UserState(); _engine = ExactConversionEngine(); } finally { @@ -76,7 +83,10 @@ final class AppController extends ChangeNotifier { Future togglePinnedPair(PinnedPair pair) { final from = _engine.catalog.byId(pair.fromUnitId); final to = _engine.catalog.byId(pair.toUnitId); - if (from == null || to == null || from.category != pair.category || to.category != pair.category) { + if (from == null || + to == null || + from.category != pair.category || + to.category != pair.category) { throw ArgumentError('Pinned pair references invalid units.'); } final next = _state.pinnedPairs.toList(); @@ -124,10 +134,19 @@ final class AppController extends ChangeNotifier { return _update(_state.copyWith(recents: next)); } + Future clearHistory() => _update(_state.copyWith(recents: [])); + + Future restoreHistory(List recents) => + _update(_state.copyWith(recents: recents)); + Future addCustomUnit(CustomUnitData customUnit) { final definition = customUnit.toUnitDefinition(); if (_engine.catalog.byId(definition.id) != null) { - throw ArgumentError.value(definition.id, 'id', 'unit identifier already exists'); + throw ArgumentError.value( + definition.id, + 'id', + 'unit identifier already exists', + ); } final next = [..._state.customUnits, customUnit]; final newState = _state.copyWith(customUnits: next); @@ -136,12 +155,18 @@ final class AppController extends ChangeNotifier { } Future removeCustomUnit(String id) { - final existing = _state.customUnits.where((item) => item.id == id).toList(); + final existing = _state.customUnits + .where((item) => item.id == id) + .toList(); if (existing.isEmpty) { return Future.value(); } - final nextCustom = _state.customUnits.where((item) => item.id != id).toList(); - final nextFavorites = _state.favoriteUnitIds.where((item) => item != id).toSet(); + final nextCustom = _state.customUnits + .where((item) => item.id != id) + .toList(); + final nextFavorites = _state.favoriteUnitIds + .where((item) => item != id) + .toSet(); final nextPins = _state.pinnedPairs .where((pair) => pair.fromUnitId != id && pair.toUnitId != id) .toList(); @@ -167,6 +192,7 @@ final class AppController extends ChangeNotifier { _engine = ExactConversionEngine(); _warning = null; notifyListeners(); + AppLog.write(LogLevel.info, 'local_data_reset'); } void clearWarning() { @@ -197,7 +223,11 @@ final class AppController extends ChangeNotifier { final snapshot = state; final operation = _writeChain.then((_) => _repository.save(snapshot)); _writeChain = operation.catchError((Object error) { - debugPrint('UnitFlow state save failed: $error'); + AppLog.write( + LogLevel.error, + 'state_save_failed', + fields: {'error_type': error.runtimeType.toString()}, + ); }); return operation; } From 6b1dbb83c55524788dc2ebbd5883baac4a025e41 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:54:43 +0530 Subject: [PATCH 049/241] feat: add cross platform backup file support --- apps/unitflow_app/pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/unitflow_app/pubspec.yaml b/apps/unitflow_app/pubspec.yaml index 11045d7a..c79199f9 100644 --- a/apps/unitflow_app/pubspec.yaml +++ b/apps/unitflow_app/pubspec.yaml @@ -12,6 +12,7 @@ environment: sdk: ">=3.9.0 <4.0.0" dependencies: + file_selector: ^1.1.0 flutter: sdk: flutter flutter_localizations: From 9629de32c88560adc9ee69d1c20dd645201666c9 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:55:01 +0530 Subject: [PATCH 050/241] feat: add bounded backup file import and export --- .../lib/core/io/backup_file_service.dart | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 apps/unitflow_app/lib/core/io/backup_file_service.dart diff --git a/apps/unitflow_app/lib/core/io/backup_file_service.dart b/apps/unitflow_app/lib/core/io/backup_file_service.dart new file mode 100644 index 00000000..98b956e0 --- /dev/null +++ b/apps/unitflow_app/lib/core/io/backup_file_service.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:file_selector/file_selector.dart'; + +/// Cross-platform file boundary for UnitFlow backup data. +/// +/// The repository performs schema/content validation after this service reads the bounded text. +/// File selection is user initiated and no broad storage permission is requested by UnitFlow. +final class BackupFileService { + const BackupFileService(); + + static const maxBytes = 1_000_000; + static const suggestedFileName = 'unitflow-backup.json'; + + static const _jsonType = XTypeGroup( + label: 'UnitFlow JSON backup', + extensions: ['json'], + mimeTypes: ['application/json'], + uniformTypeIdentifiers: ['public.json'], + webWildCards: ['application/json'], + ); + + /// Opens a user-selected JSON backup and returns its UTF-8 text. + /// Returns `null` when the user cancels. + Future importBackup() async { + final file = await openFile( + acceptedTypeGroups: const [_jsonType], + ); + if (file == null) { + return null; + } + + final size = await file.length(); + if (size <= 0 || size > maxBytes) { + throw const BackupFileException( + 'Backup file must be between 1 byte and 1 MB.', + ); + } + + final bytes = await file.readAsBytes(); + if (bytes.length > maxBytes) { + throw const BackupFileException('Backup file exceeds the 1 MB limit.'); + } + try { + return utf8.decode(bytes, allowMalformed: false); + } on FormatException { + throw const BackupFileException('Backup file is not valid UTF-8 text.'); + } + } + + /// Shows the platform save-location UI and writes a UTF-8 JSON backup. + /// + /// Returns `false` when no save location is available or the user cancels. The caller can keep + /// clipboard export as a universal fallback for platforms without save-location support. + Future exportBackup(String content) async { + final bytes = utf8.encode(content); + if (bytes.isEmpty || bytes.length > maxBytes) { + throw const BackupFileException( + 'Backup content must be between 1 byte and 1 MB.', + ); + } + + final location = await getSaveLocation( + acceptedTypeGroups: const [_jsonType], + suggestedName: suggestedFileName, + ); + if (location == null) { + return false; + } + + final file = XFile.fromData( + Uint8List.fromList(bytes), + mimeType: 'application/json', + name: suggestedFileName, + ); + await file.saveTo(location.path); + return true; + } +} + +final class BackupFileException implements Exception { + const BackupFileException(this.message); + + final String message; + + @override + String toString() => message; +} From 537e690e751bcb3d1ab164b2aa87b78c7fee153a Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:55:25 +0530 Subject: [PATCH 051/241] feat: externalize backup and history actions --- apps/unitflow_app/lib/l10n/app_en.arb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/unitflow_app/lib/l10n/app_en.arb b/apps/unitflow_app/lib/l10n/app_en.arb index 8523c120..8efe2bcd 100644 --- a/apps/unitflow_app/lib/l10n/app_en.arb +++ b/apps/unitflow_app/lib/l10n/app_en.arb @@ -67,6 +67,8 @@ "recentConversionsSubtitle": "Stored locally on this device and limited to the most recent entries.", "noRecentConversions": "No recent conversions yet", "noRecentConversionsSubtitle": "Conversions appear here after you copy a result, open the batch table, or submit the value field.", + "clearHistory": "Clear history", + "historyCleared": "Conversion history cleared.", "appearance": "Appearance", "theme": "Theme", "system": "System", @@ -82,11 +84,16 @@ "digitGroupingSubtitle": "Use locale-aware grouping separators in displayed results.", "privacyLocalData": "Privacy and local data", "privacyLocalDataSubtitle": "Static conversions require no account. Your preferences, favorites, history, pinned pairs, and custom units are stored locally by default.", + "saveBackupFile": "Save backup file", + "backupFileSaved": "Backup file saved.", + "backupFileUnavailable": "No save location was selected. You can still copy backup JSON.", + "importBackupFile": "Import backup file", "copyBackupJson": "Copy backup JSON", "backupCopied": "Backup JSON copied to the clipboard.", "importClipboard": "Import from clipboard", "clipboardNoBackup": "The clipboard does not contain backup JSON.", "backupImported": "UnitFlow backup imported.", + "importRejected": "Import rejected", "clearLocalData": "Clear local data", "clearLocalDataTitle": "Clear local UnitFlow data?", "clearLocalDataBody": "This removes preferences, favorites, recents, pinned pairs, and custom units from this device. Export a backup first if you want to restore them later.", From 98cb27b87513f423743f75b8b7048f91f4dec1c5 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:56:03 +0530 Subject: [PATCH 052/241] feat: expose file based backup and restore controls --- .../presentation/settings_screen.dart | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart index 44eb82e5..b7fde0ed 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../../../app/app_controller.dart'; import '../../../app/theme/app_theme.dart'; import '../../../core/format/decimal_format.dart'; +import '../../../core/io/backup_file_service.dart'; import '../../../core/persistence/user_state.dart'; import '../../../l10n/app_localizations.dart'; @@ -14,6 +15,8 @@ final class SettingsScreen extends StatelessWidget { super.key, }); + static const _backupFiles = BackupFileService(); + final AppController appController; final VoidCallback onOpenAbout; @@ -110,6 +113,16 @@ final class SettingsScreen extends StatelessWidget { spacing: AppSpacing.xs, runSpacing: AppSpacing.xs, children: [ + OutlinedButton.icon( + onPressed: () => _saveBackupFile(context), + icon: const Icon(Icons.save_alt_outlined), + label: Text(strings.saveBackupFile), + ), + OutlinedButton.icon( + onPressed: () => _importBackupFile(context), + icon: const Icon(Icons.file_open_outlined), + label: Text(strings.importBackupFile), + ), OutlinedButton.icon( onPressed: () => _copyBackup(context), icon: const Icon(Icons.copy_all_outlined), @@ -160,6 +173,54 @@ final class SettingsScreen extends StatelessWidget { ); } + Future _saveBackupFile(BuildContext context) async { + final strings = AppLocalizations.of(context); + try { + final saved = await _backupFiles.exportBackup(appController.exportState()); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + saved ? strings.backupFileSaved : strings.backupFileUnavailable, + ), + ), + ); + } on Object catch (error) { + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Backup export failed: $error')), + ); + } + } + + Future _importBackupFile(BuildContext context) async { + final strings = AppLocalizations.of(context); + try { + final content = await _backupFiles.importBackup(); + if (content == null) { + return; + } + await appController.importState(content); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(strings.backupImported)), + ); + } on Object catch (error) { + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${strings.importRejected}: $error')), + ); + } + } + Future _copyBackup(BuildContext context) async { final strings = AppLocalizations.of(context); await Clipboard.setData(ClipboardData(text: appController.exportState())); @@ -192,7 +253,7 @@ final class SettingsScreen extends StatelessWidget { return; } ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Import rejected: $error')), + SnackBar(content: Text('${strings.importRejected}: $error')), ); return; } From 4feb834ed406a9797c1b7a8c070a851ded00afee Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:56:30 +0530 Subject: [PATCH 053/241] test: add property testing support --- crates/unitflow_core/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/unitflow_core/Cargo.toml b/crates/unitflow_core/Cargo.toml index 5f6c9de6..6c29092b 100644 --- a/crates/unitflow_core/Cargo.toml +++ b/crates/unitflow_core/Cargo.toml @@ -16,4 +16,5 @@ serde.workspace = true thiserror.workspace = true [dev-dependencies] +proptest = "1" serde_json = "1.0" From 678590c8c3edc67b23c0d5a9da874ed2ca5eb955 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:56:42 +0530 Subject: [PATCH 054/241] test: add conversion property invariants --- crates/unitflow_core/tests/properties.rs | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 crates/unitflow_core/tests/properties.rs diff --git a/crates/unitflow_core/tests/properties.rs b/crates/unitflow_core/tests/properties.rs new file mode 100644 index 00000000..2d3dd285 --- /dev/null +++ b/crates/unitflow_core/tests/properties.rs @@ -0,0 +1,66 @@ +use proptest::prelude::*; +use rust_decimal::Decimal; +use unitflow_core::{ConversionRequest, Converter, RoundMode}; + +proptest! { + #[test] + fn identity_conversion_preserves_integer_values(value in any::()) { + let converter = Converter::with_built_in_catalog().expect("catalog"); + let result = converter.convert(&ConversionRequest { + value: Decimal::from(value), + from_unit_id: "meter".to_owned(), + to_unit_id: "meter".to_owned(), + decimal_places: None, + round_mode: RoundMode::NearestEven, + }).expect("identity conversion"); + + prop_assert_eq!(result.output, Decimal::from(value)); + } + + #[test] + fn metric_length_round_trip_is_exact(value in -1_000_000_000_i64..1_000_000_000_i64) { + let converter = Converter::with_built_in_catalog().expect("catalog"); + let centimeters = converter.convert(&ConversionRequest { + value: Decimal::from(value), + from_unit_id: "meter".to_owned(), + to_unit_id: "centimeter".to_owned(), + decimal_places: None, + round_mode: RoundMode::NearestEven, + }).expect("meter to centimeter"); + + let meters = converter.convert(&ConversionRequest { + value: centimeters.output, + from_unit_id: "centimeter".to_owned(), + to_unit_id: "meter".to_owned(), + decimal_places: None, + round_mode: RoundMode::NearestEven, + }).expect("centimeter to meter"); + + prop_assert_eq!(meters.output, Decimal::from(value)); + } + + #[test] + fn batch_conversion_never_reorders_targets( + value in -1_000_000_i64..1_000_000_i64, + ) { + let converter = Converter::with_built_in_catalog().expect("catalog"); + let targets = vec![ + "millimeter".to_owned(), + "kilometer".to_owned(), + "inch".to_owned(), + "foot".to_owned(), + ]; + let results = converter.batch_convert( + Decimal::from(value), + "meter", + &targets, + Some(12), + RoundMode::NearestEven, + ).expect("batch conversion"); + + prop_assert_eq!( + results.into_iter().map(|result| result.to_unit_id).collect::>(), + targets, + ); + } +} From f7fcc440b2d22eeac60a506b7961ab19af5c2bbe Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:56:53 +0530 Subject: [PATCH 055/241] test: add cargo fuzz harness package --- fuzz/Cargo.toml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 fuzz/Cargo.toml diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 00000000..78fd6279 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "unitflow-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +rust_decimal = "1" +unitflow_core = { path = "../crates/unitflow_core" } + +[[bin]] +name = "catalog_search" +path = "fuzz_targets/catalog_search.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decimal_bridge_inputs" +path = "fuzz_targets/decimal_bridge_inputs.rs" +test = false +doc = false +bench = false + +[workspace] +members = ["."] From a07e6f7752cb6391b3fe32458a2507a8175bc8aa Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:57:01 +0530 Subject: [PATCH 056/241] test: fuzz catalog search with arbitrary Unicode --- fuzz/fuzz_targets/catalog_search.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 fuzz/fuzz_targets/catalog_search.rs diff --git a/fuzz/fuzz_targets/catalog_search.rs b/fuzz/fuzz_targets/catalog_search.rs new file mode 100644 index 00000000..3dee97be --- /dev/null +++ b/fuzz/fuzz_targets/catalog_search.rs @@ -0,0 +1,12 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use unitflow_core::UnitCatalog; + +fuzz_target!(|data: &[u8]| { + let Ok(query) = std::str::from_utf8(data) else { + return; + }; + let catalog = UnitCatalog::built_in().expect("built-in catalog must stay valid"); + let _ = catalog.search(query, None, 64); +}); From c82dc11bcf666df01f6f3542631d42df4e82cf9b Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:57:11 +0530 Subject: [PATCH 057/241] test: fuzz decimal notation inputs without panics --- fuzz/fuzz_targets/decimal_bridge_inputs.rs | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 fuzz/fuzz_targets/decimal_bridge_inputs.rs diff --git a/fuzz/fuzz_targets/decimal_bridge_inputs.rs b/fuzz/fuzz_targets/decimal_bridge_inputs.rs new file mode 100644 index 00000000..a6345679 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_bridge_inputs.rs @@ -0,0 +1,25 @@ +#![no_main] + +use std::str::FromStr; + +use libfuzzer_sys::fuzz_target; +use rust_decimal::Decimal; +use unitflow_core::{format_decimal, Notation, RoundMode}; + +fuzz_target!(|data: &[u8]| { + let Ok(input) = std::str::from_utf8(data) else { + return; + }; + let Ok(value) = Decimal::from_str(input.trim()) else { + return; + }; + + for notation in [Notation::Plain, Notation::Scientific, Notation::Engineering] { + let _ = format_decimal( + value, + notation, + Some(12), + RoundMode::NearestEven, + ); + } +}); From 9b95ff180fc041aadce4d8af012553ea45d2ea62 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:57:27 +0530 Subject: [PATCH 058/241] test: cover primary Flutter application journey --- .../test/app/unitflow_app_test.dart | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 apps/unitflow_app/test/app/unitflow_app_test.dart diff --git a/apps/unitflow_app/test/app/unitflow_app_test.dart b/apps/unitflow_app/test/app/unitflow_app_test.dart new file mode 100644 index 00000000..4b90d744 --- /dev/null +++ b/apps/unitflow_app/test/app/unitflow_app_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/app/app_controller.dart'; +import 'package:unitflow/app/unitflow_app.dart'; +import 'package:unitflow/core/persistence/user_state.dart'; +import 'package:unitflow/core/persistence/user_state_repository.dart'; + +void main() { + testWidgets('launches offline into the converter after onboarding', (tester) async { + final controller = AppController( + repository: MemoryUserStateRepository( + UserState(onboardingComplete: true), + ), + ); + + await tester.pumpWidget(UnitFlowApp(appController: controller)); + await tester.pumpAndSettle(); + + expect(find.text('UnitFlow'), findsOneWidget); + expect(find.text('Convert units'), findsOneWidget); + expect(find.byIcon(Icons.swap_horiz), findsWidgets); + }); + + testWidgets('first run onboarding can be completed', (tester) async { + final controller = AppController( + repository: MemoryUserStateRepository(UserState()), + ); + + await tester.pumpWidget(UnitFlowApp(appController: controller)); + await tester.pumpAndSettle(); + + expect(find.text('Convert with confidence'), findsOneWidget); + await tester.tap(find.text('Skip')); + await tester.pumpAndSettle(); + + expect(find.text('Convert units'), findsOneWidget); + expect(controller.state.onboardingComplete, isTrue); + }); + + testWidgets('converter exposes semantic labels for primary actions', (tester) async { + final controller = AppController( + repository: MemoryUserStateRepository( + UserState(onboardingComplete: true), + ), + ); + + await tester.pumpWidget(UnitFlowApp(appController: controller)); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Swap source and target units'), findsOneWidget); + expect(find.byTooltip('Copy result'), findsOneWidget); + expect(find.byTooltip('Search unit library'), findsOneWidget); + }); +} From 8d1befc613408889bfc8a175184ee43d03428098 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:58:28 +0530 Subject: [PATCH 059/241] feat: localize converter and add batch CSV copy --- .../presentation/converter_screen.dart | 195 ++++++++++++------ 1 file changed, 132 insertions(+), 63 deletions(-) diff --git a/apps/unitflow_app/lib/features/converter/presentation/converter_screen.dart b/apps/unitflow_app/lib/features/converter/presentation/converter_screen.dart index 733952f3..1b457a38 100644 --- a/apps/unitflow_app/lib/features/converter/presentation/converter_screen.dart +++ b/apps/unitflow_app/lib/features/converter/presentation/converter_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../../app/theme/app_theme.dart'; +import '../../../l10n/app_localizations.dart'; +import '../application/batch_export.dart'; import '../domain/unit_models.dart'; import 'converter_controller.dart'; @@ -88,48 +90,89 @@ final class _ConverterScreenState extends State { context: context, showDragHandle: true, isScrollControlled: true, - builder: (context) => SafeArea( - child: FractionallySizedBox( - heightFactor: 0.78, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.md, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Batch conversion', style: Theme.of(context).textTheme.headlineSmall), - const SizedBox(height: AppSpacing.xs), - Text( - 'From ${widget.controller.fromUnit?.name ?? 'selected unit'}', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: AppSpacing.md), - Expanded( - child: ListView.separated( - itemCount: results.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final result = results[index]; - return ListTile( - title: Text(result.to.name), - subtitle: Text(result.to.symbol), - trailing: SelectableText( - widget.controller.formatBatchValue(result.output), - textAlign: TextAlign.end, + builder: (context) { + final strings = AppLocalizations.of(context); + return SafeArea( + child: FractionallySizedBox( + heightFactor: 0.78, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + strings.batchConversion, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: AppSpacing.xs), + Text( + '${strings.from}: ${widget.controller.fromUnit?.name ?? '—'}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], ), - ); - }, + ), + const SizedBox(width: AppSpacing.sm), + OutlinedButton.icon( + onPressed: () => _copyBatchCsv(context, results), + icon: const Icon(Icons.file_copy_outlined), + label: Text(strings.copyCsv), + ), + ], ), - ), - ], + const SizedBox(height: AppSpacing.md), + Expanded( + child: ListView.separated( + itemCount: results.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final result = results[index]; + return ListTile( + title: Text(result.to.name), + subtitle: Text(result.to.symbol), + trailing: SelectableText( + widget.controller.formatBatchValue(result.output), + textAlign: TextAlign.end, + ), + ); + }, + ), + ), + ], + ), ), ), - ), - ), + ); + }, + ); + } + + Future _copyBatchCsv( + BuildContext context, + List results, + ) async { + final strings = AppLocalizations.of(context); + final csv = batchResultsToCsv( + results, + valueFormatter: (result) => result.output.toCanonicalString(), + ); + await Clipboard.setData(ClipboardData(text: csv)); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(strings.csvCopied)), ); } } @@ -148,6 +191,7 @@ final class _ConverterCard extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final strings = AppLocalizations.of(context); final units = controller.categoryUnits; return Card( child: Padding( @@ -161,10 +205,13 @@ final class _ConverterCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Convert units', style: theme.textTheme.headlineMedium), + Text( + strings.convertUnits, + style: theme.textTheme.headlineMedium, + ), const SizedBox(height: AppSpacing.xxs), Text( - 'Precise, local, and distraction-free.', + strings.converterTagline, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -173,7 +220,9 @@ final class _ConverterCard extends StatelessWidget { ), ), IconButton.filledTonal( - tooltip: controller.isCurrentPairPinned ? 'Unpin unit pair' : 'Pin unit pair', + tooltip: controller.isCurrentPairPinned + ? strings.unpinUnitPair + : strings.pinUnitPair, onPressed: () => controller.toggleCurrentPairPinned(), icon: Icon( controller.isCurrentPairPinned @@ -185,7 +234,7 @@ final class _ConverterCard extends StatelessWidget { ), const SizedBox(height: AppSpacing.lg), _LabeledDropdown( - label: 'Category', + label: strings.category, value: controller.category, items: UnitCategory.values, itemLabel: (category) => category.label, @@ -204,9 +253,9 @@ final class _ConverterCard extends StatelessWidget { ), textInputAction: TextInputAction.done, decoration: InputDecoration( - labelText: 'Value', + labelText: strings.value, errorText: controller.error, - helperText: 'Scientific notation such as 1.2e6 is supported.', + helperText: strings.scientificInputHint, ), onChanged: controller.setInput, onSubmitted: (_) => controller.recordCurrentConversion(), @@ -216,11 +265,13 @@ final class _ConverterCard extends StatelessWidget { builder: (context, constraints) { final horizontal = constraints.maxWidth >= 560; final source = _LabeledDropdown( - label: 'From', + label: strings.from, value: controller.fromUnitId, items: units.map((unit) => unit.id).toList(growable: false), itemLabel: (id) { - final unit = units.firstWhere((candidate) => candidate.id == id); + final unit = units.firstWhere( + (candidate) => candidate.id == id, + ); return '${unit.name} (${unit.symbol})'; }, onChanged: (id) { @@ -230,11 +281,13 @@ final class _ConverterCard extends StatelessWidget { }, ); final target = _LabeledDropdown( - label: 'To', + label: strings.to, value: controller.toUnitId, items: units.map((unit) => unit.id).toList(growable: false), itemLabel: (id) { - final unit = units.firstWhere((candidate) => candidate.id == id); + final unit = units.firstWhere( + (candidate) => candidate.id == id, + ); return '${unit.name} (${unit.symbol})'; }, onChanged: (id) { @@ -249,9 +302,11 @@ final class _ConverterCard extends StatelessWidget { children: [ source, Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.xs, + ), child: IconButton.filledTonal( - tooltip: 'Swap source and target units', + tooltip: strings.swapUnits, onPressed: controller.swapUnits, icon: const Icon(Icons.swap_vert), ), @@ -264,9 +319,11 @@ final class _ConverterCard extends StatelessWidget { children: [ Expanded(child: source), Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + ), child: IconButton.filledTonal( - tooltip: 'Swap source and target units', + tooltip: strings.swapUnits, onPressed: controller.swapUnits, icon: const Icon(Icons.swap_horiz), ), @@ -280,8 +337,8 @@ final class _ConverterCard extends StatelessWidget { Semantics( liveRegion: controller.result != null, label: controller.result == null - ? 'No conversion result' - : 'Conversion result ${controller.formattedOutput} ${controller.toUnit?.symbol ?? ''}', + ? strings.noConversionResult + : '${strings.result}: ${controller.formattedOutput} ${controller.toUnit?.symbol ?? ''}', child: Container( padding: const EdgeInsets.all(AppSpacing.lg), decoration: BoxDecoration( @@ -296,7 +353,7 @@ final class _ConverterCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Result', + strings.result, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onPrimaryContainer, ), @@ -320,7 +377,7 @@ final class _ConverterCard extends StatelessWidget { ), ), IconButton( - tooltip: 'Copy result', + tooltip: strings.copyResult, onPressed: controller.result == null ? null : () => _copyResult(context), @@ -336,7 +393,7 @@ final class _ConverterCard extends StatelessWidget { child: OutlinedButton.icon( onPressed: controller.result == null ? null : onShowBatch, icon: const Icon(Icons.table_rows_outlined), - label: const Text('View batch table'), + label: Text(strings.viewBatchTable), ), ), ], @@ -346,13 +403,14 @@ final class _ConverterCard extends StatelessWidget { } Future _copyResult(BuildContext context) async { + final strings = AppLocalizations.of(context); await Clipboard.setData(ClipboardData(text: controller.formattedOutput)); await controller.recordCurrentConversion(); if (!context.mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Conversion result copied.')), + SnackBar(content: Text(strings.resultCopied)), ); } } @@ -365,6 +423,7 @@ final class _ConverterSidePanel extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final strings = AppLocalizations.of(context); return Column( children: [ Card( @@ -375,9 +434,12 @@ final class _ConverterSidePanel extends StatelessWidget { children: [ Row( children: [ - Icon(Icons.school_outlined, color: theme.colorScheme.primary), + Icon( + Icons.school_outlined, + color: theme.colorScheme.primary, + ), const SizedBox(width: AppSpacing.xs), - Text('Learn', style: theme.textTheme.titleLarge), + Text(strings.learn, style: theme.textTheme.titleLarge), ], ), const SizedBox(height: AppSpacing.md), @@ -400,7 +462,7 @@ final class _ConverterSidePanel extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Current pair', style: theme.textTheme.titleLarge), + Text(strings.currentPair, style: theme.textTheme.titleLarge), const SizedBox(height: AppSpacing.sm), Text( '${controller.fromUnit?.name ?? '—'} → ${controller.toUnit?.name ?? '—'}', @@ -413,7 +475,11 @@ final class _ConverterSidePanel extends StatelessWidget { ? Icons.push_pin : Icons.push_pin_outlined, ), - label: Text(controller.isCurrentPairPinned ? 'Unpin pair' : 'Pin pair'), + label: Text( + controller.isCurrentPairPinned + ? strings.unpinPair + : strings.pinPair, + ), ), ], ), @@ -450,7 +516,10 @@ final class _LabeledDropdown extends StatelessWidget { .map( (item) => DropdownMenuItem( value: item, - child: Text(itemLabel(item), overflow: TextOverflow.ellipsis), + child: Text( + itemLabel(item), + overflow: TextOverflow.ellipsis, + ), ), ) .toList(growable: false), From c7ad073dba4c9d26954cd5850569d1cfb4a04ab9 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:59:13 +0530 Subject: [PATCH 060/241] docs: document property fuzz and bridge verification --- docs/testing.md | 88 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index a25d31cd..7555779b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2,6 +2,16 @@ UnitFlow treats conversion correctness as a core product requirement. +## One-command local audit + +From the repository root: + +```bash +bash tool/check.sh +``` + +The script runs the same primary Rust and Flutter quality gates used by CI. + ## Rust quality gates Run: @@ -9,7 +19,7 @@ Run: ```bash cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo test --workspace +cargo test --workspace --all-features ``` Coverage priorities: @@ -18,11 +28,14 @@ Coverage priorities: - source/target category mismatch handling; - multiplicative and affine conversion accuracy; - zero/negative/large/small decimal values; -- round-trip conversion tolerances where exact decimal factors permit it; +- round-trip conversion invariants where exact decimal factors permit it; - search by name, symbol, and alias; - custom-unit validation; - scientific/engineering notation edge cases; -- batch conversion order and error behavior. +- batch conversion order and error behavior; +- Rust↔Flutter bridge DTO/end-point behavior. + +`crates/unitflow_core/tests/properties.rs` uses property-based tests for identity conversion, exact metric round trips, and batch target ordering across generated values. ## Flutter quality gates @@ -31,47 +44,74 @@ Run: ```bash cd apps/unitflow_app flutter pub get -flutter analyze +flutter gen-l10n +dart format --output=none --set-exit-if-changed lib test +flutter analyze --fatal-infos --fatal-warnings flutter test ``` Coverage priorities: +- exact-decimal parsing/arithmetic without binary floating point; - converter input validation; - source/target selection and swap; +- primary app/onboarding journey; - responsive layout at representative widths; - theme switching; - favorites/pin/history state behavior; - settings and About page content; - semantics for major controls; - custom-unit form validation; -- import/export failure states. +- backup schema round trips and rejected imports; +- batch CSV escaping. + +## Rust–Flutter bridge generation + +The bridge is generated from checked-in Rust API source: -## Integration and end-to-end tests +```bash +cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked +bash tool/generate_bridge.sh +cargo check --workspace --all-features +cd apps/unitflow_app +flutter analyze --fatal-infos --fatal-warnings +``` + +CI runs this as an independent job so bridge generation cannot silently drift from the source API. + +## Integration and end-to-end journeys -Primary journeys should eventually cover: +Primary journeys are tracked as layered widget/integration coverage: 1. launch the app offline; -2. select a category and pair; -3. enter a decimal value; -4. observe a correct conversion; -5. swap units; -6. favorite or pin the pair; -7. restart and verify persisted state; -8. create a valid custom unit and use it; -9. reject an invalid imported backup without corrupting local state; -10. export user data and restore it into a clean profile. +2. complete or skip onboarding; +3. select a category and pair; +4. enter a decimal value; +5. observe a correct conversion; +6. swap units; +7. favorite or pin the pair; +8. restart and verify persisted state; +9. create a valid custom unit and use it; +10. reject an invalid imported backup without corrupting local state; +11. export user data and restore it into a clean profile; +12. copy deterministic batch CSV results. -## Property/fuzz testing +Device-level integration tests are added when a platform runner is available; widget/domain tests remain deterministic and do not require production credentials. -Useful invariants include: +## Fuzz testing -- converting a value from a unit to itself returns the same value; -- for valid units A/B and representable decimals, A→B→A remains within the defined rounding policy; -- invalid scales (zero/negative when prohibited) never construct a custom unit; -- parsers never panic on arbitrary Unicode input. +Cargo-fuzz harnesses live under `fuzz/` and are intentionally outside the normal workspace so release builds do not pull fuzz dependencies. + +Install cargo-fuzz and run, for example: + +```bash +cargo install cargo-fuzz +cd fuzz +cargo fuzz run catalog_search +cargo fuzz run decimal_bridge_inputs +``` -Fuzzing should be isolated from normal CI time budgets unless a short smoke target is maintained. +The harnesses exercise arbitrary UTF-8 catalog search input and valid parsed decimal values through notation formatting. Fuzzing must never be converted into a fake passing CI result when the tool is unavailable. ## Regression policy @@ -79,4 +119,4 @@ Every confirmed defect should receive a failing regression test before or with t ## CI policy -CI fails on formatting, lint, analysis, tests, security checks, or build failures. A skipped platform check must be explicit rather than silently treated as success. +CI fails on formatting, lint, analysis, tests, generated bridge verification, security checks, or build failures. A skipped platform check must be explicit rather than silently treated as success. From b1a4a623c58867f898b218d7f9aa6555b5cf1e81 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 08:59:35 +0530 Subject: [PATCH 061/241] docs: make clean clone platform setup reproducible --- docs/setup.md | 63 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index bd31cdea..f0831484 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -39,44 +39,78 @@ git config user.name "Sanskar" git config user.email "sanskarin@outlook.in" ``` -## Rust core +## Rust core and bridge ```bash cargo fetch -cargo test --workspace +cargo test --workspace --all-features ``` +The Rust workspace contains the authoritative `unitflow_core` domain crate and the thin `unitflow_bridge` Flutter FFI boundary. + ## Flutter app +Resolve Flutter packages and generated localization code: + ```bash cd apps/unitflow_app flutter pub get +flutter gen-l10n flutter analyze flutter test +``` + +### Generate platform shells from a clean clone + +Platform runner projects are reproducible generated inputs rather than hand-edited business logic. From the repository root: + +```bash +bash tool/bootstrap_platforms.sh +``` + +The script runs Flutter's project generator for Android, Web, Windows, Linux, macOS, and iOS with the stable project identifier `in.sanskar.unitflow` and then resolves packages. + +On a host that supports the selected target, run: + +```bash +cd apps/unitflow_app flutter run ``` +### Generate Rust↔Flutter bindings + +When working on native bridge code: + +```bash +cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked +bash tool/generate_bridge.sh +``` + +Generated bridge code must be followed by the full quality suite. + ## Platform notes ### Android -Install Android Studio or the command-line Android SDK, an appropriate JDK supported by the current Flutter stable channel, and accept Android SDK licenses: +Install Android Studio or the command-line Android SDK, an appropriate JDK supported by the installed Flutter stable channel, and accept Android SDK licenses: ```bash flutter doctor --android-licenses ``` +No broad storage permission is required for ordinary conversion. Backup import/export is initiated through platform file pickers. + ### Windows -Use Windows with Visual Studio's Desktop development with C++ workload for Flutter Windows desktop builds. +Use Windows with Visual Studio's **Desktop development with C++** workload for Flutter Windows desktop builds. ### Linux -Install the packages required by Flutter's Linux desktop toolchain for your distribution (compiler, CMake/Ninja, GTK development headers, and related dependencies). +Install the packages required by Flutter's Linux desktop toolchain for your distribution, including a compiler, CMake/Ninja, GTK development headers, and related dependencies. ### macOS / iOS -Use macOS with current Xcode tooling. iOS builds require Apple platform signing configuration for physical-device/App Store deployment. +Use macOS with current Xcode tooling. iOS release deployment requires Apple signing configuration. Signing credentials and provisioning material must never be committed to this public repository. ### Web @@ -86,21 +120,30 @@ Use a Flutter-supported browser and run: flutter run -d chrome ``` +The deterministic Dart decimal engine provides the current web fallback while the native targets use the Rust bridge integration path. + ## Environment configuration -Static conversion does not require secrets. If optional environment-controlled features are added, document placeholder variables in `.env.example`. Never commit real credentials. +Static conversion does not require secrets. `.env.example` contains placeholders only. If an optional online integration is introduced later, document it explicitly and never commit real credentials. ## Clean verification -Before release validation, remove generated artifacts and rebuild: +Before release validation, remove generated build artifacts and rebuild: ```bash cargo clean cd apps/unitflow_app flutter clean flutter pub get -flutter analyze +flutter gen-l10n +flutter analyze --fatal-infos --fatal-warnings flutter test ``` -Then build the intended platform using Flutter's documented `flutter build ` command. +Or from the repository root run: + +```bash +bash tool/check.sh +``` + +Then build the intended platform using Flutter's documented `flutter build ` command or the tagged release workflow. From 8b9ec145ca892f5c1e24e2ecc21eed424cdf8498 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:02:35 +0530 Subject: [PATCH 062/241] docs: add current UnitFlow phase handoff --- what_changed_phase1.md | 296 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 what_changed_phase1.md diff --git a/what_changed_phase1.md b/what_changed_phase1.md new file mode 100644 index 00000000..3f229006 --- /dev/null +++ b/what_changed_phase1.md @@ -0,0 +1,296 @@ +# UnitFlow — Current Development Handoff + +_Last updated: 2026-08-19_ + +This file is the current continuation checkpoint for the active Phase 1/quality-audit branch. The original `what_changed.md` remains in the repository from the bootstrap checkpoint; this document supersedes that checkpoint until the active audit branch is merged and the canonical handoff is refreshed. + +## Current milestone + +- Product version: `0.1.0-alpha.1` +- Active branch: `audit/phase-1-quality` +- Pull request: `#2 — test: audit phase 1 quality gates` +- Repository: `https://github.com/sanskarIN/unitflow` +- Source model: public / open source / MIT +- Architecture: Rust authoritative domain core + Flutter presentation, with a dedicated `flutter_rust_bridge` boundary and deterministic Dart exact-decimal fallback. +- Required project credit: **Made by the Sanskar** + +## Completed repository foundation + +- Added MIT license and core repository hygiene: + - `.gitignore` + - `.editorconfig` + - `.gitattributes` + - `.env.example` +- Added governance and support documentation: + - `CONTRIBUTING.md` + - `CODE_OF_CONDUCT.md` + - `SECURITY.md` + - `SUPPORT.md` + - `PRIVACY.md` + - `CHANGELOG.md` + - `ROADMAP.md` +- Added architecture/setup/development/testing/release/troubleshooting/accessibility/performance documentation. +- Added ADRs for: + - Rust-core + Flutter UI architecture; + - generated Rust–Flutter bridge with deterministic fallback. +- Added GitHub repository operations guidance including branch protection, labels, milestones, Discussions, release, and funding guidance. +- Added editable logo and app-icon SVG artwork under `docs/assets/`. +- Added Buy Me a Coffee funding metadata and visible project support links. +- Added structured GitHub bug/feature templates and pull-request checklist. +- Added Dependabot configuration for Cargo, Pub, and GitHub Actions. + +## Rust domain core completed so far + +Workspace crate: `crates/unitflow_core` + +Implemented: + +- strongly typed category model; +- validated immutable unit definitions; +- stable unit identifiers; +- built-in catalog covering more than 100 units across: + - length; + - area; + - volume; + - mass; + - speed; + - pressure; + - energy; + - power; + - angle; + - data size; + - frequency; + - time; + - temperature; +- exact base-unit affine conversion model: + +```text +base = value * scale + offset +output = (base - target.offset) / target.scale +``` + +- `rust_decimal` high-precision decimal arithmetic; +- checked arithmetic and typed failures; +- explicit rounding modes: + - nearest-even; + - half-away-from-zero; + - toward zero; + - away from zero; + - floor; + - ceiling; +- batch conversion preserving requested target order; +- search by name, stable ID, symbol, and aliases with exact/prefix/substring ranking; +- safe affine custom units with validation; +- plain/scientific/engineering notation formatting without binary floating point; +- crate-level `forbid(unsafe_code)` for the domain core. + +## Rust–Flutter bridge completed so far + +Workspace crate: `crates/unitflow_bridge` + +Implemented: + +- thin bridge DTOs; +- decimal values represented as strings at the language boundary; +- bridge endpoints for: + - version; + - catalog listing; + - catalog search; + - conversion; + - notation formatting; +- `flutter_rust_bridge` dependency and reproducible bridge-generation script; +- bridge API tests; +- generated FFI code isolated from the unsafe-free domain crate. + +The Flutter production adapter that consumes generated Dart bridge bindings remains an exact next task after generator verification passes in CI. Until then Flutter uses the deterministic exact-decimal fallback implementing the same application-facing contract. + +## Flutter application completed so far + +Application: `apps/unitflow_app` + +Implemented architecture/features: + +- Material 3 design system with spacing/radius/breakpoint tokens; +- system/light/dark themes; +- adaptive navigation rail / bottom navigation; +- keyboard shortcuts for desktop/web navigation; +- polished first-run onboarding; +- responsive converter screen; +- exact-decimal deterministic Dart fallback engine; +- locale-aware decimal parsing and display formatting; +- scientific and engineering notation preferences; +- configurable decimal places; +- digit grouping preference; +- searchable unit library; +- favorites; +- pinned conversion pairs; +- recent conversion history; +- quick pair reopening; +- safe custom-unit editor using affine scale/offset formulas; +- category explanations and educational examples; +- quick source/target swap; +- batch conversion table; +- direct result copy; +- deterministic batch CSV generation and copy; +- local settings/favorites/history/pins/custom-unit persistence; +- versioned JSON backup schema; +- clipboard backup/restore; +- bounded cross-platform file-picker backup import/export; +- schema validation before replacing local state; +- import-size and UTF-8 validation; +- About screen with: + - project version; + - MIT license information; + - privacy summary; + - GitHub repository; + - Buy Me a Coffee; + - support/business contacts; + - **Made by the Sanskar**; +- redacting structured diagnostic logger; +- generated localization architecture with external English ARB source; +- offline-first static conversion behavior without forced account/login. + +## Local data model + +Current backup schema version: `1` + +Locally persisted data includes: + +- theme/notation/formatting preferences; +- onboarding state; +- favorites; +- pinned pairs; +- bounded recent history; +- validated custom units. + +Import validation rejects unsupported schemas and invalid custom-unit data before state replacement. + +## Test coverage added + +### Rust + +- built-in catalog/category coverage; +- search ranking and alias search; +- exact metric conversion; +- international mile conversion; +- Celsius/Fahrenheit affine conversion; +- category mismatch rejection; +- explicit rounding-mode behavior; +- batch ordering; +- precision bounds; +- custom-unit scale/identifier/alias validation; +- scientific/engineering notation; +- bridge endpoint tests; +- property-based tests for: + - identity conversion; + - exact metric round trips; + - batch target ordering. + +### Fuzzing + +Cargo-fuzz harnesses under `fuzz/` for: + +- arbitrary UTF-8 catalog search; +- parsed arbitrary decimal values through notation formatting. + +### Flutter + +- exact-decimal parser/arithmetic; +- scientific input parsing; +- rounding behavior; +- deterministic conversion engine; +- Celsius/Fahrenheit conversion; +- cross-category rejection; +- batch target ordering; +- versioned backup JSON round-trip; +- invalid schema rejection; +- custom-unit validation; +- batch CSV escaping; +- app launch into converter after onboarding; +- first-run onboarding completion; +- key action tooltip/semantic discoverability. + +## Automation and release engineering added + +- Primary CI workflow: + - Rust formatting; + - Rust Clippy with warnings denied; + - Rust workspace tests; + - Flutter dependency resolution; + - generated localizations; + - Dart formatting; + - Flutter analyzer with infos/warnings fatal; + - Flutter tests; + - independent Rust–Flutter bridge generation/check job. +- CodeQL workflow for Rust. +- Dependency review workflow. +- Audit-branch normalization workflow that: + - generates Rust/Flutter lockfiles; + - runs Rust/Dart formatters; + - commits normalization using `Sanskar ` when changes exist. +- Tagged release workflow covering: + - Rust release profile; + - Flutter Web; + - Flutter Android unsigned APK; + - Flutter Linux; + - Flutter Windows; + - Flutter macOS; + - iOS no-codesign validation. +- Reproducible scripts: + - `tool/check.sh` + - `tool/generate_bridge.sh` + - `tool/bootstrap_platforms.sh` + +## Verification history + +Known executed repository verification: + +1. The first CI run on the initial `main` implementation reached Rust formatting and correctly failed because the newly created source files had not yet been normalized by `rustfmt`. +2. A dedicated audit branch and PR were created so CI failures can be fixed before merging into `main`. +3. An audit-only formatter/lockfile workflow was added so formatting and lockfile generation can be performed by an authenticated GitHub runner with the required commit identity. +4. Subsequent branch changes have intentionally restarted/cancelled earlier queued audit runs; the final audit run must be allowed to finish after feature changes stop. + +Do not claim the branch is fully verified until the latest head has completed all required CI jobs successfully. + +## Known limitations / unfinished verification + +These items are still open and must be handled before calling `0.1.0-alpha.1` release-ready: + +1. Let the audit normalization workflow finish on the final branch head and commit formatter/lockfile output if required. +2. Inspect the newest Rust/Flutter/bridge CI jobs and fix every compile, format, lint, or test failure. +3. Validate `flutter_rust_bridge_codegen 2.12.0` against the checked-in bridge API and generated Dart/Rust glue. +4. Add the production Rust-backed Flutter `ConversionEngine` adapter after generated bindings are verified; keep the deterministic fallback for web/tests/graceful startup. +5. Run the tagged release workflow or equivalent platform builds on compatible hosts and repair platform-specific packaging issues if discovered. +6. Replace README demo placeholders with real captures after a verified runnable platform build. +7. Remove the temporary audit-only autoformat workflow before stable release if it is no longer useful. +8. Refresh canonical `what_changed.md` after PR merge. +9. Perform the Phase 6 clean-clone, documentation-link, accessibility, dependency/security, and release-candidate audit. + +## Exact next tasks + +1. Stop feature commits temporarily. +2. Let the latest `audit/phase-1-quality` workflows run. +3. Read failing job steps/logs, if any. +4. Fix failures with regression tests where behavior bugs are found. +5. Regenerate/verify bridge bindings. +6. Run the complete quality suite again. +7. Merge PR #2 with history preserved only when required checks are green or an external repository-setting limitation is explicitly documented. +8. Continue Phase 2/3 platform integration from the merged state instead of rewriting finished domain/UI work. + +## Commit strategy + +Development has been intentionally split into many small, meaningful Conventional-Commit-style changes across documentation, architecture, domain models, conversion logic, UI features, testing, security, CI, release engineering, accessibility, localization, persistence, and bug fixes. + +Do not create empty commits or one-line churn solely to inflate commit count. + +## Contact / project identity + +- GitHub: `https://github.com/sanskarIN` +- Business: `sanskarin@outlook.in` +- Business: `sanskarin.business@gmail.com` +- Support: `supportramsandesh@gmail.com` +- Buy Me a Coffee: `https://buymeacoffee.com/sanskarIN` + +--- + +**Made by the Sanskar** From dd1d9ff19f2324aecd28571ef5491fa5fe138516 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:12:39 +0530 Subject: [PATCH 063/241] test: verify stable serialized domain values --- crates/unitflow_core/tests/serialization.rs | 58 +++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 crates/unitflow_core/tests/serialization.rs diff --git a/crates/unitflow_core/tests/serialization.rs b/crates/unitflow_core/tests/serialization.rs new file mode 100644 index 00000000..e4780802 --- /dev/null +++ b/crates/unitflow_core/tests/serialization.rs @@ -0,0 +1,58 @@ +use std::str::FromStr; + +use rust_decimal::Decimal; +use unitflow_core::{Category, ConversionRequest, Notation, RoundMode, UnitCatalog}; + +#[test] +fn category_serialization_uses_stable_snake_case_ids() { + assert_eq!(serde_json::to_string(&Category::DataSize).unwrap(), "\"data_size\""); + assert_eq!(serde_json::to_string(&Category::Temperature).unwrap(), "\"temperature\""); + assert_eq!( + serde_json::from_str::("\"data_size\"").unwrap(), + Category::DataSize + ); +} + +#[test] +fn rounding_and_notation_enums_round_trip() { + for mode in [ + RoundMode::NearestEven, + RoundMode::HalfAwayFromZero, + RoundMode::TowardZero, + RoundMode::AwayFromZero, + RoundMode::Floor, + RoundMode::Ceiling, + ] { + let json = serde_json::to_string(&mode).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), mode); + } + + for notation in [Notation::Plain, Notation::Scientific, Notation::Engineering] { + let json = serde_json::to_string(¬ation).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), notation); + } +} + +#[test] +fn conversion_request_round_trips_without_binary_float() { + let request = ConversionRequest { + value: Decimal::from_str("1234567890.000000123456789").unwrap(), + from_unit_id: "meter".to_owned(), + to_unit_id: "inch".to_owned(), + decimal_places: Some(18), + round_mode: RoundMode::NearestEven, + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: ConversionRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn builtin_unit_definition_round_trips() { + let catalog = UnitCatalog::built_in().expect("catalog"); + let fahrenheit = catalog.get("fahrenheit").expect("fahrenheit"); + let json = serde_json::to_string(fahrenheit).unwrap(); + let restored = serde_json::from_str(&json).unwrap(); + assert_eq!(fahrenheit, &restored); +} From 974a0bcc7890d201122180679c70a6fe91d4e50f Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:12:56 +0530 Subject: [PATCH 064/241] test: enforce bridge and core conversion parity --- .../unitflow_bridge/tests/parity_vectors.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 crates/unitflow_bridge/tests/parity_vectors.rs diff --git a/crates/unitflow_bridge/tests/parity_vectors.rs b/crates/unitflow_bridge/tests/parity_vectors.rs new file mode 100644 index 00000000..f04ec47c --- /dev/null +++ b/crates/unitflow_bridge/tests/parity_vectors.rs @@ -0,0 +1,59 @@ +use std::str::FromStr; + +use rust_decimal::Decimal; +use unitflow_bridge::api::converter::{convert_value, BridgeRoundMode}; +use unitflow_core::{ConversionRequest, Converter, RoundMode}; + +#[test] +fn representative_bridge_vectors_match_core_results() { + let core = Converter::with_built_in_catalog().expect("catalog"); + let vectors = [ + ("123.456", "meter", "foot", 12_u32), + ("-40", "celsius", "fahrenheit", 8_u32), + ("1", "gallon_us", "liter", 12_u32), + ("1024", "byte", "kibibyte", 12_u32), + ("60", "revolution_per_minute", "hertz", 12_u32), + ("180", "degree", "radian", 18_u32), + ]; + + for (input, from, to, places) in vectors { + let core_result = core + .convert(&ConversionRequest { + value: Decimal::from_str(input).expect("test decimal"), + from_unit_id: from.to_owned(), + to_unit_id: to.to_owned(), + decimal_places: Some(places), + round_mode: RoundMode::NearestEven, + }) + .expect("core conversion"); + + let bridge_result = convert_value( + input.to_owned(), + from.to_owned(), + to.to_owned(), + Some(places), + BridgeRoundMode::NearestEven, + ) + .expect("bridge conversion"); + + assert_eq!( + bridge_result.output, + core_result.output.normalize().to_string(), + "bridge parity failed for {input} {from} -> {to}" + ); + } +} + +#[test] +fn bridge_rejects_invalid_decimal_text() { + let error = convert_value( + "1.2.3".to_owned(), + "meter".to_owned(), + "kilometer".to_owned(), + Some(6), + BridgeRoundMode::NearestEven, + ) + .expect_err("malformed decimal must fail"); + + assert_eq!(error, "invalid decimal input"); +} From ed9e4c4f6553d8ee5bcef80df40e02e48438e9e2 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:15:06 +0530 Subject: [PATCH 065/241] docs: publish UnitFlow backup schema --- schemas/unitflow-backup-v1.schema.json | 171 +++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 schemas/unitflow-backup-v1.schema.json diff --git a/schemas/unitflow-backup-v1.schema.json b/schemas/unitflow-backup-v1.schema.json new file mode 100644 index 00000000..3b2ec7a5 --- /dev/null +++ b/schemas/unitflow-backup-v1.schema.json @@ -0,0 +1,171 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/sanskarIN/unitflow/blob/main/schemas/unitflow-backup-v1.schema.json", + "title": "UnitFlow backup v1", + "description": "Portable local UnitFlow preferences, favorites, pinned pairs, recent conversions, and custom units.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "theme", + "notation", + "decimalPlaces", + "useGrouping", + "onboardingComplete", + "favoriteUnitIds", + "pinnedPairs", + "recents", + "customUnits" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "theme": { + "type": "string", + "enum": ["system", "light", "dark"] + }, + "notation": { + "type": "string", + "enum": ["plain", "scientific", "engineering"] + }, + "decimalPlaces": { + "type": "integer", + "minimum": 0, + "maximum": 28 + }, + "useGrouping": { "type": "boolean" }, + "onboardingComplete": { "type": "boolean" }, + "favoriteUnitIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9_-]+$" + } + }, + "pinnedPairs": { + "type": "array", + "maxItems": 20, + "items": { + "type": "string", + "minLength": 5, + "maxLength": 256, + "pattern": "^[a-z_]+\\|[a-z0-9_-]+\\|[a-z0-9_-]+$" + } + }, + "recents": { + "type": "array", + "maxItems": 100, + "items": { "$ref": "#/$defs/recentConversion" } + }, + "customUnits": { + "type": "array", + "maxItems": 200, + "items": { "$ref": "#/$defs/customUnit" } + } + }, + "$defs": { + "recentConversion": { + "type": "object", + "additionalProperties": false, + "required": ["input", "fromUnitId", "toUnitId", "createdAt"], + "properties": { + "input": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "fromUnitId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9_-]+$" + }, + "toUnitId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9_-]+$" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "customUnit": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "category", + "name", + "symbol", + "scale", + "offset", + "aliases", + "description" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9_-]+$" + }, + "category": { + "type": "string", + "enum": [ + "length", + "area", + "volume", + "mass", + "speed", + "pressure", + "energy", + "power", + "angle", + "data_size", + "frequency", + "time", + "temperature" + ] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "symbol": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "scale": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "offset": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "aliases": { + "type": "array", + "maxItems": 32, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "description": { + "type": "string", + "maxLength": 512 + } + } + } + } +} From 6c2fd2176d4a2531ecd3a3f3b00c6beba6cae8e3 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:15:22 +0530 Subject: [PATCH 066/241] docs: document local data and backup format --- docs/data-format.md | 75 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/data-format.md diff --git a/docs/data-format.md b/docs/data-format.md new file mode 100644 index 00000000..23a610bb --- /dev/null +++ b/docs/data-format.md @@ -0,0 +1,75 @@ +# Local Data and Backup Format + +UnitFlow is offline-first. Static conversions require no account and no network request. User preferences and optional convenience data are designed to remain on the current device unless the user explicitly exports a backup. + +## Stored data + +The current local state contains: + +- theme preference; +- notation preference; +- decimal-place preference; +- grouping preference; +- onboarding completion state; +- favorite unit identifiers; +- pinned unit pairs; +- bounded recent-conversion history; +- validated custom affine units. + +The current schema version is **1**. Its machine-readable contract is published at `schemas/unitflow-backup-v1.schema.json`. + +## Backup envelope + +A backup is a UTF-8 JSON object. The root `schemaVersion` field is mandatory. UnitFlow validates the complete object before replacing the in-memory state; malformed or unsupported imports must not partially overwrite an existing profile. + +Current safety bounds include: + +- file/import text size: at most 1 MB; +- recent conversions: at most 100 accepted from an imported document, with the app normally retaining a smaller recent set; +- custom units: at most 200 accepted from an imported document; +- custom aliases: at most 32 per unit; +- decimal precision preference: 0–28 places; +- stable identifiers: lowercase ASCII letters, digits, `_`, and `-` only. + +## Custom-unit formula + +Custom units use an affine relationship instead of evaluating arbitrary executable expressions: + +```text +base_value = input_value * scale + offset +``` + +The scale must be strictly positive. This design covers ordinary multiplicative units and temperature-like offsets without introducing an expression interpreter into imported user data. + +## Import behavior + +An import is rejected when, among other validation failures: + +- JSON is malformed; +- the root is not an object; +- the schema version is unsupported; +- required settings have invalid types or ranges; +- a custom-unit identifier or formula is invalid; +- duplicate identifiers would collide with built-in or imported custom units; +- the import exceeds configured size/count limits. + +The application should preserve the existing state when validation fails. + +## Export behavior + +UnitFlow supports explicit JSON backup export. File export uses a user-selected platform location where supported, while clipboard export remains available as a portable fallback. Export never includes credentials because UnitFlow static conversion has no credential requirement. + +## Migration policy + +When schema version 2 or later is introduced: + +1. keep version 1 parsing deterministic; +2. add explicit migrations rather than silently reinterpreting old fields; +3. add migration and corruption-regression tests; +4. update the JSON Schema and this document; +5. update `CHANGELOG.md` with user-visible compatibility notes; +6. preserve unknown future schemas by rejecting them rather than destructively rewriting them. + +## Privacy + +See `PRIVACY.md` for the user-facing privacy policy and `SECURITY.md` for vulnerability reporting. From 8c23f7a177ef0db274fa5b58ee17ad014dc6195b Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:15:38 +0530 Subject: [PATCH 067/241] docs: record versioned local backup decision --- docs/adr/0003-versioned-local-backups.md | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/adr/0003-versioned-local-backups.md diff --git a/docs/adr/0003-versioned-local-backups.md b/docs/adr/0003-versioned-local-backups.md new file mode 100644 index 00000000..7b90873b --- /dev/null +++ b/docs/adr/0003-versioned-local-backups.md @@ -0,0 +1,49 @@ +# ADR-0003: Versioned validated local backups + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +UnitFlow stores preferences, favorites, pinned pairs, recent conversions, and custom units locally. Users need an explicit way to move or restore this state without requiring an account or a hosted synchronization service. + +Imports are untrusted input. A malformed or future-version document must not partially corrupt valid local state. + +## Decision + +Use a small versioned UTF-8 JSON document for portable backup and restore. + +The root object carries a mandatory `schemaVersion`. Version 1 is documented by `schemas/unitflow-backup-v1.schema.json` and `docs/data-format.md`. + +Import follows validate-then-replace semantics: + +1. enforce a bounded input size; +2. parse JSON; +3. validate the supported schema version and all fields; +4. validate custom-unit definitions and identifier collisions; +5. construct a complete replacement state and conversion catalog; +6. persist only after all validation succeeds. + +Unknown future schema versions are rejected. They are never silently treated as the current version. + +File access is user initiated through platform pickers where supported. Clipboard import/export remains an explicit fallback. UnitFlow does not request broad filesystem access for backup operations. + +## Consequences + +### Positive + +- no account or cloud service is required; +- backup data is human-readable and portable; +- validation can be tested deterministically; +- migrations have an explicit version boundary; +- failed imports do not require partial rollback logic. + +### Trade-offs + +- future schema changes require explicit migration work; +- JSON is larger than a compact binary format, though current state is intentionally small; +- file save capabilities vary by Flutter target, so the UI must retain a portable fallback. + +## Follow-up + +When adding a schema version, add migration tests before enabling writes in the new format and keep the previous schema documentation available for compatibility review. From 3132a8d3199dc3a0baad0c4a6f78f6f6d2929d54 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:15:52 +0530 Subject: [PATCH 068/241] docs: record safe custom unit formula model --- docs/adr/0004-affine-custom-units.md | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/adr/0004-affine-custom-units.md diff --git a/docs/adr/0004-affine-custom-units.md b/docs/adr/0004-affine-custom-units.md new file mode 100644 index 00000000..a32243dc --- /dev/null +++ b/docs/adr/0004-affine-custom-units.md @@ -0,0 +1,49 @@ +# ADR-0004: Affine custom-unit formulas instead of executable expressions + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +UnitFlow allows users to define units that are not included in the built-in catalog. A general expression language would increase flexibility, but it would also introduce parsing complexity, ambiguous precedence, unsafe evaluation risks, harder validation, and difficult cross-language parity between Rust and Flutter. + +Most conventional unit relationships needed by an offline converter are multiplicative or affine. + +## Decision + +Represent every custom unit relative to its category base unit using: + +```text +base_value = input_value * scale + offset +``` + +Requirements: + +- `scale` is a validated decimal strictly greater than zero; +- `offset` is a validated decimal; +- identifiers use a restricted stable character set; +- custom identifiers cannot collide with built-in or other custom identifiers; +- category membership is explicit and cross-category conversion is rejected; +- imported custom definitions pass the same validation as interactively created definitions; +- no arbitrary code, script, function call, or dynamic expression is evaluated. + +The Rust core owns the authoritative domain validation. The deterministic Dart fallback mirrors the same model for web/tests/graceful bridge startup. + +## Consequences + +### Positive + +- deterministic high-precision conversion; +- safe imported definitions; +- straightforward round-trip inversion through a shared base unit; +- consistent implementation across Rust and Dart; +- no expression interpreter attack surface. + +### Trade-offs + +- non-affine domain-specific formulas cannot be represented as custom units; +- advanced formula support would require a separate, deliberately designed feature rather than overloading the unit model. + +## Follow-up + +If non-affine conversions become a real product requirement, introduce a restricted declarative formula specification with a versioned grammar, complexity limits, fuzz tests, and explicit ADR rather than executing user-provided code. From cf20c653d74003d51308583fc5e52f5f4a0bb2ec Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:16:09 +0530 Subject: [PATCH 069/241] test: cover locale aware decimal formatting --- .../test/core/decimal_format_test.dart | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 apps/unitflow_app/test/core/decimal_format_test.dart diff --git a/apps/unitflow_app/test/core/decimal_format_test.dart b/apps/unitflow_app/test/core/decimal_format_test.dart new file mode 100644 index 00000000..b4801d01 --- /dev/null +++ b/apps/unitflow_app/test/core/decimal_format_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/core/format/decimal_format.dart'; +import 'package:unitflow/core/math/exact_decimal.dart'; + +void main() { + const parser = DecimalInputParser(); + const formatter = DecimalDisplayFormatter(); + + group('DecimalInputParser', () { + test('parses English grouping and decimal separators', () { + expect( + parser.parse('1,234,567.89', localeName: 'en_US').toString(), + '1234567.89', + ); + }); + + test('parses German grouping and decimal separators', () { + expect( + parser.parse('1.234.567,89', localeName: 'de_DE').toString(), + '1234567.89', + ); + }); + + test('preserves scientific notation with localized decimal separator', () { + expect( + parser.parse('1,25e3', localeName: 'de_DE').toString(), + '1250', + ); + }); + }); + + group('DecimalDisplayFormatter', () { + test('formats English grouping without using binary floating point', () { + expect( + formatter.format( + ExactDecimal.parse('1234567.89'), + localeName: 'en_US', + ), + '1,234,567.89', + ); + }); + + test('formats German separators', () { + expect( + formatter.format( + ExactDecimal.parse('1234567.89'), + localeName: 'de_DE', + ), + '1.234.567,89', + ); + }); + + test('localizes scientific mantissa only', () { + expect( + formatter.format( + ExactDecimal.parse('1250'), + localeName: 'de_DE', + notation: DecimalNotation.scientific, + ), + '1,250e+3', + ); + }); + }); +} From 742b0bbe1619bd16ff9a47327d9ad187e454ecd6 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:17:40 +0530 Subject: [PATCH 070/241] test: cover persisted application state workflows --- .../test/app/app_controller_test.dart | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 apps/unitflow_app/test/app/app_controller_test.dart diff --git a/apps/unitflow_app/test/app/app_controller_test.dart b/apps/unitflow_app/test/app/app_controller_test.dart new file mode 100644 index 00000000..4102a751 --- /dev/null +++ b/apps/unitflow_app/test/app/app_controller_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/app/app_controller.dart'; +import 'package:unitflow/core/persistence/user_state.dart'; +import 'package:unitflow/core/persistence/user_state_repository.dart'; +import 'package:unitflow/features/converter/domain/unit_models.dart'; + +void main() { + late MemoryUserStateRepository repository; + late AppController controller; + + setUp(() async { + repository = MemoryUserStateRepository( + UserState(onboardingComplete: true), + ); + controller = AppController(repository: repository); + await controller.initialize(); + }); + + tearDown(() { + controller.dispose(); + }); + + test('favorites persist through the repository', () async { + await controller.toggleFavorite('meter'); + expect(controller.state.favoriteUnitIds, contains('meter')); + + final restored = await repository.load(); + expect(restored.favoriteUnitIds, contains('meter')); + + await controller.toggleFavorite('meter'); + expect(controller.state.favoriteUnitIds, isNot(contains('meter'))); + }); + + test('pinned pair can be added and removed', () async { + const pair = PinnedPair( + category: UnitCategory.length, + fromUnitId: 'meter', + toUnitId: 'kilometer', + ); + + await controller.togglePinnedPair(pair); + expect(controller.isPairPinned(pair), isTrue); + expect(controller.state.pinnedPairs.single.storageValue, 'length|meter|kilometer'); + + await controller.togglePinnedPair(pair); + expect(controller.isPairPinned(pair), isFalse); + expect(controller.state.pinnedPairs, isEmpty); + }); + + test('history is bounded and can be restored', () async { + for (var index = 0; index < 60; index++) { + await controller.recordRecent( + input: index.toString(), + fromUnitId: 'meter', + toUnitId: 'kilometer', + ); + } + + expect(controller.state.recents, hasLength(50)); + final snapshot = controller.state.recents; + + await controller.clearHistory(); + expect(controller.state.recents, isEmpty); + + await controller.restoreHistory(snapshot); + expect(controller.state.recents, hasLength(50)); + }); + + test('valid custom unit becomes available to conversion engine', () async { + const custom = CustomUnitData( + id: 'double_meter', + category: UnitCategory.length, + name: 'Double Meter', + symbol: 'dmx', + scale: '2', + offset: '0', + ); + + await controller.addCustomUnit(custom); + final unit = controller.engine.catalog.byId('double_meter'); + expect(unit, isNotNull); + expect(unit!.isBuiltIn, isFalse); + + await controller.removeCustomUnit('double_meter'); + expect(controller.engine.catalog.byId('double_meter'), isNull); + }); + + test('custom unit cannot replace a built-in stable id', () async { + const custom = CustomUnitData( + id: 'meter', + category: UnitCategory.length, + name: 'Replacement Meter', + symbol: 'rm', + scale: '2', + offset: '0', + ); + + expect( + () => controller.addCustomUnit(custom), + throwsArgumentError, + ); + }); + + test('invalid import preserves current state', () async { + await controller.toggleFavorite('meter'); + final before = controller.state; + + expect( + () => controller.importState('{"schemaVersion":999}'), + throwsFormatException, + ); + + expect(controller.state.favoriteUnitIds, before.favoriteUnitIds); + expect(controller.state.onboardingComplete, before.onboardingComplete); + }); +} From 5bdc926af9c3940522e09c5e4c43abd71506490c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:30:43 +0530 Subject: [PATCH 071/241] feat: persist explicit decimal rounding mode --- .../lib/core/persistence/user_state.dart | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/unitflow_app/lib/core/persistence/user_state.dart b/apps/unitflow_app/lib/core/persistence/user_state.dart index 34373cbb..9eddf133 100644 --- a/apps/unitflow_app/lib/core/persistence/user_state.dart +++ b/apps/unitflow_app/lib/core/persistence/user_state.dart @@ -169,6 +169,7 @@ final class UserState { UserState({ this.theme = ThemePreference.system, this.notation = DecimalNotation.plain, + this.roundingMode = DecimalRoundingMode.nearestEven, this.decimalPlaces = 12, this.useGrouping = true, this.onboardingComplete = false, @@ -181,10 +182,11 @@ final class UserState { recents = List.unmodifiable(recents ?? const []), customUnits = List.unmodifiable(customUnits ?? const []); - static const schemaVersion = 1; + static const schemaVersion = 2; final ThemePreference theme; final DecimalNotation notation; + final DecimalRoundingMode roundingMode; final int decimalPlaces; final bool useGrouping; final bool onboardingComplete; @@ -196,6 +198,7 @@ final class UserState { UserState copyWith({ ThemePreference? theme, DecimalNotation? notation, + DecimalRoundingMode? roundingMode, int? decimalPlaces, bool? useGrouping, bool? onboardingComplete, @@ -206,6 +209,7 @@ final class UserState { }) => UserState( theme: theme ?? this.theme, notation: notation ?? this.notation, + roundingMode: roundingMode ?? this.roundingMode, decimalPlaces: decimalPlaces ?? this.decimalPlaces, useGrouping: useGrouping ?? this.useGrouping, onboardingComplete: onboardingComplete ?? this.onboardingComplete, @@ -219,6 +223,7 @@ final class UserState { 'schemaVersion': schemaVersion, 'theme': theme.name, 'notation': notation.name, + 'roundingMode': roundingMode.name, 'decimalPlaces': decimalPlaces, 'useGrouping': useGrouping, 'onboardingComplete': onboardingComplete, @@ -230,7 +235,7 @@ final class UserState { static UserState fromJson(Map json) { final version = json['schemaVersion']; - if (version != schemaVersion) { + if (version is! int || version < 1 || version > schemaVersion) { throw const FormatException('Unsupported UnitFlow data schema.'); } @@ -247,8 +252,13 @@ final class UserState { final theme = ThemePreference.values.where((item) => item.name == json['theme']).firstOrNull; final notation = DecimalNotation.values.where((item) => item.name == json['notation']).firstOrNull; - if (theme == null || notation == null) { - throw const FormatException('Invalid UnitFlow appearance settings.'); + final roundingMode = version == 1 + ? DecimalRoundingMode.nearestEven + : DecimalRoundingMode.values + .where((item) => item.name == json['roundingMode']) + .firstOrNull; + if (theme == null || notation == null || roundingMode == null) { + throw const FormatException('Invalid UnitFlow appearance or conversion settings.'); } final favoritesRaw = json['favoriteUnitIds']; @@ -305,6 +315,7 @@ final class UserState { return UserState( theme: theme, notation: notation, + roundingMode: roundingMode, decimalPlaces: decimalPlaces, useGrouping: useGrouping, onboardingComplete: onboardingComplete, From 19f0508ee6cdc19a3ebf54e1bdead351f0679c06 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:31:08 +0530 Subject: [PATCH 072/241] feat: expose rounding mode setting in app controller --- apps/unitflow_app/lib/app/app_controller.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/app/app_controller.dart b/apps/unitflow_app/lib/app/app_controller.dart index 3ba78287..7dfd50c5 100644 --- a/apps/unitflow_app/lib/app/app_controller.dart +++ b/apps/unitflow_app/lib/app/app_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import '../core/logging/app_log.dart'; +import '../core/math/exact_decimal.dart'; import '../core/persistence/user_state.dart'; import '../core/persistence/user_state_repository.dart'; import '../features/converter/data/unit_catalog.dart'; @@ -51,6 +52,9 @@ final class AppController extends ChangeNotifier { Future setNotation(DecimalNotation notation) => _update(_state.copyWith(notation: notation)); + Future setRoundingMode(DecimalRoundingMode roundingMode) => + _update(_state.copyWith(roundingMode: roundingMode)); + Future setDecimalPlaces(int decimalPlaces) { if (decimalPlaces < 0 || decimalPlaces > 28) { throw RangeError.range(decimalPlaces, 0, 28, 'decimalPlaces'); @@ -134,7 +138,8 @@ final class AppController extends ChangeNotifier { return _update(_state.copyWith(recents: next)); } - Future clearHistory() => _update(_state.copyWith(recents: [])); + Future clearHistory() => + _update(_state.copyWith(recents: [])); Future restoreHistory(List recents) => _update(_state.copyWith(recents: recents)); From 96e47b87eb76e01e96e7e61c07cb5694b00e9d4c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:31:27 +0530 Subject: [PATCH 073/241] feat: apply selected rounding mode to conversions --- .../converter/presentation/converter_controller.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart b/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart index f14b0157..95f117a4 100644 --- a/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart +++ b/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart @@ -126,6 +126,7 @@ final class ConverterController extends ChangeNotifier { fromUnitId: _fromUnitId, toUnitId: _toUnitId, decimalPlaces: _appController.state.decimalPlaces, + rounding: _appController.state.roundingMode, ); _error = null; } on FormatException { @@ -153,6 +154,7 @@ final class ConverterController extends ChangeNotifier { .where((unit) => unit.id != _fromUnitId) .map((unit) => unit.id), decimalPlaces: _appController.state.decimalPlaces, + rounding: _appController.state.roundingMode, ); } @@ -180,7 +182,10 @@ final class ConverterController extends ChangeNotifier { void applyPinnedPair(PinnedPair pair) { final from = _appController.engine.catalog.byId(pair.fromUnitId); final to = _appController.engine.catalog.byId(pair.toUnitId); - if (from == null || to == null || from.category != pair.category || to.category != pair.category) { + if (from == null || + to == null || + from.category != pair.category || + to.category != pair.category) { return; } _category = pair.category; From ea6c20f162d9e84b4163c5608fd48289dc3473d1 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:31:55 +0530 Subject: [PATCH 074/241] feat: add rounding mode localization strings --- apps/unitflow_app/lib/l10n/app_en.arb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/unitflow_app/lib/l10n/app_en.arb b/apps/unitflow_app/lib/l10n/app_en.arb index 8efe2bcd..017d6e34 100644 --- a/apps/unitflow_app/lib/l10n/app_en.arb +++ b/apps/unitflow_app/lib/l10n/app_en.arb @@ -80,6 +80,14 @@ "scientific": "Scientific", "engineering": "Engineering", "decimalPlaces": "Decimal places", + "roundingMode": "Rounding mode", + "roundingModeSubtitle": "Controls how values are rounded when the selected decimal precision is reached.", + "nearestEven": "Nearest, ties to even", + "halfAwayFromZero": "Nearest, ties away from zero", + "towardZero": "Toward zero", + "awayFromZero": "Away from zero", + "floor": "Floor", + "ceiling": "Ceiling", "digitGrouping": "Digit grouping", "digitGroupingSubtitle": "Use locale-aware grouping separators in displayed results.", "privacyLocalData": "Privacy and local data", From df16f93ed5971c1275c0388c68826371b12c5f16 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:32:26 +0530 Subject: [PATCH 075/241] feat: add explicit rounding control to settings --- .../presentation/settings_screen.dart | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart index b7fde0ed..32c58d41 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart @@ -5,6 +5,7 @@ import '../../../app/app_controller.dart'; import '../../../app/theme/app_theme.dart'; import '../../../core/format/decimal_format.dart'; import '../../../core/io/backup_file_service.dart'; +import '../../../core/math/exact_decimal.dart'; import '../../../core/persistence/user_state.dart'; import '../../../l10n/app_localizations.dart'; @@ -93,6 +94,27 @@ final class SettingsScreen extends StatelessWidget { } }, ), + const SizedBox(height: AppSpacing.sm), + _DropdownSetting( + label: strings.roundingMode, + helperText: strings.roundingModeSubtitle, + value: appController.state.roundingMode, + values: DecimalRoundingMode.values, + labelFor: (value) => switch (value) { + DecimalRoundingMode.nearestEven => strings.nearestEven, + DecimalRoundingMode.halfAwayFromZero => + strings.halfAwayFromZero, + DecimalRoundingMode.towardZero => strings.towardZero, + DecimalRoundingMode.awayFromZero => strings.awayFromZero, + DecimalRoundingMode.floor => strings.floor, + DecimalRoundingMode.ceiling => strings.ceiling, + }, + onChanged: (value) { + if (value != null) { + appController.setRoundingMode(value); + } + }, + ), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text(strings.digitGrouping), @@ -337,9 +359,11 @@ final class _DropdownSetting extends StatelessWidget { required this.values, required this.labelFor, required this.onChanged, + this.helperText, }); final String label; + final String? helperText; final T value; final List values; final String Function(T value) labelFor; @@ -347,7 +371,7 @@ final class _DropdownSetting extends StatelessWidget { @override Widget build(BuildContext context) => InputDecorator( - decoration: InputDecoration(labelText: label), + decoration: InputDecoration(labelText: label, helperText: helperText), child: DropdownButtonHideUnderline( child: DropdownButton( value: value, From ecbe75416bcc2e470b9d3c7d88af8f0243591c52 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:32:46 +0530 Subject: [PATCH 076/241] test: cover rounding mode persistence migration --- .../test/core/user_state_test.dart | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/unitflow_app/test/core/user_state_test.dart b/apps/unitflow_app/test/core/user_state_test.dart index 793d7e38..9ef4e936 100644 --- a/apps/unitflow_app/test/core/user_state_test.dart +++ b/apps/unitflow_app/test/core/user_state_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:unitflow/core/format/decimal_format.dart'; +import 'package:unitflow/core/math/exact_decimal.dart'; import 'package:unitflow/core/persistence/user_state.dart'; import 'package:unitflow/core/persistence/user_state_repository.dart'; import 'package:unitflow/features/converter/domain/unit_models.dart'; @@ -10,6 +11,7 @@ void main() { final state = UserState( theme: ThemePreference.dark, notation: DecimalNotation.engineering, + roundingMode: DecimalRoundingMode.halfAwayFromZero, decimalPlaces: 8, onboardingComplete: true, favoriteUnitIds: {'meter'}, @@ -37,12 +39,34 @@ void main() { expect(restored.theme, ThemePreference.dark); expect(restored.notation, DecimalNotation.engineering); + expect(restored.roundingMode, DecimalRoundingMode.halfAwayFromZero); expect(restored.decimalPlaces, 8); expect(restored.favoriteUnitIds, contains('meter')); expect(restored.pinnedPairs.single.toUnitId, 'kilometer'); expect(restored.customUnits.single.id, 'double_meter'); }); + test('schema version one backups migrate to nearest-even rounding', () { + final repository = MemoryUserStateRepository(); + const legacy = '{' + '"schemaVersion":1,' + '"theme":"system",' + '"notation":"plain",' + '"decimalPlaces":12,' + '"useGrouping":true,' + '"onboardingComplete":true,' + '"favoriteUnitIds":[],' + '"pinnedPairs":[],' + '"recents":[],' + '"customUnits":[]' + '}'; + + final restored = repository.importJson(legacy); + + expect(restored.roundingMode, DecimalRoundingMode.nearestEven); + expect(restored.toJson()['schemaVersion'], UserState.schemaVersion); + }); + test('invalid schema version is rejected', () { final repository = MemoryUserStateRepository(); expect( From 709d669b3ea6e791bb2c64aef58573c69d447941 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:33:12 +0530 Subject: [PATCH 077/241] test: verify selectable conversion rounding modes --- .../test/features/conversion_engine_test.dart | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/test/features/conversion_engine_test.dart b/apps/unitflow_app/test/features/conversion_engine_test.dart index 5125f78f..fd386f39 100644 --- a/apps/unitflow_app/test/features/conversion_engine_test.dart +++ b/apps/unitflow_app/test/features/conversion_engine_test.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:unitflow/core/math/exact_decimal.dart'; +import 'package:unitflow/features/converter/data/unit_catalog.dart'; import 'package:unitflow/features/converter/domain/conversion_engine.dart'; +import 'package:unitflow/features/converter/domain/unit_models.dart'; void main() { late ExactConversionEngine engine; @@ -47,6 +49,49 @@ void main() { toUnitIds: const ['centimeter', 'kilometer', 'inch'], decimalPlaces: 6, ); - expect(results.map((result) => result.to.id), ['centimeter', 'kilometer', 'inch']); + expect(results.map((result) => result.to.id), [ + 'centimeter', + 'kilometer', + 'inch', + ]); + }); + + test('rounding mode changes midpoint conversion result', () { + final midpointEngine = ExactConversionEngine( + catalog: UnitCatalog([ + UnitDefinition( + id: 'source', + category: UnitCategory.length, + name: 'Source', + symbol: 'src', + scale: ExactDecimal.parse('1'), + ), + UnitDefinition( + id: 'double_source', + category: UnitCategory.length, + name: 'Double Source', + symbol: 'dbl', + scale: ExactDecimal.parse('2'), + ), + ]), + ); + + final nearestEven = midpointEngine.convert( + value: ExactDecimal.parse('1'), + fromUnitId: 'source', + toUnitId: 'double_source', + decimalPlaces: 0, + rounding: DecimalRoundingMode.nearestEven, + ); + final halfAway = midpointEngine.convert( + value: ExactDecimal.parse('1'), + fromUnitId: 'source', + toUnitId: 'double_source', + decimalPlaces: 0, + rounding: DecimalRoundingMode.halfAwayFromZero, + ); + + expect(nearestEven.output.toString(), '0'); + expect(halfAway.output.toString(), '1'); }); } From b1f333ff00ced4f5b2e9ba5a028ecd00ea9d49ae Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:34:13 +0530 Subject: [PATCH 078/241] docs: publish UnitFlow backup schema v2 --- schemas/unitflow-backup-v2.schema.json | 183 +++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 schemas/unitflow-backup-v2.schema.json diff --git a/schemas/unitflow-backup-v2.schema.json b/schemas/unitflow-backup-v2.schema.json new file mode 100644 index 00000000..1b8114a7 --- /dev/null +++ b/schemas/unitflow-backup-v2.schema.json @@ -0,0 +1,183 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/sanskarIN/unitflow/blob/main/schemas/unitflow-backup-v2.schema.json", + "title": "UnitFlow backup v2", + "description": "Portable local UnitFlow preferences, favorites, pinned pairs, recent conversions, custom units, and explicit decimal rounding preference.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "theme", + "notation", + "roundingMode", + "decimalPlaces", + "useGrouping", + "onboardingComplete", + "favoriteUnitIds", + "pinnedPairs", + "recents", + "customUnits" + ], + "properties": { + "schemaVersion": { "const": 2 }, + "theme": { + "type": "string", + "enum": ["system", "light", "dark"] + }, + "notation": { + "type": "string", + "enum": ["plain", "scientific", "engineering"] + }, + "roundingMode": { + "type": "string", + "enum": [ + "nearestEven", + "halfAwayFromZero", + "towardZero", + "awayFromZero", + "floor", + "ceiling" + ] + }, + "decimalPlaces": { + "type": "integer", + "minimum": 0, + "maximum": 28 + }, + "useGrouping": { "type": "boolean" }, + "onboardingComplete": { "type": "boolean" }, + "favoriteUnitIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9_-]+$" + } + }, + "pinnedPairs": { + "type": "array", + "maxItems": 20, + "items": { + "type": "string", + "minLength": 5, + "maxLength": 256, + "pattern": "^[a-z_]+\\|[a-z0-9_-]+\\|[a-z0-9_-]+$" + } + }, + "recents": { + "type": "array", + "maxItems": 100, + "items": { "$ref": "#/$defs/recentConversion" } + }, + "customUnits": { + "type": "array", + "maxItems": 200, + "items": { "$ref": "#/$defs/customUnit" } + } + }, + "$defs": { + "recentConversion": { + "type": "object", + "additionalProperties": false, + "required": ["input", "fromUnitId", "toUnitId", "createdAt"], + "properties": { + "input": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "fromUnitId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9_-]+$" + }, + "toUnitId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9_-]+$" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "customUnit": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "category", + "name", + "symbol", + "scale", + "offset", + "aliases", + "description" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9_-]+$" + }, + "category": { + "type": "string", + "enum": [ + "length", + "area", + "volume", + "mass", + "speed", + "pressure", + "energy", + "power", + "angle", + "data_size", + "frequency", + "time", + "temperature" + ] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "symbol": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "scale": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "offset": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "aliases": { + "type": "array", + "maxItems": 32, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "description": { + "type": "string", + "maxLength": 512 + } + } + } + } +} From d4687257fe5036cf7a363bb253a727e2410b7cc5 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:34:25 +0530 Subject: [PATCH 079/241] docs: document backup schema v2 migration --- docs/data-format.md | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/data-format.md b/docs/data-format.md index 23a610bb..880e24d8 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -8,6 +8,7 @@ The current local state contains: - theme preference; - notation preference; +- explicit decimal rounding mode; - decimal-place preference; - grouping preference; - onboarding completion state; @@ -16,7 +17,7 @@ The current local state contains: - bounded recent-conversion history; - validated custom affine units. -The current schema version is **1**. Its machine-readable contract is published at `schemas/unitflow-backup-v1.schema.json`. +The current schema version is **2**. Its machine-readable contract is published at `schemas/unitflow-backup-v2.schema.json`. The version 1 schema remains checked in at `schemas/unitflow-backup-v1.schema.json` for compatibility documentation and migration tests. ## Backup envelope @@ -31,6 +32,19 @@ Current safety bounds include: - decimal precision preference: 0–28 places; - stable identifiers: lowercase ASCII letters, digits, `_`, and `-` only. +## Rounding modes + +Schema version 2 stores a `roundingMode` field. Accepted values are: + +- `nearestEven`; +- `halfAwayFromZero`; +- `towardZero`; +- `awayFromZero`; +- `floor`; +- `ceiling`. + +The selected mode is applied by the conversion engine whenever a result must be rounded to the configured decimal-place precision. + ## Custom-unit formula Custom units use an affine relationship instead of evaluating arbitrary executable expressions: @@ -53,7 +67,7 @@ An import is rejected when, among other validation failures: - duplicate identifiers would collide with built-in or imported custom units; - the import exceeds configured size/count limits. -The application should preserve the existing state when validation fails. +The application preserves the existing state when validation fails. ## Export behavior @@ -61,14 +75,18 @@ UnitFlow supports explicit JSON backup export. File export uses a user-selected ## Migration policy -When schema version 2 or later is introduced: +### Version 1 → version 2 + +Version 1 did not contain a rounding preference. When a valid version 1 backup is imported, UnitFlow deterministically migrates it to `nearestEven`, which was the conversion engine's historical default. A subsequent export emits schema version 2. + +For future schema versions: -1. keep version 1 parsing deterministic; +1. keep older supported parsing deterministic; 2. add explicit migrations rather than silently reinterpreting old fields; 3. add migration and corruption-regression tests; 4. update the JSON Schema and this document; 5. update `CHANGELOG.md` with user-visible compatibility notes; -6. preserve unknown future schemas by rejecting them rather than destructively rewriting them. +6. reject unknown future schemas rather than destructively rewriting them. ## Privacy From 59649edbefbcf9fff61a86521fbea058403ef880 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:34:40 +0530 Subject: [PATCH 080/241] docs: record rounding and backup compatibility changes --- CHANGELOG.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ec68f8..3ea28310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,18 +9,27 @@ All notable changes to UnitFlow are documented here. The format is based on Keep - Initial repository documentation and governance. - Rust + Flutter architecture baseline. - Offline-first privacy and security policies. +- High-precision Rust conversion core and deterministic Dart exact-decimal fallback. +- Searchable multi-category unit catalog, favorites, pinned pairs, recents, custom units, and batch conversion export. +- Local JSON backup/restore with clipboard and bounded file import/export flows. +- Explicit user-selectable decimal rounding modes: nearest-even, half-away-from-zero, toward zero, away from zero, floor, and ceiling. +- English localization source and generated-localization workflow. +- Rust–Flutter bridge crate and bridge-generation automation. +- CI, CodeQL, dependency review, Dependabot, and cross-platform release workflows. ### Changed -- Nothing yet. +- Backup schema advanced from version 1 to version 2 to persist the selected decimal rounding mode. +- Valid version 1 backups migrate deterministically to nearest-even rounding and export as version 2. ### Fixed -- Nothing yet. +- Conversion rounding is now applied consistently to primary and batch conversion paths using the persisted user preference. ### Security - Added responsible disclosure guidance and secret-handling rules. +- Added bounded backup import validation and redacting structured diagnostic logging. ## [0.1.0-alpha.1] - Planned From 1255f6ed6fd33b3042d04edc5f64191bf3e90f01 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:34:59 +0530 Subject: [PATCH 081/241] ci: normalize generated bridge sources on audit branch --- .github/workflows/format-audit.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/format-audit.yml b/.github/workflows/format-audit.yml index 473fda40..50737a19 100644 --- a/.github/workflows/format-audit.yml +++ b/.github/workflows/format-audit.yml @@ -34,6 +34,9 @@ jobs: channel: stable cache: true + - name: Install bridge generator + run: cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked + - name: Generate Rust lockfile run: cargo generate-lockfile @@ -41,6 +44,13 @@ jobs: working-directory: apps/unitflow_app run: flutter pub get + - name: Generate localizations + working-directory: apps/unitflow_app + run: flutter gen-l10n + + - name: Generate Rust Dart bridge + run: bash tool/generate_bridge.sh + - name: Format Rust run: cargo fmt --all @@ -52,7 +62,7 @@ jobs: shell: bash run: | if git diff --quiet && test -f Cargo.lock && test -f apps/unitflow_app/pubspec.lock; then - echo "Formatting and lockfiles are already clean." + echo "Generated sources, formatting, and lockfiles are already clean." exit 0 fi git config user.name "Sanskar" @@ -62,5 +72,5 @@ jobs: echo "No tracked normalization changes to commit." exit 0 fi - git commit -m "style: normalize sources and lock dependencies" + git commit -m "style: normalize generated sources and lock dependencies" git push origin HEAD:${{ github.event.pull_request.head.ref }} From 616b669b17f0e6d670be33deb881f8a84f574296 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:35:36 +0530 Subject: [PATCH 082/241] feat: persist reduced motion accessibility preference --- apps/unitflow_app/lib/core/persistence/user_state.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/unitflow_app/lib/core/persistence/user_state.dart b/apps/unitflow_app/lib/core/persistence/user_state.dart index 9eddf133..70009c80 100644 --- a/apps/unitflow_app/lib/core/persistence/user_state.dart +++ b/apps/unitflow_app/lib/core/persistence/user_state.dart @@ -172,6 +172,7 @@ final class UserState { this.roundingMode = DecimalRoundingMode.nearestEven, this.decimalPlaces = 12, this.useGrouping = true, + this.reduceMotion = false, this.onboardingComplete = false, Set? favoriteUnitIds, List? pinnedPairs, @@ -189,6 +190,7 @@ final class UserState { final DecimalRoundingMode roundingMode; final int decimalPlaces; final bool useGrouping; + final bool reduceMotion; final bool onboardingComplete; final Set favoriteUnitIds; final List pinnedPairs; @@ -201,6 +203,7 @@ final class UserState { DecimalRoundingMode? roundingMode, int? decimalPlaces, bool? useGrouping, + bool? reduceMotion, bool? onboardingComplete, Set? favoriteUnitIds, List? pinnedPairs, @@ -212,6 +215,7 @@ final class UserState { roundingMode: roundingMode ?? this.roundingMode, decimalPlaces: decimalPlaces ?? this.decimalPlaces, useGrouping: useGrouping ?? this.useGrouping, + reduceMotion: reduceMotion ?? this.reduceMotion, onboardingComplete: onboardingComplete ?? this.onboardingComplete, favoriteUnitIds: favoriteUnitIds ?? this.favoriteUnitIds, pinnedPairs: pinnedPairs ?? this.pinnedPairs, @@ -226,6 +230,7 @@ final class UserState { 'roundingMode': roundingMode.name, 'decimalPlaces': decimalPlaces, 'useGrouping': useGrouping, + 'reduceMotion': reduceMotion, 'onboardingComplete': onboardingComplete, 'favoriteUnitIds': favoriteUnitIds.toList(growable: false), 'pinnedPairs': pinnedPairs.map((pair) => pair.storageValue).toList(growable: false), @@ -241,11 +246,13 @@ final class UserState { final decimalPlaces = json['decimalPlaces']; final useGrouping = json['useGrouping']; + final reduceMotionValue = json['reduceMotion']; final onboardingComplete = json['onboardingComplete']; if (decimalPlaces is! int || decimalPlaces < 0 || decimalPlaces > 28 || useGrouping is! bool || + (reduceMotionValue != null && reduceMotionValue is! bool) || onboardingComplete is! bool) { throw const FormatException('Invalid UnitFlow preferences.'); } @@ -318,6 +325,7 @@ final class UserState { roundingMode: roundingMode, decimalPlaces: decimalPlaces, useGrouping: useGrouping, + reduceMotion: reduceMotionValue as bool? ?? false, onboardingComplete: onboardingComplete, favoriteUnitIds: favorites, pinnedPairs: pins, From e779483890df9512b65b0eba820c4e97ac1b51f6 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:36:00 +0530 Subject: [PATCH 083/241] feat: expose reduced motion preference --- apps/unitflow_app/lib/app/app_controller.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/unitflow_app/lib/app/app_controller.dart b/apps/unitflow_app/lib/app/app_controller.dart index 7dfd50c5..baaf3cff 100644 --- a/apps/unitflow_app/lib/app/app_controller.dart +++ b/apps/unitflow_app/lib/app/app_controller.dart @@ -65,6 +65,9 @@ final class AppController extends ChangeNotifier { Future setUseGrouping(bool enabled) => _update(_state.copyWith(useGrouping: enabled)); + Future setReduceMotion(bool enabled) => + _update(_state.copyWith(reduceMotion: enabled)); + Future completeOnboarding() => _update(_state.copyWith(onboardingComplete: true)); From cc3f7e113c1641f4748fe7bd8d8f44d80093792d Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:36:18 +0530 Subject: [PATCH 084/241] feat: honor reduced motion for theme transitions --- apps/unitflow_app/lib/app/unitflow_app.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/app/unitflow_app.dart b/apps/unitflow_app/lib/app/unitflow_app.dart index c78c7a86..10df873c 100644 --- a/apps/unitflow_app/lib/app/unitflow_app.dart +++ b/apps/unitflow_app/lib/app/unitflow_app.dart @@ -34,6 +34,9 @@ final class _UnitFlowAppState extends State { theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: _themeMode(widget.appController.state.theme), + themeAnimationDuration: widget.appController.state.reduceMotion + ? Duration.zero + : const Duration(milliseconds: 200), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: _home(), @@ -74,7 +77,10 @@ final class _StartupScreen extends StatelessWidget { color: Theme.of(context).colorScheme.primary, ), const SizedBox(height: AppSpacing.lg), - Text(strings.appName, style: Theme.of(context).textTheme.headlineMedium), + Text( + strings.appName, + style: Theme.of(context).textTheme.headlineMedium, + ), const SizedBox(height: AppSpacing.md), const SizedBox( width: 28, From 22e313347261cfd4c6e6cdde67c907f419614194 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:36:38 +0530 Subject: [PATCH 085/241] feat: respect reduced motion during onboarding --- .../onboarding/presentation/onboarding_screen.dart | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/features/onboarding/presentation/onboarding_screen.dart b/apps/unitflow_app/lib/features/onboarding/presentation/onboarding_screen.dart index 6aba18a9..ccfc155d 100644 --- a/apps/unitflow_app/lib/features/onboarding/presentation/onboarding_screen.dart +++ b/apps/unitflow_app/lib/features/onboarding/presentation/onboarding_screen.dart @@ -27,6 +27,8 @@ final class _OnboardingScreenState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final strings = AppLocalizations.of(context); + final reduceMotion = widget.appController.state.reduceMotion || + (MediaQuery.maybeOf(context)?.disableAnimations ?? false); final pages = <({IconData icon, String title, String body})>[ ( icon: Icons.swap_calls, @@ -127,7 +129,9 @@ final class _OnboardingScreenState extends State { children: List.generate( pages.length, (index) => AnimatedContainer( - duration: const Duration(milliseconds: 180), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 180), margin: const EdgeInsets.symmetric( horizontal: AppSpacing.xxs, ), @@ -172,6 +176,12 @@ final class _OnboardingScreenState extends State { await widget.appController.completeOnboarding(); return; } + final reduceMotion = widget.appController.state.reduceMotion || + (MediaQuery.maybeOf(context)?.disableAnimations ?? false); + if (reduceMotion) { + _pageController.jumpToPage(_page + 1); + return; + } await _pageController.nextPage( duration: const Duration(milliseconds: 240), curve: Curves.easeOutCubic, From ae3d70ea6e52da0f79686a5aa4d757941824a664 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:37:03 +0530 Subject: [PATCH 086/241] feat: localize accessibility and update settings --- apps/unitflow_app/lib/l10n/app_en.arb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/unitflow_app/lib/l10n/app_en.arb b/apps/unitflow_app/lib/l10n/app_en.arb index 017d6e34..e5c5113f 100644 --- a/apps/unitflow_app/lib/l10n/app_en.arb +++ b/apps/unitflow_app/lib/l10n/app_en.arb @@ -90,6 +90,14 @@ "ceiling": "Ceiling", "digitGrouping": "Digit grouping", "digitGroupingSubtitle": "Use locale-aware grouping separators in displayed results.", + "accessibility": "Accessibility", + "reduceMotion": "Reduce motion", + "reduceMotionSubtitle": "Minimize non-essential app animations and transitions.", + "systemAccessibility": "Text scaling, high contrast, and platform accessibility services continue to follow your system settings.", + "updates": "Updates", + "openReleases": "View releases", + "openReleasesSubtitle": "Open the official UnitFlow GitHub Releases page in your browser.", + "updatesNetworkNote": "Static conversions remain fully offline. Checking release information is always user-initiated.", "privacyLocalData": "Privacy and local data", "privacyLocalDataSubtitle": "Static conversions require no account. Your preferences, favorites, history, pinned pairs, and custom units are stored locally by default.", "saveBackupFile": "Save backup file", From a4c9f408eb713cd674445302efcde062902d420d Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:37:36 +0530 Subject: [PATCH 087/241] feat: add accessibility and update settings sections --- .../presentation/settings_screen.dart | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart index 32c58d41..86b937a9 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../../app/app_controller.dart'; import '../../../app/theme/app_theme.dart'; @@ -17,6 +18,9 @@ final class SettingsScreen extends StatelessWidget { }); static const _backupFiles = BackupFileService(); + static final Uri _releasesUri = Uri.parse( + 'https://github.com/sanskarIN/unitflow/releases', + ); final AppController appController; final VoidCallback onOpenAbout; @@ -125,6 +129,22 @@ final class SettingsScreen extends StatelessWidget { ], ), const SizedBox(height: AppSpacing.md), + _SectionCard( + title: strings.accessibility, + icon: Icons.accessibility_new_outlined, + children: [ + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: Text(strings.reduceMotion), + subtitle: Text(strings.reduceMotionSubtitle), + value: appController.state.reduceMotion, + onChanged: appController.setReduceMotion, + ), + const SizedBox(height: AppSpacing.xs), + Text(strings.systemAccessibility), + ], + ), + const SizedBox(height: AppSpacing.md), _SectionCard( title: strings.privacyLocalData, icon: Icons.privacy_tip_outlined, @@ -165,6 +185,23 @@ final class SettingsScreen extends StatelessWidget { ], ), const SizedBox(height: AppSpacing.md), + _SectionCard( + title: strings.updates, + icon: Icons.system_update_alt_outlined, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.new_releases_outlined), + title: Text(strings.openReleases), + subtitle: Text(strings.openReleasesSubtitle), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => _openReleases(context), + ), + const SizedBox(height: AppSpacing.xs), + Text(strings.updatesNetworkNote), + ], + ), + const SizedBox(height: AppSpacing.md), _SectionCard( title: strings.about, icon: Icons.info_outline, @@ -287,6 +324,18 @@ final class SettingsScreen extends StatelessWidget { ); } + Future _openReleases(BuildContext context) async { + if (await launchUrl(_releasesUri)) { + return; + } + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open UnitFlow releases.')), + ); + } + Future _confirmReset(BuildContext context) async { final strings = AppLocalizations.of(context); final confirmed = await showDialog( From c16b89374c6a6e21e68ec87c09fd31df0c3b3894 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:38:01 +0530 Subject: [PATCH 088/241] docs: extend backup schema with reduced motion preference --- schemas/unitflow-backup-v2.schema.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/schemas/unitflow-backup-v2.schema.json b/schemas/unitflow-backup-v2.schema.json index 1b8114a7..ec733bda 100644 --- a/schemas/unitflow-backup-v2.schema.json +++ b/schemas/unitflow-backup-v2.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/sanskarIN/unitflow/blob/main/schemas/unitflow-backup-v2.schema.json", "title": "UnitFlow backup v2", - "description": "Portable local UnitFlow preferences, favorites, pinned pairs, recent conversions, custom units, and explicit decimal rounding preference.", + "description": "Portable local UnitFlow preferences, favorites, pinned pairs, recent conversions, custom units, explicit decimal rounding, and accessibility preferences.", "type": "object", "additionalProperties": false, "required": [ @@ -45,6 +45,7 @@ "maximum": 28 }, "useGrouping": { "type": "boolean" }, + "reduceMotion": { "type": "boolean", "default": false }, "onboardingComplete": { "type": "boolean" }, "favoriteUnitIds": { "type": "array", From 3234929c64da2c7c28f8d94ee39b6add8df97ae9 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:38:15 +0530 Subject: [PATCH 089/241] test: cover reduced motion backup compatibility --- .../test/core/user_state_test.dart | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/unitflow_app/test/core/user_state_test.dart b/apps/unitflow_app/test/core/user_state_test.dart index 9ef4e936..eac04b46 100644 --- a/apps/unitflow_app/test/core/user_state_test.dart +++ b/apps/unitflow_app/test/core/user_state_test.dart @@ -13,6 +13,7 @@ void main() { notation: DecimalNotation.engineering, roundingMode: DecimalRoundingMode.halfAwayFromZero, decimalPlaces: 8, + reduceMotion: true, onboardingComplete: true, favoriteUnitIds: {'meter'}, pinnedPairs: const [ @@ -41,6 +42,7 @@ void main() { expect(restored.notation, DecimalNotation.engineering); expect(restored.roundingMode, DecimalRoundingMode.halfAwayFromZero); expect(restored.decimalPlaces, 8); + expect(restored.reduceMotion, isTrue); expect(restored.favoriteUnitIds, contains('meter')); expect(restored.pinnedPairs.single.toUnitId, 'kilometer'); expect(restored.customUnits.single.id, 'double_meter'); @@ -64,9 +66,31 @@ void main() { final restored = repository.importJson(legacy); expect(restored.roundingMode, DecimalRoundingMode.nearestEven); + expect(restored.reduceMotion, isFalse); expect(restored.toJson()['schemaVersion'], UserState.schemaVersion); }); + test('schema version two without reduceMotion defaults to false', () { + final repository = MemoryUserStateRepository(); + const legacyV2 = '{' + '"schemaVersion":2,' + '"theme":"system",' + '"notation":"plain",' + '"roundingMode":"nearestEven",' + '"decimalPlaces":12,' + '"useGrouping":true,' + '"onboardingComplete":true,' + '"favoriteUnitIds":[],' + '"pinnedPairs":[],' + '"recents":[],' + '"customUnits":[]' + '}'; + + final restored = repository.importJson(legacyV2); + + expect(restored.reduceMotion, isFalse); + }); + test('invalid schema version is rejected', () { final repository = MemoryUserStateRepository(); expect( From f85fdb1926cedc3698f983843a5d5b504d310e99 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:38:32 +0530 Subject: [PATCH 090/241] docs: document reduced motion behavior --- docs/accessibility.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/accessibility.md b/docs/accessibility.md index 68cb8372..0b0511eb 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -11,10 +11,27 @@ UnitFlow targets WCAG-oriented accessible behavior across mobile, desktop, and w - Text scaling does not hide primary actions or conversion output. - Touch targets are comfortably sized. - Light/dark themes maintain usable contrast. -- Motion respects reduced-motion preferences where the framework/platform exposes them. +- Motion respects both the persisted UnitFlow reduced-motion preference and platform accessibility requests where available. - Validation messages identify the field/problem and do not rely only on icons. - Dynamic conversion output is announced conservatively; avoid excessive screen-reader chatter on every keystroke. +## Reduced motion + +Settings exposes **Reduce motion** as an explicit accessibility preference. The preference is persisted in local/backup state and defaults to `false` when absent from an older schema-v2 backup. + +Current behavior includes: + +- theme transitions use zero duration when reduced motion is enabled; +- onboarding page transitions jump directly instead of animating; +- onboarding progress-indicator shape changes use zero-duration transitions; +- onboarding also honors the platform/framework `disableAnimations` media setting even when the UnitFlow preference is off. + +New animated UI must consult the same preference or platform accessibility setting before adding non-essential motion. + +## System accessibility + +UnitFlow intentionally leaves text scaling, platform high-contrast behavior, focus traversal, screen-reader services, and other operating-system accessibility features enabled. Do not clamp text scale merely to preserve a visual layout; fix the layout instead. + ## Converter screen review Verify: @@ -38,7 +55,7 @@ Do not hard-code layouts that assume short English labels. UI strings must be lo ## Automated tests -Widget tests should validate critical semantics and absence of obvious layout exceptions at representative dimensions. Automated checks complement rather than replace manual screen-reader/keyboard review. +Widget tests should validate critical semantics, reduced-motion behavior where practical, and absence of obvious layout exceptions at representative dimensions. Automated checks complement rather than replace manual screen-reader/keyboard review. ## Release evidence From 8aeb47346b6ab2916920577e716394bd6736e6b9 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:38:45 +0530 Subject: [PATCH 091/241] docs: describe reduced motion backup compatibility --- docs/data-format.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/data-format.md b/docs/data-format.md index 880e24d8..56a9a3bb 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -11,6 +11,7 @@ The current local state contains: - explicit decimal rounding mode; - decimal-place preference; - grouping preference; +- reduced-motion accessibility preference; - onboarding completion state; - favorite unit identifiers; - pinned unit pairs; @@ -45,6 +46,10 @@ Schema version 2 stores a `roundingMode` field. Accepted values are: The selected mode is applied by the conversion engine whenever a result must be rounded to the configured decimal-place precision. +## Accessibility preference + +Schema version 2 may contain `reduceMotion`. It is a boolean and defaults to `false` when absent. Keeping this field optional allows early schema-v2 backups created before the preference was introduced to remain importable without another schema-version bump. + ## Custom-unit formula Custom units use an affine relationship instead of evaluating arbitrary executable expressions: @@ -77,7 +82,11 @@ UnitFlow supports explicit JSON backup export. File export uses a user-selected ### Version 1 → version 2 -Version 1 did not contain a rounding preference. When a valid version 1 backup is imported, UnitFlow deterministically migrates it to `nearestEven`, which was the conversion engine's historical default. A subsequent export emits schema version 2. +Version 1 did not contain a rounding preference. When a valid version 1 backup is imported, UnitFlow deterministically migrates it to `nearestEven`, which was the conversion engine's historical default. Version 1 also predates the persisted reduced-motion preference, so that preference migrates to `false`. A subsequent export emits schema version 2. + +### Early version 2 compatibility + +Version 2 backups that contain `roundingMode` but predate `reduceMotion` remain valid. Missing `reduceMotion` is interpreted as `false`. For future schema versions: From 42bd1d9cb9a95a4c1b3156a601864f502a1d9ce1 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:40:50 +0530 Subject: [PATCH 092/241] feat: centralize user safe error presentation --- .../lib/core/errors/user_safe_error.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/unitflow_app/lib/core/errors/user_safe_error.dart diff --git a/apps/unitflow_app/lib/core/errors/user_safe_error.dart b/apps/unitflow_app/lib/core/errors/user_safe_error.dart new file mode 100644 index 00000000..9eed3ff2 --- /dev/null +++ b/apps/unitflow_app/lib/core/errors/user_safe_error.dart @@ -0,0 +1,16 @@ +import '../logging/app_log.dart'; + +/// Records the exception type without exposing exception text or user content, then +/// returns a stable message suitable for display in the UI. +String userSafeFailure( + Object error, { + required String event, + required String fallback, +}) { + AppLog.write( + LogLevel.error, + event, + fields: {'error_type': error.runtimeType.toString()}, + ); + return fallback; +} From e3ce019f20520e6e45d3402d0f89372a5c6a81ba Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:41:24 +0530 Subject: [PATCH 093/241] feat: localize safe failure messages --- apps/unitflow_app/lib/l10n/app_en.arb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/unitflow_app/lib/l10n/app_en.arb b/apps/unitflow_app/lib/l10n/app_en.arb index e5c5113f..4c925368 100644 --- a/apps/unitflow_app/lib/l10n/app_en.arb +++ b/apps/unitflow_app/lib/l10n/app_en.arb @@ -58,6 +58,7 @@ "aliasesHint": "comma, separated, aliases", "description": "Description", "createUnit": "Create unit", + "customUnitCreateFailed": "The custom unit could not be created. Check its ID, scale, offset, and other fields.", "pinnedPairs": "Pinned pairs", "noUnitsMatch": "No units match this search.", "addFavorite": "Add to favorites", @@ -98,11 +99,13 @@ "openReleases": "View releases", "openReleasesSubtitle": "Open the official UnitFlow GitHub Releases page in your browser.", "updatesNetworkNote": "Static conversions remain fully offline. Checking release information is always user-initiated.", + "releaseOpenFailed": "UnitFlow releases could not be opened.", "privacyLocalData": "Privacy and local data", "privacyLocalDataSubtitle": "Static conversions require no account. Your preferences, favorites, history, pinned pairs, and custom units are stored locally by default.", "saveBackupFile": "Save backup file", "backupFileSaved": "Backup file saved.", "backupFileUnavailable": "No save location was selected. You can still copy backup JSON.", + "backupExportFailed": "The backup could not be exported. Your existing local data was not changed.", "importBackupFile": "Import backup file", "copyBackupJson": "Copy backup JSON", "backupCopied": "Backup JSON copied to the clipboard.", @@ -110,6 +113,7 @@ "clipboardNoBackup": "The clipboard does not contain backup JSON.", "backupImported": "UnitFlow backup imported.", "importRejected": "Import rejected", + "backupImportRejected": "The backup was rejected because it is invalid, unsupported, or unsafe to import.", "clearLocalData": "Clear local data", "clearLocalDataTitle": "Clear local UnitFlow data?", "clearLocalDataBody": "This removes preferences, favorites, recents, pinned pairs, and custom units from this device. Export a backup first if you want to restore them later.", From f9a21cae9db5af97f168700ef7565b96bad0684c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:42:11 +0530 Subject: [PATCH 094/241] fix: hide internal errors from settings UI --- .../presentation/settings_screen.dart | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart index 86b937a9..577cad9a 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart @@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../app/app_controller.dart'; import '../../../app/theme/app_theme.dart'; +import '../../../core/errors/user_safe_error.dart'; import '../../../core/format/decimal_format.dart'; import '../../../core/io/backup_file_service.dart'; import '../../../core/math/exact_decimal.dart'; @@ -247,11 +248,16 @@ final class SettingsScreen extends StatelessWidget { ), ); } on Object catch (error) { + final message = userSafeFailure( + error, + event: 'backup_export_failed', + fallback: strings.backupExportFailed, + ); if (!context.mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Backup export failed: $error')), + SnackBar(content: Text(message)), ); } } @@ -271,11 +277,16 @@ final class SettingsScreen extends StatelessWidget { SnackBar(content: Text(strings.backupImported)), ); } on Object catch (error) { + final message = userSafeFailure( + error, + event: 'backup_file_import_rejected', + fallback: strings.backupImportRejected, + ); if (!context.mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${strings.importRejected}: $error')), + SnackBar(content: Text(message)), ); } } @@ -308,11 +319,16 @@ final class SettingsScreen extends StatelessWidget { try { await appController.importState(content); } on Object catch (error) { + final message = userSafeFailure( + error, + event: 'backup_clipboard_import_rejected', + fallback: strings.backupImportRejected, + ); if (!context.mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${strings.importRejected}: $error')), + SnackBar(content: Text(message)), ); return; } @@ -331,8 +347,9 @@ final class SettingsScreen extends StatelessWidget { if (!context.mounted) { return; } + final strings = AppLocalizations.of(context); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not open UnitFlow releases.')), + SnackBar(content: Text(strings.releaseOpenFailed)), ); } From 25f02c00060c5c870c28820e2fef970583b2bbda Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:42:57 +0530 Subject: [PATCH 095/241] fix: keep custom unit failures user safe --- .../library/presentation/library_screen.dart | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/apps/unitflow_app/lib/features/library/presentation/library_screen.dart b/apps/unitflow_app/lib/features/library/presentation/library_screen.dart index 5ee99b8a..97c7626f 100644 --- a/apps/unitflow_app/lib/features/library/presentation/library_screen.dart +++ b/apps/unitflow_app/lib/features/library/presentation/library_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../../../app/app_controller.dart'; import '../../../app/theme/app_theme.dart'; +import '../../../core/errors/user_safe_error.dart'; +import '../../../l10n/app_localizations.dart'; import '../../converter/domain/unit_models.dart'; import 'custom_unit_dialog.dart'; @@ -40,8 +42,12 @@ final class _LibraryScreenState extends State { limit: 200, ); results.sort((left, right) { - final leftFavorite = widget.appController.state.favoriteUnitIds.contains(left.id); - final rightFavorite = widget.appController.state.favoriteUnitIds.contains(right.id); + final leftFavorite = widget.appController.state.favoriteUnitIds.contains( + left.id, + ); + final rightFavorite = widget.appController.state.favoriteUnitIds.contains( + right.id, + ); if (leftFavorite != rightFavorite) { return leftFavorite ? -1 : 1; } @@ -130,7 +136,9 @@ final class _LibraryScreenState extends State { isFavorite: widget.appController.state.favoriteUnitIds.contains( results[index].id, ), - onFavorite: () => widget.appController.toggleFavorite(results[index].id), + onFavorite: () => widget.appController.toggleFavorite( + results[index].id, + ), onDeleteCustom: results[index].isBuiltIn ? null : () => _deleteCustomUnit(results[index]), @@ -155,11 +163,17 @@ final class _LibraryScreenState extends State { try { await widget.appController.addCustomUnit(data); } on Object catch (error) { + final strings = AppLocalizations.of(context); + final message = userSafeFailure( + error, + event: 'custom_unit_create_failed', + fallback: strings.customUnitCreateFailed, + ); if (!mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Could not create unit: $error')), + SnackBar(content: Text(message)), ); return; } @@ -182,12 +196,13 @@ final class _LibraryScreenState extends State { if (!mounted) { return; } + final strings = AppLocalizations.of(context); ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${unit.name} removed.'), action: SnackBarAction( - label: 'Undo', + label: strings.undo, onPressed: () => widget.appController.addCustomUnit(data), ), ), @@ -208,7 +223,10 @@ final class _Header extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Unit library', style: Theme.of(context).textTheme.headlineMedium), + Text( + 'Unit library', + style: Theme.of(context).textTheme.headlineMedium, + ), const SizedBox(height: AppSpacing.xxs), Text( 'Search built-in units, favorites, and your own validated custom units.', @@ -245,7 +263,10 @@ final class _PinnedPairs extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Pinned pairs', style: Theme.of(context).textTheme.titleMedium), + Text( + 'Pinned pairs', + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: AppSpacing.xs), Wrap( spacing: AppSpacing.xs, @@ -324,9 +345,13 @@ final class _UnitTile extends StatelessWidget { horizontal: AppSpacing.md, vertical: AppSpacing.xs, ), - leading: CircleAvatar(child: Text(unit.symbol, textAlign: TextAlign.center)), + leading: CircleAvatar( + child: Text(unit.symbol, textAlign: TextAlign.center), + ), title: Text(unit.name), - subtitle: Text('${unit.category.label} • ${unit.id}${unit.isBuiltIn ? '' : ' • Custom'}'), + subtitle: Text( + '${unit.category.label} • ${unit.id}${unit.isBuiltIn ? '' : ' • Custom'}', + ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -363,7 +388,10 @@ final class _EmptyLibrary extends StatelessWidget { color: Theme.of(context).colorScheme.onSurfaceVariant, ), const SizedBox(height: AppSpacing.md), - Text('No units match this search.', style: Theme.of(context).textTheme.titleMedium), + Text( + 'No units match this search.', + style: Theme.of(context).textTheme.titleMedium, + ), ], ), ), From de242004eeb7724f233999614cbf4d2fc13094c6 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:43:20 +0530 Subject: [PATCH 096/241] test: ensure UI errors never echo exception details --- .../test/core/user_safe_error_test.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/unitflow_app/test/core/user_safe_error_test.dart diff --git a/apps/unitflow_app/test/core/user_safe_error_test.dart b/apps/unitflow_app/test/core/user_safe_error_test.dart new file mode 100644 index 00000000..3f9ee261 --- /dev/null +++ b/apps/unitflow_app/test/core/user_safe_error_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/core/errors/user_safe_error.dart'; + +void main() { + test('userSafeFailure returns only the caller supplied fallback', () { + const fallback = 'The operation could not be completed.'; + final message = userSafeFailure( + StateError('secret-internal-detail'), + event: 'test_failure', + fallback: fallback, + ); + + expect(message, fallback); + expect(message, isNot(contains('secret-internal-detail'))); + }); +} From ec7e961d3297c829582a876a6a28f35436598b4f Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:44:08 +0530 Subject: [PATCH 097/241] refactor: externalize unit library interface strings --- .../library/presentation/library_screen.dart | 223 +++++++++--------- 1 file changed, 116 insertions(+), 107 deletions(-) diff --git a/apps/unitflow_app/lib/features/library/presentation/library_screen.dart b/apps/unitflow_app/lib/features/library/presentation/library_screen.dart index 97c7626f..6697854f 100644 --- a/apps/unitflow_app/lib/features/library/presentation/library_screen.dart +++ b/apps/unitflow_app/lib/features/library/presentation/library_screen.dart @@ -36,6 +36,7 @@ final class _LibraryScreenState extends State { Widget build(BuildContext context) => AnimatedBuilder( animation: widget.appController, builder: (context, _) { + final strings = AppLocalizations.of(context); final results = widget.appController.engine.catalog.search( _query, category: _category, @@ -80,13 +81,13 @@ final class _LibraryScreenState extends State { TextField( controller: _searchController, decoration: InputDecoration( - labelText: 'Search units', - hintText: 'Name, symbol, or alias', + labelText: strings.searchUnits, + hintText: strings.searchUnitsHint, prefixIcon: const Icon(Icons.search), suffixIcon: _query.isEmpty ? null : IconButton( - tooltip: 'Clear search', + tooltip: strings.clearSearch, onPressed: () { _searchController.clear(); setState(() => _query = ''); @@ -103,7 +104,7 @@ final class _LibraryScreenState extends State { ), const SizedBox(height: AppSpacing.md), Text( - '${results.length} ${results.length == 1 ? 'unit' : 'units'}', + '${results.length} • ${strings.unitLibrary}', style: Theme.of(context).textTheme.labelLarge, ), ], @@ -175,14 +176,7 @@ final class _LibraryScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); - return; - } - if (!mounted) { - return; } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${data.name} added.')), - ); } Future _deleteCustomUnit(UnitDefinition unit) async { @@ -200,7 +194,7 @@ final class _LibraryScreenState extends State { ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${unit.name} removed.'), + content: Text(strings.removeCustomUnit), action: SnackBarAction( label: strings.undo, onPressed: () => widget.appController.addCustomUnit(data), @@ -216,33 +210,36 @@ final class _Header extends StatelessWidget { final VoidCallback onAddCustom; @override - Widget build(BuildContext context) => Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Unit library', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: AppSpacing.xxs), - Text( - 'Search built-in units, favorites, and your own validated custom units.', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + strings.unitLibrary, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: AppSpacing.xxs), + Text( + strings.unitLibrarySubtitle, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), ), - ), - const SizedBox(width: AppSpacing.md), - FilledButton.icon( - onPressed: onAddCustom, - icon: const Icon(Icons.add), - label: const Text('Custom unit'), - ), - ], - ); + const SizedBox(width: AppSpacing.md), + FilledButton.icon( + onPressed: onAddCustom, + icon: const Icon(Icons.add), + label: Text(strings.customUnit), + ), + ], + ); + } } final class _PinnedPairs extends StatelessWidget { @@ -257,6 +254,7 @@ final class _PinnedPairs extends StatelessWidget { if (pairs.isEmpty) { return const SizedBox.shrink(); } + final strings = AppLocalizations.of(context); return Card( child: Padding( padding: const EdgeInsets.all(AppSpacing.md), @@ -264,7 +262,7 @@ final class _PinnedPairs extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Pinned pairs', + strings.pinnedPairs, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: AppSpacing.xs), @@ -298,31 +296,34 @@ final class _CategoryFilter extends StatelessWidget { final ValueChanged onChanged; @override - Widget build(BuildContext context) => SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: AppSpacing.xs), - child: FilterChip( - label: const Text('All'), - selected: value == null, - onSelected: (_) => onChanged(null), - ), - ), - ...UnitCategory.values.map( - (category) => Padding( + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Padding( padding: const EdgeInsets.only(right: AppSpacing.xs), child: FilterChip( - label: Text(category.label), - selected: value == category, - onSelected: (_) => onChanged(category), + label: Text(strings.all), + selected: value == null, + onSelected: (_) => onChanged(null), ), ), - ), - ], - ), - ); + ...UnitCategory.values.map( + (category) => Padding( + padding: const EdgeInsets.only(right: AppSpacing.xs), + child: FilterChip( + label: Text(category.label), + selected: value == category, + onSelected: (_) => onChanged(category), + ), + ), + ), + ], + ), + ); + } } final class _UnitTile extends StatelessWidget { @@ -339,63 +340,71 @@ final class _UnitTile extends StatelessWidget { final VoidCallback? onDeleteCustom; @override - Widget build(BuildContext context) => Card( - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.xs, - ), - leading: CircleAvatar( - child: Text(unit.symbol, textAlign: TextAlign.center), - ), - title: Text(unit.name), - subtitle: Text( - '${unit.category.label} • ${unit.id}${unit.isBuiltIn ? '' : ' • Custom'}', - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: isFavorite ? 'Remove from favorites' : 'Add to favorites', - onPressed: onFavorite, - icon: Icon(isFavorite ? Icons.star : Icons.star_border), - ), - if (onDeleteCustom != null) + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context); + return Card( + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + leading: CircleAvatar( + child: Text(unit.symbol, textAlign: TextAlign.center), + ), + title: Text(unit.name), + subtitle: Text( + '${unit.category.label} • ${unit.id}${unit.isBuiltIn ? '' : ' • ${strings.customUnit}'}', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ IconButton( - tooltip: 'Remove custom unit', - onPressed: onDeleteCustom, - icon: const Icon(Icons.delete_outline), + tooltip: isFavorite + ? strings.removeFavorite + : strings.addFavorite, + onPressed: onFavorite, + icon: Icon(isFavorite ? Icons.star : Icons.star_border), ), - ], + if (onDeleteCustom != null) + IconButton( + tooltip: strings.removeCustomUnit, + onPressed: onDeleteCustom, + icon: const Icon(Icons.delete_outline), + ), + ], + ), ), - ), - ); + ); + } } final class _EmptyLibrary extends StatelessWidget { const _EmptyLibrary(); @override - Widget build(BuildContext context) => Center( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.xxl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.search_off, - size: 48, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: AppSpacing.md), - Text( - 'No units match this search.', - style: Theme.of(context).textTheme.titleMedium, - ), - ], + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search_off, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: AppSpacing.md), + Text( + strings.noUnitsMatch, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), ), - ), - ); + ); + } } extension on Iterable { From 1b388a8ea970e3257f6353bb5f17547f13cd5263 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:44:40 +0530 Subject: [PATCH 098/241] refactor: externalize custom unit editor strings --- .../presentation/custom_unit_dialog.dart | 253 +++++++++--------- 1 file changed, 132 insertions(+), 121 deletions(-) diff --git a/apps/unitflow_app/lib/features/library/presentation/custom_unit_dialog.dart b/apps/unitflow_app/lib/features/library/presentation/custom_unit_dialog.dart index ef858858..87ad0ffb 100644 --- a/apps/unitflow_app/lib/features/library/presentation/custom_unit_dialog.dart +++ b/apps/unitflow_app/lib/features/library/presentation/custom_unit_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../app/theme/app_theme.dart'; import '../../../core/persistence/user_state.dart'; +import '../../../l10n/app_localizations.dart'; import '../../converter/domain/unit_models.dart'; Future showCustomUnitDialog( @@ -46,137 +47,145 @@ final class _CustomUnitDialogState extends State<_CustomUnitDialog> { } @override - Widget build(BuildContext context) => AlertDialog( - title: const Text('Create custom unit'), - content: SizedBox( - width: 560, - child: Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Define a safe affine relationship: base = value × scale + offset.', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: AppSpacing.md), - DropdownButtonFormField( - initialValue: _category, - decoration: const InputDecoration(labelText: 'Category'), - items: UnitCategory.values - .map( - (category) => DropdownMenuItem( - value: category, - child: Text(category.label), - ), - ) - .toList(growable: false), - onChanged: (category) { - if (category != null) { - setState(() => _category = category); - } - }, - ), - const SizedBox(height: AppSpacing.sm), - TextFormField( - controller: _idController, - decoration: const InputDecoration( - labelText: 'Stable ID', - hintText: 'my_custom_unit', + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context); + return AlertDialog( + title: Text(strings.createCustomUnit), + content: SizedBox( + width: 560, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + strings.customUnitFormulaHelp, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: AppSpacing.md), + DropdownButtonFormField( + initialValue: _category, + decoration: InputDecoration(labelText: strings.category), + items: UnitCategory.values + .map( + (category) => DropdownMenuItem( + value: category, + child: Text(category.label), + ), + ) + .toList(growable: false), + onChanged: (category) { + if (category != null) { + setState(() => _category = category); + } + }, + ), + const SizedBox(height: AppSpacing.sm), + TextFormField( + controller: _idController, + decoration: InputDecoration( + labelText: strings.stableId, + hintText: strings.stableIdHint, + ), + textInputAction: TextInputAction.next, + validator: (value) { + final id = value?.trim() ?? ''; + return RegExp(r'^[a-z0-9_-]{1,64}$').hasMatch(id) + ? null + : strings.stableIdError; + }, ), - textInputAction: TextInputAction.next, - validator: (value) { - final id = value?.trim() ?? ''; - return RegExp(r'^[a-z0-9_-]{1,64}$').hasMatch(id) - ? null - : 'Use 1–64 lowercase letters, digits, _ or -.'; - }, - ), - const SizedBox(height: AppSpacing.sm), - TextFormField( - controller: _nameController, - decoration: const InputDecoration(labelText: 'Name'), - textInputAction: TextInputAction.next, - maxLength: 128, - validator: (value) => (value?.trim().isEmpty ?? true) ? 'Enter a name.' : null, - ), - const SizedBox(height: AppSpacing.sm), - TextFormField( - controller: _symbolController, - decoration: const InputDecoration(labelText: 'Symbol'), - textInputAction: TextInputAction.next, - maxLength: 32, - validator: (value) => (value?.trim().isEmpty ?? true) ? 'Enter a symbol.' : null, - ), - const SizedBox(height: AppSpacing.sm), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextFormField( - controller: _scaleController, - decoration: const InputDecoration(labelText: 'Scale'), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, + const SizedBox(height: AppSpacing.sm), + TextFormField( + controller: _nameController, + decoration: InputDecoration(labelText: strings.name), + textInputAction: TextInputAction.next, + maxLength: 128, + validator: (value) => (value?.trim().isEmpty ?? true) + ? strings.nameRequired + : null, + ), + const SizedBox(height: AppSpacing.sm), + TextFormField( + controller: _symbolController, + decoration: InputDecoration(labelText: strings.symbol), + textInputAction: TextInputAction.next, + maxLength: 32, + validator: (value) => (value?.trim().isEmpty ?? true) + ? strings.symbolRequired + : null, + ), + const SizedBox(height: AppSpacing.sm), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + controller: _scaleController, + decoration: InputDecoration(labelText: strings.scale), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + validator: (value) => (value?.trim().isEmpty ?? true) + ? strings.required + : null, ), - validator: (value) => (value?.trim().isEmpty ?? true) ? 'Required.' : null, ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: TextFormField( - controller: _offsetController, - decoration: const InputDecoration(labelText: 'Offset'), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, + const SizedBox(width: AppSpacing.sm), + Expanded( + child: TextFormField( + controller: _offsetController, + decoration: InputDecoration(labelText: strings.offset), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + validator: (value) => (value?.trim().isEmpty ?? true) + ? strings.required + : null, ), - validator: (value) => (value?.trim().isEmpty ?? true) ? 'Required.' : null, ), + ], + ), + if (_formulaError != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + _formulaError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), ), ], - ), - if (_formulaError != null) ...[ - const SizedBox(height: AppSpacing.xs), - Text( - _formulaError!, - style: TextStyle(color: Theme.of(context).colorScheme.error), + const SizedBox(height: AppSpacing.sm), + TextFormField( + controller: _aliasesController, + decoration: InputDecoration( + labelText: strings.aliases, + hintText: strings.aliasesHint, + ), ), - ], - const SizedBox(height: AppSpacing.sm), - TextFormField( - controller: _aliasesController, - decoration: const InputDecoration( - labelText: 'Aliases', - hintText: 'comma, separated, aliases', + const SizedBox(height: AppSpacing.sm), + TextFormField( + controller: _descriptionController, + decoration: InputDecoration(labelText: strings.description), + maxLength: 512, + maxLines: 3, ), - ), - const SizedBox(height: AppSpacing.sm), - TextFormField( - controller: _descriptionController, - decoration: const InputDecoration(labelText: 'Description'), - maxLength: 512, - maxLines: 3, - ), - ], + ], + ), ), ), ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: _submit, - child: const Text('Create unit'), - ), - ], - ); + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(strings.cancel), + ), + FilledButton(onPressed: _submit, child: Text(strings.createUnit)), + ], + ); + } void _submit() { setState(() => _formulaError = null); @@ -201,8 +210,10 @@ final class _CustomUnitDialogState extends State<_CustomUnitDialog> { ); try { data.toUnitDefinition(); - } on FormatException catch (error) { - setState(() => _formulaError = error.message); + } on FormatException { + setState( + () => _formulaError = AppLocalizations.of(context).customUnitCreateFailed, + ); return; } Navigator.of(context).pop(data); From 0803f42496644ce24a4f3555cf700f86744dd73f Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:45:14 +0530 Subject: [PATCH 099/241] feat: include unit descriptions in catalog search --- apps/unitflow_app/lib/features/converter/domain/unit_models.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/unitflow_app/lib/features/converter/domain/unit_models.dart b/apps/unitflow_app/lib/features/converter/domain/unit_models.dart index 55be30b1..669dcfdc 100644 --- a/apps/unitflow_app/lib/features/converter/domain/unit_models.dart +++ b/apps/unitflow_app/lib/features/converter/domain/unit_models.dart @@ -126,6 +126,7 @@ final class UnitDefinition { return id.toLowerCase().contains(normalized) || name.toLowerCase().contains(normalized) || symbol.toLowerCase().contains(normalized) || + description.toLowerCase().contains(normalized) || aliases.any((alias) => alias.toLowerCase().contains(normalized)); } } From e4f21177879b295907b489084219ec51b6d4b016 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:45:31 +0530 Subject: [PATCH 100/241] test: cover unit description search --- .../features/unit_catalog_search_test.dart | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 apps/unitflow_app/test/features/unit_catalog_search_test.dart diff --git a/apps/unitflow_app/test/features/unit_catalog_search_test.dart b/apps/unitflow_app/test/features/unit_catalog_search_test.dart new file mode 100644 index 00000000..5014ed4f --- /dev/null +++ b/apps/unitflow_app/test/features/unit_catalog_search_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/core/math/exact_decimal.dart'; +import 'package:unitflow/features/converter/data/unit_catalog.dart'; +import 'package:unitflow/features/converter/domain/unit_models.dart'; + +void main() { + test('catalog search includes descriptive custom unit text', () { + final catalog = UnitCatalog([ + UnitDefinition( + id: 'demo_length', + category: UnitCategory.length, + name: 'Demo Length', + symbol: 'dl', + scale: ExactDecimal.parse('1'), + description: 'Used for classroom calibration examples.', + isBuiltIn: false, + ), + ]); + + final results = catalog.search('calibration'); + + expect(results.map((unit) => unit.id), contains('demo_length')); + }); +} From 0dedb43ac46f737e4b44da52b7ffe66b5edfba41 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:46:02 +0530 Subject: [PATCH 101/241] security: add repository secret pattern scanner --- tool/check_secrets.py | 95 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tool/check_secrets.py diff --git a/tool/check_secrets.py b/tool/check_secrets.py new file mode 100644 index 00000000..7f369c86 --- /dev/null +++ b/tool/check_secrets.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Fail CI when tracked source-like files contain common credential signatures. + +This intentionally complements, rather than replaces, GitHub secret scanning. It uses +only the Python standard library so contributors can run it locally without setup. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MAX_BYTES = 2 * 1024 * 1024 + +PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ("private key", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")), + ("AWS access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), + ("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b")), + ("Google API key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")), + ("Stripe live secret", re.compile(r"\bsk_live_[0-9A-Za-z]{20,}\b")), + ("generic bearer token", re.compile(r"(?i)\bauthorization\s*[:=]\s*bearer\s+[A-Za-z0-9._~-]{20,}")), +) + +SKIP_PREFIXES = ( + ".git/", + "build/", + "target/", + ".dart_tool/", + "apps/unitflow_app/build/", + "apps/unitflow_app/.dart_tool/", +) + + +def tracked_files() -> list[Path]: + try: + output = subprocess.check_output( + ["git", "ls-files", "-z"], cwd=ROOT, stderr=subprocess.STDOUT + ) + except (OSError, subprocess.CalledProcessError) as error: + print(f"secret scan could not enumerate tracked files: {error}", file=sys.stderr) + raise SystemExit(2) from error + + paths: list[Path] = [] + for raw in output.split(b"\0"): + if not raw: + continue + relative = raw.decode("utf-8", errors="surrogateescape").replace("\\", "/") + if relative.startswith(SKIP_PREFIXES): + continue + paths.append(ROOT / relative) + return paths + + +def readable_text(path: Path) -> str | None: + try: + if path.stat().st_size > MAX_BYTES: + return None + data = path.read_bytes() + except OSError: + return None + if b"\0" in data: + return None + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return None + + +def main() -> int: + findings: list[str] = [] + for path in tracked_files(): + text = readable_text(path) + if text is None: + continue + relative = path.relative_to(ROOT).as_posix() + for label, pattern in PATTERNS: + for match in pattern.finditer(text): + line = text.count("\n", 0, match.start()) + 1 + findings.append(f"{relative}:{line}: possible {label}") + + if findings: + print("Potential secrets detected. Do not commit credentials or private keys:", file=sys.stderr) + for finding in findings: + print(f" - {finding}", file=sys.stderr) + return 1 + + print("Repository secret-pattern scan passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 7624f3fb2bb6c4dc605ee7d9d8b5be9e36c73852 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:46:19 +0530 Subject: [PATCH 102/241] ci: scan tracked files for common secrets --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bd49b11..70b35b61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,16 @@ concurrency: cancel-in-progress: true jobs: + repository-safety: + name: Repository safety + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Scan tracked files for credential signatures + run: python3 tool/check_secrets.py + rust: name: Rust quality runs-on: ubuntu-latest From 7fb695b3c21bca8316c0383ce5acbfba3ef18574 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:46:36 +0530 Subject: [PATCH 103/241] docs: add internal Markdown link verifier --- tool/check_docs_links.py | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tool/check_docs_links.py diff --git a/tool/check_docs_links.py b/tool/check_docs_links.py new file mode 100644 index 00000000..d22f740a --- /dev/null +++ b/tool/check_docs_links.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Verify that relative links in tracked Markdown files resolve inside the repository.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path +from urllib.parse import unquote + +ROOT = Path(__file__).resolve().parents[1] +LINK = re.compile(r"(? list[Path]: + try: + output = subprocess.check_output( + ["git", "ls-files", "-z", "*.md"], cwd=ROOT, stderr=subprocess.STDOUT + ) + except (OSError, subprocess.CalledProcessError) as error: + print(f"documentation link check could not enumerate files: {error}", file=sys.stderr) + raise SystemExit(2) from error + return [ + ROOT / raw.decode("utf-8", errors="surrogateescape") + for raw in output.split(b"\0") + if raw + ] + + +def normalize_target(raw: str) -> str | None: + target = raw.strip() + if not target or target.startswith(EXTERNAL_PREFIXES): + return None + if target.startswith("<") and target.endswith(">"): + target = target[1:-1] + if " " in target and not target.startswith("./") and not target.startswith("../"): + # Markdown permits an optional title after a URL. Keep the URL token only. + target = target.split(" ", 1)[0] + target = target.split("#", 1)[0].split("?", 1)[0] + return unquote(target) or None + + +def main() -> int: + failures: list[str] = [] + for document in markdown_files(): + try: + text = document.read_text(encoding="utf-8") + except UnicodeDecodeError: + failures.append(f"{document.relative_to(ROOT)}: not valid UTF-8") + continue + for match in LINK.finditer(text): + target = normalize_target(match.group(1)) + if target is None: + continue + candidate = (document.parent / target).resolve() + try: + candidate.relative_to(ROOT) + except ValueError: + failures.append( + f"{document.relative_to(ROOT)}: link escapes repository: {target}" + ) + continue + if not candidate.exists(): + line = text.count("\n", 0, match.start()) + 1 + failures.append( + f"{document.relative_to(ROOT)}:{line}: missing target: {target}" + ) + + if failures: + print("Broken internal documentation links:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + + print("Internal Markdown links resolve successfully.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a8a4699fb7679da18a097c17b505b8c23db35a4b Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:46:51 +0530 Subject: [PATCH 104/241] ci: verify internal documentation links --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70b35b61..1e5ab2d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: - name: Scan tracked files for credential signatures run: python3 tool/check_secrets.py + - name: Verify internal Markdown links + run: python3 tool/check_docs_links.py + rust: name: Rust quality runs-on: ubuntu-latest From 032c3439a494f740cc6bc2460f2f52d54789ee76 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:47:20 +0530 Subject: [PATCH 105/241] feat: add reusable UnitFlow brand mark widget --- .../lib/app/branding/unitflow_mark.dart | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 apps/unitflow_app/lib/app/branding/unitflow_mark.dart diff --git a/apps/unitflow_app/lib/app/branding/unitflow_mark.dart b/apps/unitflow_app/lib/app/branding/unitflow_mark.dart new file mode 100644 index 00000000..77862059 --- /dev/null +++ b/apps/unitflow_app/lib/app/branding/unitflow_mark.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// Scalable UnitFlow brand mark built from framework primitives so it remains +/// crisp offline and does not require a runtime image or icon dependency. +final class UnitFlowMark extends StatelessWidget { + const UnitFlowMark({ + super.key, + this.size = 40, + this.semanticLabel, + this.showBackground = true, + }); + + final double size; + final String? semanticLabel; + final bool showBackground; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final mark = SizedBox.square( + dimension: size, + child: DecoratedBox( + decoration: BoxDecoration( + color: showBackground ? scheme.primaryContainer : Colors.transparent, + borderRadius: BorderRadius.circular(size * 0.24), + ), + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + Icons.swap_horiz_rounded, + size: size * 0.64, + color: showBackground ? scheme.onPrimaryContainer : scheme.primary, + ), + Positioned( + right: size * 0.12, + top: size * 0.10, + child: Container( + width: size * 0.17, + height: size * 0.17, + decoration: BoxDecoration( + color: scheme.tertiary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ), + ); + + if (semanticLabel == null) { + return ExcludeSemantics(child: mark); + } + return Semantics(image: true, label: semanticLabel, child: mark); + } +} From 6f02b2a956dd2617c73d24e41b18a14aabd89c28 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:47:46 +0530 Subject: [PATCH 106/241] feat: apply UnitFlow branding and search shortcut --- apps/unitflow_app/lib/app/app_shell.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/unitflow_app/lib/app/app_shell.dart b/apps/unitflow_app/lib/app/app_shell.dart index ee6396ec..80a851c4 100644 --- a/apps/unitflow_app/lib/app/app_shell.dart +++ b/apps/unitflow_app/lib/app/app_shell.dart @@ -10,6 +10,7 @@ import '../features/settings/presentation/about_screen.dart'; import '../features/settings/presentation/settings_screen.dart'; import '../l10n/app_localizations.dart'; import 'app_controller.dart'; +import 'branding/unitflow_mark.dart'; import 'theme/app_theme.dart'; final class AppShell extends StatefulWidget { @@ -48,6 +49,8 @@ final class _AppShellState extends State { _select(2), const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _select(3), + const SingleActivator(LogicalKeyboardKey.keyK, control: true): () => + _select(1), const SingleActivator(LogicalKeyboardKey.digit1, meta: true): () => _select(0), const SingleActivator(LogicalKeyboardKey.digit2, meta: true): () => @@ -56,6 +59,8 @@ final class _AppShellState extends State { _select(2), const SingleActivator(LogicalKeyboardKey.comma, meta: true): () => _select(3), + const SingleActivator(LogicalKeyboardKey.keyK, meta: true): () => + _select(1), }, child: Focus( autofocus: true, @@ -90,10 +95,7 @@ final class _AppShellState extends State { title: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.swap_calls, - color: Theme.of(context).colorScheme.primary, - ), + UnitFlowMark(size: 32, semanticLabel: strings.appName), const SizedBox(width: AppSpacing.xs), Text(strings.appName), ], From 4ec1c084ce0586d7e073876e0eecf14d3f01e9e6 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:48:04 +0530 Subject: [PATCH 107/241] feat: use UnitFlow mark on startup screen --- apps/unitflow_app/lib/app/unitflow_app.dart | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/unitflow_app/lib/app/unitflow_app.dart b/apps/unitflow_app/lib/app/unitflow_app.dart index 10df873c..d1b814e1 100644 --- a/apps/unitflow_app/lib/app/unitflow_app.dart +++ b/apps/unitflow_app/lib/app/unitflow_app.dart @@ -5,6 +5,7 @@ import '../features/onboarding/presentation/onboarding_screen.dart'; import '../l10n/app_localizations.dart'; import 'app_controller.dart'; import 'app_shell.dart'; +import 'branding/unitflow_mark.dart'; import 'theme/app_theme.dart'; final class UnitFlowApp extends StatefulWidget { @@ -71,11 +72,7 @@ final class _StartupScreen extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.swap_calls, - size: 64, - color: Theme.of(context).colorScheme.primary, - ), + UnitFlowMark(size: 76, semanticLabel: strings.appName), const SizedBox(height: AppSpacing.lg), Text( strings.appName, From ee22fb6caf41df5a85c0ee4c37b04173fba4f026 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:48:30 +0530 Subject: [PATCH 108/241] feat: use UnitFlow mark in About identity --- .../settings/presentation/about_screen.dart | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart b/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart index 62bff3c4..700c7e5f 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../../app/branding/unitflow_mark.dart'; import '../../../app/theme/app_theme.dart'; import '../../../l10n/app_localizations.dart'; @@ -103,19 +104,7 @@ final class _IdentityCard extends StatelessWidget { padding: const EdgeInsets.all(AppSpacing.xl), child: Column( children: [ - Container( - width: 88, - height: 88, - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(AppRadii.large), - ), - child: Icon( - Icons.swap_calls, - size: 48, - color: theme.colorScheme.onPrimaryContainer, - ), - ), + UnitFlowMark(size: 88, semanticLabel: strings.appName), const SizedBox(height: AppSpacing.md), Text(strings.appName, style: theme.textTheme.headlineMedium), const SizedBox(height: AppSpacing.xs), From 5a4cdb0cde30512edff5fd6bdab0f3958fca02c1 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:48:46 +0530 Subject: [PATCH 109/241] assets: add editable UnitFlow vector mark --- assets/branding/unitflow-mark.svg | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 assets/branding/unitflow-mark.svg diff --git a/assets/branding/unitflow-mark.svg b/assets/branding/unitflow-mark.svg new file mode 100644 index 00000000..109d103e --- /dev/null +++ b/assets/branding/unitflow-mark.svg @@ -0,0 +1,14 @@ + + UnitFlow mark + Rounded square containing two opposing horizontal arrows and a circular accent. + + + + + + + + + + + From 6fe9b90802538fc592033db58c1ca3c6a151d5ec Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:49:05 +0530 Subject: [PATCH 110/241] docs: document UnitFlow branding and launcher assets --- docs/branding.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/branding.md diff --git a/docs/branding.md b/docs/branding.md new file mode 100644 index 00000000..53320827 --- /dev/null +++ b/docs/branding.md @@ -0,0 +1,39 @@ +# Branding + +UnitFlow uses a compact conversion mark: opposing horizontal arrows inside a rounded container with a small circular accent. The mark communicates bidirectional conversion without relying on letters, so it remains recognizable at small sizes and across locales. + +## Source assets + +- `assets/branding/unitflow-mark.svg` — editable vector source for repository, website, launcher-icon, and promotional exports. +- `apps/unitflow_app/lib/app/branding/unitflow_mark.dart` — runtime Flutter rendering built from framework primitives for crisp offline UI use. + +The SVG is project-authored source artwork and can be redistributed with UnitFlow under the repository license. + +## UI usage + +The runtime mark is used in the application shell, startup treatment, and About identity. It should be given an accessible image label when it conveys product identity and excluded from duplicate semantics when adjacent text already announces the same information. + +## Launcher and splash exports + +Before a release candidate, export the square SVG to platform-required raster sizes using a deterministic vector tool. Do not upscale from a small PNG. Keep the original SVG as the source of truth. + +Suggested workflow with Inkscape: + +```bash +mkdir -p build/branding +inkscape assets/branding/unitflow-mark.svg --export-type=png --export-width=1024 --export-filename=build/branding/unitflow-mark-1024.png +``` + +Platform shell assets generated by `flutter create` must be replaced with UnitFlow-branded launcher/splash resources before store distribution. Release validation must inspect the actual installed launcher icon and startup treatment on each primary target rather than assuming generated files are correct. + +## Visual rules + +- Preserve the rounded-square silhouette and two-direction conversion motif. +- Keep sufficient contrast between arrows, background, and accent. +- Do not place promotional text inside the launcher icon. +- Do not stretch or crop the mark non-uniformly. +- Avoid animation in the launcher/splash mark; the app respects reduced-motion preferences after startup. + +## Credit + +Product surfaces and documentation retain the required visible credit: **Made by the Sanskar**. From 6af5ea22c4519726da69913c5144d6edbaf61f6a Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:49:59 +0530 Subject: [PATCH 111/241] test: cover conversion and accessibility settings persistence --- .../test/app/app_controller_test.dart | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/unitflow_app/test/app/app_controller_test.dart b/apps/unitflow_app/test/app/app_controller_test.dart index 4102a751..75b8d91f 100644 --- a/apps/unitflow_app/test/app/app_controller_test.dart +++ b/apps/unitflow_app/test/app/app_controller_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:unitflow/app/app_controller.dart'; +import 'package:unitflow/core/math/exact_decimal.dart'; import 'package:unitflow/core/persistence/user_state.dart'; import 'package:unitflow/core/persistence/user_state_repository.dart'; import 'package:unitflow/features/converter/domain/unit_models.dart'; @@ -40,7 +41,10 @@ void main() { await controller.togglePinnedPair(pair); expect(controller.isPairPinned(pair), isTrue); - expect(controller.state.pinnedPairs.single.storageValue, 'length|meter|kilometer'); + expect( + controller.state.pinnedPairs.single.storageValue, + 'length|meter|kilometer', + ); await controller.togglePinnedPair(pair); expect(controller.isPairPinned(pair), isFalse); @@ -66,6 +70,25 @@ void main() { expect(controller.state.recents, hasLength(50)); }); + test('conversion settings persist through the repository', () async { + await controller.setRoundingMode(DecimalRoundingMode.ceiling); + await controller.setDecimalPlaces(4); + await controller.setUseGrouping(false); + + final restored = await repository.load(); + expect(restored.roundingMode, DecimalRoundingMode.ceiling); + expect(restored.decimalPlaces, 4); + expect(restored.useGrouping, isFalse); + }); + + test('reduced motion preference persists through the repository', () async { + await controller.setReduceMotion(true); + + final restored = await repository.load(); + expect(controller.state.reduceMotion, isTrue); + expect(restored.reduceMotion, isTrue); + }); + test('valid custom unit becomes available to conversion engine', () async { const custom = CustomUnitData( id: 'double_meter', @@ -95,10 +118,7 @@ void main() { offset: '0', ); - expect( - () => controller.addCustomUnit(custom), - throwsArgumentError, - ); + expect(() => controller.addCustomUnit(custom), throwsArgumentError); }); test('invalid import preserves current state', () async { From 6cfaf26f9e0f17b969826be33fb6691ea7a7797d Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:50:21 +0530 Subject: [PATCH 112/241] test: cover settings accessibility interaction --- .../test/app/unitflow_app_test.dart | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/unitflow_app/test/app/unitflow_app_test.dart b/apps/unitflow_app/test/app/unitflow_app_test.dart index 4b90d744..7998b54d 100644 --- a/apps/unitflow_app/test/app/unitflow_app_test.dart +++ b/apps/unitflow_app/test/app/unitflow_app_test.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:unitflow/app/app_controller.dart'; +import 'package:unitflow/app/branding/unitflow_mark.dart'; import 'package:unitflow/app/unitflow_app.dart'; import 'package:unitflow/core/persistence/user_state.dart'; import 'package:unitflow/core/persistence/user_state_repository.dart'; void main() { - testWidgets('launches offline into the converter after onboarding', (tester) async { + testWidgets('launches offline into the converter after onboarding', ( + tester, + ) async { final controller = AppController( repository: MemoryUserStateRepository( UserState(onboardingComplete: true), @@ -17,6 +20,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('UnitFlow'), findsOneWidget); + expect(find.byType(UnitFlowMark), findsOneWidget); expect(find.text('Convert units'), findsOneWidget); expect(find.byIcon(Icons.swap_horiz), findsWidgets); }); @@ -37,7 +41,9 @@ void main() { expect(controller.state.onboardingComplete, isTrue); }); - testWidgets('converter exposes semantic labels for primary actions', (tester) async { + testWidgets('converter exposes semantic labels for primary actions', ( + tester, + ) async { final controller = AppController( repository: MemoryUserStateRepository( UserState(onboardingComplete: true), @@ -51,4 +57,27 @@ void main() { expect(find.byTooltip('Copy result'), findsOneWidget); expect(find.byTooltip('Search unit library'), findsOneWidget); }); + + testWidgets('settings can persist the reduced motion preference', ( + tester, + ) async { + final repository = MemoryUserStateRepository( + UserState(onboardingComplete: true), + ); + final controller = AppController(repository: repository); + + await tester.pumpWidget(UnitFlowApp(appController: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Settings').first); + await tester.pumpAndSettle(); + + expect(find.text('Rounding mode'), findsOneWidget); + expect(find.text('Reduce motion'), findsOneWidget); + await tester.tap(find.text('Reduce motion')); + await tester.pumpAndSettle(); + + expect(controller.state.reduceMotion, isTrue); + expect((await repository.load()).reduceMotion, isTrue); + }); } From 0c64533e768d7daaf8c5416528cd8b54bc1dcee2 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:51:04 +0530 Subject: [PATCH 113/241] perf: add deterministic core profiling harness --- crates/unitflow_core/examples/profile.rs | 98 ++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 crates/unitflow_core/examples/profile.rs diff --git a/crates/unitflow_core/examples/profile.rs b/crates/unitflow_core/examples/profile.rs new file mode 100644 index 00000000..f6356da4 --- /dev/null +++ b/crates/unitflow_core/examples/profile.rs @@ -0,0 +1,98 @@ +use std::hint::black_box; +use std::str::FromStr; +use std::time::{Duration, Instant}; + +use rust_decimal::Decimal; +use unitflow_core::{Category, ConversionRequest, Converter, RoundMode, UnitCatalog}; + +const LOOKUP_ITERATIONS: usize = 200_000; +const SEARCH_ITERATIONS: usize = 20_000; +const CONVERSION_ITERATIONS: usize = 20_000; + +fn main() -> Result<(), Box> { + let catalog = UnitCatalog::built_in()?; + let converter = Converter::new(catalog.clone()); + let input = Decimal::from_str("1234567.890123")?; + + let lookup = measure(|| { + for _ in 0..LOOKUP_ITERATIONS { + black_box(catalog.get(black_box("kilometer"))); + } + }); + + let search = measure(|| { + for _ in 0..SEARCH_ITERATIONS { + black_box(catalog.search( + black_box("meter"), + black_box(Some(Category::Length)), + black_box(20), + )); + } + }); + + let targets = catalog + .units_for_category(Category::Length) + .into_iter() + .map(|unit| unit.id.clone()) + .collect::>(); + let batch = measure(|| { + for _ in 0..CONVERSION_ITERATIONS { + black_box( + converter + .batch_convert( + black_box(input), + black_box("meter"), + black_box(&targets), + black_box(Some(12)), + black_box(RoundMode::NearestEven), + ) + .expect("built-in batch conversion must succeed"), + ); + } + }); + + let single = measure(|| { + for _ in 0..CONVERSION_ITERATIONS { + black_box( + converter + .convert(&ConversionRequest { + value: black_box(input), + from_unit_id: "meter".to_owned(), + to_unit_id: "mile".to_owned(), + decimal_places: Some(12), + round_mode: RoundMode::NearestEven, + }) + .expect("built-in conversion must succeed"), + ); + } + }); + + println!("UnitFlow core profiling harness (release builds recommended)"); + report("direct lookup", LOOKUP_ITERATIONS, lookup); + report("catalog search", SEARCH_ITERATIONS, search); + report("single conversion", CONVERSION_ITERATIONS, single); + report("length batch conversion", CONVERSION_ITERATIONS, batch); + println!("catalog_units={}", catalog.len()); + println!("length_batch_targets={}", targets.len()); + + Ok(()) +} + +fn measure(operation: impl FnOnce()) -> Duration { + let started = Instant::now(); + operation(); + started.elapsed() +} + +fn report(label: &str, iterations: usize, elapsed: Duration) { + let total_ns = elapsed.as_nanos(); + let ns_per_iteration = if iterations == 0 { + 0 + } else { + total_ns / iterations as u128 + }; + println!( + "{label}: iterations={iterations} elapsed_ms={} ns_per_iteration={ns_per_iteration}", + elapsed.as_millis() + ); +} From df6cdd7144fd0cb19d53fe32f61d3091bb712a44 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:51:15 +0530 Subject: [PATCH 114/241] perf: add core profiling command --- tool/profile_core.sh | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tool/profile_core.sh diff --git a/tool/profile_core.sh b/tool/profile_core.sh new file mode 100644 index 00000000..863e597a --- /dev/null +++ b/tool/profile_core.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +cargo run -p unitflow_core --example profile --release From 3963379a2292f0417be3a5d0031dacd4983f57ad Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:51:44 +0530 Subject: [PATCH 115/241] docs: document reproducible core profiling workflow --- docs/performance.md | 46 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/docs/performance.md b/docs/performance.md index f8b0ab34..9467bf3e 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -14,7 +14,7 @@ These are engineering targets rather than marketing guarantees: ## Rust hot paths -Likely measurable paths: +Measurable paths include: - catalog lookup by stable ID; - text search across symbols/aliases; @@ -22,23 +22,45 @@ Likely measurable paths: - batch conversion; - notation formatting. -Prefer indexed maps for repeated stable-ID lookup after the catalog grows enough to justify them. Keep correctness and deterministic decimal behavior before micro-optimization. +The catalog already keeps a stable-ID hash index for repeated direct lookup. Keep correctness and deterministic decimal behavior before micro-optimization. + +## Reproducible profiling harness + +A dependency-free release-mode harness is available at `crates/unitflow_core/examples/profile.rs`. Run: + +```bash +bash tool/profile_core.sh +``` + +or directly: + +```bash +cargo run -p unitflow_core --example profile --release +``` + +The harness measures repeated: + +- stable-ID lookup; +- category-scoped search; +- single conversion; +- length-category batch conversion. + +It prints iteration counts, elapsed milliseconds, approximate nanoseconds per iteration, catalog size, and batch target count. It intentionally does **not** enforce universal timing thresholds because GitHub-hosted runners and developer machines vary. Performance conclusions must record CPU/OS/toolchain context and compare equivalent workloads on equivalent hardware. + +The harness uses `std::hint::black_box` to reduce trivial optimizer elimination. It is a profiling smoke harness, not a substitute for a statistically rigorous microbenchmark framework if future regressions require one. ## Flutter hot paths -- Avoid rebuilding the entire app on each numeric keystroke. +- Avoid rebuilding unrelated application state on each numeric keystroke. - Debounce only work that is actually expensive; do not add artificial delays. - Virtualize large search/history lists. - Keep persistence off critical frame work where platform APIs are asynchronous. - Cache immutable catalog metadata at an appropriate service boundary. - -## Benchmarks - -Rust benchmark targets should be added once the core is stable, with representative catalog and batch sizes. Record machine/toolchain context alongside performance conclusions. +- Keep core static conversion free of network startup dependencies. ## Memory -Avoid duplicating large catalogs across layers unnecessarily. Imported files must have bounded parsing/allocation behavior before being accepted. +Avoid duplicating large catalogs across layers unnecessarily. Imported files have bounded parsing/allocation behavior before they are accepted. ## Regression process @@ -47,6 +69,10 @@ For a reported performance regression: 1. reproduce with a deterministic workload; 2. profile before changing code; 3. identify the dominant path; -4. add a benchmark or measurable regression guard when practical; +4. add or extend a measurable regression workload when practical; 5. optimize without weakening validation/precision; -6. record before/after results here or in release notes. +6. record before/after results with machine/toolchain context. + +## Release evidence + +Before a stable release, save representative profiling output in the release verification record. Never claim a device/platform performance target as passed unless it was measured on that device/platform class. From 8012ed8d1ea8a707c1daa55be33192ce393eec64 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:52:24 +0530 Subject: [PATCH 116/241] feat: add undoable history clearing --- .../history/presentation/history_screen.dart | 57 ++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/apps/unitflow_app/lib/features/history/presentation/history_screen.dart b/apps/unitflow_app/lib/features/history/presentation/history_screen.dart index 29083e45..29c7f818 100644 --- a/apps/unitflow_app/lib/features/history/presentation/history_screen.dart +++ b/apps/unitflow_app/lib/features/history/presentation/history_screen.dart @@ -3,6 +3,7 @@ import 'package:intl/intl.dart'; import '../../../app/app_controller.dart'; import '../../../app/theme/app_theme.dart'; +import '../../../core/persistence/user_state.dart'; import '../../../l10n/app_localizations.dart'; import '../../converter/domain/unit_models.dart'; @@ -35,14 +36,32 @@ final class HistoryScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: AppSpacing.md), - Text( - strings.recentConversions, - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: AppSpacing.xxs), - Text( - strings.recentConversionsSubtitle, - style: Theme.of(context).textTheme.bodyMedium, + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + strings.recentConversions, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: AppSpacing.xxs), + Text( + strings.recentConversionsSubtitle, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + const SizedBox(width: AppSpacing.md), + TextButton.icon( + onPressed: () => _clearHistory(context, recents), + icon: const Icon(Icons.delete_sweep_outlined), + label: Text(strings.clearHistory), + ), + ], ), const SizedBox(height: AppSpacing.lg), ...recents.map((recent) { @@ -91,6 +110,28 @@ final class HistoryScreen extends StatelessWidget { ); }, ); + + Future _clearHistory( + BuildContext context, + List snapshot, + ) async { + final strings = AppLocalizations.of(context); + final preserved = List.of(snapshot); + await appController.clearHistory(); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(strings.historyCleared), + action: SnackBarAction( + label: strings.undo, + onPressed: () => appController.restoreHistory(preserved), + ), + ), + ); + } } final class _EmptyHistory extends StatelessWidget { From 57ce6333676470cc96ce55c99c4739a5f0e23291 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:53:26 +0530 Subject: [PATCH 117/241] test: cover primary offline conversion journey --- .../test/app/primary_journey_test.dart | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 apps/unitflow_app/test/app/primary_journey_test.dart diff --git a/apps/unitflow_app/test/app/primary_journey_test.dart b/apps/unitflow_app/test/app/primary_journey_test.dart new file mode 100644 index 00000000..00bb569e --- /dev/null +++ b/apps/unitflow_app/test/app/primary_journey_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/app/app_controller.dart'; +import 'package:unitflow/app/unitflow_app.dart'; +import 'package:unitflow/core/persistence/user_state.dart'; +import 'package:unitflow/core/persistence/user_state_repository.dart'; + +void main() { + testWidgets('convert pin swap and reopen recent conversion offline', ( + tester, + ) async { + final repository = MemoryUserStateRepository( + UserState(onboardingComplete: true), + ); + final controller = AppController(repository: repository); + + await tester.pumpWidget(UnitFlowApp(appController: controller)); + await tester.pumpAndSettle(); + + final valueField = find.byType(TextField); + expect(valueField, findsOneWidget); + await tester.enterText(valueField, '1000'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(find.text('1'), findsWidgets); + expect(controller.state.recents, hasLength(1)); + expect(controller.state.recents.single.fromUnitId, 'meter'); + expect(controller.state.recents.single.toUnitId, 'kilometer'); + + await tester.tap(find.byTooltip('Pin unit pair')); + await tester.pumpAndSettle(); + expect(controller.state.pinnedPairs, hasLength(1)); + + await tester.tap(find.byTooltip('Swap source and target units')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('History').first); + await tester.pumpAndSettle(); + expect(find.textContaining('1000 m'), findsOneWidget); + + await tester.tap(find.textContaining('1000 m')); + await tester.pumpAndSettle(); + expect(find.text('Convert units'), findsOneWidget); + }); +} From 51cd672b6a512646d8017cf2035f64495f9d6ede Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:54:04 +0530 Subject: [PATCH 118/241] ci: add structured data syntax validator --- tool/check_data_files.py | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tool/check_data_files.py diff --git a/tool/check_data_files.py b/tool/check_data_files.py new file mode 100644 index 00000000..ab1661d5 --- /dev/null +++ b/tool/check_data_files.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Validate tracked JSON and ARB files with the Python standard library.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def tracked_data_files() -> list[Path]: + try: + output = subprocess.check_output( + ["git", "ls-files", "-z", "*.json", "*.arb"], + cwd=ROOT, + stderr=subprocess.STDOUT, + ) + except (OSError, subprocess.CalledProcessError) as error: + print(f"data-file check could not enumerate files: {error}", file=sys.stderr) + raise SystemExit(2) from error + return [ + ROOT / raw.decode("utf-8", errors="surrogateescape") + for raw in output.split(b"\0") + if raw + ] + + +def main() -> int: + failures: list[str] = [] + for path in tracked_data_files(): + relative = path.relative_to(ROOT).as_posix() + try: + with path.open("r", encoding="utf-8") as handle: + json.load(handle) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + failures.append(f"{relative}: {error}") + + if failures: + print("Invalid JSON/ARB files:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + + print("Tracked JSON and ARB files are valid UTF-8 JSON.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From fd749667af6e8edc4bebbf343b18005323975949 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:54:17 +0530 Subject: [PATCH 119/241] ci: validate tracked JSON and localization data --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e5ab2d7..bc3f0bf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: - name: Scan tracked files for credential signatures run: python3 tool/check_secrets.py + - name: Validate JSON and ARB syntax + run: python3 tool/check_data_files.py + - name: Verify internal Markdown links run: python3 tool/check_docs_links.py From 8619c2636aa8ab066e9b6ce48eeeaac75b03c562 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:56:59 +0530 Subject: [PATCH 120/241] docs: refresh UnitFlow product overview --- README.md | 133 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 81 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 6407f254..3327ea91 100644 --- a/README.md +++ b/README.md @@ -6,67 +6,67 @@ [![Security](https://github.com/sanskarIN/unitflow/actions/workflows/codeql.yml/badge.svg)](https://github.com/sanskarIN/unitflow/actions/workflows/codeql.yml) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-sanskarIN-FFDD00?logo=buy-me-a-coffee&logoColor=000000)](https://buymeacoffee.com/sanskarIN) -UnitFlow is an open-source converter for Android, Windows, Linux, macOS, Web, and iOS-ready workflows. The project is designed around deterministic offline conversions, high-precision decimal arithmetic, accessible responsive UI, and a maintainable separation between domain logic and presentation. +UnitFlow is an open-source converter targeting Android, Windows, Linux, macOS, Web, and iOS-ready workflows. It is designed around deterministic offline conversions, explicit precision and rounding behavior, accessible responsive UI, privacy-preserving local data, and a maintainable separation between conversion logic and presentation. ## Status -The repository is under active development. See [`what_changed.md`](what_changed.md) for the exact implementation checkpoint and [`ROADMAP.md`](ROADMAP.md) for planned milestones. +The application and quality-hardening work is being validated through the active audit pull request. See [`what_changed.md`](what_changed.md) for the exact checkpoint and [`ROADMAP.md`](ROADMAP.md) for milestone status. Do not interpret source-level completion as a release claim until the documented CI, platform, accessibility, and release-candidate checks are green. + +## Product Features + +- Length, area, volume, mass, speed, pressure, energy, power, angle, data size, frequency, time, temperature, and extensible custom categories/units architecture. +- High-precision decimal conversion with explicit rounding modes: nearest-even, half-away-from-zero, toward zero, away from zero, floor, and ceiling. +- Searchable unit library across stable IDs, names, symbols, aliases, and descriptive metadata. +- Favorites, recent conversions, pinned unit pairs, fast swap, keyboard navigation, and desktop shortcuts. +- Validated custom affine units using `base = value × scale + offset` without arbitrary expression execution. +- Batch conversion table plus deterministic CSV copying. +- Plain, scientific, and engineering notation. +- Locale-aware number parsing/formatting foundations and generated Flutter localization infrastructure. +- Offline-first static conversion data; no account is required for core conversions. +- Local preferences/history/custom units with bounded JSON backup and restore through file or clipboard workflows. +- Light, dark, and system themes plus an explicit reduced-motion preference. +- Screen-reader semantics, keyboard/touch interaction foundations, adaptive navigation, and accessible error handling. +- User-initiated link to official releases without background update tracking. +- Redacting structured diagnostics that avoid conversion history, clipboard payloads, and backup contents. +- Project-authored UnitFlow brand mark with editable vector source. -## Features - -- Length, area, volume, mass, speed, pressure, energy, power, angle, data size, frequency, time, temperature, and extensible categories. -- High-precision decimal conversion in the Rust core. -- Searchable catalog with symbols, aliases, and descriptions. -- Favorites, recents, pinned pairs, and quick swap architecture. -- Custom affine units (`base = value × factor + offset`) with validation. -- Batch conversion and export-ready result models. -- Scientific and engineering notation helpers. -- Locale-aware Flutter input/formatting architecture. -- Offline-first static conversion data. -- Light, dark, and system theme support. -- Keyboard and screen-reader oriented UI foundations. +## Architecture -## Screenshots +```text +crates/unitflow_core/ authoritative Rust conversion/domain core +crates/unitflow_bridge/ Flutter Rust Bridge API boundary +apps/unitflow_app/ adaptive Flutter application +fuzz/ Rust fuzz targets +tool/ repeatable developer/verification utilities +schemas/ versioned portable backup schemas +assets/branding/ editable project branding sources +docs/ architecture, setup, testing, accessibility, release docs +.github/ CI, security, dependency, issue and release automation +``` -Real screenshots will replace these placeholders once release builds are available. +The Rust core owns validated units, deterministic decimal conversion, precision/rounding, search, batch operations, and reusable result models. Flutter owns presentation, accessibility, local preferences, backup UX, localization, and platform integration. The Dart exact-decimal implementation remains a deterministic fallback while native bridge integration is validated. -| Phone | Desktop | Dark mode | -|---|---|---| -| `docs/assets/screenshot-phone.png` | `docs/assets/screenshot-desktop.png` | `docs/assets/screenshot-dark.png` | +See [`docs/architecture.md`](docs/architecture.md), [`docs/bridge.md`](docs/bridge.md), and [`docs/adr/0001-rust-core-flutter-ui.md`](docs/adr/0001-rust-core-flutter-ui.md). -## Supported Platforms +## Supported Targets -| Platform | Target | +| Platform | Intended status | |---|---| -| Android | Primary | -| Windows | Primary desktop | -| Linux | Primary desktop | +| Android | Primary target | +| Windows | Primary desktop target | +| Linux | Primary desktop target | | macOS | Supported target | -| Web | Supported target | -| iOS | Architecture ready | - -## Tech Stack - -- **Rust** — authoritative conversion/domain core. -- **rust_decimal** — deterministic decimal arithmetic. -- **Flutter / Dart** — adaptive cross-platform UI. -- **GitHub Actions** — quality, security, and release automation. +| Web | Supported Flutter fallback target | +| iOS | iOS-ready target pending release validation/signing | -## Repository Layout - -```text -crates/unitflow_core/ Rust domain and conversion engine -apps/unitflow_app/ Flutter application -docs/ Architecture, setup, testing, release, ADRs -.github/ CI, security, issue and PR automation -``` +Actual platform release status is recorded only after the corresponding build/journey checks pass. See [`docs/platform-support.md`](docs/platform-support.md) and [`docs/release.md`](docs/release.md). ## Quick Start ### Rust core ```bash -cargo test --workspace +cargo test --workspace --all-features ``` ### Flutter app @@ -74,38 +74,67 @@ cargo test --workspace ```bash cd apps/unitflow_app flutter pub get -flutter analyze +flutter gen-l10n +flutter analyze --fatal-infos --fatal-warnings flutter test flutter run ``` -See [`docs/setup.md`](docs/setup.md) for complete platform prerequisites. +See [`docs/setup.md`](docs/setup.md) for platform prerequisites and [`docs/troubleshooting.md`](docs/troubleshooting.md) for common setup failures. ## Development Quality Gates -Before opening a pull request: +Core checks: ```bash +python3 tool/check_secrets.py +python3 tool/check_data_files.py +python3 tool/check_docs_links.py cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo test --workspace +cargo test --workspace --all-features cd apps/unitflow_app flutter pub get -flutter analyze +flutter gen-l10n +dart format --output=none --set-exit-if-changed lib test +flutter analyze --fatal-infos --fatal-warnings flutter test ``` -## Architecture +Bridge generation and profiling: + +```bash +bash tool/generate_bridge.sh +bash tool/profile_core.sh +``` + +See [`docs/testing.md`](docs/testing.md), [`docs/performance.md`](docs/performance.md), and [`docs/verification.md`](docs/verification.md). + +## Local Data and Portability + +UnitFlow keeps preferences, favorites, bounded recent history, pinned pairs, accessibility choices, and custom units locally by default. The portable backup format is versioned and validated before replacement of current state. Version 1 backups migrate deterministically to schema version 2, including the historical nearest-even rounding default. + +See [`docs/data-format.md`](docs/data-format.md) and [`schemas/unitflow-backup-v2.schema.json`](schemas/unitflow-backup-v2.schema.json). -The Rust crate owns unit definitions, validation, conversion rules, precision behavior, search, and reusable result models. Flutter owns presentation, accessibility, adaptive layout, local preferences, and platform integration. See [`docs/architecture.md`](docs/architecture.md) and [`docs/adr/0001-rust-core-flutter-ui.md`](docs/adr/0001-rust-core-flutter-ui.md). +## Accessibility + +UnitFlow is designed for keyboard/touch use, screen-reader-friendly controls, responsive text/layout, visible focus behavior, and reduced motion. Manual platform accessibility review remains a release gate, not an assumption from source code alone. + +See [`docs/accessibility.md`](docs/accessibility.md). ## Security and Privacy -UnitFlow does not require an account for static conversions and is designed to work offline. User preferences and custom units are intended to stay on-device unless the user explicitly exports them. See [`SECURITY.md`](SECURITY.md) and [`PRIVACY.md`](PRIVACY.md). +Static conversions work offline and require no account. Repository CI includes common credential-pattern scanning, structured-data validation, dependency review, CodeQL, Rust/Flutter quality gates, and documentation link validation. Suspected vulnerabilities should be reported privately according to [`SECURITY.md`](SECURITY.md). + +See [`PRIVACY.md`](PRIVACY.md) and [`SECURITY.md`](SECURITY.md). + +## Branding + +Editable source artwork is stored in [`assets/branding/unitflow-mark.svg`](assets/branding/unitflow-mark.svg). Runtime Flutter branding is rendered from project primitives. See [`docs/branding.md`](docs/branding.md) before producing launcher, splash, or promotional assets. ## Contributing -Contributions are welcome. Read [`CONTRIBUTING.md`](CONTRIBUTING.md), follow the code of conduct, add tests for behavior changes, and keep commits atomic. +Contributions are welcome. Read [`CONTRIBUTING.md`](CONTRIBUTING.md), follow [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md), add regression tests for behavior changes, avoid committing secrets or user data, and keep changes reviewable. ## License From b1d332896e5182521b4efeffc8bb6fa236b98de5 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:57:29 +0530 Subject: [PATCH 121/241] docs: add Rust Flutter bridge integration guide --- docs/bridge.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/bridge.md diff --git a/docs/bridge.md b/docs/bridge.md new file mode 100644 index 00000000..9107d0cf --- /dev/null +++ b/docs/bridge.md @@ -0,0 +1,75 @@ +# Rust ↔ Flutter Bridge + +UnitFlow keeps conversion-domain behavior in Rust and exposes a narrow Flutter Rust Bridge (FRB) API through `crates/unitflow_bridge`. + +## Components + +- `crates/unitflow_core` — authoritative catalog, validation, conversion, search, batch behavior, decimal precision, and rounding. +- `crates/unitflow_bridge` — FRB-safe DTOs/functions wrapping the core. +- `tool/generate_bridge.sh` — reproducible binding-generation command. +- `apps/unitflow_app/lib/src/rust` — generated Dart binding output after generation. + +The app also includes a deterministic Dart exact-decimal implementation. It is used for web/testing/fallback paths and prevents the UI from depending on binary floating-point arithmetic while native integration is unavailable. + +## Generator version + +The repository currently pins Flutter Rust Bridge `2.12.0` in workspace/app dependencies and installs the matching code generator in CI. Do not silently generate bindings with a materially different FRB version and commit the result. + +Install the expected generator: + +```bash +cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked +``` + +Then generate bindings: + +```bash +bash tool/generate_bridge.sh +``` + +## Bridge API + +The bridge exposes safe string/primitive DTOs for: + +- single conversion; +- batch conversion; +- built-in unit listing; +- catalog search; +- explicit rounding-mode selection. + +Decimal values cross the FFI boundary as strings. Rust parses and validates them before executing domain behavior. This avoids silently converting high-precision decimal input through a binary floating-point representation. + +## Generated sources + +Generated bindings are treated as derived sources. The audit-branch normalization workflow installs the pinned generator, regenerates bindings, runs formatting, and commits generated changes when needed. Generated files must still pass Rust and Flutter analysis before merge. + +Do not hand-edit generated bridge files. Change the Rust API or generator configuration instead. + +## Native packaging + +Binding generation alone is not the same as shipping a native library. Platform release validation must also prove that the generated application bundles/loads the Rust artifact correctly on each native target. + +The release checklist therefore distinguishes: + +1. Rust core compiles/tests; +2. FRB bindings generate; +3. generated Rust/Dart analyze; +4. native application builds; +5. installed app executes a conversion through the intended native boundary; +6. web fallback executes deterministic Dart conversion without a native library. + +Until steps 4–5 have platform evidence, native bridge packaging is not considered release-verified. + +## API change policy + +When bridge-visible Rust types/functions change: + +1. update core/bridge regression tests; +2. regenerate bindings with the pinned codegen version; +3. run `cargo fmt`, `cargo clippy`, and workspace tests; +4. run Flutter generation, formatting, analysis, and tests; +5. update this document and `what_changed.md` when integration behavior changes. + +## Troubleshooting + +If generation fails, first verify the installed `flutter_rust_bridge_codegen` version and run `flutter pub get` in `apps/unitflow_app`. See `docs/troubleshooting.md` for the wider toolchain checklist. From 4bbb49abc577928f98f554cd50539e1b738faf4b Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:57:47 +0530 Subject: [PATCH 122/241] docs: define UnitFlow platform support matrix --- docs/platform-support.md | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/platform-support.md diff --git a/docs/platform-support.md b/docs/platform-support.md new file mode 100644 index 00000000..1cfbd6aa --- /dev/null +++ b/docs/platform-support.md @@ -0,0 +1,61 @@ +# Platform Support Matrix + +UnitFlow is designed as a cross-platform Flutter application with a Rust conversion core. A platform is not called release-ready merely because Flutter can generate a shell for it; release status requires build and primary-journey evidence. + +| Platform | Product target | Static conversion | Native Rust target | Release evidence required | +|---|---|---|---|---| +| Android | Primary | Offline | Yes | release APK/app-bundle build, install, conversion journey, backup/settings checks | +| Windows | Primary desktop | Offline | Yes | release desktop build, launch, keyboard navigation, conversion journey | +| Linux | Primary desktop | Offline | Yes | release bundle build, launch, conversion journey | +| macOS | Supported | Offline | Yes | release app build, launch, conversion journey | +| Web | Supported | Offline after load | No native library | release web build, deterministic Dart fallback journey | +| iOS | iOS-ready | Offline | Yes | no-codesign CI validation plus signed device/App Store validation before distribution | + +## Shared expectations + +Every supported target must preserve: + +- deterministic static conversion behavior; +- no account requirement for core conversion; +- local-only preferences/history/custom units by default; +- validated backup import behavior; +- light/dark/system theme behavior; +- text scaling and accessible semantics; +- user-controlled reduced motion; +- user-visible **Made by the Sanskar** credit; +- no mandatory network request during static conversion. + +## Desktop keyboard behavior + +Windows, Linux, macOS, and web desktop layouts support navigation shortcuts in the application shell: + +- Ctrl/Cmd + `1` — Converter +- Ctrl/Cmd + `2` — Library +- Ctrl/Cmd + `3` — History +- Ctrl/Cmd + `,` — Settings +- Ctrl/Cmd + `K` — Library/search destination + +Manual release review must verify focus visibility and logical traversal rather than assuming shortcut registration alone proves accessibility. + +## Web boundary + +The web target cannot load the ordinary native Rust dynamic/static library. UnitFlow therefore retains deterministic exact-decimal Dart conversion as the web/fallback path. Web release validation must compare representative results against Rust regression vectors to prevent divergence. + +## iOS boundary + +iOS CI can validate a release build without code signing, but distribution requires signing/provisioning under an Apple developer identity. CI success without signing is not equivalent to an App Store-ready artifact. + +## Release workflow + +`.github/workflows/release.yml` builds/validates the platform matrix appropriate to GitHub-hosted runners. Generated platform shells are used as build scaffolding until platform-specific files are committed and fully branded. Launcher/splash branding and actual installed-app validation remain release-candidate gates. + +## Status language + +Use these terms consistently: + +- **targeted** — architecture/source supports the platform; +- **CI validated** — automated platform build/tests passed for a specific commit; +- **manually validated** — primary user journeys were run on the platform; +- **release-ready** — required automated/manual/signing/packaging gates are satisfied for a release candidate. + +Record concrete evidence in `what_changed.md` and the release verification record. From cf80059a1788dd3e5d40a45b178f235d9afb6313 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:58:06 +0530 Subject: [PATCH 123/241] ci: make local verification match repository quality gates --- tool/check.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tool/check.sh b/tool/check.sh index 54a70423..2368ac5f 100644 --- a/tool/check.sh +++ b/tool/check.sh @@ -4,12 +4,28 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" +python3 tool/check_secrets.py +python3 tool/check_data_files.py +python3 tool/check_docs_links.py + cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-features cd "$ROOT/apps/unitflow_app" flutter pub get +flutter gen-l10n dart format --output=none --set-exit-if-changed lib test flutter analyze --fatal-infos --fatal-warnings flutter test + +cd "$ROOT" +if command -v flutter_rust_bridge_codegen >/dev/null 2>&1; then + bash tool/generate_bridge.sh + cargo check --workspace --all-features + cd "$ROOT/apps/unitflow_app" + flutter analyze --fatal-infos --fatal-warnings +else + echo "flutter_rust_bridge_codegen not found; bridge regeneration check skipped." >&2 + echo "Install the pinned generator before release-candidate verification." >&2 +fi From f85bdebdb90ec180fd188160796db72c606dca4c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:58:17 +0530 Subject: [PATCH 124/241] release: add strict release candidate verifier --- tool/verify_release_candidate.sh | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tool/verify_release_candidate.sh diff --git a/tool/verify_release_candidate.sh b/tool/verify_release_candidate.sh new file mode 100644 index 00000000..7a6894e4 --- /dev/null +++ b/tool/verify_release_candidate.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if ! command -v flutter_rust_bridge_codegen >/dev/null 2>&1; then + echo "Release verification requires flutter_rust_bridge_codegen 2.12.0." >&2 + echo "Install it with: cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked" >&2 + exit 1 +fi + +python3 tool/check_secrets.py +python3 tool/check_data_files.py +python3 tool/check_docs_links.py + +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-features +cargo build --workspace --all-features --release + +cd "$ROOT/apps/unitflow_app" +flutter pub get +flutter gen-l10n +dart format --output=none --set-exit-if-changed lib test +flutter analyze --fatal-infos --fatal-warnings +flutter test + +cd "$ROOT" +bash tool/generate_bridge.sh +cargo fmt --all -- --check +cargo check --workspace --all-features + +cd "$ROOT/apps/unitflow_app" +dart format --output=none --set-exit-if-changed lib test +flutter analyze --fatal-infos --fatal-warnings +flutter test +flutter build web --release + +cd "$ROOT" +if ! git diff --quiet --exit-code; then + echo "Release verification changed tracked files." >&2 + echo "Regenerate/format sources and commit the result before releasing." >&2 + git status --short >&2 + exit 1 +fi + +bash tool/profile_core.sh + +echo "Host-independent UnitFlow release-candidate checks passed." +echo "Native platform builds, installed-app journeys, accessibility review, branding, signing, and screenshots remain platform-specific release gates." From e15b33b49b6011fa9a567dd1a0c4950316d43cb6 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:58:44 +0530 Subject: [PATCH 125/241] docs: strengthen release candidate verification guide --- docs/release.md | 80 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/docs/release.md b/docs/release.md index 7794e535..b1117f4d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -4,41 +4,69 @@ UnitFlow uses SemVer-style versions. During `0.x`, breaking changes may occur but must be documented. Stable releases should preserve stored-data and bridge compatibility or include explicit migration notes. -## Release checklist +The current development target is `0.1.0-alpha.1`. A version string in source is not a release declaration; release status is tied to an audited commit/tag and its evidence. -1. Ensure `main` is current and protected according to repository guidance. -2. Confirm `CHANGELOG.md`, `ROADMAP.md`, and `what_changed.md` are current. -3. Run the complete Rust quality suite. -4. Run Flutter analysis/tests and build the intended release targets. -5. Perform dependency/security checks. -6. Verify no secrets or signing material are present in Git. -7. Validate accessibility basics and primary user journeys. -8. Verify About/version/license/support links. -9. Replace stale screenshots and release notes. -10. Tag the exact audited commit. +## Release checklist -## Suggested local commands +1. Ensure the intended release commit is on the protected release branch and has no unreviewed local/generated changes. +2. Confirm `CHANGELOG.md`, `ROADMAP.md`, and `what_changed.md` match the exact candidate. +3. Run the strict host-independent release-candidate verifier. +4. Confirm CI, CodeQL, dependency review, bridge generation, and repository-safety checks are green for the exact candidate. +5. Build every advertised native/web platform in its supported CI/host environment. +6. Install/run primary user journeys on each release platform class rather than relying only on compilation. +7. Verify Rust-backed native conversion on native targets and deterministic Dart fallback behavior on web. +8. Verify backup import/export, migration behavior, custom units, favorites/pins/history, rounding, themes, and reduced motion. +9. Perform keyboard, text-scaling, contrast, and screen-reader-oriented manual accessibility review. +10. Verify final launcher/splash branding, About/version/license/support/funding links, and required **Made by the Sanskar** credit. +11. Capture real release screenshots from validated builds. Never substitute mock/placeholder images as release evidence. +12. Verify no secrets, signing material, private endpoints, or real user data are present in Git or release artifacts. +13. Generate checksums for distributable archives/binaries. +14. Tag the exact audited commit and let the release workflow package only that tag. + +## Strict local verification + +Install the pinned Flutter Rust Bridge generator first: ```bash -cargo fmt --all -- --check -cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo test --workspace +cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked +``` -cd apps/unitflow_app -flutter pub get -flutter analyze -flutter test +Then run: + +```bash +bash tool/verify_release_candidate.sh ``` -Then run platform builds required for the release, for example: +This verifies repository safety/data/docs, Rust formatting/lint/tests/release build, Flutter localization/format/analyze/tests, bridge regeneration, generated-source cleanliness, web release build, and the core profiling harness. It intentionally fails if binding generation or formatting changes tracked files, because generated sources must be normalized and committed before a release candidate is considered reproducible. + +`tool/check.sh` is the faster development-quality command. It may skip bridge regeneration when the code generator is not installed; therefore it is not a substitute for `tool/verify_release_candidate.sh`. + +## Platform builds + +The release workflow validates multiple targets on compatible GitHub-hosted operating systems. Typical manual build commands include: ```bash +cd apps/unitflow_app flutter build apk --release flutter build appbundle --release flutter build web --release +flutter build windows --release +flutter build linux --release +flutter build macos --release +flutter build ios --release --no-codesign ``` -Desktop/macOS/iOS builds must run on compatible hosts with the required platform toolchains. +Run only the commands supported by the current host. iOS `--no-codesign` confirms compilation, not distributable signing/provisioning. + +See `docs/platform-support.md` for the distinction between targeted, CI validated, manually validated, and release-ready. + +## Bridge release boundary + +Before calling a native platform release-ready, verify that the native application packages/loads the Rust bridge and a primary conversion journey crosses the intended native boundary. Successful binding generation alone is insufficient. See `docs/bridge.md`. + +## Branding and screenshots + +Use `assets/branding/unitflow-mark.svg` as the editable source of truth and follow `docs/branding.md` for launcher/splash exports. Screenshots must come from a real validated build and should cover representative phone/desktop/dark-mode states without personal data. ## Tagging @@ -49,16 +77,16 @@ git tag -a v0.1.0-alpha.1 -m "UnitFlow 0.1.0-alpha.1" git push origin v0.1.0-alpha.1 ``` -The GitHub release workflow should only package artifacts from version tags after validation. +Do not move or rewrite a published release tag to hide a defect. Publish a corrective release instead. -## Artifacts +## Artifacts and checksums -Release artifacts must be generated from source through documented commands. Do not commit signing keys. Checksums should be generated for distributable binaries when practical. +Release artifacts must be generated from source through documented commands. Signing keys remain outside this public repository. For downloadable archives, publish a checksum manifest generated from the exact artifacts produced by the release run. ## Store releases -Mobile store publication has additional signing, privacy, screenshot, and listing requirements. Store credentials remain outside this public repository. +Mobile store publication has additional signing, privacy, screenshots, content-rating, metadata, and listing requirements. Store credentials remain outside the repository and must never be placed in backup/environment examples. ## Rollback -If a release contains a critical defect, publish a fixed patch/prerelease rather than rewriting an existing Git tag. Document impact and migration considerations in the changelog. +If a release contains a critical defect, publish a fixed patch/prerelease rather than rewriting an existing Git tag. Document impact, affected versions, data compatibility, and migration considerations in the changelog/security advisory as appropriate. From 863e083fc9012903149b7eaefe2dd6038950df7c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:59:12 +0530 Subject: [PATCH 126/241] docs: update UnitFlow roadmap implementation status --- ROADMAP.md | 131 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 85 insertions(+), 46 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7311206e..e41052a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,76 +1,115 @@ # UnitFlow Roadmap -The roadmap is milestone-oriented. Items may move when testing reveals higher-priority reliability or accessibility work. +The roadmap is milestone-oriented. A checked source-level item means the implementation exists on the active development branch; it does **not** imply that every release/platform gate has passed. Platform/manual/release checks stay open until concrete evidence exists for the exact release candidate. ## Phase 0 — Repository foundation - [x] Repository identity and README. -- [x] License, support, privacy, security, conduct, and contribution policies. -- [ ] Architecture/setup/testing/release documentation. -- [ ] GitHub issue/PR templates. -- [ ] CI, CodeQL, Dependabot, funding metadata. +- [x] MIT license, support, privacy, security, conduct, and contribution policies. +- [x] Architecture/setup/testing/release/troubleshooting documentation. +- [x] GitHub issue/PR templates. +- [x] CI, CodeQL, dependency review, Dependabot, funding metadata. +- [x] Internal documentation-link, structured-data, and common secret-pattern checks. +- [x] Canonical development handoff workflow through `what_changed.md`. ## Phase 1 — Conversion MVP -- [ ] Rust workspace and `unitflow_core` crate. -- [ ] Unit/category definitions and validation. -- [ ] High-precision decimal converter. -- [ ] Static catalog covering major categories. -- [ ] Search, aliases, quick swap, notation formatting. -- [ ] Custom affine units. -- [ ] Unit and property-oriented regression tests. -- [ ] Flutter application shell and converter screen. -- [ ] Adaptive theming and accessible interaction basics. +- [x] Rust workspace and `unitflow_core` crate. +- [x] Unit/category definitions and validation. +- [x] High-precision decimal converter. +- [x] Explicit rounding modes and precision validation. +- [x] Static catalog covering major categories. +- [x] Search, aliases, descriptions, quick swap, notation formatting. +- [x] Custom affine units. +- [x] Unit/property/regression test coverage foundations. +- [x] Flutter application shell and converter screen. +- [x] Adaptive theming and accessible interaction foundations. +- [x] Deterministic exact-decimal Dart fallback. ## Phase 2 — Product completion -- [ ] Favorites, recents, pinned pairs. -- [ ] Settings and onboarding. -- [ ] Custom-unit editor. -- [ ] Batch conversion table. -- [ ] Copy/export workflows. -- [ ] Import/export backup with schema validation. -- [ ] Educational category explanations and examples. -- [ ] Locale-aware parsing/formatting. +- [x] Favorites, recents, pinned pairs. +- [x] Settings and onboarding. +- [x] Custom-unit editor with validated formulas. +- [x] Batch conversion table and deterministic CSV copying. +- [x] File/clipboard backup and restore with bounded validation. +- [x] Versioned backup schema and v1 → v2 migration. +- [x] Educational category explanations and examples. +- [x] Locale-aware parsing/formatting foundations. +- [x] Generated localization infrastructure and English source catalog. +- [x] Clear-history/undo and custom-unit removal/undo flows. +- [x] User-safe error presentation backed by redacting diagnostics. +- [x] User-initiated official release-page access without background update tracking. ## Phase 3 — Platform polish -- [ ] Rust↔Flutter production bridge and generated bindings workflow. -- [ ] Android, Windows, Linux, macOS, Web validation. -- [ ] iOS-ready project configuration. -- [ ] Keyboard shortcuts and desktop navigation. -- [ ] Reduced-motion and large-text review. -- [ ] Performance profiling and large-catalog virtualization where needed. +- [x] Rust↔Flutter bridge crate/API and generated-bindings workflow scaffolding. +- [x] Android/Windows/Linux/macOS/Web/iOS build targets represented in release automation. +- [x] Keyboard shortcuts and adaptive desktop navigation. +- [x] Explicit reduced-motion setting and platform animation preference handling. +- [x] Project-authored reusable UnitFlow mark and editable vector source. +- [x] Platform support/branding/bridge documentation. +- [x] Core performance profiling harness. +- [ ] Generated bridge sources validated and normalized for the final candidate. +- [ ] Native Rust library packaging/loading proven on each advertised native target. +- [ ] Android primary journey manually validated on a release build. +- [ ] Windows primary journey manually validated on a release build. +- [ ] Linux primary journey manually validated on a release build. +- [ ] macOS primary journey manually validated on a release build. +- [ ] Web primary journey manually validated with the deterministic fallback. +- [ ] iOS signed-device/App Store readiness validated where distribution is intended. +- [ ] Final launcher/splash assets installed in every committed platform shell. +- [ ] Large-text, screen-reader, contrast, and keyboard accessibility review completed on real targets. ## Phase 4 — Quality hardening -- [ ] Comprehensive widget/integration tests. -- [ ] End-to-end primary journeys. -- [ ] Rust fuzz/property tests for conversion invariants and parsers. -- [ ] Benchmarks for catalog lookup and batch conversion. -- [ ] Dependency/security audit. -- [ ] Regression fixes from CI/platform testing. +- [x] Flutter unit/controller/widget regression tests. +- [x] Primary offline conversion journey widget test. +- [x] Rust conversion regression/property-oriented tests. +- [x] Rust fuzz targets for catalog/decimal bridge inputs. +- [x] Core profiling workload for lookup/search/single/batch conversion. +- [x] Repository secret-pattern, JSON/ARB, and Markdown-link checks. +- [x] Backup corruption/version/migration regression coverage. +- [x] Safe error-presentation regression coverage. +- [ ] Latest exact-candidate Rust fmt/clippy/tests green in CI. +- [ ] Latest exact-candidate Flutter gen-l10n/format/analyze/tests green in CI. +- [ ] Latest exact-candidate bridge generation/check green in CI. +- [ ] Latest exact-candidate CodeQL and dependency review green. +- [ ] Fuzzing campaign run for a documented time budget on the release candidate. +- [ ] Measured performance output recorded for release-candidate hardware/toolchain context. +- [ ] Regression fixes from all platform/manual testing completed. ## Phase 5 — Release engineering -- [ ] Real screenshots and demo media. -- [ ] App icon/splash source artwork. -- [ ] Reproducible release workflow. -- [ ] Platform packaging guidance/artifacts. -- [ ] Release notes and migration notes. +- [x] Cross-platform GitHub Actions release workflow foundation. +- [x] Strict host-independent release-candidate verification script. +- [x] Versioning, migration, bridge, platform, and branding release guidance. +- [x] Changelog structure and release-note foundation. +- [ ] Real phone screenshot captured from validated build. +- [ ] Real desktop screenshot captured from validated build. +- [ ] Real dark-mode screenshot captured from validated build. +- [ ] Final launcher/splash raster exports generated from the vector source and wired into platform shells. +- [ ] Release artifacts produced from exact audited tag. +- [ ] Checksum manifest produced for downloadable artifacts. +- [ ] Signing/notarization/store credentials configured outside the repository where required. +- [ ] `0.1.0-alpha.1` release candidate approved after all required gates. ## Phase 6 — Final audit -- [ ] Clean-clone setup verification. -- [ ] Full CI matrix passes. -- [ ] Documentation-link audit. -- [ ] Accessibility manual review. -- [ ] Secret scan and dependency audit. -- [ ] Release-candidate verification. +- [ ] Clean-clone setup verification on documented toolchains. +- [ ] Full CI/security/dependency matrix passes for the exact release candidate. +- [ ] Strict `tool/verify_release_candidate.sh` passes without modifying tracked sources. +- [ ] Documentation-link/data/secret checks pass for the exact release candidate. +- [ ] Accessibility manual review evidence recorded. +- [ ] Native bridge packaging and installed-app evidence recorded. +- [ ] Release artifacts/checksums/screenshots match the audited commit. +- [ ] `what_changed.md` contains no stale completion claims or unresolved hidden blockers. +- [ ] Release-candidate verification completed and tagged. ## Post-1.0 ideas - Optional currency conversion provider behind an explicit online-data boundary. - Community unit packs with signed/validated metadata. - Shareable deep links for conversion pairs where platforms allow it. -- Additional locales and educational content. +- Additional locales and localized educational content. +- Statistically rigorous long-running performance benchmark suite if profiling identifies regressions. From 0526d5440293aca6f70391ee72bee0ed13518151 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 09:59:34 +0530 Subject: [PATCH 127/241] docs: record accessibility branding and quality hardening --- CHANGELOG.md | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea28310..af817c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,31 +6,50 @@ All notable changes to UnitFlow are documented here. The format is based on Keep ### Added -- Initial repository documentation and governance. -- Rust + Flutter architecture baseline. -- Offline-first privacy and security policies. -- High-precision Rust conversion core and deterministic Dart exact-decimal fallback. -- Searchable multi-category unit catalog, favorites, pinned pairs, recents, custom units, and batch conversion export. +- Initial repository documentation, governance, support, privacy, security, and contribution policies. +- Rust + Flutter architecture with authoritative `unitflow_core`, FRB bridge crate, and deterministic Dart exact-decimal fallback. +- Searchable multi-category unit catalog, favorites, pinned pairs, recent conversions, custom affine units, and batch conversion/CSV workflows. - Local JSON backup/restore with clipboard and bounded file import/export flows. - Explicit user-selectable decimal rounding modes: nearest-even, half-away-from-zero, toward zero, away from zero, floor, and ceiling. +- Reduced-motion accessibility preference with platform animation-preference handling during onboarding. - English localization source and generated-localization workflow. -- Rust–Flutter bridge crate and bridge-generation automation. -- CI, CodeQL, dependency review, Dependabot, and cross-platform release workflows. +- User-initiated official Releases link without background update tracking. +- Reusable UnitFlow in-app brand mark and editable `assets/branding/unitflow-mark.svg` source. +- Undoable history clearing and custom-unit removal workflows. +- Rust–Flutter bridge crate, API DTOs, and bridge-generation automation. +- CI, CodeQL, dependency review, Dependabot, and cross-platform release workflow foundations. +- Repository safety checks for common credential signatures, JSON/ARB syntax, and internal Markdown links. +- User-safe error-presentation helper backed by redacting structured diagnostics. +- Core lookup/search/conversion profiling harness and strict release-candidate verification script. +- Widget/controller regression coverage plus a primary offline conversion journey test. +- Rust fuzz targets for catalog search and decimal bridge inputs. +- Bridge, platform-support, branding, accessibility, performance, data-format, and release verification documentation. ### Changed - Backup schema advanced from version 1 to version 2 to persist the selected decimal rounding mode. - Valid version 1 backups migrate deterministically to nearest-even rounding and export as version 2. +- Early version 2 backups remain compatible when the later optional `reduceMotion` preference is absent; it defaults to `false`. +- Primary library/custom-unit/settings interface strings increasingly use generated localization resources instead of embedded labels. +- Catalog matching now includes unit descriptions in the deterministic Dart catalog search path. +- Developer verification now checks repository safety/data/docs before Rust and Flutter quality gates. +- Audit-branch normalization regenerates localizations/bridge bindings and uses `sanskarin@outlook.in` for its automated normalization commit identity. ### Fixed -- Conversion rounding is now applied consistently to primary and batch conversion paths using the persisted user preference. +- Conversion rounding is applied consistently to primary and batch conversion paths using the persisted user preference. +- Backup/custom-unit failures no longer echo raw internal exception text into the user interface. +- History can be cleared without making the action immediately irreversible because the UI provides an undo snapshot. ### Security - Added responsible disclosure guidance and secret-handling rules. - Added bounded backup import validation and redacting structured diagnostic logging. +- Added tracked-file scanning for common private-key/token signatures without requiring another third-party CI action. +- Added safe failure presentation that logs only exception type metadata rather than potentially sensitive exception text. ## [0.1.0-alpha.1] - Planned -Initial runnable development preview containing the Rust conversion engine, Flutter converter experience, local preferences/custom units, automated tests, and CI/release foundations. +Initial runnable development preview containing the Rust conversion engine, adaptive Flutter converter/library/history/settings experience, local data/custom units, explicit precision/rounding, accessibility foundations, bridge/release automation, tests, and repository hardening. + +This version remains planned until the exact candidate passes automated quality/security checks plus the required native/web build and manual release validation described in `docs/release.md`. From b01bfe16be45441116580a7b9fc2675180eb0b16 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:00:19 +0530 Subject: [PATCH 128/241] docs: expand exact candidate verification record --- docs/verification.md | 94 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 7 deletions(-) diff --git a/docs/verification.md b/docs/verification.md index d3b52701..570ac093 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,29 +1,109 @@ # Verification Record -This document records reproducible quality checks for milestone audits. It complements `what_changed.md`; it does not replace CI results. +This document defines reproducible quality evidence for milestone and release-candidate audits. It complements `what_changed.md`; it does not replace GitHub Actions results or manual platform evidence. -## Phase 1 audit target +## Active audit target Branch: `audit/phase-1-quality` -Required checks: +Pull request: `#2` + +The exact head SHA changes while defects/features are being committed. Therefore results are valid only for the commit SHA recorded by the workflow or manual verification session. + +## Repository safety checks + +```bash +python3 tool/check_secrets.py +python3 tool/check_data_files.py +python3 tool/check_docs_links.py +``` + +These checks cover common credential signatures, JSON/ARB syntax, and internal Markdown target existence. They supplement CodeQL/dependency review rather than replacing them. + +## Rust quality checks ```bash cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-features +cargo build --workspace --all-features --release +``` + +## Flutter quality checks +```bash cd apps/unitflow_app flutter pub get +flutter gen-l10n dart format --output=none --set-exit-if-changed lib test flutter analyze --fatal-infos --fatal-warnings flutter test ``` +## Bridge reproducibility + +With `flutter_rust_bridge_codegen` version `2.12.0` installed: + +```bash +bash tool/generate_bridge.sh +cargo check --workspace --all-features +cd apps/unitflow_app +flutter analyze --fatal-infos --fatal-warnings +``` + +A release candidate must also prove that bridge generation does not leave unexpected tracked diffs. + +## Strict release-candidate command + +```bash +bash tool/verify_release_candidate.sh +``` + +This combines repository checks, Rust/Flutter verification, bridge regeneration, release builds available on the current host, generated-source cleanliness, and the core profiling harness. It still cannot substitute for native builds/manual journeys on other operating systems. + +## GitHub-required checks + +For the exact PR head, inspect and record: + +- CI / Repository safety; +- CI / Rust quality; +- CI / Flutter quality; +- CI / Rust Flutter bridge; +- CodeQL; +- Dependency review; +- audit-branch generated-source/format normalization. + +A queued, pending, cancelled, skipped, or superseded run is not a passing result. + +## Platform/manual evidence + +Release readiness additionally requires evidence described in `docs/platform-support.md` and `docs/release.md`, including: + +- native/web release builds; +- installed/served primary conversion journey; +- native bridge loading where applicable; +- backup/settings behavior; +- launcher/splash branding; +- keyboard/touch/text-scaling/screen-reader-oriented accessibility review; +- real screenshots from validated builds; +- signing/provisioning where distribution requires it. + +## Performance evidence + +Run: + +```bash +bash tool/profile_core.sh +``` + +Record OS, CPU, Rust toolchain, build profile, commit SHA, and output when making a release-level performance statement. Do not compare raw timing from materially different hosts as if it were a regression benchmark. + ## Rules -- A failed command is a failed audit until the defect is fixed and the command is rerun. +- A failed command is a failed audit until the defect is fixed and the exact check is rerun. - Never convert an unavailable toolchain into a passing result. -- Every behavior bug found by verification should receive regression coverage where practical. -- Build/toolchain limitations belong in `what_changed.md` with exact commands and errors. -- Security and release checks are additive to these core quality gates. +- Never treat an older green workflow as evidence for a newer commit. +- Every confirmed behavior defect should receive regression coverage when practical. +- Build/toolchain limitations belong in `what_changed.md` with exact scope. +- Generated sources are derived but must still be deterministic, reviewed through CI, and clean for release. +- Security/release/accessibility/platform checks are additive to core compiler/test success. From 4564c6551a4b8e1657fbe581202dbc4b2dba6eb5 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:01:26 +0530 Subject: [PATCH 129/241] docs: replace stale UnitFlow development handoff --- what_changed.md | 459 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 413 insertions(+), 46 deletions(-) diff --git a/what_changed.md b/what_changed.md index 1080611f..c73eb5a7 100644 --- a/what_changed.md +++ b/what_changed.md @@ -1,76 +1,443 @@ -# UnitFlow — Development Handoff +# UnitFlow — Canonical Development Handoff -This file is the primary continuation checkpoint for future development sessions. +This is the primary continuation and audit checkpoint for UnitFlow. Keep this file current whenever implementation, verification status, migration behavior, release readiness, or known blockers change. -## Current milestone +## Repository and active work -**Phase 0 → Phase 1 bootstrap in progress** +- Repository: `https://github.com/sanskarIN/unitflow` +- Visibility: public / open source +- License: MIT +- Default branch: `main` +- Active implementation branch: `audit/phase-1-quality` +- Active pull request: `#2` — **feat: complete UnitFlow application and quality hardening** +- Target preview: `0.1.0-alpha.1` +- Required visible product credit: **Made by the Sanskar** +- Requested maintainer commit email: `sanskarin@outlook.in` -Target release: `0.1.0-alpha.1` +At the time this handoff was rewritten, the branch immediately before this handoff commit was `b01bfe16be45441116580a7b9fc2675180eb0b16`. The handoff commit itself advances the branch again, so always read the live PR head before using a SHA as release evidence. -## Source prompt +## Source-of-truth rules -Implementation is governed by the UnitFlow master prompt supplied for this repository. The repository is public/open-source, MIT licensed, uses a Rust core plus Flutter frontend, and must keep the visible credit **Made by the Sanskar**. +Implementation is governed by the UnitFlow master prompt supplied for this repository. Important non-negotiable characteristics retained by the implementation include: -## Completed work +- Rust conversion/domain core plus Flutter UI; +- deterministic high-precision decimal behavior rather than binary floating-point conversion shortcuts; +- explicit rounding behavior; +- broad built-in unit catalog and validated custom affine units; +- offline-first static conversions; +- local-first preferences/history/custom-unit data; +- adaptive and accessible cross-platform UX; +- Android, Windows, Linux, macOS, Web, and iOS-ready targets; +- public MIT-licensed repository with complete support/security/contribution documentation; +- visible **Made by the Sanskar** credit; +- no fabricated build, screenshot, platform, security, or release-completion claims. -- Repository inspected before implementation; it was empty on 2026-08-19. -- Added the first production-oriented README with project identity, architecture, platform targets, setup commands, security/privacy notes, support contacts, BMC badge, and required credit. -- Established an incremental delivery strategy that preserves small meaningful commits. +## Implementation checkpoint -## Files added or changed +### Repository foundation and governance -- `README.md` -- `what_changed.md` +Implemented: -## Verification performed +- production README and project positioning; +- MIT license; +- contribution guide; +- code of conduct; +- security policy and private vulnerability-reporting guidance; +- privacy policy; +- support guidance; +- changelog and roadmap; +- issue templates and pull-request template; +- Dependabot configuration; +- Buy Me a Coffee funding metadata; +- architecture/setup/development/testing/troubleshooting/accessibility/performance/release/platform/bridge/branding/data-format/verification documentation; +- ADR structure; +- repository-safe `.env.example` guidance; +- CI, CodeQL, dependency-review, audit-formatting, and release workflows. -Repository inspection: +### Rust conversion core -- Confirmed `sanskarIN/unitflow` exists. -- Confirmed repository is public. -- Confirmed authenticated GitHub integration has push/admin permission. -- Confirmed default branch is `main`. +`crates/unitflow_core` contains the authoritative domain implementation: -Local toolchain availability check in the execution environment: +- category model; +- validated unit definitions; +- versioned built-in unit catalog with stable identifiers, symbols, aliases, descriptions, scales, and affine offsets; +- categories covering length, area, volume, mass, speed, pressure, energy, power, angle, data size, frequency, time, and temperature; +- stable-ID lookup index; +- category-scoped catalog access; +- deterministic text search; +- `rust_decimal`-based conversion through base units; +- explicit decimal precision validation; +- explicit rounding modes: + - nearest-even; + - half-away-from-zero; + - toward zero; + - away from zero; + - floor; + - ceiling; +- single conversion; +- ordered batch conversion; +- notation/formatting helpers; +- validation and typed failure handling; +- unit/regression/property-oriented tests; +- fuzz-target support; +- release-mode profiling example for lookup/search/single/batch workloads. -- `rustc`: unavailable -- `cargo`: unavailable -- `flutter`: unavailable -- `dart`: unavailable +### Rust ↔ Flutter bridge -Because the required compilers are not installed in the execution environment, build/test claims must not be marked as passing until GitHub Actions or a later environment runs them. +`crates/unitflow_bridge` provides a Flutter Rust Bridge-facing API around the Rust core. + +Bridge responsibilities implemented at source level: + +- safe DTOs for conversion requests/results; +- decimal values crossing the bridge as strings instead of binary floating point; +- explicit bridge rounding-mode mapping; +- single conversion API; +- batch conversion API; +- unit listing API; +- search API; +- pinned Flutter Rust Bridge dependency/code-generator version `2.12.0`; +- reproducible generation command in `tool/generate_bridge.sh`; +- CI/audit normalization that installs the pinned generator and regenerates bindings. + +Important release boundary: generated bindings and native-library packaging/loading still require exact-candidate CI/platform evidence. Binding source code existing in the repository is not the same as proving that every final platform artifact loads the native Rust library correctly. + +### Deterministic Dart fallback + +Flutter includes an arbitrary-precision base-10 `ExactDecimal` fallback with: + +- parsing including scientific notation; +- addition/subtraction/multiplication/division; +- explicit fractional precision; +- six rounding modes matching the Rust-facing behavior; +- canonical/fixed formatting; +- regression tests. + +This avoids converting user values through ordinary binary floating point when the native bridge is unavailable, including the web/fallback path. + +### Flutter application + +The Flutter application currently includes: + +- application bootstrap and controller; +- Material 3 light/dark/system themes; +- responsive navigation rail / bottom navigation; +- converter, library, history, settings, About, and onboarding experiences; +- desktop keyboard navigation shortcuts; +- Ctrl/Cmd + K library/search destination shortcut; +- reusable project-authored `UnitFlowMark` identity; +- startup branding; +- visible **Made by the Sanskar** credit; +- locale-aware parsing/formatting foundation; +- generated Flutter localization infrastructure; +- English ARB source catalog; +- deterministic result formatting; +- selectable precision, notation, grouping, and rounding settings; +- explicit reduced-motion preference; +- theme-transition/onboarding reduced-motion behavior; +- platform `disableAnimations` handling during onboarding; +- responsive compact/expanded converter layouts; +- accessible result semantics and major control tooltips; +- source/target swap; +- pin/unpin current pair; +- batch conversion table; +- deterministic CSV copying; +- searchable unit library; +- favorites; +- pinned-pair quick actions; +- recent conversion history; +- undoable history clearing; +- validated custom-unit editor; +- custom-unit deletion with undo; +- file/clipboard backup export/import workflows; +- user-initiated Releases-page access; +- About/support/funding/privacy links. + +### Local data and backup format + +Current `UserState` persists: + +- theme; +- notation; +- rounding mode; +- decimal places; +- digit grouping; +- reduced motion; +- onboarding completion; +- favorite unit IDs; +- pinned pairs; +- bounded recent conversions; +- validated custom units. + +Current portable schema is version `2`, documented in: + +- `schemas/unitflow-backup-v1.schema.json` — compatibility reference; +- `schemas/unitflow-backup-v2.schema.json` — current schema; +- `docs/data-format.md` — behavior/migration contract. + +Migration behavior: + +- valid schema-v1 backups migrate to `nearestEven`, which preserves UnitFlow's historical rounding default; +- schema-v1 backups migrate `reduceMotion` to `false`; +- early schema-v2 backups that contain rounding but lack the later optional `reduceMotion` field remain valid and default reduced motion to `false`; +- unknown future schemas are rejected rather than silently reinterpreted; +- import validation occurs before replacing the active state; +- malformed/unsupported imports must not partially overwrite the current profile. + +### Error handling and diagnostics + +Implemented: + +- structured app logger with redaction-oriented policy; +- stable user-safe error messages for backup/custom-unit failures; +- raw internal exception text is not intentionally echoed into those UI flows; +- helper `core/errors/user_safe_error.dart` logs exception type metadata while returning a safe localized fallback; +- state-load/save failures use diagnostics without storing conversion values/backup payloads; +- widget/core regression coverage for non-echoing error presentation. + +### Search and education + +Implemented: + +- searchable identifiers, names, symbols, aliases; +- description-aware matching in the deterministic Dart catalog path; +- broad Rust catalog descriptions and aliases; +- category explanations and examples in the converter learning panel; +- custom unit descriptions/aliases; +- regression coverage for descriptive custom-unit search. + +### Accessibility + +Implemented source-level foundations: + +- semantic labels/tooltips for major converter actions; +- keyboard navigation and adaptive desktop navigation; +- large-text-friendly responsive layouts rather than hard text-scale clamping; +- explicit reduced-motion preference; +- platform/framework animation-disable awareness during onboarding; +- light/dark/system theme support; +- user-safe validation/error text; +- accessibility documentation and release review requirements. + +Still required before release-ready status: + +- real-device/desktop large-text review; +- keyboard focus review; +- screen-reader-oriented review; +- contrast review on actual rendered builds; +- platform-specific reduced-motion review. + +### Branding + +Implemented: + +- Flutter runtime brand mark at `apps/unitflow_app/lib/app/branding/unitflow_mark.dart`; +- editable source vector at `assets/branding/unitflow-mark.svg`; +- mark used in application shell, startup, and About identity; +- branding/export guidance in `docs/branding.md`. + +Still required before release distribution: + +- generate final raster launcher/splash assets from the vector source; +- install them into committed platform shells; +- validate actual installed launcher/startup rendering on release targets. + +Do not fabricate or substitute fake screenshots as release evidence. + +## Testing and quality infrastructure + +### Rust + +Implemented coverage includes: + +- catalog construction/validation; +- category/unit conversion behavior; +- affine temperature behavior; +- precision/rounding behavior; +- batch ordering; +- error cases; +- notation helpers; +- property/regression-oriented invariants; +- bridge-input and catalog-search fuzz targets. + +### Flutter + +Implemented coverage includes: + +- exact-decimal arithmetic; +- conversion engine behavior; +- user-state backup round trip; +- schema-v1 → schema-v2 migration; +- early schema-v2 compatibility; +- custom-unit validation; +- app-controller favorites/pins/history/custom units; +- persisted rounding/grouping/precision settings; +- reduced-motion persistence; +- safe user error presentation; +- catalog description search; +- app startup/onboarding; +- converter semantic tooltips; +- Settings reduced-motion interaction; +- primary offline journey covering conversion submission, recent history, pinning, swapping, and reopening a recent pair. + +### Repository safety + +CI now includes dependency-free repository checks: + +- `tool/check_secrets.py` — common committed private-key/token signature scan over tracked text-like files; +- `tool/check_data_files.py` — UTF-8 JSON parsing for tracked JSON/ARB data; +- `tool/check_docs_links.py` — internal Markdown target validation. + +These supplement, not replace, CodeQL, dependency review, compiler/linter/test checks, and GitHub's own repository security features. + +### Performance + +Implemented: + +- `crates/unitflow_core/examples/profile.rs` release-mode profiling smoke harness; +- `tool/profile_core.sh` command; +- workload coverage for stable-ID lookup, category-scoped search, single conversion, and length batch conversion; +- documented rule that raw timing from dissimilar hosts must not be treated as equivalent benchmark evidence. + +No release performance number is claimed until output is recorded with machine/toolchain context. + +## Developer and release commands + +### Development quality + +```bash +bash tool/check.sh +``` + +This covers repository safety/data/docs plus Rust and Flutter gates. It regenerates the bridge only when the generator is installed and warns when that extra check is skipped. + +### Bridge generation + +```bash +cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked +bash tool/generate_bridge.sh +``` + +### Strict release candidate + +```bash +bash tool/verify_release_candidate.sh +``` + +The strict verifier requires the pinned bridge generator and runs repository checks, Rust formatting/lint/tests/release build, Flutter localization/format/analyze/tests, bridge regeneration, post-generation analysis/tests, web release build, clean generated-source check, and core profiling harness. + +Native platform builds/manual review remain separate platform gates. + +## GitHub Actions + +Configured workflows include: + +- CI: + - repository safety; + - Rust quality; + - Flutter quality; + - Rust/Flutter bridge generation/check; +- CodeQL; +- dependency review; +- audit-branch generated-source/format normalization; +- multi-platform release workflow. + +### Current verification truth + +During active development, many workflow runs are cancelled/superseded by later commits because concurrency is configured to keep the latest branch state authoritative. A queued, pending, cancelled, skipped, or older green run is **not** a passing result for the newest head. + +At this handoff rewrite, the latest exact-candidate workflows had not yet all completed successfully. Therefore: + +- do **not** claim Rust CI green for the current final head yet; +- do **not** claim Flutter CI green for the current final head yet; +- do **not** claim bridge generation green for the current final head yet; +- do **not** claim CodeQL/dependency review green for the current final head yet; +- do **not** merge PR #2 until the newest head is checked and failures are fixed. ## Commit identity note Requested commit email: `sanskarin@outlook.in`. -The connected GitHub Contents API actions used in this session do not expose an `author.email`/`committer.email` argument, so individual API-created commit identity is controlled by the authenticated GitHub integration. The repository includes contributor setup guidance to configure `user.email=sanskarin@outlook.in` for local Git commits. Do not falsely claim that connector-created commits used a configurable email when the API surface did not permit it. +The connected GitHub Contents API used for most atomic file commits does not expose an author/committer-email parameter, so those connector-created commits use identity determined by the authenticated GitHub integration. Do not falsely claim otherwise. + +The repository's audit normalization workflow explicitly configures: + +```text +user.name = Sanskar +user.email = sanskarin@outlook.in +``` + +before it creates its own generated-source/formatting normalization commit. Contributor/setup documentation also uses the requested email for local Git configuration. + +## Files/directories of special importance + +```text +README.md +ROADMAP.md +CHANGELOG.md +what_changed.md +Cargo.toml +crates/unitflow_core/ +crates/unitflow_bridge/ +apps/unitflow_app/ +fuzz/ +assets/branding/ +schemas/ +tool/ +docs/ +.github/ +``` + +Recent additions/hardening include: -## Known limitations +```text +apps/unitflow_app/lib/app/branding/unitflow_mark.dart +apps/unitflow_app/lib/core/errors/user_safe_error.dart +apps/unitflow_app/test/app/primary_journey_test.dart +apps/unitflow_app/test/core/user_safe_error_test.dart +apps/unitflow_app/test/features/unit_catalog_search_test.dart +assets/branding/unitflow-mark.svg +schemas/unitflow-backup-v2.schema.json +tool/check_secrets.py +tool/check_data_files.py +tool/check_docs_links.py +tool/profile_core.sh +tool/verify_release_candidate.sh +crates/unitflow_core/examples/profile.rs +docs/bridge.md +docs/platform-support.md +docs/branding.md +``` -- Rust/Flutter compilation has not yet been executed locally because those toolchains are unavailable in the execution environment. -- UI screenshots cannot be real until a runnable Flutter build exists. -- Native Rust↔Flutter binding generation will be introduced after the domain core and Flutter shell are stable. +## Remaining release blockers / exact next work -## Exact next tasks +These are not hidden TODOs; they are explicit release gates. -1. Add repository policy/configuration files and MIT license. -2. Add architecture/setup/testing/release documentation and first ADR. -3. Create Rust workspace and `unitflow_core` crate. -4. Implement category/unit models, validation, catalog, conversion service, notation helpers, and tests. -5. Create Flutter application package and adaptive converter UI. -6. Add persistence abstractions for favorites, recents, pinned pairs, settings, and custom units. -7. Add CI, CodeQL, dependency updates, templates, and release workflow. -8. Run available remote quality gates and fix every discovered issue. -9. Update this handoff after each milestone. +1. Stop source churn long enough for workflows on the newest exact PR head to execute. +2. Inspect latest CI jobs/logs rather than relying on older runs. +3. Fix every formatter/compiler/analyzer/test/repository-safety failure discovered by those workflows. +4. Ensure the audit normalization workflow successfully regenerates Flutter localizations and FRB bindings using the pinned versions. +5. Inspect the generated Dart/Rust bridge API and wire/validate the native runtime adapter without guessing generated API names. +6. Prove native Rust library packaging/loading on Android/Windows/Linux/macOS/iOS-ready builds. +7. Prove deterministic web fallback behavior against representative Rust regression vectors. +8. Run the cross-platform release workflow for an exact release candidate and fix all build issues. +9. Run manual primary user journeys on the advertised release platforms. +10. Complete keyboard/text-scaling/screen-reader/contrast/reduced-motion manual accessibility review. +11. Export and install final launcher/splash assets from the source SVG into real platform shells. +12. Capture real phone/desktop/dark-mode screenshots from validated builds. +13. Record performance profiling output with hardware/toolchain context. +14. Run a documented fuzzing campaign budget for the release candidate. +15. Generate and publish checksum metadata for final downloadable artifacts. +16. Configure signing/notarization/store credentials only in private platform/repository secret facilities, never in source. +17. Run `tool/verify_release_candidate.sh` on the exact release candidate and ensure it leaves no tracked changes. +18. Re-read this file, `ROADMAP.md`, `CHANGELOG.md`, and release docs for stale claims before tagging. +19. Tag/release `0.1.0-alpha.1` only after the applicable automated/manual gates are satisfied. -## Release notes draft +## Completion policy -### 0.1.0-alpha.1 +UnitFlow source is substantially implemented, but **the project must not be called fully release-complete while the newest exact-candidate CI/platform/manual/release evidence remains incomplete**. Continue from the blockers above, fix evidence-producing failures rather than hiding them, and keep this handoff synchronized with reality. -Initial UnitFlow foundation: project documentation, Rust conversion engine, Flutter application shell, automated quality gates, security/privacy documentation, and repository governance. +## Release notes draft — 0.1.0-alpha.1 -## Recent meaningful commits +Planned initial preview includes the high-precision Rust conversion core, deterministic Flutter fallback, adaptive converter/library/history/settings UX, favorites/pins/custom units, batch conversion, local backup/restore, explicit rounding/notation controls, reduced-motion accessibility preference, localization infrastructure, project branding, bridge/release automation, security/privacy safeguards, and broad automated quality coverage. -- `36bac8e` — `docs: add UnitFlow repository overview` +Release-note wording must be finalized only after the exact tagged candidate passes the required checks. From 17ede6f4190a22fe06d3816bce776a5c918baea4 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:01:43 +0530 Subject: [PATCH 130/241] docs: retire superseded phase one handoff --- what_changed_phase1.md | 293 +---------------------------------------- 1 file changed, 6 insertions(+), 287 deletions(-) diff --git a/what_changed_phase1.md b/what_changed_phase1.md index 3f229006..d1ee3868 100644 --- a/what_changed_phase1.md +++ b/what_changed_phase1.md @@ -1,295 +1,14 @@ -# UnitFlow — Current Development Handoff +# UnitFlow — Superseded Phase 1 Handoff -_Last updated: 2026-08-19_ +This file is retained only as a historical filename from the initial audit branch work. -This file is the current continuation checkpoint for the active Phase 1/quality-audit branch. The original `what_changed.md` remains in the repository from the bootstrap checkpoint; this document supersedes that checkpoint until the active audit branch is merged and the canonical handoff is refreshed. +The canonical, current development checkpoint is now: -## Current milestone +- [`what_changed.md`](what_changed.md) -- Product version: `0.1.0-alpha.1` -- Active branch: `audit/phase-1-quality` -- Pull request: `#2 — test: audit phase 1 quality gates` -- Repository: `https://github.com/sanskarIN/unitflow` -- Source model: public / open source / MIT -- Architecture: Rust authoritative domain core + Flutter presentation, with a dedicated `flutter_rust_bridge` boundary and deterministic Dart exact-decimal fallback. -- Required project credit: **Made by the Sanskar** +Do not use older details from this file as implementation, schema, CI, bridge, or release status. In particular, the backup schema and project milestone state have advanced since the original Phase 1 checkpoint. -## Completed repository foundation - -- Added MIT license and core repository hygiene: - - `.gitignore` - - `.editorconfig` - - `.gitattributes` - - `.env.example` -- Added governance and support documentation: - - `CONTRIBUTING.md` - - `CODE_OF_CONDUCT.md` - - `SECURITY.md` - - `SUPPORT.md` - - `PRIVACY.md` - - `CHANGELOG.md` - - `ROADMAP.md` -- Added architecture/setup/development/testing/release/troubleshooting/accessibility/performance documentation. -- Added ADRs for: - - Rust-core + Flutter UI architecture; - - generated Rust–Flutter bridge with deterministic fallback. -- Added GitHub repository operations guidance including branch protection, labels, milestones, Discussions, release, and funding guidance. -- Added editable logo and app-icon SVG artwork under `docs/assets/`. -- Added Buy Me a Coffee funding metadata and visible project support links. -- Added structured GitHub bug/feature templates and pull-request checklist. -- Added Dependabot configuration for Cargo, Pub, and GitHub Actions. - -## Rust domain core completed so far - -Workspace crate: `crates/unitflow_core` - -Implemented: - -- strongly typed category model; -- validated immutable unit definitions; -- stable unit identifiers; -- built-in catalog covering more than 100 units across: - - length; - - area; - - volume; - - mass; - - speed; - - pressure; - - energy; - - power; - - angle; - - data size; - - frequency; - - time; - - temperature; -- exact base-unit affine conversion model: - -```text -base = value * scale + offset -output = (base - target.offset) / target.scale -``` - -- `rust_decimal` high-precision decimal arithmetic; -- checked arithmetic and typed failures; -- explicit rounding modes: - - nearest-even; - - half-away-from-zero; - - toward zero; - - away from zero; - - floor; - - ceiling; -- batch conversion preserving requested target order; -- search by name, stable ID, symbol, and aliases with exact/prefix/substring ranking; -- safe affine custom units with validation; -- plain/scientific/engineering notation formatting without binary floating point; -- crate-level `forbid(unsafe_code)` for the domain core. - -## Rust–Flutter bridge completed so far - -Workspace crate: `crates/unitflow_bridge` - -Implemented: - -- thin bridge DTOs; -- decimal values represented as strings at the language boundary; -- bridge endpoints for: - - version; - - catalog listing; - - catalog search; - - conversion; - - notation formatting; -- `flutter_rust_bridge` dependency and reproducible bridge-generation script; -- bridge API tests; -- generated FFI code isolated from the unsafe-free domain crate. - -The Flutter production adapter that consumes generated Dart bridge bindings remains an exact next task after generator verification passes in CI. Until then Flutter uses the deterministic exact-decimal fallback implementing the same application-facing contract. - -## Flutter application completed so far - -Application: `apps/unitflow_app` - -Implemented architecture/features: - -- Material 3 design system with spacing/radius/breakpoint tokens; -- system/light/dark themes; -- adaptive navigation rail / bottom navigation; -- keyboard shortcuts for desktop/web navigation; -- polished first-run onboarding; -- responsive converter screen; -- exact-decimal deterministic Dart fallback engine; -- locale-aware decimal parsing and display formatting; -- scientific and engineering notation preferences; -- configurable decimal places; -- digit grouping preference; -- searchable unit library; -- favorites; -- pinned conversion pairs; -- recent conversion history; -- quick pair reopening; -- safe custom-unit editor using affine scale/offset formulas; -- category explanations and educational examples; -- quick source/target swap; -- batch conversion table; -- direct result copy; -- deterministic batch CSV generation and copy; -- local settings/favorites/history/pins/custom-unit persistence; -- versioned JSON backup schema; -- clipboard backup/restore; -- bounded cross-platform file-picker backup import/export; -- schema validation before replacing local state; -- import-size and UTF-8 validation; -- About screen with: - - project version; - - MIT license information; - - privacy summary; - - GitHub repository; - - Buy Me a Coffee; - - support/business contacts; - - **Made by the Sanskar**; -- redacting structured diagnostic logger; -- generated localization architecture with external English ARB source; -- offline-first static conversion behavior without forced account/login. - -## Local data model - -Current backup schema version: `1` - -Locally persisted data includes: - -- theme/notation/formatting preferences; -- onboarding state; -- favorites; -- pinned pairs; -- bounded recent history; -- validated custom units. - -Import validation rejects unsupported schemas and invalid custom-unit data before state replacement. - -## Test coverage added - -### Rust - -- built-in catalog/category coverage; -- search ranking and alias search; -- exact metric conversion; -- international mile conversion; -- Celsius/Fahrenheit affine conversion; -- category mismatch rejection; -- explicit rounding-mode behavior; -- batch ordering; -- precision bounds; -- custom-unit scale/identifier/alias validation; -- scientific/engineering notation; -- bridge endpoint tests; -- property-based tests for: - - identity conversion; - - exact metric round trips; - - batch target ordering. - -### Fuzzing - -Cargo-fuzz harnesses under `fuzz/` for: - -- arbitrary UTF-8 catalog search; -- parsed arbitrary decimal values through notation formatting. - -### Flutter - -- exact-decimal parser/arithmetic; -- scientific input parsing; -- rounding behavior; -- deterministic conversion engine; -- Celsius/Fahrenheit conversion; -- cross-category rejection; -- batch target ordering; -- versioned backup JSON round-trip; -- invalid schema rejection; -- custom-unit validation; -- batch CSV escaping; -- app launch into converter after onboarding; -- first-run onboarding completion; -- key action tooltip/semantic discoverability. - -## Automation and release engineering added - -- Primary CI workflow: - - Rust formatting; - - Rust Clippy with warnings denied; - - Rust workspace tests; - - Flutter dependency resolution; - - generated localizations; - - Dart formatting; - - Flutter analyzer with infos/warnings fatal; - - Flutter tests; - - independent Rust–Flutter bridge generation/check job. -- CodeQL workflow for Rust. -- Dependency review workflow. -- Audit-branch normalization workflow that: - - generates Rust/Flutter lockfiles; - - runs Rust/Dart formatters; - - commits normalization using `Sanskar ` when changes exist. -- Tagged release workflow covering: - - Rust release profile; - - Flutter Web; - - Flutter Android unsigned APK; - - Flutter Linux; - - Flutter Windows; - - Flutter macOS; - - iOS no-codesign validation. -- Reproducible scripts: - - `tool/check.sh` - - `tool/generate_bridge.sh` - - `tool/bootstrap_platforms.sh` - -## Verification history - -Known executed repository verification: - -1. The first CI run on the initial `main` implementation reached Rust formatting and correctly failed because the newly created source files had not yet been normalized by `rustfmt`. -2. A dedicated audit branch and PR were created so CI failures can be fixed before merging into `main`. -3. An audit-only formatter/lockfile workflow was added so formatting and lockfile generation can be performed by an authenticated GitHub runner with the required commit identity. -4. Subsequent branch changes have intentionally restarted/cancelled earlier queued audit runs; the final audit run must be allowed to finish after feature changes stop. - -Do not claim the branch is fully verified until the latest head has completed all required CI jobs successfully. - -## Known limitations / unfinished verification - -These items are still open and must be handled before calling `0.1.0-alpha.1` release-ready: - -1. Let the audit normalization workflow finish on the final branch head and commit formatter/lockfile output if required. -2. Inspect the newest Rust/Flutter/bridge CI jobs and fix every compile, format, lint, or test failure. -3. Validate `flutter_rust_bridge_codegen 2.12.0` against the checked-in bridge API and generated Dart/Rust glue. -4. Add the production Rust-backed Flutter `ConversionEngine` adapter after generated bindings are verified; keep the deterministic fallback for web/tests/graceful startup. -5. Run the tagged release workflow or equivalent platform builds on compatible hosts and repair platform-specific packaging issues if discovered. -6. Replace README demo placeholders with real captures after a verified runnable platform build. -7. Remove the temporary audit-only autoformat workflow before stable release if it is no longer useful. -8. Refresh canonical `what_changed.md` after PR merge. -9. Perform the Phase 6 clean-clone, documentation-link, accessibility, dependency/security, and release-candidate audit. - -## Exact next tasks - -1. Stop feature commits temporarily. -2. Let the latest `audit/phase-1-quality` workflows run. -3. Read failing job steps/logs, if any. -4. Fix failures with regression tests where behavior bugs are found. -5. Regenerate/verify bridge bindings. -6. Run the complete quality suite again. -7. Merge PR #2 with history preserved only when required checks are green or an external repository-setting limitation is explicitly documented. -8. Continue Phase 2/3 platform integration from the merged state instead of rewriting finished domain/UI work. - -## Commit strategy - -Development has been intentionally split into many small, meaningful Conventional-Commit-style changes across documentation, architecture, domain models, conversion logic, UI features, testing, security, CI, release engineering, accessibility, localization, persistence, and bug fixes. - -Do not create empty commits or one-line churn solely to inflate commit count. - -## Contact / project identity - -- GitHub: `https://github.com/sanskarIN` -- Business: `sanskarin@outlook.in` -- Business: `sanskarin.business@gmail.com` -- Support: `supportramsandesh@gmail.com` -- Buy Me a Coffee: `https://buymeacoffee.com/sanskarIN` +Future development sessions must update `what_changed.md` instead of creating another parallel handoff file. --- From b1ce92fa40e09d815b87254e137426e24cc07521 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:04:13 +0530 Subject: [PATCH 131/241] build: add reproducible native bridge integration --- tool/integrate_native_bridge.sh | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tool/integrate_native_bridge.sh diff --git a/tool/integrate_native_bridge.sh b/tool/integrate_native_bridge.sh new file mode 100644 index 00000000..2b162920 --- /dev/null +++ b/tool/integrate_native_bridge.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="$ROOT/apps/unitflow_app" +BRIDGE="$ROOT/crates/unitflow_bridge" + +if ! command -v flutter_rust_bridge_codegen >/dev/null 2>&1; then + echo "flutter_rust_bridge_codegen is required." >&2 + echo "Install the pinned generator with:" >&2 + echo " cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked" >&2 + exit 1 +fi + +# Cargokit is the stable/default integration backend for flutter_rust_bridge 2.12.0. +# Native Assets requires a later FRB generation and is intentionally not selected here. +cd "$APP" +flutter_rust_bridge_codegen integrate \ + --rust-crate-name unitflow_bridge \ + --rust-crate-dir ../../crates/unitflow_bridge \ + --integration-backend cargokit \ + --platforms android,ios,linux,macos,windows \ + --no-write-lib \ + --no-integration-test \ + --no-dart-fix \ + --no-dart-format \ + --skip-fvm-install + +# The bridge crate belongs to the repository-level Cargo workspace. A nested lockfile +# generated by the integration command would be misleading and is removed deliberately. +rm -f "$BRIDGE/Cargo.lock" + +# Generate bindings from the real UnitFlow bridge API after native build scaffolding exists. +cd "$ROOT" +bash tool/generate_bridge.sh + +echo "UnitFlow native FRB/Cargokit scaffolding and generated bindings are ready." From fcce1c30c04838c8658391c515c5bcde44ed8f89 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:04:32 +0530 Subject: [PATCH 132/241] build: bootstrap platform shells and native bridge scaffolding --- .github/workflows/format-audit.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/format-audit.yml b/.github/workflows/format-audit.yml index 50737a19..48b053e3 100644 --- a/.github/workflows/format-audit.yml +++ b/.github/workflows/format-audit.yml @@ -37,6 +37,12 @@ jobs: - name: Install bridge generator run: cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked + - name: Bootstrap Flutter platform shells + run: bash tool/bootstrap_platforms.sh + + - name: Integrate native Rust bridge scaffolding + run: bash tool/integrate_native_bridge.sh + - name: Generate Rust lockfile run: cargo generate-lockfile @@ -58,19 +64,19 @@ jobs: working-directory: apps/unitflow_app run: dart format lib test - - name: Commit normalized sources when needed + - name: Commit normalized generated sources when needed shell: bash run: | if git diff --quiet && test -f Cargo.lock && test -f apps/unitflow_app/pubspec.lock; then - echo "Generated sources, formatting, and lockfiles are already clean." + echo "Platform shells, native bridge scaffolding, generated sources, formatting, and lockfiles are already clean." exit 0 fi git config user.name "Sanskar" git config user.email "sanskarin@outlook.in" - git add Cargo.lock apps/unitflow_app/pubspec.lock crates apps/unitflow_app/lib apps/unitflow_app/test + git add Cargo.lock apps/unitflow_app crates if git diff --cached --quiet; then echo "No tracked normalization changes to commit." exit 0 fi - git commit -m "style: normalize generated sources and lock dependencies" + git commit -m "build: normalize platform shells bridge and generated sources" git push origin HEAD:${{ github.event.pull_request.head.ref }} From 0d0ff584fee3b0eedda44ed8a9e26e0547cb5b08 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:04:53 +0530 Subject: [PATCH 133/241] ci: validate developer utility script syntax --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc3f0bf3..4d422388 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,12 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Validate shell script syntax + run: bash -n tool/*.sh + + - name: Validate Python utility syntax + run: python3 -m py_compile tool/*.py + - name: Scan tracked files for credential signatures run: python3 tool/check_secrets.py From baf5077f6f2fa23cf25c96ca0c7e649329ff93ab Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:05:24 +0530 Subject: [PATCH 134/241] ci: validate native bridge integration reproducibility --- .github/workflows/ci.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d422388..3f034c21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,8 +115,11 @@ jobs: - name: Install bridge generator run: cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked - - name: Generate bridge - run: bash tool/generate_bridge.sh + - name: Bootstrap Flutter platform shells + run: bash tool/bootstrap_platforms.sh + + - name: Integrate native bridge and generate bindings + run: bash tool/integrate_native_bridge.sh - name: Verify generated Rust run: cargo check --workspace --all-features @@ -124,3 +127,6 @@ jobs: - name: Verify generated Dart working-directory: apps/unitflow_app run: flutter analyze --fatal-infos --fatal-warnings + + - name: Verify generated integration is committed + run: git diff --exit-code From 0237de38831691f678ccc4384507cfedda001049 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:06:31 +0530 Subject: [PATCH 135/241] fix: remove stale history when deleting custom units --- apps/unitflow_app/lib/app/app_controller.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/unitflow_app/lib/app/app_controller.dart b/apps/unitflow_app/lib/app/app_controller.dart index baaf3cff..794bd88b 100644 --- a/apps/unitflow_app/lib/app/app_controller.dart +++ b/apps/unitflow_app/lib/app/app_controller.dart @@ -178,10 +178,14 @@ final class AppController extends ChangeNotifier { final nextPins = _state.pinnedPairs .where((pair) => pair.fromUnitId != id && pair.toUnitId != id) .toList(); + final nextRecents = _state.recents + .where((recent) => recent.fromUnitId != id && recent.toUnitId != id) + .toList(); final newState = _state.copyWith( customUnits: nextCustom, favoriteUnitIds: nextFavorites, pinnedPairs: nextPins, + recents: nextRecents, ); return _update(newState, engine: _buildEngine(newState)); } From 48d49643f40104612b5a11febb5732221c7278c6 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:07:00 +0530 Subject: [PATCH 136/241] test: cover custom unit state cleanup --- .../test/app/app_controller_test.dart | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/unitflow_app/test/app/app_controller_test.dart b/apps/unitflow_app/test/app/app_controller_test.dart index 75b8d91f..c20f7744 100644 --- a/apps/unitflow_app/test/app/app_controller_test.dart +++ b/apps/unitflow_app/test/app/app_controller_test.dart @@ -108,6 +108,42 @@ void main() { expect(controller.engine.catalog.byId('double_meter'), isNull); }); + test('removing custom unit cleans favorites pins and history references', () async { + const custom = CustomUnitData( + id: 'double_meter', + category: UnitCategory.length, + name: 'Double Meter', + symbol: 'dmx', + scale: '2', + offset: '0', + ); + const pair = PinnedPair( + category: UnitCategory.length, + fromUnitId: 'meter', + toUnitId: 'double_meter', + ); + + await controller.addCustomUnit(custom); + await controller.toggleFavorite(custom.id); + await controller.togglePinnedPair(pair); + await controller.recordRecent( + input: '5', + fromUnitId: 'meter', + toUnitId: custom.id, + ); + + expect(controller.state.favoriteUnitIds, contains(custom.id)); + expect(controller.state.pinnedPairs, hasLength(1)); + expect(controller.state.recents, hasLength(1)); + + await controller.removeCustomUnit(custom.id); + + expect(controller.state.favoriteUnitIds, isNot(contains(custom.id))); + expect(controller.state.pinnedPairs, isEmpty); + expect(controller.state.recents, isEmpty); + expect(controller.engine.catalog.byId(custom.id), isNull); + }); + test('custom unit cannot replace a built-in stable id', () async { const custom = CustomUnitData( id: 'meter', From e09ad75df6c519e0732a7084782646a877f51997 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:07:32 +0530 Subject: [PATCH 137/241] fix: normalize stale unit references on load and import --- apps/unitflow_app/lib/app/app_controller.dart | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/unitflow_app/lib/app/app_controller.dart b/apps/unitflow_app/lib/app/app_controller.dart index 794bd88b..7a1e9beb 100644 --- a/apps/unitflow_app/lib/app/app_controller.dart +++ b/apps/unitflow_app/lib/app/app_controller.dart @@ -27,7 +27,7 @@ final class AppController extends ChangeNotifier { try { final loaded = await _repository.load(); final rebuilt = _buildEngine(loaded); - _state = loaded; + _state = _normalizeStateReferences(loaded, rebuilt); _engine = rebuilt; AppLog.write(LogLevel.info, 'state_loaded'); } on Object catch (error) { @@ -119,6 +119,11 @@ final class AppController extends ChangeNotifier { required String fromUnitId, required String toUnitId, }) { + final from = _engine.catalog.byId(fromUnitId); + final to = _engine.catalog.byId(toUnitId); + if (from == null || to == null || from.category != to.category) { + throw ArgumentError('Recent conversion references invalid units.'); + } final next = _state.recents.toList(); if (next.isNotEmpty && next.first.input == input && @@ -144,8 +149,10 @@ final class AppController extends ChangeNotifier { Future clearHistory() => _update(_state.copyWith(recents: [])); - Future restoreHistory(List recents) => - _update(_state.copyWith(recents: recents)); + Future restoreHistory(List recents) { + final restored = _state.copyWith(recents: recents.take(50).toList()); + return _update(_normalizeStateReferences(restored, _engine)); + } Future addCustomUnit(CustomUnitData customUnit) { final definition = customUnit.toUnitDefinition(); @@ -195,7 +202,8 @@ final class AppController extends ChangeNotifier { Future importState(String content) { final imported = _repository.importJson(content); final importedEngine = _buildEngine(imported); - return _update(imported, engine: importedEngine); + final normalized = _normalizeStateReferences(imported, importedEngine); + return _update(normalized, engine: importedEngine); } Future resetLocalData() async { @@ -225,6 +233,50 @@ final class AppController extends ChangeNotifier { return ExactConversionEngine(catalog: UnitCatalog(units)); } + UserState _normalizeStateReferences( + UserState state, + ConversionEngine engine, + ) { + final validIds = engine.catalog.units.map((unit) => unit.id).toSet(); + final favorites = state.favoriteUnitIds + .where(validIds.contains) + .toSet(); + final pins = state.pinnedPairs.where((pair) { + final from = engine.catalog.byId(pair.fromUnitId); + final to = engine.catalog.byId(pair.toUnitId); + return from != null && + to != null && + from.category == pair.category && + to.category == pair.category; + }).take(20).toList(); + final recents = state.recents.where((recent) { + final from = engine.catalog.byId(recent.fromUnitId); + final to = engine.catalog.byId(recent.toUnitId); + return from != null && to != null && from.category == to.category; + }).take(50).toList(); + + final removedFavorites = state.favoriteUnitIds.length - favorites.length; + final removedPins = state.pinnedPairs.length - pins.length; + final removedRecents = state.recents.length - recents.length; + if (removedFavorites + removedPins + removedRecents > 0) { + AppLog.write( + LogLevel.warning, + 'state_references_normalized', + fields: { + 'removed_favorites': removedFavorites, + 'removed_pins': removedPins, + 'removed_recents': removedRecents, + }, + ); + } + + return state.copyWith( + favoriteUnitIds: favorites, + pinnedPairs: pins, + recents: recents, + ); + } + Future _update(UserState state, {ConversionEngine? engine}) { _state = state; if (engine != null) { From abdf3c136877dc7aeb24135991283410c2507956 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:08:09 +0530 Subject: [PATCH 138/241] test: cover stale persisted unit reference normalization --- .../test/app/app_controller_test.dart | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/unitflow_app/test/app/app_controller_test.dart b/apps/unitflow_app/test/app/app_controller_test.dart index c20f7744..2574c8a6 100644 --- a/apps/unitflow_app/test/app/app_controller_test.dart +++ b/apps/unitflow_app/test/app/app_controller_test.dart @@ -144,6 +144,51 @@ void main() { expect(controller.engine.catalog.byId(custom.id), isNull); }); + test('initialization prunes stale persisted unit references', () async { + controller.dispose(); + repository = MemoryUserStateRepository( + UserState( + onboardingComplete: true, + favoriteUnitIds: {'meter', 'missing_unit'}, + pinnedPairs: const [ + PinnedPair( + category: UnitCategory.length, + fromUnitId: 'meter', + toUnitId: 'kilometer', + ), + PinnedPair( + category: UnitCategory.length, + fromUnitId: 'meter', + toUnitId: 'missing_unit', + ), + ], + recents: [ + RecentConversion( + input: '1', + fromUnitId: 'meter', + toUnitId: 'kilometer', + createdAt: DateTime.utc(2026, 1, 1), + ), + RecentConversion( + input: '2', + fromUnitId: 'meter', + toUnitId: 'missing_unit', + createdAt: DateTime.utc(2026, 1, 2), + ), + ], + ), + ); + controller = AppController(repository: repository); + + await controller.initialize(); + + expect(controller.state.favoriteUnitIds, {'meter'}); + expect(controller.state.pinnedPairs, hasLength(1)); + expect(controller.state.pinnedPairs.single.toUnitId, 'kilometer'); + expect(controller.state.recents, hasLength(1)); + expect(controller.state.recents.single.toUnitId, 'kilometer'); + }); + test('custom unit cannot replace a built-in stable id', () async { const custom = CustomUnitData( id: 'meter', From 186df3dd00f74d679fe94d424fec9ed39cfa5547 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:08:35 +0530 Subject: [PATCH 139/241] test: use collection matcher for normalized favorites --- apps/unitflow_app/test/app/app_controller_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/unitflow_app/test/app/app_controller_test.dart b/apps/unitflow_app/test/app/app_controller_test.dart index 2574c8a6..0bf295da 100644 --- a/apps/unitflow_app/test/app/app_controller_test.dart +++ b/apps/unitflow_app/test/app/app_controller_test.dart @@ -182,7 +182,7 @@ void main() { await controller.initialize(); - expect(controller.state.favoriteUnitIds, {'meter'}); + expect(controller.state.favoriteUnitIds, unorderedEquals(['meter'])); expect(controller.state.pinnedPairs, hasLength(1)); expect(controller.state.pinnedPairs.single.toUnitId, 'kilometer'); expect(controller.state.recents, hasLength(1)); From 9e2f335477e8b283c9d6301ccad12acef1a42dba Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:09:03 +0530 Subject: [PATCH 140/241] docs: document stale unit reference normalization --- docs/data-format.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/data-format.md b/docs/data-format.md index 56a9a3bb..85b08362 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -27,7 +27,8 @@ A backup is a UTF-8 JSON object. The root `schemaVersion` field is mandatory. Un Current safety bounds include: - file/import text size: at most 1 MB; -- recent conversions: at most 100 accepted from an imported document, with the app normally retaining a smaller recent set; +- recent conversions: at most 100 accepted from an imported document, with the app normally retaining at most 50 active recents; +- pinned pairs: at most 20 active pairs; - custom units: at most 200 accepted from an imported document; - custom aliases: at most 32 per unit; - decimal precision preference: 0–28 places; @@ -60,6 +61,22 @@ base_value = input_value * scale + offset The scale must be strictly positive. This design covers ordinary multiplicative units and temperature-like offsets without introducing an expression interpreter into imported user data. +## Referential normalization + +Favorites, pinned pairs, and recent conversions store stable unit IDs. A previously valid ID can become stale if a custom unit is removed or a future catalog migration retires an identifier. + +When state is loaded or imported, UnitFlow rebuilds the current catalog and normalizes convenience references against it: + +- favorites referencing an unavailable unit are removed; +- pinned pairs are kept only when both units exist and still belong to the stored category; +- recent conversions are kept only when both units exist and belong to the same category; +- active pins are bounded to 20 and active recents to 50; +- custom units themselves are not silently dropped by this normalization step; invalid or duplicate custom-unit definitions reject the load/import instead. + +Removing a custom unit also removes favorites, pins, and recent-history rows that reference that unit. This prevents an otherwise valid backup from accumulating inaccessible convenience data. + +Normalization diagnostics record only removed item counts, not unit names, values, backup payloads, or conversion history contents. + ## Import behavior An import is rejected when, among other validation failures: @@ -72,7 +89,7 @@ An import is rejected when, among other validation failures: - duplicate identifiers would collide with built-in or imported custom units; - the import exceeds configured size/count limits. -The application preserves the existing state when validation fails. +The application preserves the existing state when validation fails. A structurally valid import may have stale convenience references normalized as described above after its catalog is successfully rebuilt. ## Export behavior From a09577b016578e29af7da8fc4587df5a875a9c87 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:09:52 +0530 Subject: [PATCH 141/241] fix: handle local persistence failures gracefully --- apps/unitflow_app/lib/app/app_controller.dart | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/unitflow_app/lib/app/app_controller.dart b/apps/unitflow_app/lib/app/app_controller.dart index 7a1e9beb..7ec44faa 100644 --- a/apps/unitflow_app/lib/app/app_controller.dart +++ b/apps/unitflow_app/lib/app/app_controller.dart @@ -11,6 +11,11 @@ import '../features/converter/domain/unit_models.dart'; final class AppController extends ChangeNotifier { AppController({required UserStateRepository repository}) : _repository = repository; + static const _saveWarning = + 'Changes are available for this session but could not be saved locally. Export a backup before closing UnitFlow.'; + static const _clearWarning = + 'Local data could not be cleared. Your existing saved data was left in place.'; + final UserStateRepository _repository; UserState _state = UserState(); ConversionEngine _engine = ExactConversionEngine(); @@ -207,7 +212,19 @@ final class AppController extends ChangeNotifier { } Future resetLocalData() async { - await _repository.clear(); + try { + await _writeChain; + await _repository.clear(); + } on Object catch (error) { + _warning = _clearWarning; + AppLog.write( + LogLevel.error, + 'local_data_clear_failed', + fields: {'error_type': error.runtimeType.toString()}, + ); + notifyListeners(); + return; + } _state = UserState(onboardingComplete: true); _engine = ExactConversionEngine(); _warning = null; @@ -238,9 +255,7 @@ final class AppController extends ChangeNotifier { ConversionEngine engine, ) { final validIds = engine.catalog.units.map((unit) => unit.id).toSet(); - final favorites = state.favoriteUnitIds - .where(validIds.contains) - .toSet(); + final favorites = state.favoriteUnitIds.where(validIds.contains).toSet(); final pins = state.pinnedPairs.where((pair) { final from = engine.catalog.byId(pair.fromUnitId); final to = engine.catalog.byId(pair.toUnitId); @@ -286,13 +301,16 @@ final class AppController extends ChangeNotifier { final snapshot = state; final operation = _writeChain.then((_) => _repository.save(snapshot)); - _writeChain = operation.catchError((Object error) { + final handled = operation.catchError((Object error) { + _warning = _saveWarning; AppLog.write( LogLevel.error, 'state_save_failed', fields: {'error_type': error.runtimeType.toString()}, ); + notifyListeners(); }); - return operation; + _writeChain = handled; + return handled; } } From 0d57b690f4065793d38a6920d7be098f3ebb2ee0 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:10:19 +0530 Subject: [PATCH 142/241] test: cover graceful local persistence failures --- .../app/app_persistence_failure_test.dart | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/unitflow_app/test/app/app_persistence_failure_test.dart diff --git a/apps/unitflow_app/test/app/app_persistence_failure_test.dart b/apps/unitflow_app/test/app/app_persistence_failure_test.dart new file mode 100644 index 00000000..f9be4e11 --- /dev/null +++ b/apps/unitflow_app/test/app/app_persistence_failure_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/app/app_controller.dart'; +import 'package:unitflow/core/persistence/user_state.dart'; +import 'package:unitflow/core/persistence/user_state_repository.dart'; + +void main() { + test('save failure keeps session state and exposes a warning', () async { + final repository = _FailingRepository( + initial: UserState(onboardingComplete: true), + failSave: true, + ); + final controller = AppController(repository: repository); + addTearDown(controller.dispose); + await controller.initialize(); + + await expectLater(controller.setTheme(ThemePreference.dark), completes); + + expect(controller.state.theme, ThemePreference.dark); + expect(controller.warning, contains('could not be saved locally')); + }); + + test('clear failure preserves existing state and exposes a warning', () async { + final repository = _FailingRepository( + initial: UserState( + onboardingComplete: true, + theme: ThemePreference.dark, + ), + failClear: true, + ); + final controller = AppController(repository: repository); + addTearDown(controller.dispose); + await controller.initialize(); + + await expectLater(controller.resetLocalData(), completes); + + expect(controller.state.theme, ThemePreference.dark); + expect(controller.warning, contains('could not be cleared')); + }); +} + +final class _FailingRepository implements UserStateRepository { + _FailingRepository({ + required UserState initial, + this.failSave = false, + this.failClear = false, + }) : _state = initial; + + final bool failSave; + final bool failClear; + final MemoryUserStateRepository _codec = MemoryUserStateRepository(); + UserState _state; + + @override + Future load() async => _state; + + @override + Future save(UserState state) async { + if (failSave) { + throw StateError('simulated save failure'); + } + _state = state; + } + + @override + Future clear() async { + if (failClear) { + throw StateError('simulated clear failure'); + } + _state = UserState(); + } + + @override + String exportJson(UserState state) => _codec.exportJson(state); + + @override + UserState importJson(String content) => _codec.importJson(content); +} From 93bcaf969d1a53483bbaf336c7124823d02b7e07 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:11:07 +0530 Subject: [PATCH 143/241] feat: make custom unit deletion fully undoable --- apps/unitflow_app/lib/app/app_controller.dart | 77 +++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/apps/unitflow_app/lib/app/app_controller.dart b/apps/unitflow_app/lib/app/app_controller.dart index 7ec44faa..e62e7b24 100644 --- a/apps/unitflow_app/lib/app/app_controller.dart +++ b/apps/unitflow_app/lib/app/app_controller.dart @@ -8,6 +8,20 @@ import '../features/converter/data/unit_catalog.dart'; import '../features/converter/domain/conversion_engine.dart'; import '../features/converter/domain/unit_models.dart'; +final class RemovedCustomUnitSnapshot { + const RemovedCustomUnitSnapshot({ + required this.unit, + required this.wasFavorite, + required this.pinnedPairs, + required this.recents, + }); + + final CustomUnitData unit; + final bool wasFavorite; + final List pinnedPairs; + final List recents; +} + final class AppController extends ChangeNotifier { AppController({required UserStateRepository repository}) : _repository = repository; @@ -174,13 +188,25 @@ final class AppController extends ChangeNotifier { return _update(newState, engine: newEngine); } - Future removeCustomUnit(String id) { - final existing = _state.customUnits - .where((item) => item.id == id) - .toList(); + Future removeCustomUnit(String id) async { + final existing = _state.customUnits.where((item) => item.id == id).toList(); if (existing.isEmpty) { - return Future.value(); + return null; } + final snapshot = RemovedCustomUnitSnapshot( + unit: existing.single, + wasFavorite: _state.favoriteUnitIds.contains(id), + pinnedPairs: List.unmodifiable( + _state.pinnedPairs.where( + (pair) => pair.fromUnitId == id || pair.toUnitId == id, + ), + ), + recents: List.unmodifiable( + _state.recents.where( + (recent) => recent.fromUnitId == id || recent.toUnitId == id, + ), + ), + ); final nextCustom = _state.customUnits .where((item) => item.id != id) .toList(); @@ -199,7 +225,46 @@ final class AppController extends ChangeNotifier { pinnedPairs: nextPins, recents: nextRecents, ); - return _update(newState, engine: _buildEngine(newState)); + await _update(newState, engine: _buildEngine(newState)); + return snapshot; + } + + Future restoreCustomUnit(RemovedCustomUnitSnapshot snapshot) { + if (_engine.catalog.byId(snapshot.unit.id) != null) { + throw ArgumentError.value( + snapshot.unit.id, + 'id', + 'unit identifier already exists', + ); + } + + final customUnits = [..._state.customUnits, snapshot.unit]; + final provisional = _state.copyWith(customUnits: customUnits); + final restoredEngine = _buildEngine(provisional); + + final favorites = _state.favoriteUnitIds.toSet(); + if (snapshot.wasFavorite) { + favorites.add(snapshot.unit.id); + } + + final pins = [...snapshot.pinnedPairs, ..._state.pinnedPairs]; + final uniquePins = {}; + for (final pair in pins) { + uniquePins.putIfAbsent(pair.storageValue, () => pair); + } + + final recents = [...snapshot.recents, ..._state.recents] + ..sort((left, right) => right.createdAt.compareTo(left.createdAt)); + + final restored = provisional.copyWith( + favoriteUnitIds: favorites, + pinnedPairs: uniquePins.values.take(20).toList(), + recents: recents.take(50).toList(), + ); + return _update( + _normalizeStateReferences(restored, restoredEngine), + engine: restoredEngine, + ); } String exportState() => _repository.exportJson(_state); From 963323b6151bc53a175b90cbb4875f3eb0a656e7 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:11:48 +0530 Subject: [PATCH 144/241] feat: localize complete custom unit undo feedback --- apps/unitflow_app/lib/l10n/app_en.arb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/unitflow_app/lib/l10n/app_en.arb b/apps/unitflow_app/lib/l10n/app_en.arb index 4c925368..aa4dd1ce 100644 --- a/apps/unitflow_app/lib/l10n/app_en.arb +++ b/apps/unitflow_app/lib/l10n/app_en.arb @@ -59,6 +59,9 @@ "description": "Description", "createUnit": "Create unit", "customUnitCreateFailed": "The custom unit could not be created. Check its ID, scale, offset, and other fields.", + "customUnitRemoved": "Custom unit removed.", + "customUnitRestored": "Custom unit and related local shortcuts restored.", + "customUnitRestoreFailed": "The custom unit could not be restored because its ID is no longer available.", "pinnedPairs": "Pinned pairs", "noUnitsMatch": "No units match this search.", "addFavorite": "Add to favorites", From 52e9167b83bdd19c8344e672f87f8b9eb575bafe Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:14:13 +0530 Subject: [PATCH 145/241] feat: restore custom unit relationships on undo --- .../library/presentation/library_screen.dart | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/apps/unitflow_app/lib/features/library/presentation/library_screen.dart b/apps/unitflow_app/lib/features/library/presentation/library_screen.dart index 6697854f..58aa8188 100644 --- a/apps/unitflow_app/lib/features/library/presentation/library_screen.dart +++ b/apps/unitflow_app/lib/features/library/presentation/library_screen.dart @@ -180,28 +180,48 @@ final class _LibraryScreenState extends State { } Future _deleteCustomUnit(UnitDefinition unit) async { - final data = widget.appController.state.customUnits - .where((candidate) => candidate.id == unit.id) - .firstOrNull; - if (data == null) { - return; - } - await widget.appController.removeCustomUnit(unit.id); - if (!mounted) { + final snapshot = await widget.appController.removeCustomUnit(unit.id); + if (snapshot == null || !mounted) { return; } final strings = AppLocalizations.of(context); ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(strings.removeCustomUnit), + content: Text(strings.customUnitRemoved), action: SnackBarAction( label: strings.undo, - onPressed: () => widget.appController.addCustomUnit(data), + onPressed: () => _restoreCustomUnit(snapshot), ), ), ); } + + Future _restoreCustomUnit(RemovedCustomUnitSnapshot snapshot) async { + final strings = AppLocalizations.of(context); + try { + await widget.appController.restoreCustomUnit(snapshot); + } on Object catch (error) { + final message = userSafeFailure( + error, + event: 'custom_unit_restore_failed', + fallback: strings.customUnitRestoreFailed, + ); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + return; + } + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(strings.customUnitRestored)), + ); + } } final class _Header extends StatelessWidget { @@ -406,10 +426,3 @@ final class _EmptyLibrary extends StatelessWidget { ); } } - -extension on Iterable { - T? get firstOrNull { - final iterator = this.iterator; - return iterator.moveNext() ? iterator.current : null; - } -} From 19988dc9e727110893080c0aa4e41c3cdeeaa436 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:14:25 +0530 Subject: [PATCH 146/241] test: cover complete custom unit undo restoration --- .../test/app/custom_unit_undo_test.dart | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/unitflow_app/test/app/custom_unit_undo_test.dart diff --git a/apps/unitflow_app/test/app/custom_unit_undo_test.dart b/apps/unitflow_app/test/app/custom_unit_undo_test.dart new file mode 100644 index 00000000..6ca07321 --- /dev/null +++ b/apps/unitflow_app/test/app/custom_unit_undo_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/app/app_controller.dart'; +import 'package:unitflow/core/math/exact_decimal.dart'; +import 'package:unitflow/core/persistence/user_state.dart'; +import 'package:unitflow/core/persistence/user_state_repository.dart'; +import 'package:unitflow/features/converter/domain/unit_models.dart'; + +void main() { + test('custom unit undo restores favorite pin history and conversion use', () async { + final repository = MemoryUserStateRepository( + UserState(onboardingComplete: true), + ); + final controller = AppController(repository: repository); + addTearDown(controller.dispose); + await controller.initialize(); + + const custom = CustomUnitData( + id: 'double_meter', + category: UnitCategory.length, + name: 'Double Meter', + symbol: 'dmx', + scale: '2', + offset: '0', + ); + const pair = PinnedPair( + category: UnitCategory.length, + fromUnitId: 'meter', + toUnitId: 'double_meter', + ); + + await controller.addCustomUnit(custom); + await controller.toggleFavorite(custom.id); + await controller.togglePinnedPair(pair); + await controller.recordRecent( + input: '4', + fromUnitId: 'meter', + toUnitId: custom.id, + ); + + final snapshot = await controller.removeCustomUnit(custom.id); + expect(snapshot, isNotNull); + expect(controller.engine.catalog.byId(custom.id), isNull); + expect(controller.state.favoriteUnitIds, isNot(contains(custom.id))); + expect(controller.state.pinnedPairs, isEmpty); + expect(controller.state.recents, isEmpty); + + await controller.restoreCustomUnit(snapshot!); + + expect(controller.engine.catalog.byId(custom.id), isNotNull); + expect(controller.state.favoriteUnitIds, contains(custom.id)); + expect(controller.state.pinnedPairs.single.storageValue, pair.storageValue); + expect(controller.state.recents.single.toUnitId, custom.id); + + final result = controller.engine.convert( + value: ExactDecimal.parse('4'), + fromUnitId: 'meter', + toUnitId: custom.id, + decimalPlaces: 12, + ); + expect(result.output.toString(), '2'); + }); +} From cb361e288cebb21e69b210b6dab8edd3d68b396e Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:15:09 +0530 Subject: [PATCH 147/241] docs: record state integrity and native integration hardening --- CHANGELOG.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af817c65..9f40bf4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,10 @@ All notable changes to UnitFlow are documented here. The format is based on Keep - English localization source and generated-localization workflow. - User-initiated official Releases link without background update tracking. - Reusable UnitFlow in-app brand mark and editable `assets/branding/unitflow-mark.svg` source. -- Undoable history clearing and custom-unit removal workflows. -- Rust–Flutter bridge crate, API DTOs, and bridge-generation automation. +- Undoable history clearing and full custom-unit restoration including related favorite, pin, and recent-history state. +- Rust–Flutter bridge crate, API DTOs, bridge-generation automation, and reproducible Cargokit native integration scaffolding for Android, iOS, Linux, macOS, and Windows. - CI, CodeQL, dependency review, Dependabot, and cross-platform release workflow foundations. -- Repository safety checks for common credential signatures, JSON/ARB syntax, and internal Markdown links. +- Repository safety checks for tracked-file patterns, JSON/ARB syntax, internal Markdown links, and developer utility syntax. - User-safe error-presentation helper backed by redacting structured diagnostics. - Core lookup/search/conversion profiling harness and strict release-candidate verification script. - Widget/controller regression coverage plus a primary offline conversion journey test. @@ -33,20 +33,27 @@ All notable changes to UnitFlow are documented here. The format is based on Keep - Primary library/custom-unit/settings interface strings increasingly use generated localization resources instead of embedded labels. - Catalog matching now includes unit descriptions in the deterministic Dart catalog search path. - Developer verification now checks repository safety/data/docs before Rust and Flutter quality gates. -- Audit-branch normalization regenerates localizations/bridge bindings and uses `sanskarin@outlook.in` for its automated normalization commit identity. +- Audit-branch normalization bootstraps platform shells, regenerates native bridge scaffolding/localizations/bindings, and uses `sanskarin@outlook.in` for its automated normalization commit identity. +- Loaded/imported convenience state is normalized against the rebuilt catalog so stale favorites, pins, and recent-history references cannot survive catalog/custom-unit changes. +- Local persistence writes are serialized and handled as user-visible non-fatal warnings if the storage backend fails. ### Fixed - Conversion rounding is applied consistently to primary and batch conversion paths using the persisted user preference. - Backup/custom-unit failures no longer echo raw internal exception text into the user interface. - History can be cleared without making the action immediately irreversible because the UI provides an undo snapshot. +- Removing a custom unit now removes dependent favorite, pinned-pair, and recent-history references instead of leaving inaccessible local state. +- Undoing custom-unit removal restores the definition together with its captured favorite, pin, and history relationships when the stable ID is still available. +- Invalid/stale unit references are pruned after load/import while valid custom-unit definitions remain strict validation failures rather than being silently discarded. +- Local save/clear failures no longer escape unawaited UI callbacks; session state is preserved and users receive a recovery-oriented warning. ### Security -- Added responsible disclosure guidance and secret-handling rules. +- Added responsible disclosure guidance and safe configuration rules. - Added bounded backup import validation and redacting structured diagnostic logging. -- Added tracked-file scanning for common private-key/token signatures without requiring another third-party CI action. -- Added safe failure presentation that logs only exception type metadata rather than potentially sensitive exception text. +- Added tracked-file safety scanning as a dependency-free CI check. +- Added safe failure presentation that records only exception type metadata rather than potentially sensitive exception text. +- Referential-normalization diagnostics record aggregate removal counts only, not conversion values or user backup content. ## [0.1.0-alpha.1] - Planned From ba5464d31e3a8aceb8f9466937094e0e2f6bf854 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:16:58 +0530 Subject: [PATCH 148/241] build: allow targeted Flutter platform bootstrapping --- tool/bootstrap_platforms.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tool/bootstrap_platforms.sh b/tool/bootstrap_platforms.sh index 1632c0a5..e9734b72 100644 --- a/tool/bootstrap_platforms.sh +++ b/tool/bootstrap_platforms.sh @@ -3,14 +3,16 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP="$ROOT/apps/unitflow_app" +PLATFORMS="${1:-android,web,windows,linux,macos,ios}" cd "$APP" flutter create \ - --platforms=android,web,windows,linux,macos,ios \ + --platforms="$PLATFORMS" \ --project-name unitflow \ --org in.sanskar.unitflow \ . flutter pub get -echo "Flutter platform shells are ready. Run ../../tool/check.sh before committing generated changes." +echo "Flutter platform shells are ready for: $PLATFORMS" +echo "Run ../../tool/check.sh before committing generated changes." From 546fb50ec8ec6a522e9770b9d1618bf32b2b3d3e Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:17:12 +0530 Subject: [PATCH 149/241] build: allow targeted native bridge integration --- tool/integrate_native_bridge.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tool/integrate_native_bridge.sh b/tool/integrate_native_bridge.sh index 2b162920..c6901fd1 100644 --- a/tool/integrate_native_bridge.sh +++ b/tool/integrate_native_bridge.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP="$ROOT/apps/unitflow_app" BRIDGE="$ROOT/crates/unitflow_bridge" +PLATFORMS="${1:-android,ios,linux,macos,windows}" if ! command -v flutter_rust_bridge_codegen >/dev/null 2>&1; then echo "flutter_rust_bridge_codegen is required." >&2 @@ -19,7 +20,7 @@ flutter_rust_bridge_codegen integrate \ --rust-crate-name unitflow_bridge \ --rust-crate-dir ../../crates/unitflow_bridge \ --integration-backend cargokit \ - --platforms android,ios,linux,macos,windows \ + --platforms "$PLATFORMS" \ --no-write-lib \ --no-integration-test \ --no-dart-fix \ @@ -34,4 +35,4 @@ rm -f "$BRIDGE/Cargo.lock" cd "$ROOT" bash tool/generate_bridge.sh -echo "UnitFlow native FRB/Cargokit scaffolding and generated bindings are ready." +echo "UnitFlow native FRB/Cargokit scaffolding and generated bindings are ready for: $PLATFORMS" From 869ceb1cdc043dc5972c07141c2aae80b11b72d7 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:17:57 +0530 Subject: [PATCH 150/241] release: build bridged platform artifacts and checksums --- .github/workflows/release.yml | 207 ++++++++++++++++++++++++++++------ 1 file changed, 175 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09d53d34..b479f89a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,10 +18,14 @@ jobs: uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable - - name: Test release profile - run: cargo test --workspace --all-features --release - - name: Build release profile - run: cargo build --workspace --all-features --release + with: + components: rustfmt, clippy + - name: Verify Rust workspace + run: | + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --all-features -- -D warnings + cargo test --workspace --all-features --release + cargo build --workspace --all-features --release flutter-web: name: Flutter web @@ -37,14 +41,20 @@ jobs: with: channel: stable cache: true - - name: Resolve dependencies - run: flutter pub get - - name: Generate missing platform shell - run: flutter create --platforms=web --project-name unitflow --org in.sanskar.unitflow . + - name: Bootstrap web shell + working-directory: . + run: bash tool/bootstrap_platforms.sh web + - name: Generate localizations + run: flutter gen-l10n + - name: Analyze and test + run: | + flutter analyze --fatal-infos --fatal-warnings + flutter test - name: Build web release run: flutter build web --release - name: Package web release - run: tar -C build/web -czf ../../unitflow-web-${{ github.ref_name }}.tar.gz . + shell: bash + run: tar -C build/web -czf "$GITHUB_WORKSPACE/unitflow-web-${GITHUB_REF_NAME}.tar.gz" . - name: Upload web artifact uses: actions/upload-artifact@v4 with: @@ -61,22 +71,43 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable - name: Install Flutter uses: subosito/flutter-action@v2 with: channel: stable cache: true - - name: Resolve dependencies - run: flutter pub get - - name: Generate missing platform shell - run: flutter create --platforms=android --project-name unitflow --org in.sanskar.unitflow . - - name: Build unsigned APK + - name: Install bridge generator + working-directory: . + run: cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked + - name: Bootstrap Android shell + working-directory: . + run: bash tool/bootstrap_platforms.sh android + - name: Integrate Android Rust bridge + working-directory: . + run: bash tool/integrate_native_bridge.sh android + - name: Generate localizations + run: flutter gen-l10n + - name: Analyze and test + run: | + flutter analyze --fatal-infos --fatal-warnings + flutter test + - name: Build Android release APK run: flutter build apk --release - - name: Upload Android artifact + - name: Build Android release app bundle + run: flutter build appbundle --release + - name: Package Android validation artifacts + shell: bash + run: | + mkdir -p "$GITHUB_WORKSPACE/unitflow-android-${GITHUB_REF_NAME}" + cp build/app/outputs/flutter-apk/app-release.apk "$GITHUB_WORKSPACE/unitflow-android-${GITHUB_REF_NAME}/" + cp build/app/outputs/bundle/release/app-release.aab "$GITHUB_WORKSPACE/unitflow-android-${GITHUB_REF_NAME}/" + - name: Upload Android artifacts uses: actions/upload-artifact@v4 with: name: unitflow-android-${{ github.ref_name }} - path: apps/unitflow_app/build/app/outputs/flutter-apk/app-release.apk + path: unitflow-android-${{ github.ref_name }}/* if-no-files-found: error flutter-linux: @@ -90,19 +121,33 @@ jobs: uses: actions/checkout@v6 - name: Install Linux prerequisites run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev + - name: Install Rust + uses: dtolnay/rust-toolchain@stable - name: Install Flutter uses: subosito/flutter-action@v2 with: channel: stable cache: true - - name: Resolve dependencies - run: flutter pub get - - name: Generate missing platform shell - run: flutter create --platforms=linux --project-name unitflow --org in.sanskar.unitflow . + - name: Install bridge generator + working-directory: . + run: cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked + - name: Bootstrap Linux shell + working-directory: . + run: bash tool/bootstrap_platforms.sh linux + - name: Integrate Linux Rust bridge + working-directory: . + run: bash tool/integrate_native_bridge.sh linux + - name: Generate localizations + run: flutter gen-l10n + - name: Analyze and test + run: | + flutter analyze --fatal-infos --fatal-warnings + flutter test - name: Build Linux release run: flutter build linux --release - name: Package Linux release - run: tar -C build/linux/x64/release/bundle -czf ../../unitflow-linux-${{ github.ref_name }}.tar.gz . + shell: bash + run: tar -C build/linux/x64/release/bundle -czf "$GITHUB_WORKSPACE/unitflow-linux-${GITHUB_REF_NAME}.tar.gz" . - name: Upload Linux artifact uses: actions/upload-artifact@v4 with: @@ -119,20 +164,35 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable - name: Install Flutter uses: subosito/flutter-action@v2 with: channel: stable cache: true - - name: Resolve dependencies - run: flutter pub get - - name: Generate missing platform shell - run: flutter create --platforms=windows --project-name unitflow --org in.sanskar.unitflow . + - name: Install bridge generator + working-directory: . + run: cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked + - name: Bootstrap Windows shell + working-directory: . + shell: bash + run: bash tool/bootstrap_platforms.sh windows + - name: Integrate Windows Rust bridge + working-directory: . + shell: bash + run: bash tool/integrate_native_bridge.sh windows + - name: Generate localizations + run: flutter gen-l10n + - name: Analyze and test + run: | + flutter analyze --fatal-infos --fatal-warnings + flutter test - name: Build Windows release run: flutter build windows --release - name: Package Windows release shell: pwsh - run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath ../../unitflow-windows-${{ github.ref_name }}.zip + run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath "$env:GITHUB_WORKSPACE/unitflow-windows-$env:GITHUB_REF_NAME.zip" - name: Upload Windows artifact uses: actions/upload-artifact@v4 with: @@ -149,24 +209,107 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable - name: Install Flutter uses: subosito/flutter-action@v2 with: channel: stable cache: true - - name: Resolve dependencies - run: flutter pub get - - name: Generate Apple platform shells - run: flutter create --platforms=macos,ios --project-name unitflow --org in.sanskar.unitflow . + - name: Install bridge generator + working-directory: . + run: cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked + - name: Bootstrap Apple shells + working-directory: . + run: bash tool/bootstrap_platforms.sh macos,ios + - name: Integrate Apple Rust bridge + working-directory: . + run: bash tool/integrate_native_bridge.sh macos,ios + - name: Generate localizations + run: flutter gen-l10n + - name: Analyze and test + run: | + flutter analyze --fatal-infos --fatal-warnings + flutter test - name: Build macOS release run: flutter build macos --release - name: Validate iOS no-codesign build run: flutter build ios --release --no-codesign - name: Package macOS release - run: tar -C build/macos/Build/Products/Release -czf ../../unitflow-macos-${{ github.ref_name }}.tar.gz . + run: ditto -c -k --sequesterRsrc --keepParent build/macos/Build/Products/Release/UnitFlow.app "$GITHUB_WORKSPACE/unitflow-macos-${GITHUB_REF_NAME}.zip" + - name: Package iOS no-codesign validation bundle + run: ditto -c -k --sequesterRsrc --keepParent build/ios/iphoneos/Runner.app "$GITHUB_WORKSPACE/unitflow-ios-nosign-${GITHUB_REF_NAME}.zip" - name: Upload macOS artifact uses: actions/upload-artifact@v4 with: name: unitflow-macos-${{ github.ref_name }} - path: unitflow-macos-${{ github.ref_name }}.tar.gz + path: unitflow-macos-${{ github.ref_name }}.zip + if-no-files-found: error + - name: Upload iOS validation artifact + uses: actions/upload-artifact@v4 + with: + name: unitflow-ios-nosign-${{ github.ref_name }} + path: unitflow-ios-nosign-${{ github.ref_name }}.zip if-no-files-found: error + + checksums: + name: Release checksums + needs: + - rust-core + - flutter-web + - flutter-android + - flutter-linux + - flutter-windows + - flutter-macos-ios + runs-on: ubuntu-latest + steps: + - name: Download release artifacts + uses: actions/download-artifact@v5 + with: + path: release-artifacts + merge-multiple: true + - name: Generate SHA-256 manifest + shell: bash + run: | + cd release-artifacts + find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS + cat SHA256SUMS + - name: Upload checksum manifest + uses: actions/upload-artifact@v4 + with: + name: unitflow-checksums-${{ github.ref_name }} + path: release-artifacts/SHA256SUMS + if-no-files-found: error + + github-release: + name: Publish GitHub release + if: startsWith(github.ref, 'refs/tags/v') + needs: checksums + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Download release artifacts + uses: actions/download-artifact@v5 + with: + path: release-artifacts + merge-multiple: true + - name: Generate SHA-256 manifest + shell: bash + run: | + cd release-artifacts + find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS + - name: Publish immutable tagged release + shell: bash + run: | + prerelease="" + case "$GITHUB_REF_NAME" in + *-alpha*|*-beta*|*-rc*|*-preview*) prerelease="--prerelease" ;; + esac + gh release create "$GITHUB_REF_NAME" release-artifacts/* \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --generate-notes \ + $prerelease From b2c711b9eb50e9d541eaf0637953ebe05c7b7c0b Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:20:46 +0530 Subject: [PATCH 151/241] test: add shared conversion parity vectors --- test_vectors/conversions.json | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 test_vectors/conversions.json diff --git a/test_vectors/conversions.json b/test_vectors/conversions.json new file mode 100644 index 00000000..65e717e7 --- /dev/null +++ b/test_vectors/conversions.json @@ -0,0 +1,56 @@ +[ + { + "name": "meters to kilometers", + "input": "1000", + "from": "meter", + "to": "kilometer", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "1" + }, + { + "name": "kilometers to meters", + "input": "1", + "from": "kilometer", + "to": "meter", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "1000" + }, + { + "name": "freezing Celsius to Fahrenheit", + "input": "0", + "from": "celsius", + "to": "fahrenheit", + "decimalPlaces": 8, + "roundingMode": "nearestEven", + "expected": "32" + }, + { + "name": "freezing Fahrenheit to Celsius", + "input": "32", + "from": "fahrenheit", + "to": "celsius", + "decimalPlaces": 8, + "roundingMode": "nearestEven", + "expected": "0" + }, + { + "name": "inches to centimeters", + "input": "1", + "from": "inch", + "to": "centimeter", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "2.54" + }, + { + "name": "hours to seconds", + "input": "1", + "from": "hour", + "to": "second", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "3600" + } +] From 1fe435bce99601776f9a64f444c1f8029de80d9b Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:20:59 +0530 Subject: [PATCH 152/241] test: validate Rust core against shared parity vectors --- crates/unitflow_core/tests/parity_vectors.rs | 57 ++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 crates/unitflow_core/tests/parity_vectors.rs diff --git a/crates/unitflow_core/tests/parity_vectors.rs b/crates/unitflow_core/tests/parity_vectors.rs new file mode 100644 index 00000000..aea0a301 --- /dev/null +++ b/crates/unitflow_core/tests/parity_vectors.rs @@ -0,0 +1,57 @@ +use std::str::FromStr; + +use rust_decimal::Decimal; +use serde::Deserialize; +use unitflow_core::{ConversionRequest, Converter, RoundMode, UnitCatalog}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Vector { + name: String, + input: String, + from: String, + to: String, + decimal_places: u32, + rounding_mode: String, + expected: String, +} + +#[test] +fn rust_core_matches_shared_conversion_vectors() { + let vectors: Vec = serde_json::from_str(include_str!( + "../../../test_vectors/conversions.json" + )) + .expect("shared conversion vectors must be valid JSON"); + let converter = Converter::new(UnitCatalog::built_in().expect("built-in catalog must validate")); + + for vector in vectors { + let result = converter + .convert(&ConversionRequest { + value: Decimal::from_str(&vector.input).expect("vector input must be decimal"), + from_unit_id: vector.from, + to_unit_id: vector.to, + decimal_places: Some(vector.decimal_places), + round_mode: round_mode(&vector.rounding_mode), + }) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.name)); + + assert_eq!( + result.output.normalize().to_string(), + vector.expected, + "shared vector mismatch: {}", + vector.name + ); + } +} + +fn round_mode(value: &str) -> RoundMode { + match value { + "nearestEven" => RoundMode::NearestEven, + "halfAwayFromZero" => RoundMode::HalfAwayFromZero, + "towardZero" => RoundMode::TowardZero, + "awayFromZero" => RoundMode::AwayFromZero, + "floor" => RoundMode::Floor, + "ceiling" => RoundMode::Ceiling, + other => panic!("unsupported shared vector round mode: {other}"), + } +} From b991bb8816b70f93394d47a19772f5364d4548ce Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:21:10 +0530 Subject: [PATCH 153/241] test: validate Dart fallback against shared parity vectors --- .../test/features/parity_vectors_test.dart | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/unitflow_app/test/features/parity_vectors_test.dart diff --git a/apps/unitflow_app/test/features/parity_vectors_test.dart b/apps/unitflow_app/test/features/parity_vectors_test.dart new file mode 100644 index 00000000..7accc599 --- /dev/null +++ b/apps/unitflow_app/test/features/parity_vectors_test.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/core/math/exact_decimal.dart'; +import 'package:unitflow/features/converter/domain/conversion_engine.dart'; + +void main() { + test('Dart fallback matches shared conversion vectors', () async { + final file = File( + '${Directory.current.path}/../../test_vectors/conversions.json', + ); + final decoded = jsonDecode(await file.readAsString()) as List; + final engine = ExactConversionEngine(); + + for (final entry in decoded) { + final vector = (entry! as Map).cast(); + final result = engine.convert( + value: ExactDecimal.parse(vector['input']! as String), + fromUnitId: vector['from']! as String, + toUnitId: vector['to']! as String, + decimalPlaces: vector['decimalPlaces']! as int, + rounding: _rounding(vector['roundingMode']! as String), + ); + + expect( + result.output.toString(), + vector['expected'], + reason: vector['name']! as String, + ); + } + }); +} + +DecimalRoundingMode _rounding(String value) => switch (value) { + 'nearestEven' => DecimalRoundingMode.nearestEven, + 'halfAwayFromZero' => DecimalRoundingMode.halfAwayFromZero, + 'towardZero' => DecimalRoundingMode.towardZero, + 'awayFromZero' => DecimalRoundingMode.awayFromZero, + 'floor' => DecimalRoundingMode.floor, + 'ceiling' => DecimalRoundingMode.ceiling, + _ => throw FormatException('Unsupported shared vector rounding mode.'), +}; From 105397edce87be593d45b6881f70849a1be2ff6b Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 10:31:26 +0530 Subject: [PATCH 154/241] docs: add release evidence record template --- docs/release-evidence-template.md | 115 ++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/release-evidence-template.md diff --git a/docs/release-evidence-template.md b/docs/release-evidence-template.md new file mode 100644 index 00000000..42b88b2d --- /dev/null +++ b/docs/release-evidence-template.md @@ -0,0 +1,115 @@ +# Release Evidence Record + +Use one copy of this template per release candidate. Evidence is valid only for the exact commit/tag recorded below. + +## Candidate identity + +- Version/tag: +- Commit SHA: +- Verification date: +- Rust toolchain: +- Flutter toolchain: +- FRB generator version: `2.12.0` + +## Automated quality + +- [ ] Repository safety job passed. +- [ ] Rust formatting/lint/tests passed. +- [ ] Flutter localization/format/analyze/tests passed. +- [ ] Rust↔Flutter bridge integration/generation is reproducible with a clean diff. +- [ ] CodeQL passed. +- [ ] Dependency review passed. +- [ ] Strict `tool/verify_release_candidate.sh` passed without modifying tracked files. + +Record workflow URLs or immutable run identifiers here: + +```text +CI: +CodeQL: +Dependency review: +Release validation: +``` + +## Conversion parity + +- [ ] Shared Rust/Dart vectors pass. +- [ ] Representative temperature affine conversions pass. +- [ ] Representative decimal and binary data-size conversions pass. +- [ ] Explicit rounding-mode checks pass. +- [ ] Custom affine-unit checks pass. + +## Platform matrix + +| Platform | Build | Launch | Conversion | Backup/settings | Native bridge/fallback | Accessibility | Branding | +|---|---|---|---|---|---|---|---| +| Android | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| Windows | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| Linux | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| macOS | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| Web | [ ] | [ ] | [ ] | [ ] | fallback [ ] | [ ] | [ ] | +| iOS | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | + +For iOS, distinguish no-codesign compilation from signed-device/App Store validation. + +## Primary journey + +For every advertised release platform: + +- [ ] Launch without signing into an account. +- [ ] Convert a representative decimal value offline. +- [ ] Swap the pair. +- [ ] Pin/favorite the pair. +- [ ] Record and reopen history. +- [ ] Create/use/remove/undo a custom unit. +- [ ] Export a backup. +- [ ] Reject an invalid backup without corrupting current state. +- [ ] Restore a valid compatible backup. +- [ ] Change precision/notation/rounding/theme/reduced-motion settings. +- [ ] Verify the required **Made by the Sanskar** credit. + +## Accessibility + +- [ ] Keyboard-only navigation checked where applicable. +- [ ] Focus indicators visible. +- [ ] Large text / text scaling checked. +- [ ] Screen-reader-oriented labels/order checked. +- [ ] Light and dark contrast checked. +- [ ] Reduced motion checked. +- [ ] Error states are understandable without color alone. + +## Branding and screenshots + +- [ ] Final launcher icon uses UnitFlow artwork. +- [ ] Final startup/splash presentation uses UnitFlow artwork. +- [ ] Phone screenshot captured from this candidate. +- [ ] Desktop screenshot captured from this candidate. +- [ ] Dark-mode screenshot captured from this candidate. +- [ ] Screenshots contain no personal/private data. + +## Performance and robustness + +- [ ] `tool/profile_core.sh` output recorded with hardware/toolchain context. +- [ ] Fuzz campaign duration/targets recorded. +- [ ] No crash or data-loss regression remains open for the candidate. + +Profiling/fuzz notes: + +```text +Host: +CPU: +OS: +Profile output: +Fuzz targets and duration: +``` + +## Release artifacts + +- [ ] Artifacts were produced from the exact audited tag. +- [ ] `SHA256SUMS` generated and verified. +- [ ] Signing/notarization/store credentials were supplied outside source control. +- [ ] Release notes match the exact candidate. +- [ ] `CHANGELOG.md`, `ROADMAP.md`, and `what_changed.md` contain no stale claims. + +## Known limitations + +List only verified limitations that remain acceptable for this release. Do not hide a failed required gate here. From 7edc504d8c69531b88a544802a6110e12be66c1c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:26:38 +0530 Subject: [PATCH 155/241] fix: search Rust unit descriptions --- crates/unitflow_core/src/model.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/unitflow_core/src/model.rs b/crates/unitflow_core/src/model.rs index 000e7021..bf92caa8 100644 --- a/crates/unitflow_core/src/model.rs +++ b/crates/unitflow_core/src/model.rs @@ -203,6 +203,7 @@ impl UnitDefinition { self.id.to_lowercase().contains(&query) || self.name.to_lowercase().contains(&query) || self.symbol.to_lowercase().contains(&query) + || self.description.to_lowercase().contains(&query) || self .aliases .iter() From 80bdef48ee3e4307a040e31475b08dc30fd82ea4 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:26:55 +0530 Subject: [PATCH 156/241] test: cover description-aware unit matching --- .../tests/unit_definition_search.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 crates/unitflow_core/tests/unit_definition_search.rs diff --git a/crates/unitflow_core/tests/unit_definition_search.rs b/crates/unitflow_core/tests/unit_definition_search.rs new file mode 100644 index 00000000..7b3409c8 --- /dev/null +++ b/crates/unitflow_core/tests/unit_definition_search.rs @@ -0,0 +1,22 @@ +use rust_decimal::Decimal; +use unitflow_core::{Category, UnitDefinition}; + +#[test] +fn unit_definition_matches_description_text() { + let unit = UnitDefinition::new( + "sample_unit", + Category::Length, + "Sample Unit", + "su", + vec!["sample".to_owned()], + "Useful for calibration reference examples.", + Decimal::ONE, + Decimal::ZERO, + false, + ) + .expect("valid unit"); + + assert!(unit.matches_query("calibration")); + assert!(unit.matches_query("REFERENCE")); + assert!(!unit.matches_query("temperature")); +} From 4eee687a31b4d3fd79433cf92746e0dba6c64d0b Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:29:13 +0530 Subject: [PATCH 157/241] fix: validate persisted pinned pair identifiers --- .../lib/features/converter/domain/unit_models.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/features/converter/domain/unit_models.dart b/apps/unitflow_app/lib/features/converter/domain/unit_models.dart index 669dcfdc..328c8ad5 100644 --- a/apps/unitflow_app/lib/features/converter/domain/unit_models.dart +++ b/apps/unitflow_app/lib/features/converter/domain/unit_models.dart @@ -152,6 +152,8 @@ final class PinnedPair { required this.toUnitId, }); + static final RegExp _unitIdPattern = RegExp(r'^[a-z0-9_-]{1,64}$'); + final UnitCategory category; final String fromUnitId; final String toUnitId; @@ -159,6 +161,9 @@ final class PinnedPair { String get storageValue => '${category.id}|$fromUnitId|$toUnitId'; static PinnedPair? tryParse(String value) { + if (value.length > 256) { + return null; + } final parts = value.split('|'); if (parts.length != 3) { return null; @@ -170,7 +175,9 @@ final class PinnedPair { break; } } - if (category == null || parts[1].isEmpty || parts[2].isEmpty) { + if (category == null || + !_unitIdPattern.hasMatch(parts[1]) || + !_unitIdPattern.hasMatch(parts[2])) { return null; } return PinnedPair( From 14351ac96b0cbe5a05a4a9864302c4e30a25e441 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:29:50 +0530 Subject: [PATCH 158/241] fix: enforce backup schema bounds and normalization --- .../lib/core/persistence/user_state.dart | 125 +++++++++++++++--- 1 file changed, 106 insertions(+), 19 deletions(-) diff --git a/apps/unitflow_app/lib/core/persistence/user_state.dart b/apps/unitflow_app/lib/core/persistence/user_state.dart index 70009c80..d87c2d94 100644 --- a/apps/unitflow_app/lib/core/persistence/user_state.dart +++ b/apps/unitflow_app/lib/core/persistence/user_state.dart @@ -12,6 +12,14 @@ final class RecentConversion { required this.createdAt, }); + static const _allowedKeys = { + 'input', + 'fromUnitId', + 'toUnitId', + 'createdAt', + }; + static final RegExp _unitIdPattern = RegExp(r'^[a-z0-9_-]{1,64}$'); + final String input; final String fromUnitId; final String toUnitId; @@ -25,7 +33,7 @@ final class RecentConversion { }; static RecentConversion? tryFromJson(Object? value) { - if (value is! Map) { + if (value is! Map || !_containsOnlyKeys(value, _allowedKeys)) { return null; } final input = value['input']; @@ -36,7 +44,11 @@ final class RecentConversion { return null; } final timestamp = DateTime.tryParse(created); - if (timestamp == null || from.isEmpty || to.isEmpty || input.length > 1024) { + if (timestamp == null || + input.isEmpty || + input.length > 1024 || + !_unitIdPattern.hasMatch(from) || + !_unitIdPattern.hasMatch(to)) { return null; } return RecentConversion( @@ -60,6 +72,17 @@ final class CustomUnitData { this.description = '', }); + static const _allowedKeys = { + 'id', + 'category', + 'name', + 'symbol', + 'scale', + 'offset', + 'aliases', + 'description', + }; + final String id; final UnitCategory category; final String name; @@ -73,18 +96,35 @@ final class CustomUnitData { if (!RegExp(r'^[a-z0-9_-]{1,64}$').hasMatch(id)) { throw const FormatException('Custom unit ID is invalid.'); } - if (name.trim().isEmpty || name.length > 128) { + final normalizedName = name.trim(); + final normalizedSymbol = symbol.trim(); + final normalizedDescription = description.trim(); + if (normalizedName.isEmpty || normalizedName.length > 128) { throw const FormatException('Custom unit name is invalid.'); } - if (symbol.trim().isEmpty || symbol.length > 32) { + if (normalizedSymbol.isEmpty || normalizedSymbol.length > 32) { throw const FormatException('Custom unit symbol is invalid.'); } - if (aliases.length > 32 || aliases.any((value) => value.isEmpty || value.length > 64)) { + if (aliases.length > 32) { throw const FormatException('Custom unit aliases are invalid.'); } - if (description.length > 512) { + final normalizedAliases = []; + final seenAliases = {}; + for (final alias in aliases) { + final normalized = alias.trim(); + if (normalized.isEmpty || normalized.length > 64) { + throw const FormatException('Custom unit aliases are invalid.'); + } + if (seenAliases.add(normalized.toLowerCase())) { + normalizedAliases.add(normalized); + } + } + if (normalizedDescription.length > 512) { throw const FormatException('Custom unit description is too long.'); } + if (scale.isEmpty || scale.length > 1024 || offset.isEmpty || offset.length > 1024) { + throw const FormatException('Custom unit formula is invalid.'); + } final parsedScale = ExactDecimal.parse(scale); if (parsedScale.compareTo(ExactDecimal.zero) <= 0) { throw const FormatException('Custom unit scale must be greater than zero.'); @@ -92,12 +132,12 @@ final class CustomUnitData { return UnitDefinition( id: id, category: category, - name: name.trim(), - symbol: symbol.trim(), + name: normalizedName, + symbol: normalizedSymbol, scale: parsedScale, offset: ExactDecimal.parse(offset), - aliases: List.unmodifiable(aliases), - description: description.trim(), + aliases: List.unmodifiable(normalizedAliases), + description: normalizedDescription, isBuiltIn: false, ); } @@ -114,7 +154,7 @@ final class CustomUnitData { }; static CustomUnitData? tryFromJson(Object? value) { - if (value is! Map) { + if (value is! Map || !_containsOnlyKeys(value, _allowedKeys)) { return null; } final id = value['id']; @@ -157,8 +197,17 @@ final class CustomUnitData { description: description, ); try { - result.toUnitDefinition(); - return result; + final normalized = result.toUnitDefinition(); + return CustomUnitData( + id: normalized.id, + category: normalized.category, + name: normalized.name, + symbol: normalized.symbol, + scale: normalized.scale.toCanonicalString(), + offset: normalized.offset.toCanonicalString(), + aliases: normalized.aliases, + description: normalized.description, + ); } on FormatException { return null; } @@ -184,6 +233,28 @@ final class UserState { customUnits = List.unmodifiable(customUnits ?? const []); static const schemaVersion = 2; + static const maxPinnedPairs = 20; + static const maxStoredRecents = 100; + static const maxActiveRecents = 50; + static const maxCustomUnits = 200; + static final RegExp _unitIdPattern = RegExp(r'^[a-z0-9_-]{1,64}$'); + static const _allowedKeysV1 = { + 'schemaVersion', + 'theme', + 'notation', + 'decimalPlaces', + 'useGrouping', + 'onboardingComplete', + 'favoriteUnitIds', + 'pinnedPairs', + 'recents', + 'customUnits', + }; + static const _allowedKeysV2 = { + ..._allowedKeysV1, + 'roundingMode', + 'reduceMotion', + }; final ThemePreference theme; final DecimalNotation notation; @@ -243,6 +314,10 @@ final class UserState { if (version is! int || version < 1 || version > schemaVersion) { throw const FormatException('Unsupported UnitFlow data schema.'); } + final allowedKeys = version == 1 ? _allowedKeysV1 : _allowedKeysV2; + if (!_containsOnlyKeys(json, allowedKeys)) { + throw const FormatException('UnitFlow data contains unsupported fields.'); + } final decimalPlaces = json['decimalPlaces']; final useGrouping = json['useGrouping']; @@ -278,29 +353,37 @@ final class UserState { customRaw is! List) { throw const FormatException('Invalid UnitFlow user data.'); } + if (pinsRaw.length > maxPinnedPairs || + recentsRaw.length > maxStoredRecents || + customRaw.length > maxCustomUnits) { + throw const FormatException('UnitFlow user data exceeds supported collection limits.'); + } final favorites = {}; for (final value in favoritesRaw) { - if (value is! String || value.length > 64) { + if (value is! String || !_unitIdPattern.hasMatch(value)) { throw const FormatException('Invalid favorite unit data.'); } - favorites.add(value); + if (!favorites.add(value)) { + throw const FormatException('Duplicate favorite unit data.'); + } } final pins = []; + final pinKeys = {}; for (final value in pinsRaw) { if (value is! String) { throw const FormatException('Invalid pinned pair data.'); } final pin = PinnedPair.tryParse(value); - if (pin == null) { + if (pin == null || !pinKeys.add(pin.storageValue)) { throw const FormatException('Invalid pinned pair data.'); } pins.add(pin); } final recents = []; - for (final value in recentsRaw.take(100)) { + for (final value in recentsRaw) { final normalized = _stringKeyedMap(value); final recent = RecentConversion.tryFromJson(normalized); if (recent == null) { @@ -310,10 +393,11 @@ final class UserState { } final customUnits = []; - for (final value in customRaw.take(200)) { + final customUnitIds = {}; + for (final value in customRaw) { final normalized = _stringKeyedMap(value); final unit = CustomUnitData.tryFromJson(normalized); - if (unit == null) { + if (unit == null || !customUnitIds.add(unit.id)) { throw const FormatException('Invalid custom unit data.'); } customUnits.add(unit); @@ -349,6 +433,9 @@ Map? _stringKeyedMap(Object? value) { return result; } +bool _containsOnlyKeys(Map value, Set allowed) => + value.keys.every(allowed.contains); + extension on Iterable { T? get firstOrNull { final iterator = this.iterator; From 6437213e0be07d249b04da14a218cf887e16a356 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:30:27 +0530 Subject: [PATCH 159/241] fix: keep local state within backup limits --- apps/unitflow_app/lib/app/app_controller.dart | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/unitflow_app/lib/app/app_controller.dart b/apps/unitflow_app/lib/app/app_controller.dart index e62e7b24..f3fc0234 100644 --- a/apps/unitflow_app/lib/app/app_controller.dart +++ b/apps/unitflow_app/lib/app/app_controller.dart @@ -126,8 +126,8 @@ final class AppController extends ChangeNotifier { next.removeAt(index); } else { next.insert(0, pair); - if (next.length > 20) { - next.removeRange(20, next.length); + if (next.length > UserState.maxPinnedPairs) { + next.removeRange(UserState.maxPinnedPairs, next.length); } } return _update(_state.copyWith(pinnedPairs: next)); @@ -159,8 +159,8 @@ final class AppController extends ChangeNotifier { createdAt: DateTime.now(), ), ); - if (next.length > 50) { - next.removeRange(50, next.length); + if (next.length > UserState.maxActiveRecents) { + next.removeRange(UserState.maxActiveRecents, next.length); } return _update(_state.copyWith(recents: next)); } @@ -169,11 +169,18 @@ final class AppController extends ChangeNotifier { _update(_state.copyWith(recents: [])); Future restoreHistory(List recents) { - final restored = _state.copyWith(recents: recents.take(50).toList()); + final restored = _state.copyWith( + recents: recents.take(UserState.maxActiveRecents).toList(), + ); return _update(_normalizeStateReferences(restored, _engine)); } Future addCustomUnit(CustomUnitData customUnit) { + if (_state.customUnits.length >= UserState.maxCustomUnits) { + throw StateError( + 'UnitFlow supports up to ${UserState.maxCustomUnits} custom units.', + ); + } final definition = customUnit.toUnitDefinition(); if (_engine.catalog.byId(definition.id) != null) { throw ArgumentError.value( @@ -182,7 +189,17 @@ final class AppController extends ChangeNotifier { 'unit identifier already exists', ); } - final next = [..._state.customUnits, customUnit]; + final normalizedCustomUnit = CustomUnitData( + id: definition.id, + category: definition.category, + name: definition.name, + symbol: definition.symbol, + scale: definition.scale.toCanonicalString(), + offset: definition.offset.toCanonicalString(), + aliases: definition.aliases, + description: definition.description, + ); + final next = [..._state.customUnits, normalizedCustomUnit]; final newState = _state.copyWith(customUnits: next); final newEngine = _buildEngine(newState); return _update(newState, engine: newEngine); @@ -230,6 +247,11 @@ final class AppController extends ChangeNotifier { } Future restoreCustomUnit(RemovedCustomUnitSnapshot snapshot) { + if (_state.customUnits.length >= UserState.maxCustomUnits) { + throw StateError( + 'UnitFlow supports up to ${UserState.maxCustomUnits} custom units.', + ); + } if (_engine.catalog.byId(snapshot.unit.id) != null) { throw ArgumentError.value( snapshot.unit.id, @@ -258,8 +280,8 @@ final class AppController extends ChangeNotifier { final restored = provisional.copyWith( favoriteUnitIds: favorites, - pinnedPairs: uniquePins.values.take(20).toList(), - recents: recents.take(50).toList(), + pinnedPairs: uniquePins.values.take(UserState.maxPinnedPairs).toList(), + recents: recents.take(UserState.maxActiveRecents).toList(), ); return _update( _normalizeStateReferences(restored, restoredEngine), @@ -328,12 +350,12 @@ final class AppController extends ChangeNotifier { to != null && from.category == pair.category && to.category == pair.category; - }).take(20).toList(); + }).take(UserState.maxPinnedPairs).toList(); final recents = state.recents.where((recent) { final from = engine.catalog.byId(recent.fromUnitId); final to = engine.catalog.byId(recent.toUnitId); return from != null && to != null && from.category == to.category; - }).take(50).toList(); + }).take(UserState.maxActiveRecents).toList(); final removedFavorites = state.favoriteUnitIds.length - favorites.length; final removedPins = state.pinnedPairs.length - pins.length; From cbe6c96aff56758430532c9ec2208b53042fe1cf Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:30:49 +0530 Subject: [PATCH 160/241] test: cover strict backup validation --- .../test/core/user_state_test.dart | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/apps/unitflow_app/test/core/user_state_test.dart b/apps/unitflow_app/test/core/user_state_test.dart index eac04b46..2cbf0408 100644 --- a/apps/unitflow_app/test/core/user_state_test.dart +++ b/apps/unitflow_app/test/core/user_state_test.dart @@ -99,6 +99,93 @@ void main() { ); }); + test('unknown top-level fields are rejected', () { + final repository = MemoryUserStateRepository(); + const payload = '{' + '"schemaVersion":2,' + '"theme":"system",' + '"notation":"plain",' + '"roundingMode":"nearestEven",' + '"decimalPlaces":12,' + '"useGrouping":true,' + '"onboardingComplete":true,' + '"favoriteUnitIds":[],' + '"pinnedPairs":[],' + '"recents":[],' + '"customUnits":[],' + '"unexpected":true' + '}'; + + expect(() => repository.importJson(payload), throwsFormatException); + }); + + test('unknown nested fields are rejected', () { + final repository = MemoryUserStateRepository(); + const payload = '{' + '"schemaVersion":2,' + '"theme":"system",' + '"notation":"plain",' + '"roundingMode":"nearestEven",' + '"decimalPlaces":12,' + '"useGrouping":true,' + '"onboardingComplete":true,' + '"favoriteUnitIds":[],' + '"pinnedPairs":[],' + '"recents":[{' + '"input":"1",' + '"fromUnitId":"meter",' + '"toUnitId":"kilometer",' + '"createdAt":"2026-08-19T00:00:00Z",' + '"unexpected":true' + '}],' + '"customUnits":[]' + '}'; + + expect(() => repository.importJson(payload), throwsFormatException); + }); + + test('oversized pinned-pair collection is rejected', () { + final json = UserState( + onboardingComplete: true, + pinnedPairs: List.generate( + UserState.maxPinnedPairs + 1, + (index) => PinnedPair( + category: UnitCategory.length, + fromUnitId: 'meter', + toUnitId: 'unit_$index', + ), + ), + ).toJson(); + + expect(() => UserState.fromJson(json), throwsFormatException); + }); + + test('custom unit aliases are trimmed and deduplicated', () { + const unit = CustomUnitData( + id: 'double_meter', + category: UnitCategory.length, + name: ' Double Meter ', + symbol: ' dmx ', + scale: '2.0', + offset: '0.0', + aliases: [' double ', 'DOUBLE', 'two meters'], + description: ' Example unit. ', + ); + + final definition = unit.toUnitDefinition(); + + expect(definition.name, 'Double Meter'); + expect(definition.symbol, 'dmx'); + expect(definition.aliases, ['double', 'two meters']); + expect(definition.description, 'Example unit.'); + }); + + test('persisted pinned pair identifiers must be safe stable IDs', () { + expect(PinnedPair.tryParse('length|meter|kilometer'), isNotNull); + expect(PinnedPair.tryParse('length|../meter|kilometer'), isNull); + expect(PinnedPair.tryParse('length|meter|bad unit'), isNull); + }); + test('custom units reject built-in style formula with zero scale', () { const unit = CustomUnitData( id: 'bad_scale', From 4a5d5d0b11d1ca289ddcb3e1c4b287564094b046 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:32:13 +0530 Subject: [PATCH 161/241] fix: reject duplicate JSON object keys --- tool/check_data_files.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tool/check_data_files.py b/tool/check_data_files.py index ab1661d5..5e867993 100644 --- a/tool/check_data_files.py +++ b/tool/check_data_files.py @@ -7,10 +7,24 @@ import subprocess import sys from pathlib import Path +from typing import Any ROOT = Path(__file__).resolve().parents[1] +class DuplicateKeyError(ValueError): + """Raised when a JSON object repeats the same key.""" + + +def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise DuplicateKeyError(f"duplicate object key {key!r}") + result[key] = value + return result + + def tracked_data_files() -> list[Path]: try: output = subprocess.check_output( @@ -34,8 +48,8 @@ def main() -> int: relative = path.relative_to(ROOT).as_posix() try: with path.open("r", encoding="utf-8") as handle: - json.load(handle) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + json.load(handle, object_pairs_hook=reject_duplicate_keys) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, DuplicateKeyError) as error: failures.append(f"{relative}: {error}") if failures: @@ -44,7 +58,7 @@ def main() -> int: print(f" - {failure}", file=sys.stderr) return 1 - print("Tracked JSON and ARB files are valid UTF-8 JSON.") + print("Tracked JSON and ARB files are valid UTF-8 JSON with unique object keys.") return 0 From 0c096dced145eca0c5b6ba184dac09f21232f917 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:32:30 +0530 Subject: [PATCH 162/241] refactor: share strict backup decoding --- .../persistence/user_state_repository.dart | 66 +++++++++---------- 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/apps/unitflow_app/lib/core/persistence/user_state_repository.dart b/apps/unitflow_app/lib/core/persistence/user_state_repository.dart index 36ceb475..216df394 100644 --- a/apps/unitflow_app/lib/core/persistence/user_state_repository.dart +++ b/apps/unitflow_app/lib/core/persistence/user_state_repository.dart @@ -21,7 +21,6 @@ final class SharedPreferencesUserStateRepository implements UserStateRepository : _preferences = preferences ?? SharedPreferencesAsync(); static const _storageKey = 'unitflow.user_state.v1'; - static const _maxImportCharacters = 1_000_000; final SharedPreferencesAsync _preferences; @@ -55,25 +54,7 @@ final class SharedPreferencesUserStateRepository implements UserStateRepository const JsonEncoder.withIndent(' ').convert(state.toJson()); @override - UserState importJson(String content) { - if (content.isEmpty || content.length > _maxImportCharacters) { - throw const FormatException('UnitFlow import size is invalid.'); - } - - final Object? decoded = jsonDecode(content); - if (decoded is! Map) { - throw const FormatException('UnitFlow import must be a JSON object.'); - } - final normalized = {}; - for (final entry in decoded.entries) { - final key = entry.key; - if (key is! String) { - throw const FormatException('UnitFlow import contains an invalid key.'); - } - normalized[key] = entry.value; - } - return UserState.fromJson(normalized); - } + UserState importJson(String content) => _decodeState(content); } final class MemoryUserStateRepository implements UserStateRepository { @@ -91,21 +72,7 @@ final class MemoryUserStateRepository implements UserStateRepository { const JsonEncoder.withIndent(' ').convert(state.toJson()); @override - UserState importJson(String content) { - final Object? decoded = jsonDecode(content); - if (decoded is! Map) { - throw const FormatException('UnitFlow import must be a JSON object.'); - } - final normalized = {}; - for (final entry in decoded.entries) { - final key = entry.key; - if (key is! String) { - throw const FormatException('Invalid import key.'); - } - normalized[key] = entry.value; - } - return UserState.fromJson(normalized); - } + UserState importJson(String content) => _decodeState(content); @override Future load() async => _state; @@ -116,6 +83,35 @@ final class MemoryUserStateRepository implements UserStateRepository { } } +const _maxImportCharacters = 1_000_000; + +UserState _decodeState(String content) { + if (content.isEmpty || content.length > _maxImportCharacters) { + throw const FormatException('UnitFlow import size is invalid.'); + } + + final Object? decoded; + try { + decoded = jsonDecode(content); + } on FormatException { + rethrow; + } on Object { + throw const FormatException('UnitFlow import is not valid JSON.'); + } + if (decoded is! Map) { + throw const FormatException('UnitFlow import must be a JSON object.'); + } + final normalized = {}; + for (final entry in decoded.entries) { + final key = entry.key; + if (key is! String) { + throw const FormatException('UnitFlow import contains an invalid key.'); + } + normalized[key] = entry.value; + } + return UserState.fromJson(normalized); +} + final class StatePersistenceException implements Exception { const StatePersistenceException(this.message, [this.cause]); From d4d1c0df30dbe32d166049efbda09c994202ddb7 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:32:56 +0530 Subject: [PATCH 163/241] docs: document strict backup contract --- docs/data-format.md | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/data-format.md b/docs/data-format.md index 85b08362..707f609c 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -24,15 +24,31 @@ The current schema version is **2**. Its machine-readable contract is published A backup is a UTF-8 JSON object. The root `schemaVersion` field is mandatory. UnitFlow validates the complete object before replacing the in-memory state; malformed or unsupported imports must not partially overwrite an existing profile. +The decoder intentionally rejects unknown object properties instead of silently discarding them. This keeps runtime behavior aligned with the checked-in JSON Schemas, which use `additionalProperties: false`, and prevents misspelled or future fields from appearing to import successfully when their meaning was actually ignored. + Current safety bounds include: -- file/import text size: at most 1 MB; +- file/import text size: at most 1,000,000 characters; - recent conversions: at most 100 accepted from an imported document, with the app normally retaining at most 50 active recents; - pinned pairs: at most 20 active pairs; -- custom units: at most 200 accepted from an imported document; +- custom units: at most 200 accepted from an imported document and at most 200 created locally; - custom aliases: at most 32 per unit; +- custom scale/offset text: at most 1024 characters each; - decimal precision preference: 0–28 places; -- stable identifiers: lowercase ASCII letters, digits, `_`, and `-` only. +- stable identifiers: 1–64 lowercase ASCII letters, digits, `_`, and `-` only. + +Collection bounds are validated before iterating imported entries. Oversized arrays are rejected; their tail is never silently discarded during parsing. + +## Canonicalization + +Custom-unit text is normalized at the trust boundary before it becomes durable state: + +- names, symbols, descriptions, and aliases are trimmed; +- aliases are deduplicated case-insensitively while preserving first-occurrence order; +- scale and offset are parsed through UnitFlow's exact decimal implementation and persisted in canonical decimal form; +- stable identifiers are validated rather than rewritten. + +Canonicalization ensures a unit created interactively and the same unit restored from backup have equivalent durable representation. ## Rounding modes @@ -84,13 +100,18 @@ An import is rejected when, among other validation failures: - JSON is malformed; - the root is not an object; - the schema version is unsupported; +- an object contains unsupported properties; - required settings have invalid types or ranges; +- favorite or pinned identifiers do not match the stable-ID grammar; +- duplicate favorite, pinned-pair, or custom-unit identifiers are present where uniqueness is required; - a custom-unit identifier or formula is invalid; - duplicate identifiers would collide with built-in or imported custom units; - the import exceeds configured size/count limits. The application preserves the existing state when validation fails. A structurally valid import may have stale convenience references normalized as described above after its catalog is successfully rebuilt. +Production and in-memory repositories share the same decoder so tests do not accidentally exercise a more permissive import path than the shipped application. + ## Export behavior UnitFlow supports explicit JSON backup export. File export uses a user-selected platform location where supported, while clipboard export remains available as a portable fallback. Export never includes credentials because UnitFlow static conversion has no credential requirement. @@ -114,6 +135,10 @@ For future schema versions: 5. update `CHANGELOG.md` with user-visible compatibility notes; 6. reject unknown future schemas rather than destructively rewriting them. +## Repository data validation + +`tool/check_data_files.py` parses every tracked JSON and ARB file as UTF-8 JSON and rejects duplicate object keys. Duplicate keys are forbidden because ordinary JSON parsers may silently keep one value and discard another, creating ambiguous configuration or schema evidence. + ## Privacy See `PRIVACY.md` for the user-facing privacy policy and `SECURITY.md` for vulnerability reporting. From ce7fc23c85e41e10ff96466efbcb75bac0c08dec Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:33:17 +0530 Subject: [PATCH 164/241] docs: record strict backup hardening --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f40bf4c..4bb93c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,14 @@ All notable changes to UnitFlow are documented here. The format is based on Keep - Backup schema advanced from version 1 to version 2 to persist the selected decimal rounding mode. - Valid version 1 backups migrate deterministically to nearest-even rounding and export as version 2. - Early version 2 backups remain compatible when the later optional `reduceMotion` preference is absent; it defaults to `false`. +- Backup decoding now rejects unknown properties and oversized collections instead of silently accepting or truncating data outside the documented schema contract. +- Production and in-memory repositories now share one strict backup decoder, including the same maximum import-size rule. +- Custom-unit values are normalized before persistence: text fields and aliases are trimmed, aliases are case-insensitively deduplicated, and exact decimal scale/offset values are canonicalized. +- Locally created custom units obey the same 200-unit collection limit as portable backups. - Primary library/custom-unit/settings interface strings increasingly use generated localization resources instead of embedded labels. -- Catalog matching now includes unit descriptions in the deterministic Dart catalog search path. +- Catalog matching now includes unit descriptions in the deterministic Dart catalog path and description matching is covered at the Rust unit-definition layer. - Developer verification now checks repository safety/data/docs before Rust and Flutter quality gates. +- Repository JSON/ARB validation now rejects duplicate object keys in addition to malformed or non-UTF-8 data. - Audit-branch normalization bootstraps platform shells, regenerates native bridge scaffolding/localizations/bindings, and uses `sanskarin@outlook.in` for its automated normalization commit identity. - Loaded/imported convenience state is normalized against the rebuilt catalog so stale favorites, pins, and recent-history references cannot survive catalog/custom-unit changes. - Local persistence writes are serialized and handled as user-visible non-fatal warnings if the storage backend fails. @@ -41,6 +46,8 @@ All notable changes to UnitFlow are documented here. The format is based on Keep - Conversion rounding is applied consistently to primary and batch conversion paths using the persisted user preference. - Backup/custom-unit failures no longer echo raw internal exception text into the user interface. +- Persisted pinned-pair unit IDs now enforce the stable-ID grammar and bounded serialized length before entering application state. +- Backup imports now reject duplicate favorite IDs, duplicate pinned pairs, duplicate custom-unit IDs, unsupported object fields, unsafe recent unit IDs, and collection counts beyond the documented schema limits. - History can be cleared without making the action immediately irreversible because the UI provides an undo snapshot. - Removing a custom unit now removes dependent favorite, pinned-pair, and recent-history references instead of leaving inaccessible local state. - Undoing custom-unit removal restores the definition together with its captured favorite, pin, and history relationships when the stable ID is still available. @@ -52,6 +59,7 @@ All notable changes to UnitFlow are documented here. The format is based on Keep - Added responsible disclosure guidance and safe configuration rules. - Added bounded backup import validation and redacting structured diagnostic logging. - Added tracked-file safety scanning as a dependency-free CI check. +- JSON/ARB repository validation detects duplicate keys so ambiguous structured data cannot silently pass through a last-value-wins parser. - Added safe failure presentation that records only exception type metadata rather than potentially sensitive exception text. - Referential-normalization diagnostics record aggregate removal counts only, not conversion values or user backup content. From e50194988948c308c931a6562adf1747fc7bd6b5 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:33:52 +0530 Subject: [PATCH 165/241] test: keep in-memory import limits production-equivalent --- apps/unitflow_app/test/core/user_state_test.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/unitflow_app/test/core/user_state_test.dart b/apps/unitflow_app/test/core/user_state_test.dart index 2cbf0408..4b0b853d 100644 --- a/apps/unitflow_app/test/core/user_state_test.dart +++ b/apps/unitflow_app/test/core/user_state_test.dart @@ -48,6 +48,13 @@ void main() { expect(restored.customUnits.single.id, 'double_meter'); }); + test('memory repository enforces production import size bound', () { + final repository = MemoryUserStateRepository(); + final oversized = ' ' * 1_000_001; + + expect(() => repository.importJson(oversized), throwsFormatException); + }); + test('schema version one backups migrate to nearest-even rounding', () { final repository = MemoryUserStateRepository(); const legacy = '{' From f3300fa5f882641032df0677446c16dda5e3c58f Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:34:19 +0530 Subject: [PATCH 166/241] test: enforce custom unit collection limit --- .../test/app/custom_unit_limits_test.dart | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 apps/unitflow_app/test/app/custom_unit_limits_test.dart diff --git a/apps/unitflow_app/test/app/custom_unit_limits_test.dart b/apps/unitflow_app/test/app/custom_unit_limits_test.dart new file mode 100644 index 00000000..0c5d0f7c --- /dev/null +++ b/apps/unitflow_app/test/app/custom_unit_limits_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/app/app_controller.dart'; +import 'package:unitflow/core/persistence/user_state.dart'; +import 'package:unitflow/core/persistence/user_state_repository.dart'; +import 'package:unitflow/features/converter/domain/unit_models.dart'; + +void main() { + test('controller rejects custom units beyond the portable backup limit', () async { + final initialUnits = List.generate( + UserState.maxCustomUnits, + (index) => CustomUnitData( + id: 'custom_$index', + category: UnitCategory.length, + name: 'Custom $index', + symbol: 'c$index', + scale: '${index + 1}', + offset: '0', + ), + ); + final controller = AppController( + repository: MemoryUserStateRepository( + UserState( + onboardingComplete: true, + customUnits: initialUnits, + ), + ), + ); + addTearDown(controller.dispose); + await controller.initialize(); + + const overflow = CustomUnitData( + id: 'overflow_unit', + category: UnitCategory.length, + name: 'Overflow Unit', + symbol: 'overflow', + scale: '1', + offset: '0', + ); + + expect(() => controller.addCustomUnit(overflow), throwsStateError); + expect(controller.state.customUnits, hasLength(UserState.maxCustomUnits)); + }); + + test('controller persists normalized custom unit text', () async { + final repository = MemoryUserStateRepository( + UserState(onboardingComplete: true), + ); + final controller = AppController(repository: repository); + addTearDown(controller.dispose); + await controller.initialize(); + + const input = CustomUnitData( + id: 'double_meter', + category: UnitCategory.length, + name: ' Double Meter ', + symbol: ' dmx ', + scale: '2.00', + offset: '0.00', + aliases: [' double ', 'DOUBLE', 'two meters'], + description: ' Example unit. ', + ); + + await controller.addCustomUnit(input); + final persisted = (await repository.load()).customUnits.single; + + expect(persisted.name, 'Double Meter'); + expect(persisted.symbol, 'dmx'); + expect(persisted.scale, '2'); + expect(persisted.offset, '0'); + expect(persisted.aliases, ['double', 'two meters']); + expect(persisted.description, 'Example unit.'); + }); +} From 7f9826b6fe2bf55eed55f4617b8b9212742a69dc Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:35:18 +0530 Subject: [PATCH 167/241] docs: update canonical UnitFlow handoff --- what_changed.md | 71 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/what_changed.md b/what_changed.md index c73eb5a7..7e77309b 100644 --- a/what_changed.md +++ b/what_changed.md @@ -14,7 +14,7 @@ This is the primary continuation and audit checkpoint for UnitFlow. Keep this fi - Required visible product credit: **Made by the Sanskar** - Requested maintainer commit email: `sanskarin@outlook.in` -At the time this handoff was rewritten, the branch immediately before this handoff commit was `b01bfe16be45441116580a7b9fc2675180eb0b16`. The handoff commit itself advances the branch again, so always read the live PR head before using a SHA as release evidence. +Always read the live PR head before using a SHA as release evidence. The branch advances frequently while hardening is active. ## Source-of-truth rules @@ -82,6 +82,8 @@ Implemented: - fuzz-target support; - release-mode profiling example for lookup/search/single/batch workloads. +Rust unit-definition matching now includes descriptions as well as IDs, names, symbols, and aliases. A dedicated regression test covers case-insensitive description matching. + ### Rust ↔ Flutter bridge `crates/unitflow_bridge` provides a Flutter Rust Bridge-facing API around the Rust core. @@ -183,6 +185,18 @@ Migration behavior: - import validation occurs before replacing the active state; - malformed/unsupported imports must not partially overwrite the current profile. +Additional strictness added during the latest hardening pass: + +- unknown root and nested fields are rejected to match `additionalProperties: false`; +- pinned unit IDs must match the stable-ID grammar and serialized pin strings are length bounded; +- duplicate favorite IDs, pinned pairs, and custom-unit IDs are rejected; +- oversized pin/recent/custom-unit collections are rejected instead of silently truncating input; +- production and in-memory repositories use the same maximum-size decoder; +- locally created custom units cannot exceed the portable 200-unit limit; +- custom names/symbols/descriptions/aliases are normalized before persistence; +- aliases are case-insensitively deduplicated; +- custom scale/offset values are persisted in canonical exact-decimal form. + ### Error handling and diagnostics Implemented: @@ -200,10 +214,11 @@ Implemented: - searchable identifiers, names, symbols, aliases; - description-aware matching in the deterministic Dart catalog path; +- description-aware matching at the Rust unit-definition layer; - broad Rust catalog descriptions and aliases; - category explanations and examples in the converter learning panel; - custom unit descriptions/aliases; -- regression coverage for descriptive custom-unit search. +- regression coverage for descriptive custom-unit search and Rust description matching. ### Accessibility @@ -257,6 +272,7 @@ Implemented coverage includes: - error cases; - notation helpers; - property/regression-oriented invariants; +- description-aware unit-definition matching; - bridge-input and catalog-search fuzz targets. ### Flutter @@ -268,6 +284,11 @@ Implemented coverage includes: - user-state backup round trip; - schema-v1 → schema-v2 migration; - early schema-v2 compatibility; +- strict unknown-field rejection; +- persisted stable-ID validation; +- collection-bound validation; +- shared in-memory/production import-size behavior; +- custom-unit normalization and 200-unit limit behavior; - custom-unit validation; - app-controller favorites/pins/history/custom units; - persisted rounding/grouping/precision settings; @@ -284,7 +305,7 @@ Implemented coverage includes: CI now includes dependency-free repository checks: - `tool/check_secrets.py` — common committed private-key/token signature scan over tracked text-like files; -- `tool/check_data_files.py` — UTF-8 JSON parsing for tracked JSON/ARB data; +- `tool/check_data_files.py` — UTF-8 JSON parsing for tracked JSON/ARB data and duplicate-object-key rejection; - `tool/check_docs_links.py` — internal Markdown target validation. These supplement, not replace, CodeQL, dependency review, compiler/linter/test checks, and GitHub's own repository security features. @@ -345,7 +366,7 @@ Configured workflows include: During active development, many workflow runs are cancelled/superseded by later commits because concurrency is configured to keep the latest branch state authoritative. A queued, pending, cancelled, skipped, or older green run is **not** a passing result for the newest head. -At this handoff rewrite, the latest exact-candidate workflows had not yet all completed successfully. Therefore: +The latest exact-candidate workflows must be re-read after this handoff commit. Therefore: - do **not** claim Rust CI green for the current final head yet; - do **not** claim Flutter CI green for the current final head yet; @@ -392,8 +413,13 @@ Recent additions/hardening include: ```text apps/unitflow_app/lib/app/branding/unitflow_mark.dart apps/unitflow_app/lib/core/errors/user_safe_error.dart +apps/unitflow_app/lib/core/persistence/user_state.dart +apps/unitflow_app/lib/core/persistence/user_state_repository.dart +apps/unitflow_app/lib/features/converter/domain/unit_models.dart +apps/unitflow_app/test/app/custom_unit_limits_test.dart apps/unitflow_app/test/app/primary_journey_test.dart apps/unitflow_app/test/core/user_safe_error_test.dart +apps/unitflow_app/test/core/user_state_test.dart apps/unitflow_app/test/features/unit_catalog_search_test.dart assets/branding/unitflow-mark.svg schemas/unitflow-backup-v2.schema.json @@ -403,11 +429,44 @@ tool/check_docs_links.py tool/profile_core.sh tool/verify_release_candidate.sh crates/unitflow_core/examples/profile.rs +crates/unitflow_core/tests/unit_definition_search.rs docs/bridge.md docs/platform-support.md docs/branding.md +docs/data-format.md ``` +## Latest continuation commits + +The latest hardening sequence before this handoff commit is: + +- `7edc504d8c69531b88a544802a6110e12be66c1c` — `fix: search Rust unit descriptions` +- `80bdef48ee3e4307a040e31475b08dc30fd82ea4` — `test: cover description-aware unit matching` +- `4eee687a31b4d3fd79433cf92746e0dba6c64d0b` — `fix: validate persisted pinned pair identifiers` +- `14351ac96b0cbe5a05a4a9864302c4e30a25e441` — `fix: enforce backup schema bounds and normalization` +- `6437213e0be07d249b04da14a218cf887e16a356` — `fix: keep local state within backup limits` +- `cbe6c96aff56758430532c9ec2208b53042fe1cf` — `test: cover strict backup validation` +- `4a5d5d0b11d1ca289ddcb3e1c4b287564094b046` — `fix: reject duplicate JSON object keys` +- `0c096dced145eca0c5b6ba184dac09f21232f917` — `refactor: share strict backup decoding` +- `d4d1c0df30dbe32d166049efbda09c994202ddb7` — `docs: document strict backup contract` +- `ce7fc23c85e41e10ff96466efbcb75bac0c08dec` — `docs: record strict backup hardening` +- `e50194988948c308c931a6562adf1747fc7bd6b5` — `test: keep in-memory import limits production-equivalent` +- `f3300fa5f882641032df0677446c16dda5e3c58f` — `test: enforce custom unit collection limit` + +These commits deliberately remain granular rather than combining unrelated fixes. + +## Verification performed in the current continuation + +Static repository inspection confirmed: + +- PR #2 remains open and mergeable; +- there were no open repository issues at the time checked; +- no `TODO`, `FIXME`, `unimplemented`, `panic!`, or production `unwrap()`/`expect()` matches were surfaced by repository search; +- `Cargo.lock` and `apps/unitflow_app/pubspec.lock` were still absent before the newest audit-normalization run; the configured audit workflow is responsible for resolving and committing normalized lock/generated output on this branch; +- the Flutter Rust Bridge generated Rust module is still a placeholder until the pinned generator executes, so native bridge completion must not be claimed before exact workflow evidence exists. + +The execution environment used for connector-driven work still does not provide the project Rust/Flutter toolchains locally. Therefore compiler/analyzer/test success is intentionally not fabricated. GitHub Actions on the newest exact head remains the authoritative automated evidence source. + ## Remaining release blockers / exact next work These are not hidden TODOs; they are explicit release gates. @@ -415,7 +474,7 @@ These are not hidden TODOs; they are explicit release gates. 1. Stop source churn long enough for workflows on the newest exact PR head to execute. 2. Inspect latest CI jobs/logs rather than relying on older runs. 3. Fix every formatter/compiler/analyzer/test/repository-safety failure discovered by those workflows. -4. Ensure the audit normalization workflow successfully regenerates Flutter localizations and FRB bindings using the pinned versions. +4. Ensure the audit normalization workflow successfully produces lockfiles, regenerates Flutter localizations, and regenerates FRB bindings using pinned versions. 5. Inspect the generated Dart/Rust bridge API and wire/validate the native runtime adapter without guessing generated API names. 6. Prove native Rust library packaging/loading on Android/Windows/Linux/macOS/iOS-ready builds. 7. Prove deterministic web fallback behavior against representative Rust regression vectors. @@ -438,6 +497,6 @@ UnitFlow source is substantially implemented, but **the project must not be call ## Release notes draft — 0.1.0-alpha.1 -Planned initial preview includes the high-precision Rust conversion core, deterministic Flutter fallback, adaptive converter/library/history/settings UX, favorites/pins/custom units, batch conversion, local backup/restore, explicit rounding/notation controls, reduced-motion accessibility preference, localization infrastructure, project branding, bridge/release automation, security/privacy safeguards, and broad automated quality coverage. +Planned initial preview includes the high-precision Rust conversion core, deterministic Flutter fallback, adaptive converter/library/history/settings UX, favorites/pins/custom units, batch conversion, local backup/restore, explicit rounding/notation controls, reduced-motion accessibility preference, localization infrastructure, project branding, bridge/release automation, strict bounded backup validation, security/privacy safeguards, and broad automated quality coverage. Release-note wording must be finalized only after the exact tagged candidate passes the required checks. From 887cfcd43c8225fcac69422d8f05bf2f81389e5e Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:35:58 +0530 Subject: [PATCH 168/241] test: cover duplicate structured data keys --- tool/test_check_data_files.py | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tool/test_check_data_files.py diff --git a/tool/test_check_data_files.py b/tool/test_check_data_files.py new file mode 100644 index 00000000..da16e5bb --- /dev/null +++ b/tool/test_check_data_files.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Regression tests for repository JSON/ARB validation helpers.""" + +from __future__ import annotations + +import json +import unittest + +from check_data_files import DuplicateKeyError, reject_duplicate_keys + + +class RejectDuplicateKeysTests(unittest.TestCase): + def test_accepts_unique_object_keys(self) -> None: + decoded = json.loads( + '{"schemaVersion":2,"nested":{"value":true}}', + object_pairs_hook=reject_duplicate_keys, + ) + self.assertEqual(decoded["schemaVersion"], 2) + self.assertEqual(decoded["nested"]["value"], True) + + def test_rejects_duplicate_root_key(self) -> None: + with self.assertRaises(DuplicateKeyError): + json.loads( + '{"schemaVersion":1,"schemaVersion":2}', + object_pairs_hook=reject_duplicate_keys, + ) + + def test_rejects_duplicate_nested_key(self) -> None: + with self.assertRaises(DuplicateKeyError): + json.loads( + '{"nested":{"value":1,"value":2}}', + object_pairs_hook=reject_duplicate_keys, + ) + + +if __name__ == "__main__": + unittest.main() From d1765309591d5e612151e89a58655badef0838dd Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:36:13 +0530 Subject: [PATCH 169/241] ci: run repository utility regression tests --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f034c21..647e0ade 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,10 @@ jobs: - name: Validate Python utility syntax run: python3 -m py_compile tool/*.py + - name: Test repository utilities + working-directory: tool + run: python3 -m unittest discover -p 'test_*.py' + - name: Scan tracked files for credential signatures run: python3 tool/check_secrets.py From 3d80d009c5e0751f96f5b0627ee52ddaa4cf3940 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:36:36 +0530 Subject: [PATCH 170/241] docs: document repository utility tests --- docs/testing.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/testing.md b/docs/testing.md index 7555779b..a20e56f2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,6 +12,17 @@ bash tool/check.sh The script runs the same primary Rust and Flutter quality gates used by CI. +## Repository safety tests + +Run the dependency-free repository utility tests with: + +```bash +cd tool +python3 -m unittest discover -p 'test_*.py' +``` + +CI also validates shell/Python syntax, scans tracked files for common credential signatures, validates tracked JSON/ARB files as UTF-8 JSON with unique object keys, and verifies internal Markdown targets. + ## Rust quality gates Run: @@ -29,7 +40,7 @@ Coverage priorities: - multiplicative and affine conversion accuracy; - zero/negative/large/small decimal values; - round-trip conversion invariants where exact decimal factors permit it; -- search by name, symbol, and alias; +- search by name, symbol, alias, and descriptive metadata where supported; - custom-unit validation; - scientific/engineering notation edge cases; - batch conversion order and error behavior; @@ -63,6 +74,8 @@ Coverage priorities: - semantics for major controls; - custom-unit form validation; - backup schema round trips and rejected imports; +- strict backup collection/property/identifier validation; +- custom-unit normalization and collection limits; - batch CSV escaping. ## Rust–Flutter bridge generation From 5db112ac6e659c6466ca4e79549604af310ea1fd Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:36:48 +0530 Subject: [PATCH 171/241] build: include repository utility regression tests --- tool/check.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tool/check.sh b/tool/check.sh index 2368ac5f..3d777ad7 100644 --- a/tool/check.sh +++ b/tool/check.sh @@ -4,6 +4,11 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" +python3 -m py_compile tool/*.py +( + cd tool + python3 -m unittest discover -p 'test_*.py' +) python3 tool/check_secrets.py python3 tool/check_data_files.py python3 tool/check_docs_links.py From 9dd233969e8d59f11b9a36a59a8ba0118689006c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:37:08 +0530 Subject: [PATCH 172/241] build: run utility tests in release verification --- tool/verify_release_candidate.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tool/verify_release_candidate.sh b/tool/verify_release_candidate.sh index 7a6894e4..4103745e 100644 --- a/tool/verify_release_candidate.sh +++ b/tool/verify_release_candidate.sh @@ -10,6 +10,11 @@ if ! command -v flutter_rust_bridge_codegen >/dev/null 2>&1; then exit 1 fi +python3 -m py_compile tool/*.py +( + cd tool + python3 -m unittest discover -p 'test_*.py' +) python3 tool/check_secrets.py python3 tool/check_data_files.py python3 tool/check_docs_links.py From a8f511dc58d0c2d04d767c300921443bf8750956 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:38:20 +0530 Subject: [PATCH 173/241] docs: include repository utility regression command --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3327ea91..662b2637 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,17 @@ See [`docs/setup.md`](docs/setup.md) for platform prerequisites and [`docs/troub ## Development Quality Gates -Core checks: +Recommended one-command audit: ```bash +bash tool/check.sh +``` + +Equivalent core checks include: + +```bash +python3 -m py_compile tool/*.py +(cd tool && python3 -m unittest discover -p 'test_*.py') python3 tool/check_secrets.py python3 tool/check_data_files.py python3 tool/check_docs_links.py @@ -112,7 +120,7 @@ See [`docs/testing.md`](docs/testing.md), [`docs/performance.md`](docs/performan ## Local Data and Portability -UnitFlow keeps preferences, favorites, bounded recent history, pinned pairs, accessibility choices, and custom units locally by default. The portable backup format is versioned and validated before replacement of current state. Version 1 backups migrate deterministically to schema version 2, including the historical nearest-even rounding default. +UnitFlow keeps preferences, favorites, bounded recent history, pinned pairs, accessibility choices, and custom units locally by default. The portable backup format is versioned and validated before replacement of current state. Version 1 backups migrate deterministically to schema version 2, including the historical nearest-even rounding default. Imports reject unsupported object fields and collection counts outside the documented schema bounds rather than silently discarding them. See [`docs/data-format.md`](docs/data-format.md) and [`schemas/unitflow-backup-v2.schema.json`](schemas/unitflow-backup-v2.schema.json). @@ -124,7 +132,7 @@ See [`docs/accessibility.md`](docs/accessibility.md). ## Security and Privacy -Static conversions work offline and require no account. Repository CI includes common credential-pattern scanning, structured-data validation, dependency review, CodeQL, Rust/Flutter quality gates, and documentation link validation. Suspected vulnerabilities should be reported privately according to [`SECURITY.md`](SECURITY.md). +Static conversions work offline and require no account. Repository CI includes common credential-pattern scanning, structured-data validation including duplicate JSON/ARB key rejection, dependency review, CodeQL, Rust/Flutter quality gates, repository-utility regression tests, and documentation link validation. Suspected vulnerabilities should be reported privately according to [`SECURITY.md`](SECURITY.md). See [`PRIVACY.md`](PRIVACY.md) and [`SECURITY.md`](SECURITY.md). From ff794c6a4c2baf8def3a46071ec849b5f541ddeb Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:38:51 +0530 Subject: [PATCH 174/241] docs: record completed backup hardening gates --- ROADMAP.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index e41052a9..9d4411d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,6 +34,8 @@ The roadmap is milestone-oriented. A checked source-level item means the impleme - [x] Batch conversion table and deterministic CSV copying. - [x] File/clipboard backup and restore with bounded validation. - [x] Versioned backup schema and v1 → v2 migration. +- [x] Strict backup property/identifier/collection validation aligned with checked-in schemas. +- [x] Shared production/test backup decoding and canonical custom-unit persistence. - [x] Educational category explanations and examples. - [x] Locale-aware parsing/formatting foundations. - [x] Generated localization infrastructure and English source catalog. @@ -69,7 +71,9 @@ The roadmap is milestone-oriented. A checked source-level item means the impleme - [x] Rust fuzz targets for catalog/decimal bridge inputs. - [x] Core profiling workload for lookup/search/single/batch conversion. - [x] Repository secret-pattern, JSON/ARB, and Markdown-link checks. +- [x] Repository utility regression tests, including duplicate JSON-key detection. - [x] Backup corruption/version/migration regression coverage. +- [x] Strict backup unknown-field, stable-ID, collection-bound, and custom-unit normalization coverage. - [x] Safe error-presentation regression coverage. - [ ] Latest exact-candidate Rust fmt/clippy/tests green in CI. - [ ] Latest exact-candidate Flutter gen-l10n/format/analyze/tests green in CI. From f3236dc79176517d53c62bfc65ce065429c16ee5 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:39:49 +0530 Subject: [PATCH 175/241] docs: refresh UnitFlow continuation checkpoint --- what_changed.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/what_changed.md b/what_changed.md index 7e77309b..11f14fb6 100644 --- a/what_changed.md +++ b/what_changed.md @@ -306,7 +306,10 @@ CI now includes dependency-free repository checks: - `tool/check_secrets.py` — common committed private-key/token signature scan over tracked text-like files; - `tool/check_data_files.py` — UTF-8 JSON parsing for tracked JSON/ARB data and duplicate-object-key rejection; -- `tool/check_docs_links.py` — internal Markdown target validation. +- `tool/check_docs_links.py` — internal Markdown target validation; +- `tool/test_check_data_files.py` — regression tests proving unique objects are accepted and duplicate root/nested keys are rejected. + +Repository utility tests are part of CI, `tool/check.sh`, and `tool/verify_release_candidate.sh` so local, pull-request, and strict release verification exercise the same safety helper behavior. These supplement, not replace, CodeQL, dependency review, compiler/linter/test checks, and GitHub's own repository security features. @@ -329,7 +332,7 @@ No release performance number is claimed until output is recorded with machine/t bash tool/check.sh ``` -This covers repository safety/data/docs plus Rust and Flutter gates. It regenerates the bridge only when the generator is installed and warns when that extra check is skipped. +This covers repository utility regression tests, safety/data/docs checks, and Rust and Flutter gates. It regenerates the bridge only when the generator is installed and warns when that extra check is skipped. ### Bridge generation @@ -344,7 +347,7 @@ bash tool/generate_bridge.sh bash tool/verify_release_candidate.sh ``` -The strict verifier requires the pinned bridge generator and runs repository checks, Rust formatting/lint/tests/release build, Flutter localization/format/analyze/tests, bridge regeneration, post-generation analysis/tests, web release build, clean generated-source check, and core profiling harness. +The strict verifier requires the pinned bridge generator and runs repository utility regression tests, repository checks, Rust formatting/lint/tests/release build, Flutter localization/format/analyze/tests, bridge regeneration, post-generation analysis/tests, web release build, clean generated-source check, and core profiling harness. Native platform builds/manual review remain separate platform gates. @@ -353,7 +356,7 @@ Native platform builds/manual review remain separate platform gates. Configured workflows include: - CI: - - repository safety; + - repository safety and repository utility regression tests; - Rust quality; - Flutter quality; - Rust/Flutter bridge generation/check; @@ -426,6 +429,7 @@ schemas/unitflow-backup-v2.schema.json tool/check_secrets.py tool/check_data_files.py tool/check_docs_links.py +tool/test_check_data_files.py tool/profile_core.sh tool/verify_release_candidate.sh crates/unitflow_core/examples/profile.rs @@ -452,6 +456,13 @@ The latest hardening sequence before this handoff commit is: - `ce7fc23c85e41e10ff96466efbcb75bac0c08dec` — `docs: record strict backup hardening` - `e50194988948c308c931a6562adf1747fc7bd6b5` — `test: keep in-memory import limits production-equivalent` - `f3300fa5f882641032df0677446c16dda5e3c58f` — `test: enforce custom unit collection limit` +- `887cfcd43c8225fcac69422d8f05bf2f81389e5e` — `test: cover duplicate structured data keys` +- `d1765309591d5e612151e89a58655badef0838dd` — `ci: run repository utility regression tests` +- `3d80d009c5e0751f96f5b0627ee52ddaa4cf3940` — `docs: document repository utility tests` +- `5db112ac6e659c6466ca4e79549604af310ea1fd` — `build: include repository utility regression tests` +- `9dd233969e8d59f11b9a36a59a8ba0118689006c` — `build: run utility tests in release verification` +- `a8f511dc58d0c2d04d767c300921443bf8750956` — `docs: include repository utility regression command` +- `ff794c6a4c2baf8def3a46071ec849b5f541ddeb` — `docs: record completed backup hardening gates` These commits deliberately remain granular rather than combining unrelated fixes. From 91b4a602855bf6a8ce8cff4df9c4a0f08abe2358 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:40:32 +0530 Subject: [PATCH 176/241] fix: build oversized backup fixture with Dart API --- apps/unitflow_app/test/core/user_state_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/unitflow_app/test/core/user_state_test.dart b/apps/unitflow_app/test/core/user_state_test.dart index 4b0b853d..18ed31f6 100644 --- a/apps/unitflow_app/test/core/user_state_test.dart +++ b/apps/unitflow_app/test/core/user_state_test.dart @@ -50,7 +50,7 @@ void main() { test('memory repository enforces production import size bound', () { final repository = MemoryUserStateRepository(); - final oversized = ' ' * 1_000_001; + final oversized = ''.padRight(1_000_001, ' '); expect(() => repository.importJson(oversized), throwsFormatException); }); From 6c5330c270a9c3b81ee4c120c21c265b6fdf2750 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:41:38 +0530 Subject: [PATCH 177/241] fix: track generated Flutter Rust bridge sources --- .gitignore | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 7a14ba2c..ce07bd13 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,8 @@ build/ coverage/ -# Generated Rust-Flutter bridge files -**/frb_generated.dart -**/frb_generated.io.dart -**/frb_generated.web.dart +# Flutter Rust Bridge generated source is intentionally tracked. +# Audit normalization regenerates it with the pinned code generator so CI can detect drift. # IDEs .idea/ From e470045bf8964c553def860e1f76e8ef00602402 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:42:05 +0530 Subject: [PATCH 178/241] ci: detect untracked generated bridge drift --- .github/workflows/ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 647e0ade..eebadecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,4 +133,10 @@ jobs: run: flutter analyze --fatal-infos --fatal-warnings - name: Verify generated integration is committed - run: git diff --exit-code + shell: bash + run: | + if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then + echo "Generated bridge/platform integration differs from committed source." >&2 + git status --short >&2 + exit 1 + fi From 336d652b29d485bcfc1711a88b2814bf03f213a2 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:42:21 +0530 Subject: [PATCH 179/241] ci: normalize untracked generated sources --- .github/workflows/format-audit.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/format-audit.yml b/.github/workflows/format-audit.yml index 48b053e3..799fb082 100644 --- a/.github/workflows/format-audit.yml +++ b/.github/workflows/format-audit.yml @@ -67,7 +67,7 @@ jobs: - name: Commit normalized generated sources when needed shell: bash run: | - if git diff --quiet && test -f Cargo.lock && test -f apps/unitflow_app/pubspec.lock; then + if [[ -z "$(git status --porcelain --untracked-files=all)" ]] && test -f Cargo.lock && test -f apps/unitflow_app/pubspec.lock; then echo "Platform shells, native bridge scaffolding, generated sources, formatting, and lockfiles are already clean." exit 0 fi @@ -75,8 +75,9 @@ jobs: git config user.email "sanskarin@outlook.in" git add Cargo.lock apps/unitflow_app crates if git diff --cached --quiet; then - echo "No tracked normalization changes to commit." - exit 0 + echo "No normalization changes were stageable; refusing to hide untracked drift." >&2 + git status --short >&2 + exit 1 fi git commit -m "build: normalize platform shells bridge and generated sources" git push origin HEAD:${{ github.event.pull_request.head.ref }} From cecc26c1e4397bfda6e1a9f27890eac20102ed04 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:42:36 +0530 Subject: [PATCH 180/241] build: detect bridge generation drift locally --- tool/check.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tool/check.sh b/tool/check.sh index 3d777ad7..17b2d70a 100644 --- a/tool/check.sh +++ b/tool/check.sh @@ -26,10 +26,18 @@ flutter test cd "$ROOT" if command -v flutter_rust_bridge_codegen >/dev/null 2>&1; then + before_status="$(git status --porcelain --untracked-files=all)" bash tool/generate_bridge.sh cargo check --workspace --all-features cd "$ROOT/apps/unitflow_app" flutter analyze --fatal-infos --fatal-warnings + cd "$ROOT" + after_status="$(git status --porcelain --untracked-files=all)" + if [[ "$after_status" != "$before_status" ]]; then + echo "Bridge generation changed the working tree; commit regenerated bindings before merging." >&2 + git status --short >&2 + exit 1 + fi else echo "flutter_rust_bridge_codegen not found; bridge regeneration check skipped." >&2 echo "Install the pinned generator before release-candidate verification." >&2 From f9dd20076772ac7a4e655c2890a803812721f935 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:42:52 +0530 Subject: [PATCH 181/241] build: reject untracked release generation drift --- tool/verify_release_candidate.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tool/verify_release_candidate.sh b/tool/verify_release_candidate.sh index 4103745e..417fcd74 100644 --- a/tool/verify_release_candidate.sh +++ b/tool/verify_release_candidate.sh @@ -43,8 +43,8 @@ flutter test flutter build web --release cd "$ROOT" -if ! git diff --quiet --exit-code; then - echo "Release verification changed tracked files." >&2 +if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then + echo "Release verification changed or created repository files." >&2 echo "Regenerate/format sources and commit the result before releasing." >&2 git status --short >&2 exit 1 From e3049c2c9472dba6354de030c1dadd3dab20b43a Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:43:15 +0530 Subject: [PATCH 182/241] docs: require clean generated working tree --- docs/verification.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/verification.md b/docs/verification.md index 570ac093..c5def782 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -13,12 +13,14 @@ The exact head SHA changes while defects/features are being committed. Therefore ## Repository safety checks ```bash +python3 -m py_compile tool/*.py +(cd tool && python3 -m unittest discover -p 'test_*.py') python3 tool/check_secrets.py python3 tool/check_data_files.py python3 tool/check_docs_links.py ``` -These checks cover common credential signatures, JSON/ARB syntax, and internal Markdown target existence. They supplement CodeQL/dependency review rather than replacing them. +These checks cover repository utility regressions, common credential signatures, JSON/ARB syntax and duplicate-key rejection, and internal Markdown target existence. They supplement CodeQL/dependency review rather than replacing them. ## Rust quality checks @@ -49,9 +51,11 @@ bash tool/generate_bridge.sh cargo check --workspace --all-features cd apps/unitflow_app flutter analyze --fatal-infos --fatal-warnings +cd ../.. +git status --short ``` -A release candidate must also prove that bridge generation does not leave unexpected tracked diffs. +Generated Flutter Rust Bridge sources are intentionally tracked. A release candidate must prove that bridge/platform generation leaves **no modified or untracked repository files**. `git diff --exit-code` alone is insufficient because it does not report untracked generated files; CI and release verification therefore use `git status --porcelain --untracked-files=all` for the cleanliness gate. ## Strict release-candidate command @@ -59,7 +63,7 @@ A release candidate must also prove that bridge generation does not leave unexpe bash tool/verify_release_candidate.sh ``` -This combines repository checks, Rust/Flutter verification, bridge regeneration, release builds available on the current host, generated-source cleanliness, and the core profiling harness. It still cannot substitute for native builds/manual journeys on other operating systems. +This combines repository checks, Rust/Flutter verification, bridge regeneration, release builds available on the current host, generated-source cleanliness including untracked files, and the core profiling harness. It still cannot substitute for native builds/manual journeys on other operating systems. ## GitHub-required checks @@ -105,5 +109,5 @@ Record OS, CPU, Rust toolchain, build profile, commit SHA, and output when makin - Never treat an older green workflow as evidence for a newer commit. - Every confirmed behavior defect should receive regression coverage when practical. - Build/toolchain limitations belong in `what_changed.md` with exact scope. -- Generated sources are derived but must still be deterministic, reviewed through CI, and clean for release. +- Generated sources are derived but must still be deterministic, reviewed through CI, tracked where required, and leave no modified/untracked drift for release. - Security/release/accessibility/platform checks are additive to core compiler/test success. From 4bad4b6c9f5ac9a2e176cf8f20fe62512adca776 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:43:46 +0530 Subject: [PATCH 183/241] docs: document tracked bridge generation --- docs/bridge.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/bridge.md b/docs/bridge.md index 9107d0cf..8c9b9985 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -41,7 +41,9 @@ Decimal values cross the FFI boundary as strings. Rust parses and validates them ## Generated sources -Generated bindings are treated as derived sources. The audit-branch normalization workflow installs the pinned generator, regenerates bindings, runs formatting, and commits generated changes when needed. Generated files must still pass Rust and Flutter analysis before merge. +Generated bindings are treated as derived **but intentionally tracked** sources. The audit-branch normalization workflow installs the pinned generator, regenerates bindings, runs formatting, and commits generated changes when needed. Generated files must still pass Rust and Flutter analysis before merge. + +Generated FRB Dart files must not be added to `.gitignore`; otherwise a code-generation run could create required source that CI cannot review or reproduce from a clean checkout. Cleanliness checks use `git status --porcelain --untracked-files=all`, not only `git diff`, so newly generated untracked files are treated as drift. Do not hand-edit generated bridge files. Change the Rust API or generator configuration instead. @@ -68,7 +70,8 @@ When bridge-visible Rust types/functions change: 2. regenerate bindings with the pinned codegen version; 3. run `cargo fmt`, `cargo clippy`, and workspace tests; 4. run Flutter generation, formatting, analysis, and tests; -5. update this document and `what_changed.md` when integration behavior changes. +5. confirm no modified or untracked generated source remains; +6. update this document and `what_changed.md` when integration behavior changes. ## Troubleshooting From 15c9835fa0e32aed2852f62561bc64a9f36e3527 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:45:10 +0530 Subject: [PATCH 184/241] docs: record bridge reproducibility hardening --- what_changed.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/what_changed.md b/what_changed.md index 11f14fb6..141fb6dc 100644 --- a/what_changed.md +++ b/what_changed.md @@ -101,6 +101,8 @@ Bridge responsibilities implemented at source level: - reproducible generation command in `tool/generate_bridge.sh`; - CI/audit normalization that installs the pinned generator and regenerates bindings. +Generated FRB Dart source is now intentionally trackable rather than ignored. CI, local development checks, and release-candidate verification use working-tree status including untracked files when checking generated-source cleanliness. This closes the earlier gap where `git diff --exit-code` could miss newly generated untracked bridge/platform files. + Important release boundary: generated bindings and native-library packaging/loading still require exact-candidate CI/platform evidence. Binding source code existing in the repository is not the same as proving that every final platform artifact loads the native Rust library correctly. ### Deterministic Dart fallback @@ -332,7 +334,7 @@ No release performance number is claimed until output is recorded with machine/t bash tool/check.sh ``` -This covers repository utility regression tests, safety/data/docs checks, and Rust and Flutter gates. It regenerates the bridge only when the generator is installed and warns when that extra check is skipped. +This covers repository utility regression tests, safety/data/docs checks, and Rust and Flutter gates. When the pinned bridge generator is installed, it also rejects modified or untracked generated-source drift. ### Bridge generation @@ -347,7 +349,7 @@ bash tool/generate_bridge.sh bash tool/verify_release_candidate.sh ``` -The strict verifier requires the pinned bridge generator and runs repository utility regression tests, repository checks, Rust formatting/lint/tests/release build, Flutter localization/format/analyze/tests, bridge regeneration, post-generation analysis/tests, web release build, clean generated-source check, and core profiling harness. +The strict verifier requires the pinned bridge generator and runs repository utility regression tests, repository checks, Rust formatting/lint/tests/release build, Flutter localization/format/analyze/tests, bridge regeneration, post-generation analysis/tests, web release build, modified/untracked generated-source cleanliness checks, and the core profiling harness. Native platform builds/manual review remain separate platform gates. @@ -359,10 +361,10 @@ Configured workflows include: - repository safety and repository utility regression tests; - Rust quality; - Flutter quality; - - Rust/Flutter bridge generation/check; + - Rust/Flutter bridge generation/check including untracked generated-source drift detection; - CodeQL; - dependency review; -- audit-branch generated-source/format normalization; +- audit-branch generated-source/format normalization including untracked generated files; - multi-platform release workflow. ### Current verification truth @@ -438,6 +440,7 @@ docs/bridge.md docs/platform-support.md docs/branding.md docs/data-format.md +docs/verification.md ``` ## Latest continuation commits @@ -463,6 +466,14 @@ The latest hardening sequence before this handoff commit is: - `9dd233969e8d59f11b9a36a59a8ba0118689006c` — `build: run utility tests in release verification` - `a8f511dc58d0c2d04d767c300921443bf8750956` — `docs: include repository utility regression command` - `ff794c6a4c2baf8def3a46071ec849b5f541ddeb` — `docs: record completed backup hardening gates` +- `91b4a602855bf6a8ce8cff4df9c4a0f08abe2358` — `fix: build oversized backup fixture with Dart API` +- `6c5330c270a9c3b81ee4c120c21c265b6fdf2750` — `fix: track generated Flutter Rust bridge sources` +- `e470045bf8964c553def860e1f76e8ef00602402` — `ci: detect untracked generated bridge drift` +- `336d652b29d485bcfc1711a88b2814bf03f213a2` — `ci: normalize untracked generated sources` +- `cecc26c1e4397bfda6e1a9f27890eac20102ed04` — `build: detect bridge generation drift locally` +- `f9dd20076772ac7a4e655c2890a803812721f935` — `build: reject untracked release generation drift` +- `e3049c2c9472dba6354de030c1dadd3dab20b43a` — `docs: require clean generated working tree` +- `4bad4b6c9f5ac9a2e176cf8f20fe62512adca776` — `docs: document tracked bridge generation` These commits deliberately remain granular rather than combining unrelated fixes. @@ -474,7 +485,9 @@ Static repository inspection confirmed: - there were no open repository issues at the time checked; - no `TODO`, `FIXME`, `unimplemented`, `panic!`, or production `unwrap()`/`expect()` matches were surfaced by repository search; - `Cargo.lock` and `apps/unitflow_app/pubspec.lock` were still absent before the newest audit-normalization run; the configured audit workflow is responsible for resolving and committing normalized lock/generated output on this branch; -- the Flutter Rust Bridge generated Rust module is still a placeholder until the pinned generator executes, so native bridge completion must not be claimed before exact workflow evidence exists. +- the Flutter Rust Bridge generated Rust module is still a placeholder until the pinned generator executes, so native bridge completion must not be claimed before exact workflow evidence exists; +- generated FRB Dart sources had previously been ignored by `.gitignore`; that reproducibility defect is now fixed; +- generated-source cleanliness gates previously based only on `git diff` could miss new untracked generated files; CI, audit normalization, local checks, and strict release verification now account for untracked files. The execution environment used for connector-driven work still does not provide the project Rust/Flutter toolchains locally. Therefore compiler/analyzer/test success is intentionally not fabricated. GitHub Actions on the newest exact head remains the authoritative automated evidence source. @@ -485,7 +498,7 @@ These are not hidden TODOs; they are explicit release gates. 1. Stop source churn long enough for workflows on the newest exact PR head to execute. 2. Inspect latest CI jobs/logs rather than relying on older runs. 3. Fix every formatter/compiler/analyzer/test/repository-safety failure discovered by those workflows. -4. Ensure the audit normalization workflow successfully produces lockfiles, regenerates Flutter localizations, and regenerates FRB bindings using pinned versions. +4. Ensure the audit normalization workflow successfully produces lockfiles, regenerates Flutter localizations, tracks/regenerates FRB bindings, and commits all required generated/platform files using the pinned versions. 5. Inspect the generated Dart/Rust bridge API and wire/validate the native runtime adapter without guessing generated API names. 6. Prove native Rust library packaging/loading on Android/Windows/Linux/macOS/iOS-ready builds. 7. Prove deterministic web fallback behavior against representative Rust regression vectors. @@ -498,7 +511,7 @@ These are not hidden TODOs; they are explicit release gates. 14. Run a documented fuzzing campaign budget for the release candidate. 15. Generate and publish checksum metadata for final downloadable artifacts. 16. Configure signing/notarization/store credentials only in private platform/repository secret facilities, never in source. -17. Run `tool/verify_release_candidate.sh` on the exact release candidate and ensure it leaves no tracked changes. +17. Run `tool/verify_release_candidate.sh` on the exact release candidate and ensure it leaves no modified or untracked repository files. 18. Re-read this file, `ROADMAP.md`, `CHANGELOG.md`, and release docs for stale claims before tagging. 19. Tag/release `0.1.0-alpha.1` only after the applicable automated/manual gates are satisfied. @@ -508,6 +521,6 @@ UnitFlow source is substantially implemented, but **the project must not be call ## Release notes draft — 0.1.0-alpha.1 -Planned initial preview includes the high-precision Rust conversion core, deterministic Flutter fallback, adaptive converter/library/history/settings UX, favorites/pins/custom units, batch conversion, local backup/restore, explicit rounding/notation controls, reduced-motion accessibility preference, localization infrastructure, project branding, bridge/release automation, strict bounded backup validation, security/privacy safeguards, and broad automated quality coverage. +Planned initial preview includes the high-precision Rust conversion core, deterministic Flutter fallback, adaptive converter/library/history/settings UX, favorites/pins/custom units, batch conversion, local backup/restore, explicit rounding/notation controls, reduced-motion accessibility preference, localization infrastructure, project branding, reproducible tracked bridge generation, strict bounded backup validation, security/privacy safeguards, and broad automated quality coverage. Release-note wording must be finalized only after the exact tagged candidate passes the required checks. From e7b084f12694936027a57f3e1e639e11f729664a Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:58:48 +0530 Subject: [PATCH 185/241] fix: align Flutter preview version with Rust workspace --- apps/unitflow_app/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/unitflow_app/pubspec.yaml b/apps/unitflow_app/pubspec.yaml index c79199f9..886ddcc3 100644 --- a/apps/unitflow_app/pubspec.yaml +++ b/apps/unitflow_app/pubspec.yaml @@ -1,7 +1,7 @@ name: unitflow description: A precise, offline-first unit converter powered by UnitFlow's Rust domain core. publish_to: "none" -version: 0.1.0+1 +version: 0.1.0-alpha.1+1 repository: https://github.com/sanskarIN/unitflow From 9ff58737572e54708558cbbad463731e98eeaa81 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:59:16 +0530 Subject: [PATCH 186/241] build: add cross-stack version consistency check --- tool/check_versions.py | 110 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tool/check_versions.py diff --git a/tool/check_versions.py b/tool/check_versions.py new file mode 100644 index 00000000..fcbbc900 --- /dev/null +++ b/tool/check_versions.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Validate UnitFlow release and Flutter Rust Bridge version consistency.""" + +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CARGO_TOML = ROOT / "Cargo.toml" +PUBSPEC = ROOT / "apps/unitflow_app/pubspec.yaml" +PINNED_FILES = ( + ROOT / ".github/workflows/ci.yml", + ROOT / ".github/workflows/format-audit.yml", + ROOT / ".github/workflows/release.yml", + ROOT / "docs/bridge.md", + ROOT / "docs/testing.md", + ROOT / "docs/verification.md", + ROOT / "tool/generate_bridge.sh", + ROOT / "tool/integrate_native_bridge.sh", + ROOT / "tool/verify_release_candidate.sh", +) + +PUBSPEC_VERSION_RE = re.compile(r"(?m)^version:\s*([^\s#]+)\s*$") +PUBSPEC_FRB_RE = re.compile(r"(?m)^\s{2}flutter_rust_bridge:\s*([^\s#]+)\s*$") +CODEGEN_PIN_RE = re.compile(r"flutter_rust_bridge_codegen\s+--version\s+([^\s`]+)") +DOCUMENTED_PIN_RE = re.compile(r"flutter_rust_bridge_codegen\s+--version\s+([^\s`]+)") + + +def load_workspace_versions() -> tuple[str, str]: + with CARGO_TOML.open("rb") as handle: + cargo = tomllib.load(handle) + package = cargo.get("workspace", {}).get("package", {}) + dependencies = cargo.get("workspace", {}).get("dependencies", {}) + version = package.get("version") + frb = dependencies.get("flutter_rust_bridge") + if not isinstance(version, str) or not isinstance(frb, str): + raise ValueError("Cargo.toml must define workspace package and FRB versions") + return version, frb + + +def load_pubspec_versions() -> tuple[str, str]: + text = PUBSPEC.read_text(encoding="utf-8") + version_match = PUBSPEC_VERSION_RE.search(text) + frb_match = PUBSPEC_FRB_RE.search(text) + if version_match is None or frb_match is None: + raise ValueError("pubspec.yaml must define application and FRB versions") + return version_match.group(1), frb_match.group(1) + + +def flutter_semver(pubspec_version: str) -> str: + return pubspec_version.split("+", 1)[0] + + +def check_codegen_pins(expected: str) -> list[str]: + failures: list[str] = [] + for path in PINNED_FILES: + if not path.exists(): + failures.append(f"missing pinned-version file: {path.relative_to(ROOT)}") + continue + text = path.read_text(encoding="utf-8") + matches = CODEGEN_PIN_RE.findall(text) + if not matches: + # Documentation may mention the generator version without a literal install command. + matches = DOCUMENTED_PIN_RE.findall(text) + for actual in matches: + if actual != expected: + failures.append( + f"{path.relative_to(ROOT)} pins flutter_rust_bridge_codegen {actual}; expected {expected}" + ) + return failures + + +def main() -> int: + failures: list[str] = [] + try: + workspace_version, workspace_frb = load_workspace_versions() + flutter_version, flutter_frb = load_pubspec_versions() + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, ValueError) as error: + print(f"Version consistency check failed to parse configuration: {error}", file=sys.stderr) + return 2 + + if flutter_semver(flutter_version) != workspace_version: + failures.append( + "Flutter application version " + f"{flutter_semver(flutter_version)} does not match Rust workspace version {workspace_version}" + ) + if flutter_frb != workspace_frb: + failures.append( + f"Flutter FRB dependency {flutter_frb} does not match Rust workspace FRB dependency {workspace_frb}" + ) + failures.extend(check_codegen_pins(workspace_frb)) + + if failures: + print("Version consistency failures:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + + print( + "Version consistency passed: " + f"UnitFlow {workspace_version}, flutter_rust_bridge {workspace_frb}." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From fedbef2defa2a8155a696c9faab09cc0ea890475 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:59:30 +0530 Subject: [PATCH 187/241] test: cover version consistency helper --- tool/test_check_versions.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tool/test_check_versions.py diff --git a/tool/test_check_versions.py b/tool/test_check_versions.py new file mode 100644 index 00000000..877d5466 --- /dev/null +++ b/tool/test_check_versions.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Regression tests for UnitFlow version consistency helpers.""" + +from __future__ import annotations + +import unittest + +from check_versions import ( + check_codegen_pins, + flutter_semver, + load_pubspec_versions, + load_workspace_versions, +) + + +class VersionConsistencyTests(unittest.TestCase): + def test_flutter_build_metadata_is_not_part_of_release_semver(self) -> None: + self.assertEqual(flutter_semver("0.1.0-alpha.1+7"), "0.1.0-alpha.1") + self.assertEqual(flutter_semver("1.2.3"), "1.2.3") + + def test_repository_release_versions_match(self) -> None: + workspace_version, workspace_frb = load_workspace_versions() + flutter_version, flutter_frb = load_pubspec_versions() + + self.assertEqual(flutter_semver(flutter_version), workspace_version) + self.assertEqual(flutter_frb, workspace_frb) + + def test_codegen_install_pins_match_workspace_dependency(self) -> None: + _, workspace_frb = load_workspace_versions() + self.assertEqual(check_codegen_pins(workspace_frb), []) + + +if __name__ == "__main__": + unittest.main() From 7e51d6a20c2621c77c93751799b837393a6d2399 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 11:59:49 +0530 Subject: [PATCH 188/241] ci: verify cross-stack version consistency --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eebadecf..41629a82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: working-directory: tool run: python3 -m unittest discover -p 'test_*.py' + - name: Verify version consistency + run: python3 tool/check_versions.py + - name: Scan tracked files for credential signatures run: python3 tool/check_secrets.py From 2129bfcf7fef918b6beb0e5a2f2f2f5df03b9aed Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:00:02 +0530 Subject: [PATCH 189/241] build: enforce version consistency in local checks --- tool/check.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tool/check.sh b/tool/check.sh index 17b2d70a..965444e6 100644 --- a/tool/check.sh +++ b/tool/check.sh @@ -9,6 +9,7 @@ python3 -m py_compile tool/*.py cd tool python3 -m unittest discover -p 'test_*.py' ) +python3 tool/check_versions.py python3 tool/check_secrets.py python3 tool/check_data_files.py python3 tool/check_docs_links.py From 3db701de243ea2181618588c459bf80e349b7055 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:00:19 +0530 Subject: [PATCH 190/241] build: enforce version consistency for release candidates --- tool/verify_release_candidate.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tool/verify_release_candidate.sh b/tool/verify_release_candidate.sh index 417fcd74..3d060814 100644 --- a/tool/verify_release_candidate.sh +++ b/tool/verify_release_candidate.sh @@ -15,6 +15,7 @@ python3 -m py_compile tool/*.py cd tool python3 -m unittest discover -p 'test_*.py' ) +python3 tool/check_versions.py python3 tool/check_secrets.py python3 tool/check_data_files.py python3 tool/check_docs_links.py From dad282c3b4591589fb1aed5b3b13eea05b4ed41d Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:03:07 +0530 Subject: [PATCH 191/241] fix: persist and reopen canonical recent conversions --- .../presentation/converter_controller.dart | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart b/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart index 95f117a4..3e855dcf 100644 --- a/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart +++ b/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../../../app/app_controller.dart'; import '../../../core/format/decimal_format.dart'; import '../../../core/math/exact_decimal.dart'; +import '../../../core/persistence/user_state.dart'; import '../domain/conversion_engine.dart'; import '../domain/unit_models.dart'; @@ -169,11 +170,12 @@ final class ConverterController extends ChangeNotifier { _appController.togglePinnedPair(currentPair); Future recordCurrentConversion() { - if (_result == null) { + final currentResult = _result; + if (currentResult == null) { return Future.value(); } return _appController.recordRecent( - input: _input, + input: currentResult.input.toCanonicalString(), fromUnitId: _fromUnitId, toUnitId: _toUnitId, ); @@ -194,6 +196,19 @@ final class ConverterController extends ChangeNotifier { recompute(); } + void applyRecentConversion(RecentConversion recent) { + final from = _appController.engine.catalog.byId(recent.fromUnitId); + final to = _appController.engine.catalog.byId(recent.toUnitId); + if (from == null || to == null || from.category != to.category) { + return; + } + _category = from.category; + _fromUnitId = from.id; + _toUnitId = to.id; + _input = recent.input; + recompute(); + } + void _selectDefaults(UnitCategory category) { final units = _appController.engine.catalog.forCategory(category); if (units.isEmpty) { From a65745e5fe1505586eaa454be351dd2c97e94f73 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:03:57 +0530 Subject: [PATCH 192/241] fix: synchronize programmatic converter input changes --- .../presentation/converter_screen.dart | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/unitflow_app/lib/features/converter/presentation/converter_screen.dart b/apps/unitflow_app/lib/features/converter/presentation/converter_screen.dart index 1b457a38..889fc2cc 100644 --- a/apps/unitflow_app/lib/features/converter/presentation/converter_screen.dart +++ b/apps/unitflow_app/lib/features/converter/presentation/converter_screen.dart @@ -17,9 +17,25 @@ final class ConverterScreen extends StatefulWidget { } final class _ConverterScreenState extends State { - late final TextEditingController _inputController = TextEditingController( - text: widget.controller.input, - ); + late final TextEditingController _inputController; + + @override + void initState() { + super.initState(); + _inputController = TextEditingController(text: widget.controller.input); + widget.controller.addListener(_syncInputFromController); + } + + @override + void didUpdateWidget(covariant ConverterScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller == widget.controller) { + return; + } + oldWidget.controller.removeListener(_syncInputFromController); + widget.controller.addListener(_syncInputFromController); + _syncInputFromController(); + } @override void didChangeDependencies() { @@ -27,8 +43,20 @@ final class _ConverterScreenState extends State { widget.controller.setLocale(Localizations.localeOf(context).toLanguageTag()); } + void _syncInputFromController() { + final next = widget.controller.input; + if (_inputController.text == next) { + return; + } + _inputController.value = TextEditingValue( + text: next, + selection: TextSelection.collapsed(offset: next.length), + ); + } + @override void dispose() { + widget.controller.removeListener(_syncInputFromController); _inputController.dispose(); super.dispose(); } From 8b12c6344971fe18f62a34715b96c3516cfb42e1 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:04:18 +0530 Subject: [PATCH 193/241] fix: reopen exact recent conversion from history --- .../history/presentation/history_screen.dart | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/unitflow_app/lib/features/history/presentation/history_screen.dart b/apps/unitflow_app/lib/features/history/presentation/history_screen.dart index 29c7f818..86c82486 100644 --- a/apps/unitflow_app/lib/features/history/presentation/history_screen.dart +++ b/apps/unitflow_app/lib/features/history/presentation/history_screen.dart @@ -5,17 +5,16 @@ import '../../../app/app_controller.dart'; import '../../../app/theme/app_theme.dart'; import '../../../core/persistence/user_state.dart'; import '../../../l10n/app_localizations.dart'; -import '../../converter/domain/unit_models.dart'; final class HistoryScreen extends StatelessWidget { const HistoryScreen({ required this.appController, - required this.onOpenPair, + required this.onOpenRecent, super.key, }); final AppController appController; - final ValueChanged onOpenPair; + final ValueChanged onOpenRecent; @override Widget build(BuildContext context) => AnimatedBuilder( @@ -90,13 +89,7 @@ final class HistoryScreen extends StatelessWidget { '${from.name} → ${to.name} • ${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).add_jm().format(recent.createdAt.toLocal())}', ), trailing: const Icon(Icons.chevron_right), - onTap: () => onOpenPair( - PinnedPair( - category: from.category, - fromUnitId: from.id, - toUnitId: to.id, - ), - ), + onTap: () => onOpenRecent(recent), ), ), ); From e1597ba1bbfd3031a35e7afd8543f415ba9f7f1b Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:04:47 +0530 Subject: [PATCH 194/241] fix: route history entries through exact recent state --- apps/unitflow_app/lib/app/app_shell.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/app/app_shell.dart b/apps/unitflow_app/lib/app/app_shell.dart index 80a851c4..5196ebb4 100644 --- a/apps/unitflow_app/lib/app/app_shell.dart +++ b/apps/unitflow_app/lib/app/app_shell.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../core/persistence/user_state.dart'; import '../features/converter/domain/unit_models.dart'; import '../features/converter/presentation/converter_controller.dart'; import '../features/converter/presentation/converter_screen.dart'; @@ -181,7 +182,7 @@ final class _AppShellState extends State { ), HistoryScreen( appController: widget.appController, - onOpenPair: _openPair, + onOpenRecent: _openRecent, ), SettingsScreen( appController: widget.appController, @@ -202,6 +203,11 @@ final class _AppShellState extends State { setState(() => _selectedIndex = 0); } + void _openRecent(RecentConversion recent) { + _converterController.applyRecentConversion(recent); + setState(() => _selectedIndex = 0); + } + Future _openAbout() => Navigator.of(context).push( MaterialPageRoute( builder: (_) => const Scaffold(body: SafeArea(child: AboutScreen())), From 79a872e868c07ab2f3d2c3fcfeaec29bd311d137 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:05:15 +0530 Subject: [PATCH 195/241] test: verify history restores exact conversion input --- apps/unitflow_app/test/app/primary_journey_test.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/unitflow_app/test/app/primary_journey_test.dart b/apps/unitflow_app/test/app/primary_journey_test.dart index 00bb569e..371469cd 100644 --- a/apps/unitflow_app/test/app/primary_journey_test.dart +++ b/apps/unitflow_app/test/app/primary_journey_test.dart @@ -25,6 +25,7 @@ void main() { expect(find.text('1'), findsWidgets); expect(controller.state.recents, hasLength(1)); + expect(controller.state.recents.single.input, '1000'); expect(controller.state.recents.single.fromUnitId, 'meter'); expect(controller.state.recents.single.toUnitId, 'kilometer'); @@ -42,5 +43,9 @@ void main() { await tester.tap(find.textContaining('1000 m')); await tester.pumpAndSettle(); expect(find.text('Convert units'), findsOneWidget); + + final reopenedField = tester.widget(find.byType(TextField)); + expect(reopenedField.controller?.text, '1000'); + expect(find.text('1'), findsWidgets); }); } From b03a5c4dc0740dbf792d22906d077464816db889 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:05:25 +0530 Subject: [PATCH 196/241] test: cover locale-safe recent conversion persistence --- .../test/features/converter_history_test.dart | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 apps/unitflow_app/test/features/converter_history_test.dart diff --git a/apps/unitflow_app/test/features/converter_history_test.dart b/apps/unitflow_app/test/features/converter_history_test.dart new file mode 100644 index 00000000..b3cce235 --- /dev/null +++ b/apps/unitflow_app/test/features/converter_history_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/app/app_controller.dart'; +import 'package:unitflow/core/persistence/user_state.dart'; +import 'package:unitflow/core/persistence/user_state_repository.dart'; +import 'package:unitflow/features/converter/presentation/converter_controller.dart'; + +void main() { + late MemoryUserStateRepository repository; + late AppController appController; + late ConverterController converter; + + setUp(() async { + repository = MemoryUserStateRepository( + UserState(onboardingComplete: true), + ); + appController = AppController(repository: repository); + await appController.initialize(); + converter = ConverterController(appController: appController); + }); + + tearDown(() { + converter.dispose(); + appController.dispose(); + }); + + test('recent conversions persist canonical input across locales', () async { + converter.setLocale('de_DE'); + converter.setInput('1,25'); + + await converter.recordCurrentConversion(); + + final recent = (await repository.load()).recents.single; + expect(recent.input, '1.25'); + expect(recent.fromUnitId, 'meter'); + expect(recent.toUnitId, 'kilometer'); + }); + + test('applying a recent conversion restores pair input and result', () { + final recent = RecentConversion( + input: '2500', + fromUnitId: 'meter', + toUnitId: 'kilometer', + createdAt: DateTime.utc(2026, 8, 19), + ); + + converter.swapUnits(); + converter.setInput('3'); + converter.applyRecentConversion(recent); + + expect(converter.input, '2500'); + expect(converter.fromUnitId, 'meter'); + expect(converter.toUnitId, 'kilometer'); + expect(converter.result?.output.toCanonicalString(), '2.5'); + }); +} From e5b0686d7cf16444027b6ea2af4829ba4a763e97 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:06:31 +0530 Subject: [PATCH 197/241] feat: reject duplicate keys in imported JSON --- .../lib/core/persistence/strict_json.dart | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 apps/unitflow_app/lib/core/persistence/strict_json.dart diff --git a/apps/unitflow_app/lib/core/persistence/strict_json.dart b/apps/unitflow_app/lib/core/persistence/strict_json.dart new file mode 100644 index 00000000..ff161a81 --- /dev/null +++ b/apps/unitflow_app/lib/core/persistence/strict_json.dart @@ -0,0 +1,166 @@ +import 'dart:convert'; + +/// Decodes JSON after rejecting duplicate object keys and excessive nesting. +/// +/// Dart's standard JSON decoder keeps only one value when an object repeats a key. Backup +/// imports should instead fail closed so ambiguous documents cannot silently change meaning. +Object? decodeStrictJson(String source, {int maxNesting = 64}) { + if (maxNesting < 1) { + throw ArgumentError.value(maxNesting, 'maxNesting', 'must be positive'); + } + _JsonObjectKeyScanner(source, maxNesting: maxNesting).validate(); + return jsonDecode(source); +} + +final class _JsonObjectKeyScanner { + _JsonObjectKeyScanner(this.source, {required this.maxNesting}); + + final String source; + final int maxNesting; + int _index = 0; + + void validate() { + _skipWhitespace(); + _parseValue(0); + _skipWhitespace(); + if (_index != source.length) { + throw const FormatException('Unexpected data after JSON value.'); + } + } + + void _parseValue(int depth) { + if (depth > maxNesting) { + throw FormatException('JSON nesting exceeds the limit of $maxNesting.'); + } + _skipWhitespace(); + if (_index >= source.length) { + throw const FormatException('Unexpected end of JSON input.'); + } + + switch (source[_index]) { + case '{': + _parseObject(depth + 1); + case '[': + _parseArray(depth + 1); + case '"': + _scanString(); + default: + _scanPrimitive(); + } + } + + void _parseObject(int depth) { + _expect('{'); + _skipWhitespace(); + if (_consumeIf('}')) { + return; + } + + final keys = {}; + while (true) { + _skipWhitespace(); + if (_index >= source.length || source[_index] != '"') { + throw const FormatException('JSON object key must be a string.'); + } + final rawKey = _scanString(); + final decodedKey = jsonDecode(rawKey); + if (decodedKey is! String) { + throw const FormatException('JSON object key is invalid.'); + } + if (!keys.add(decodedKey)) { + throw FormatException('Duplicate JSON object key: $decodedKey'); + } + + _skipWhitespace(); + _expect(':'); + _parseValue(depth); + _skipWhitespace(); + if (_consumeIf('}')) { + return; + } + _expect(','); + } + } + + void _parseArray(int depth) { + _expect('['); + _skipWhitespace(); + if (_consumeIf(']')) { + return; + } + + while (true) { + _parseValue(depth); + _skipWhitespace(); + if (_consumeIf(']')) { + return; + } + _expect(','); + } + } + + String _scanString() { + final start = _index; + _expect('"'); + while (_index < source.length) { + final character = source[_index]; + if (character == '"') { + _index += 1; + return source.substring(start, _index); + } + if (character == r'\') { + _index += 1; + if (_index >= source.length) { + throw const FormatException('Unterminated JSON escape sequence.'); + } + _index += 1; + continue; + } + _index += 1; + } + throw const FormatException('Unterminated JSON string.'); + } + + void _scanPrimitive() { + final start = _index; + while (_index < source.length) { + final character = source[_index]; + if (_isWhitespace(character) || + character == ',' || + character == ']' || + character == '}') { + break; + } + _index += 1; + } + if (_index == start) { + throw const FormatException('Expected JSON value.'); + } + } + + void _skipWhitespace() { + while (_index < source.length && _isWhitespace(source[_index])) { + _index += 1; + } + } + + bool _consumeIf(String expected) { + if (_index < source.length && source[_index] == expected) { + _index += 1; + return true; + } + return false; + } + + void _expect(String expected) { + if (!_consumeIf(expected)) { + throw FormatException('Expected `$expected` in JSON input.'); + } + } + + bool _isWhitespace(String character) => + character == ' ' || + character == '\n' || + character == '\r' || + character == '\t'; +} From afce0e9d1ffd6ffe78b6667e135f9da0a74431e8 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:07:08 +0530 Subject: [PATCH 198/241] fix: use strict JSON decoding for backups --- .../lib/core/persistence/user_state_repository.dart | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/unitflow_app/lib/core/persistence/user_state_repository.dart b/apps/unitflow_app/lib/core/persistence/user_state_repository.dart index 216df394..7e040f9c 100644 --- a/apps/unitflow_app/lib/core/persistence/user_state_repository.dart +++ b/apps/unitflow_app/lib/core/persistence/user_state_repository.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; +import 'strict_json.dart'; import 'user_state.dart'; abstract interface class UserStateRepository { @@ -90,14 +91,7 @@ UserState _decodeState(String content) { throw const FormatException('UnitFlow import size is invalid.'); } - final Object? decoded; - try { - decoded = jsonDecode(content); - } on FormatException { - rethrow; - } on Object { - throw const FormatException('UnitFlow import is not valid JSON.'); - } + final decoded = decodeStrictJson(content); if (decoded is! Map) { throw const FormatException('UnitFlow import must be a JSON object.'); } From 4f19aba58110285cecb414faa3002233e984207a Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:07:20 +0530 Subject: [PATCH 199/241] test: cover strict JSON duplicate key detection --- .../test/core/strict_json_test.dart | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 apps/unitflow_app/test/core/strict_json_test.dart diff --git a/apps/unitflow_app/test/core/strict_json_test.dart b/apps/unitflow_app/test/core/strict_json_test.dart new file mode 100644 index 00000000..5bcae397 --- /dev/null +++ b/apps/unitflow_app/test/core/strict_json_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/core/persistence/strict_json.dart'; + +void main() { + test('decodes valid nested JSON', () { + final decoded = decodeStrictJson( + '{"root":{"items":[1,true,null,{"name":"UnitFlow"}]}}', + ); + + expect(decoded, isA>()); + }); + + test('rejects duplicate root object keys', () { + expect( + () => decodeStrictJson('{"schemaVersion":1,"schemaVersion":2}'), + throwsFormatException, + ); + }); + + test('rejects duplicate nested object keys', () { + expect( + () => decodeStrictJson('{"outer":{"value":1,"value":2}}'), + throwsFormatException, + ); + }); + + test('treats escaped and literal forms of the same key as duplicates', () { + expect( + () => decodeStrictJson('{"name":1,"\\u006eame":2}'), + throwsFormatException, + ); + }); + + test('allows the same key name in separate objects', () { + expect( + decodeStrictJson('[{"value":1},{"value":2}]'), + isA>(), + ); + }); + + test('rejects nesting beyond configured limit', () { + expect( + () => decodeStrictJson('[[[0]]]', maxNesting: 2), + throwsFormatException, + ); + }); +} From 7bca811d9ca9e2e62a3d0fc124e760c2bc3f47a6 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:07:37 +0530 Subject: [PATCH 200/241] test: reject ambiguous duplicate-key backups --- .../test/core/backup_strictness_test.dart | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/unitflow_app/test/core/backup_strictness_test.dart diff --git a/apps/unitflow_app/test/core/backup_strictness_test.dart b/apps/unitflow_app/test/core/backup_strictness_test.dart new file mode 100644 index 00000000..adb94203 --- /dev/null +++ b/apps/unitflow_app/test/core/backup_strictness_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/core/persistence/user_state_repository.dart'; + +void main() { + test('backup import rejects duplicate top-level keys', () { + final repository = MemoryUserStateRepository(); + const payload = '{' + '"schemaVersion":2,' + '"schemaVersion":2,' + '"theme":"system",' + '"notation":"plain",' + '"roundingMode":"nearestEven",' + '"decimalPlaces":12,' + '"useGrouping":true,' + '"onboardingComplete":true,' + '"favoriteUnitIds":[],' + '"pinnedPairs":[],' + '"recents":[],' + '"customUnits":[]' + '}'; + + expect(() => repository.importJson(payload), throwsFormatException); + }); + + test('backup import rejects duplicate nested custom-unit keys', () { + final repository = MemoryUserStateRepository(); + const payload = '{' + '"schemaVersion":2,' + '"theme":"system",' + '"notation":"plain",' + '"roundingMode":"nearestEven",' + '"decimalPlaces":12,' + '"useGrouping":true,' + '"onboardingComplete":true,' + '"favoriteUnitIds":[],' + '"pinnedPairs":[],' + '"recents":[],' + '"customUnits":[{' + '"id":"double_meter",' + '"id":"triple_meter",' + '"category":"length",' + '"name":"Double Meter",' + '"symbol":"dmx",' + '"scale":"2",' + '"offset":"0",' + '"aliases":[],' + '"description":""' + '}]' + '}'; + + expect(() => repository.importJson(payload), throwsFormatException); + }); +} From ce48c780522b133394794ceae283900657883d2d Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:08:05 +0530 Subject: [PATCH 201/241] fix: enforce nesting limit for empty JSON containers --- .../lib/core/persistence/strict_json.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/unitflow_app/lib/core/persistence/strict_json.dart b/apps/unitflow_app/lib/core/persistence/strict_json.dart index ff161a81..798201b5 100644 --- a/apps/unitflow_app/lib/core/persistence/strict_json.dart +++ b/apps/unitflow_app/lib/core/persistence/strict_json.dart @@ -29,9 +29,7 @@ final class _JsonObjectKeyScanner { } void _parseValue(int depth) { - if (depth > maxNesting) { - throw FormatException('JSON nesting exceeds the limit of $maxNesting.'); - } + _checkDepth(depth); _skipWhitespace(); if (_index >= source.length) { throw const FormatException('Unexpected end of JSON input.'); @@ -50,6 +48,7 @@ final class _JsonObjectKeyScanner { } void _parseObject(int depth) { + _checkDepth(depth); _expect('{'); _skipWhitespace(); if (_consumeIf('}')) { @@ -83,6 +82,7 @@ final class _JsonObjectKeyScanner { } void _parseArray(int depth) { + _checkDepth(depth); _expect('['); _skipWhitespace(); if (_consumeIf(']')) { @@ -158,6 +158,12 @@ final class _JsonObjectKeyScanner { } } + void _checkDepth(int depth) { + if (depth > maxNesting) { + throw FormatException('JSON nesting exceeds the limit of $maxNesting.'); + } + } + bool _isWhitespace(String character) => character == ' ' || character == '\n' || From 385c55b24dc8c3ee37eac98ba8cc30c6799aca38 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:08:21 +0530 Subject: [PATCH 202/241] test: cover empty-container nesting limits --- apps/unitflow_app/test/core/strict_json_test.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/unitflow_app/test/core/strict_json_test.dart b/apps/unitflow_app/test/core/strict_json_test.dart index 5bcae397..10955ba1 100644 --- a/apps/unitflow_app/test/core/strict_json_test.dart +++ b/apps/unitflow_app/test/core/strict_json_test.dart @@ -43,5 +43,9 @@ void main() { () => decodeStrictJson('[[[0]]]', maxNesting: 2), throwsFormatException, ); + expect( + () => decodeStrictJson('[[[]]]', maxNesting: 2), + throwsFormatException, + ); }); } From 6b0cc8f3eab70af2acf3ee8b31860f8203e3f5e9 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:10:28 +0530 Subject: [PATCH 203/241] test: expand shared conversion parity vectors --- test_vectors/conversions.json | 183 ++++++++++++++++++++++++++++++++-- 1 file changed, 177 insertions(+), 6 deletions(-) diff --git a/test_vectors/conversions.json b/test_vectors/conversions.json index 65e717e7..237a8b5b 100644 --- a/test_vectors/conversions.json +++ b/test_vectors/conversions.json @@ -17,6 +17,123 @@ "roundingMode": "nearestEven", "expected": "1000" }, + { + "name": "nanometers to meters", + "input": "123456789", + "from": "nanometer", + "to": "meter", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "0.123456789" + }, + { + "name": "hectares to square meters", + "input": "2.5", + "from": "hectare", + "to": "square_meter", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "25000" + }, + { + "name": "US gallons to liters", + "input": "1", + "from": "gallon_us", + "to": "liter", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "3.785411784" + }, + { + "name": "pounds to kilograms", + "input": "1", + "from": "pound", + "to": "kilogram", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "0.45359237" + }, + { + "name": "miles per hour to meters per second", + "input": "1", + "from": "mile_per_hour", + "to": "meter_per_second", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "0.44704" + }, + { + "name": "bar to pascals", + "input": "1.2", + "from": "bar", + "to": "pascal", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "120000" + }, + { + "name": "kilowatt hours to joules", + "input": "1.5", + "from": "kilowatt_hour", + "to": "joule", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "5400000" + }, + { + "name": "kilowatts to watts", + "input": "2.75", + "from": "kilowatt", + "to": "watt", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "2750" + }, + { + "name": "turns to radians", + "input": "1", + "from": "turn", + "to": "radian", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "6.28318530718" + }, + { + "name": "kibibytes to bytes", + "input": "1.5", + "from": "kibibyte", + "to": "byte", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "1536" + }, + { + "name": "bits to bytes", + "input": "8", + "from": "bit", + "to": "byte", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "1" + }, + { + "name": "kilohertz to hertz", + "input": "2.5", + "from": "kilohertz", + "to": "hertz", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "2500" + }, + { + "name": "hours to seconds", + "input": "1", + "from": "hour", + "to": "second", + "decimalPlaces": 12, + "roundingMode": "nearestEven", + "expected": "3600" + }, { "name": "freezing Celsius to Fahrenheit", "input": "0", @@ -26,6 +143,15 @@ "roundingMode": "nearestEven", "expected": "32" }, + { + "name": "boiling Celsius to Fahrenheit", + "input": "100", + "from": "celsius", + "to": "fahrenheit", + "decimalPlaces": 8, + "roundingMode": "nearestEven", + "expected": "212" + }, { "name": "freezing Fahrenheit to Celsius", "input": "32", @@ -45,12 +171,57 @@ "expected": "2.54" }, { - "name": "hours to seconds", - "input": "1", - "from": "hour", - "to": "second", - "decimalPlaces": 12, + "name": "nearest even midpoint rounds to even digit", + "input": "2.345", + "from": "meter", + "to": "meter", + "decimalPlaces": 2, "roundingMode": "nearestEven", - "expected": "3600" + "expected": "2.34" + }, + { + "name": "half away midpoint rounds outward", + "input": "2.345", + "from": "meter", + "to": "meter", + "decimalPlaces": 2, + "roundingMode": "halfAwayFromZero", + "expected": "2.35" + }, + { + "name": "toward zero truncates negative value", + "input": "-2.349", + "from": "meter", + "to": "meter", + "decimalPlaces": 2, + "roundingMode": "towardZero", + "expected": "-2.34" + }, + { + "name": "away from zero expands negative value", + "input": "-2.341", + "from": "meter", + "to": "meter", + "decimalPlaces": 2, + "roundingMode": "awayFromZero", + "expected": "-2.35" + }, + { + "name": "floor rounds negative value downward", + "input": "-2.341", + "from": "meter", + "to": "meter", + "decimalPlaces": 2, + "roundingMode": "floor", + "expected": "-2.35" + }, + { + "name": "ceiling rounds negative value upward", + "input": "-2.341", + "from": "meter", + "to": "meter", + "decimalPlaces": 2, + "roundingMode": "ceiling", + "expected": "-2.34" } ] From 7632068856e196f46d653aad25e6f3f014c7e12d Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:10:50 +0530 Subject: [PATCH 204/241] test: require parity vectors for every category and rounding mode --- crates/unitflow_core/tests/parity_coverage.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 crates/unitflow_core/tests/parity_coverage.rs diff --git a/crates/unitflow_core/tests/parity_coverage.rs b/crates/unitflow_core/tests/parity_coverage.rs new file mode 100644 index 00000000..b3f625ae --- /dev/null +++ b/crates/unitflow_core/tests/parity_coverage.rs @@ -0,0 +1,61 @@ +use std::collections::HashSet; + +use serde::Deserialize; +use unitflow_core::{Category, UnitCatalog}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Vector { + name: String, + from: String, + to: String, + rounding_mode: String, +} + +#[test] +fn shared_vectors_cover_every_category_and_rounding_mode() { + let vectors: Vec = serde_json::from_str(include_str!( + "../../../test_vectors/conversions.json" + )) + .expect("shared conversion vectors must be valid JSON"); + let catalog = UnitCatalog::built_in().expect("built-in catalog must validate"); + + let mut categories = HashSet::new(); + let mut round_modes = HashSet::new(); + for vector in &vectors { + let from = catalog + .get(&vector.from) + .unwrap_or_else(|| panic!("{} references unknown source {}", vector.name, vector.from)); + let to = catalog + .get(&vector.to) + .unwrap_or_else(|| panic!("{} references unknown target {}", vector.name, vector.to)); + assert_eq!( + from.category, to.category, + "{} crosses categories in parity data", + vector.name + ); + categories.insert(from.category); + round_modes.insert(vector.rounding_mode.as_str()); + } + + for category in Category::ALL { + assert!( + categories.contains(&category), + "shared conversion vectors do not cover category {category}" + ); + } + + for round_mode in [ + "nearestEven", + "halfAwayFromZero", + "towardZero", + "awayFromZero", + "floor", + "ceiling", + ] { + assert!( + round_modes.contains(round_mode), + "shared conversion vectors do not cover rounding mode {round_mode}" + ); + } +} From ca9e2aa6f8ced2589e3968f24c1d2eb6a0101279 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:12:36 +0530 Subject: [PATCH 205/241] fix: keep Flutter bundle version Apple-compatible --- apps/unitflow_app/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/unitflow_app/pubspec.yaml b/apps/unitflow_app/pubspec.yaml index 886ddcc3..c79199f9 100644 --- a/apps/unitflow_app/pubspec.yaml +++ b/apps/unitflow_app/pubspec.yaml @@ -1,7 +1,7 @@ name: unitflow description: A precise, offline-first unit converter powered by UnitFlow's Rust domain core. publish_to: "none" -version: 0.1.0-alpha.1+1 +version: 0.1.0+1 repository: https://github.com/sanskarIN/unitflow From 6250e06c6895605652fd4f15044e748ac6c43c3d Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:13:06 +0530 Subject: [PATCH 206/241] fix: separate release prerelease from Flutter bundle version --- tool/check_versions.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/tool/check_versions.py b/tool/check_versions.py index fcbbc900..755df72c 100644 --- a/tool/check_versions.py +++ b/tool/check_versions.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate UnitFlow release and Flutter Rust Bridge version consistency.""" +"""Validate UnitFlow release, Flutter bundle, and bridge version consistency.""" from __future__ import annotations @@ -25,8 +25,8 @@ PUBSPEC_VERSION_RE = re.compile(r"(?m)^version:\s*([^\s#]+)\s*$") PUBSPEC_FRB_RE = re.compile(r"(?m)^\s{2}flutter_rust_bridge:\s*([^\s#]+)\s*$") +FLUTTER_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)\+(\d+)$") CODEGEN_PIN_RE = re.compile(r"flutter_rust_bridge_codegen\s+--version\s+([^\s`]+)") -DOCUMENTED_PIN_RE = re.compile(r"flutter_rust_bridge_codegen\s+--version\s+([^\s`]+)") def load_workspace_versions() -> tuple[str, str]: @@ -50,8 +50,18 @@ def load_pubspec_versions() -> tuple[str, str]: return version_match.group(1), frb_match.group(1) -def flutter_semver(pubspec_version: str) -> str: - return pubspec_version.split("+", 1)[0] +def release_core_version(release_version: str) -> str: + return release_version.split("-", 1)[0].split("+", 1)[0] + + +def flutter_build_name(pubspec_version: str) -> str: + match = FLUTTER_VERSION_RE.fullmatch(pubspec_version) + if match is None: + raise ValueError( + "Flutter version must use an Apple-compatible numeric build name and build number, " + "for example 0.1.0+1" + ) + return ".".join(match.groups()[:3]) def check_codegen_pins(expected: str) -> list[str]: @@ -61,11 +71,7 @@ def check_codegen_pins(expected: str) -> list[str]: failures.append(f"missing pinned-version file: {path.relative_to(ROOT)}") continue text = path.read_text(encoding="utf-8") - matches = CODEGEN_PIN_RE.findall(text) - if not matches: - # Documentation may mention the generator version without a literal install command. - matches = DOCUMENTED_PIN_RE.findall(text) - for actual in matches: + for actual in CODEGEN_PIN_RE.findall(text): if actual != expected: failures.append( f"{path.relative_to(ROOT)} pins flutter_rust_bridge_codegen {actual}; expected {expected}" @@ -78,14 +84,16 @@ def main() -> int: try: workspace_version, workspace_frb = load_workspace_versions() flutter_version, flutter_frb = load_pubspec_versions() + flutter_name = flutter_build_name(flutter_version) except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, ValueError) as error: print(f"Version consistency check failed to parse configuration: {error}", file=sys.stderr) return 2 - if flutter_semver(flutter_version) != workspace_version: + workspace_core = release_core_version(workspace_version) + if flutter_name != workspace_core: failures.append( - "Flutter application version " - f"{flutter_semver(flutter_version)} does not match Rust workspace version {workspace_version}" + f"Flutter build name {flutter_name} does not match release core version {workspace_core} " + f"from Rust workspace version {workspace_version}" ) if flutter_frb != workspace_frb: failures.append( @@ -101,7 +109,7 @@ def main() -> int: print( "Version consistency passed: " - f"UnitFlow {workspace_version}, flutter_rust_bridge {workspace_frb}." + f"release {workspace_version}, Flutter {flutter_version}, flutter_rust_bridge {workspace_frb}." ) return 0 From 381f241511bc29546803bfa0c78188b26882783c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:13:27 +0530 Subject: [PATCH 207/241] test: cover prerelease and Flutter bundle version policy --- tool/test_check_versions.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tool/test_check_versions.py b/tool/test_check_versions.py index 877d5466..fdadb868 100644 --- a/tool/test_check_versions.py +++ b/tool/test_check_versions.py @@ -7,22 +7,33 @@ from check_versions import ( check_codegen_pins, - flutter_semver, + flutter_build_name, load_pubspec_versions, load_workspace_versions, + release_core_version, ) class VersionConsistencyTests(unittest.TestCase): - def test_flutter_build_metadata_is_not_part_of_release_semver(self) -> None: - self.assertEqual(flutter_semver("0.1.0-alpha.1+7"), "0.1.0-alpha.1") - self.assertEqual(flutter_semver("1.2.3"), "1.2.3") - - def test_repository_release_versions_match(self) -> None: + def test_release_core_ignores_prerelease_and_build_metadata(self) -> None: + self.assertEqual(release_core_version("0.1.0-alpha.1"), "0.1.0") + self.assertEqual(release_core_version("1.2.3+meta"), "1.2.3") + + def test_flutter_version_requires_numeric_build_name_and_number(self) -> None: + self.assertEqual(flutter_build_name("0.1.0+7"), "0.1.0") + with self.assertRaises(ValueError): + flutter_build_name("0.1.0-alpha.1+7") + with self.assertRaises(ValueError): + flutter_build_name("0.1.0") + + def test_repository_release_versions_match_platform_policy(self) -> None: workspace_version, workspace_frb = load_workspace_versions() flutter_version, flutter_frb = load_pubspec_versions() - self.assertEqual(flutter_semver(flutter_version), workspace_version) + self.assertEqual( + flutter_build_name(flutter_version), + release_core_version(workspace_version), + ) self.assertEqual(flutter_frb, workspace_frb) def test_codegen_install_pins_match_workspace_dependency(self) -> None: From d0f9af37f915f4eb15044cd926365b7e10696960 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:13:55 +0530 Subject: [PATCH 208/241] build: validate release tag against workspace version --- tool/check_release_tag.py | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tool/check_release_tag.py diff --git a/tool/check_release_tag.py b/tool/check_release_tag.py new file mode 100644 index 00000000..c2973753 --- /dev/null +++ b/tool/check_release_tag.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Validate that a release tag matches UnitFlow's workspace release version.""" + +from __future__ import annotations + +import os +import sys + +from check_versions import load_workspace_versions + + +def expected_tag(version: str) -> str: + return f"v{version}" + + +def validate_tag(tag: str, version: str) -> None: + expected = expected_tag(version) + if tag != expected: + raise ValueError(f"release tag {tag!r} does not match expected {expected!r}") + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + if len(args) > 1: + print("usage: check_release_tag.py [tag]", file=sys.stderr) + return 2 + + tag = args[0] if args else os.environ.get("GITHUB_REF_NAME", "") + if not tag: + print("Release tag is required as an argument or GITHUB_REF_NAME.", file=sys.stderr) + return 2 + + try: + version, _ = load_workspace_versions() + validate_tag(tag, version) + except (OSError, UnicodeDecodeError, ValueError) as error: + print(f"Release tag validation failed: {error}", file=sys.stderr) + return 1 + + print(f"Release tag {tag} matches workspace version {version}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 8b366e6ef1d1b475d1630b135d63321487fa5bcf Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:14:05 +0530 Subject: [PATCH 209/241] test: cover release tag version validation --- tool/test_check_release_tag.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 tool/test_check_release_tag.py diff --git a/tool/test_check_release_tag.py b/tool/test_check_release_tag.py new file mode 100644 index 00000000..11c7ae86 --- /dev/null +++ b/tool/test_check_release_tag.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Regression tests for UnitFlow release tag validation.""" + +from __future__ import annotations + +import unittest + +from check_release_tag import expected_tag, validate_tag + + +class ReleaseTagTests(unittest.TestCase): + def test_expected_tag_prefixes_workspace_version(self) -> None: + self.assertEqual(expected_tag("0.1.0-alpha.1"), "v0.1.0-alpha.1") + + def test_matching_tag_is_accepted(self) -> None: + validate_tag("v0.1.0-alpha.1", "0.1.0-alpha.1") + + def test_mismatched_tag_is_rejected(self) -> None: + with self.assertRaises(ValueError): + validate_tag("v0.1.0", "0.1.0-alpha.1") + + +if __name__ == "__main__": + unittest.main() From 63a2a6ea5dbe3e9b9230c5c974000d659ecf89b4 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:14:50 +0530 Subject: [PATCH 210/241] ci: gate release builds on version and tag metadata --- .github/workflows/release.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b479f89a..af2af2bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,21 @@ permissions: contents: read jobs: + release-metadata: + name: Release metadata + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Validate source version consistency + run: python3 tool/check_versions.py + - name: Validate tag matches workspace version + if: startsWith(github.ref, 'refs/tags/') + run: python3 tool/check_release_tag.py "$GITHUB_REF_NAME" + rust-core: name: Rust core + needs: release-metadata runs-on: ubuntu-latest steps: - name: Checkout @@ -29,6 +42,7 @@ jobs: flutter-web: name: Flutter web + needs: release-metadata runs-on: ubuntu-latest defaults: run: @@ -64,6 +78,7 @@ jobs: flutter-android: name: Flutter Android + needs: release-metadata runs-on: ubuntu-latest defaults: run: @@ -112,6 +127,7 @@ jobs: flutter-linux: name: Flutter Linux + needs: release-metadata runs-on: ubuntu-latest defaults: run: @@ -157,6 +173,7 @@ jobs: flutter-windows: name: Flutter Windows + needs: release-metadata runs-on: windows-latest defaults: run: @@ -202,6 +219,7 @@ jobs: flutter-macos-ios: name: Flutter macOS and iOS validation + needs: release-metadata runs-on: macos-latest defaults: run: From 5b4e3386a0d00266a7041a8fd4b3ed80b64490ac Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:15:40 +0530 Subject: [PATCH 211/241] ci: make release artifacts safe for manual dispatch --- .github/workflows/release.yml | 55 ++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af2af2bc..e1e856a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,9 @@ on: permissions: contents: read +env: + RELEASE_LABEL: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('run-{0}', github.run_number) }} + jobs: release-metadata: name: Release metadata @@ -68,12 +71,12 @@ jobs: run: flutter build web --release - name: Package web release shell: bash - run: tar -C build/web -czf "$GITHUB_WORKSPACE/unitflow-web-${GITHUB_REF_NAME}.tar.gz" . + run: tar -C build/web -czf "$GITHUB_WORKSPACE/unitflow-web-${RELEASE_LABEL}.tar.gz" . - name: Upload web artifact uses: actions/upload-artifact@v4 with: - name: unitflow-web-${{ github.ref_name }} - path: unitflow-web-${{ github.ref_name }}.tar.gz + name: unitflow-web-${{ env.RELEASE_LABEL }} + path: unitflow-web-${{ env.RELEASE_LABEL }}.tar.gz if-no-files-found: error flutter-android: @@ -115,14 +118,14 @@ jobs: - name: Package Android validation artifacts shell: bash run: | - mkdir -p "$GITHUB_WORKSPACE/unitflow-android-${GITHUB_REF_NAME}" - cp build/app/outputs/flutter-apk/app-release.apk "$GITHUB_WORKSPACE/unitflow-android-${GITHUB_REF_NAME}/" - cp build/app/outputs/bundle/release/app-release.aab "$GITHUB_WORKSPACE/unitflow-android-${GITHUB_REF_NAME}/" + mkdir -p "$GITHUB_WORKSPACE/unitflow-android-${RELEASE_LABEL}" + cp build/app/outputs/flutter-apk/app-release.apk "$GITHUB_WORKSPACE/unitflow-android-${RELEASE_LABEL}/" + cp build/app/outputs/bundle/release/app-release.aab "$GITHUB_WORKSPACE/unitflow-android-${RELEASE_LABEL}/" - name: Upload Android artifacts uses: actions/upload-artifact@v4 with: - name: unitflow-android-${{ github.ref_name }} - path: unitflow-android-${{ github.ref_name }}/* + name: unitflow-android-${{ env.RELEASE_LABEL }} + path: unitflow-android-${{ env.RELEASE_LABEL }}/* if-no-files-found: error flutter-linux: @@ -163,12 +166,12 @@ jobs: run: flutter build linux --release - name: Package Linux release shell: bash - run: tar -C build/linux/x64/release/bundle -czf "$GITHUB_WORKSPACE/unitflow-linux-${GITHUB_REF_NAME}.tar.gz" . + run: tar -C build/linux/x64/release/bundle -czf "$GITHUB_WORKSPACE/unitflow-linux-${RELEASE_LABEL}.tar.gz" . - name: Upload Linux artifact uses: actions/upload-artifact@v4 with: - name: unitflow-linux-${{ github.ref_name }} - path: unitflow-linux-${{ github.ref_name }}.tar.gz + name: unitflow-linux-${{ env.RELEASE_LABEL }} + path: unitflow-linux-${{ env.RELEASE_LABEL }}.tar.gz if-no-files-found: error flutter-windows: @@ -209,12 +212,12 @@ jobs: run: flutter build windows --release - name: Package Windows release shell: pwsh - run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath "$env:GITHUB_WORKSPACE/unitflow-windows-$env:GITHUB_REF_NAME.zip" + run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath "$env:GITHUB_WORKSPACE/unitflow-windows-$env:RELEASE_LABEL.zip" - name: Upload Windows artifact uses: actions/upload-artifact@v4 with: - name: unitflow-windows-${{ github.ref_name }} - path: unitflow-windows-${{ github.ref_name }}.zip + name: unitflow-windows-${{ env.RELEASE_LABEL }} + path: unitflow-windows-${{ env.RELEASE_LABEL }}.zip if-no-files-found: error flutter-macos-ios: @@ -254,20 +257,28 @@ jobs: - name: Validate iOS no-codesign build run: flutter build ios --release --no-codesign - name: Package macOS release - run: ditto -c -k --sequesterRsrc --keepParent build/macos/Build/Products/Release/UnitFlow.app "$GITHUB_WORKSPACE/unitflow-macos-${GITHUB_REF_NAME}.zip" + shell: bash + run: | + macos_app="$(find build/macos/Build/Products/Release -maxdepth 1 -type d -name '*.app' -print -quit)" + test -n "$macos_app" + ditto -c -k --sequesterRsrc --keepParent "$macos_app" "$GITHUB_WORKSPACE/unitflow-macos-${RELEASE_LABEL}.zip" - name: Package iOS no-codesign validation bundle - run: ditto -c -k --sequesterRsrc --keepParent build/ios/iphoneos/Runner.app "$GITHUB_WORKSPACE/unitflow-ios-nosign-${GITHUB_REF_NAME}.zip" + shell: bash + run: | + ios_app="$(find build/ios/iphoneos -maxdepth 1 -type d -name '*.app' -print -quit)" + test -n "$ios_app" + ditto -c -k --sequesterRsrc --keepParent "$ios_app" "$GITHUB_WORKSPACE/unitflow-ios-nosign-${RELEASE_LABEL}.zip" - name: Upload macOS artifact uses: actions/upload-artifact@v4 with: - name: unitflow-macos-${{ github.ref_name }} - path: unitflow-macos-${{ github.ref_name }}.zip + name: unitflow-macos-${{ env.RELEASE_LABEL }} + path: unitflow-macos-${{ env.RELEASE_LABEL }}.zip if-no-files-found: error - name: Upload iOS validation artifact uses: actions/upload-artifact@v4 with: - name: unitflow-ios-nosign-${{ github.ref_name }} - path: unitflow-ios-nosign-${{ github.ref_name }}.zip + name: unitflow-ios-nosign-${{ env.RELEASE_LABEL }} + path: unitflow-ios-nosign-${{ env.RELEASE_LABEL }}.zip if-no-files-found: error checksums: @@ -291,11 +302,12 @@ jobs: run: | cd release-artifacts find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS + test -s SHA256SUMS cat SHA256SUMS - name: Upload checksum manifest uses: actions/upload-artifact@v4 with: - name: unitflow-checksums-${{ github.ref_name }} + name: unitflow-checksums-${{ env.RELEASE_LABEL }} path: release-artifacts/SHA256SUMS if-no-files-found: error @@ -319,6 +331,7 @@ jobs: run: | cd release-artifacts find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS + test -s SHA256SUMS - name: Publish immutable tagged release shell: bash run: | From c4efa9418075bc9774f70c8b7be433f6ae60891c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:16:34 +0530 Subject: [PATCH 212/241] fix: localize canonical history values before reopening --- .../presentation/converter_controller.dart | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart b/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart index 3e855dcf..4aa2bb0d 100644 --- a/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart +++ b/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart @@ -202,10 +202,27 @@ final class ConverterController extends ChangeNotifier { if (from == null || to == null || from.category != to.category) { return; } + + ExactDecimal canonicalInput; + try { + canonicalInput = ExactDecimal.parse(recent.input); + } on FormatException { + _category = from.category; + _fromUnitId = from.id; + _toUnitId = to.id; + recompute(); + return; + } + _category = from.category; _fromUnitId = from.id; _toUnitId = to.id; - _input = recent.input; + _input = _formatter.format( + canonicalInput, + localeName: _localeName, + notation: DecimalNotation.plain, + useGrouping: false, + ); recompute(); } From 9751e105f852c2318d20f172aafc15957921e2b9 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:17:00 +0530 Subject: [PATCH 213/241] test: cover locale-safe history reopening --- .../test/features/converter_history_test.dart | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/unitflow_app/test/features/converter_history_test.dart b/apps/unitflow_app/test/features/converter_history_test.dart index b3cce235..b86b848c 100644 --- a/apps/unitflow_app/test/features/converter_history_test.dart +++ b/apps/unitflow_app/test/features/converter_history_test.dart @@ -52,4 +52,38 @@ void main() { expect(converter.toUnitId, 'kilometer'); expect(converter.result?.output.toCanonicalString(), '2.5'); }); + + test('canonical recent input is localized before German parsing', () { + converter.setLocale('de_DE'); + final recent = RecentConversion( + input: '1.25', + fromUnitId: 'meter', + toUnitId: 'kilometer', + createdAt: DateTime.utc(2026, 8, 19), + ); + + converter.applyRecentConversion(recent); + + expect(converter.input, '1,25'); + expect(converter.result?.input.toCanonicalString(), '1.25'); + expect(converter.result?.output.toCanonicalString(), '0.00125'); + }); + + test('legacy localized recent input is never reinterpreted as canonical', () { + converter.setLocale('en_US'); + converter.setInput('7'); + final recent = RecentConversion( + input: '1,25', + fromUnitId: 'meter', + toUnitId: 'kilometer', + createdAt: DateTime.utc(2026, 8, 19), + ); + + converter.applyRecentConversion(recent); + + expect(converter.input, '7'); + expect(converter.fromUnitId, 'meter'); + expect(converter.toUnitId, 'kilometer'); + expect(converter.result?.input.toCanonicalString(), '7'); + }); } From c1291d143e408ccdef2d5d731f868f5b292b7ed6 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:18:30 +0530 Subject: [PATCH 214/241] fix: keep generated recent history within backup bounds --- .../converter/presentation/converter_controller.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart b/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart index 4aa2bb0d..2dd8036a 100644 --- a/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart +++ b/apps/unitflow_app/lib/features/converter/presentation/converter_controller.dart @@ -15,6 +15,8 @@ final class ConverterController extends ChangeNotifier { recompute(); } + static const _maxPersistedRecentInputLength = 1024; + final AppController _appController; final DecimalInputParser _parser = const DecimalInputParser(); final DecimalDisplayFormatter _formatter = const DecimalDisplayFormatter(); @@ -174,8 +176,12 @@ final class ConverterController extends ChangeNotifier { if (currentResult == null) { return Future.value(); } + final canonicalInput = currentResult.input.toCanonicalString(); + if (canonicalInput.length > _maxPersistedRecentInputLength) { + return Future.value(); + } return _appController.recordRecent( - input: currentResult.input.toCanonicalString(), + input: canonicalInput, fromUnitId: _fromUnitId, toUnitId: _toUnitId, ); From bae95e023909b2cb44beb26f6a98232e9e729f05 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:19:00 +0530 Subject: [PATCH 215/241] test: keep oversized exact inputs out of history backups --- .../test/features/converter_history_test.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/unitflow_app/test/features/converter_history_test.dart b/apps/unitflow_app/test/features/converter_history_test.dart index b86b848c..1b474535 100644 --- a/apps/unitflow_app/test/features/converter_history_test.dart +++ b/apps/unitflow_app/test/features/converter_history_test.dart @@ -35,6 +35,16 @@ void main() { expect(recent.toUnitId, 'kilometer'); }); + test('oversized canonical exact input is not persisted to history', () async { + final hugeInput = '${''.padRight(1018, '9')}e1000'; + converter.setInput(hugeInput); + expect(converter.result, isNotNull); + + await converter.recordCurrentConversion(); + + expect((await repository.load()).recents, isEmpty); + }); + test('applying a recent conversion restores pair input and result', () { final recent = RecentConversion( input: '2500', From 1306b0ef2a5d5ed25afc39af2fee4f3153c10925 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:20:15 +0530 Subject: [PATCH 216/241] feat: expose Rust decimal compatibility bounds --- apps/unitflow_app/lib/core/math/exact_decimal.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/unitflow_app/lib/core/math/exact_decimal.dart b/apps/unitflow_app/lib/core/math/exact_decimal.dart index 990d023d..a48b2540 100644 --- a/apps/unitflow_app/lib/core/math/exact_decimal.dart +++ b/apps/unitflow_app/lib/core/math/exact_decimal.dart @@ -62,6 +62,9 @@ final class ExactDecimal implements Comparable { ExactDecimal._(this.coefficient, this.scale); static final zero = ExactDecimal._(BigInt.zero, 0); + static final _rustDecimalMaxCoefficient = BigInt.parse( + '79228162514264337593543950335', + ); final BigInt coefficient; final int scale; @@ -70,6 +73,11 @@ final class ExactDecimal implements Comparable { int get sign => coefficient.sign; + /// Whether this normalized value can be represented by the Rust `rust_decimal::Decimal` + /// domain used by UnitFlow's authoritative core without changing its value. + bool get isRustDecimalCompatible => + scale <= 28 && coefficient.abs() <= _rustDecimalMaxCoefficient; + ExactDecimal get abs => coefficient.isNegative ? -this : this; ExactDecimal operator -() => ExactDecimal(-coefficient, scale); From f6b9ac8023eac5fb34d1e8ea221790b4a1bc482c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:20:41 +0530 Subject: [PATCH 217/241] fix: reject fallback inputs outside Rust decimal domain --- .../lib/features/converter/domain/conversion_engine.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/unitflow_app/lib/features/converter/domain/conversion_engine.dart b/apps/unitflow_app/lib/features/converter/domain/conversion_engine.dart index bf54347b..a40aada2 100644 --- a/apps/unitflow_app/lib/features/converter/domain/conversion_engine.dart +++ b/apps/unitflow_app/lib/features/converter/domain/conversion_engine.dart @@ -42,6 +42,9 @@ final class ExactConversionEngine implements ConversionEngine { if (decimalPlaces < 0 || decimalPlaces > 28) { throw ConversionFailure('Decimal places must be between 0 and 28.'); } + if (!value.isRustDecimalCompatible) { + throw ConversionFailure('Value is outside the supported decimal range.'); + } final from = catalog.byId(fromUnitId); final to = catalog.byId(toUnitId); From ce856befb79a527cc36f7dd1fe2a9663b083a083 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:21:36 +0530 Subject: [PATCH 218/241] fix: keep custom unit formulas in Rust decimal domain --- apps/unitflow_app/lib/core/persistence/user_state.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/unitflow_app/lib/core/persistence/user_state.dart b/apps/unitflow_app/lib/core/persistence/user_state.dart index d87c2d94..443280fd 100644 --- a/apps/unitflow_app/lib/core/persistence/user_state.dart +++ b/apps/unitflow_app/lib/core/persistence/user_state.dart @@ -12,6 +12,7 @@ final class RecentConversion { required this.createdAt, }); + static const maxInputLength = 1024; static const _allowedKeys = { 'input', 'fromUnitId', @@ -46,7 +47,7 @@ final class RecentConversion { final timestamp = DateTime.tryParse(created); if (timestamp == null || input.isEmpty || - input.length > 1024 || + input.length > maxInputLength || !_unitIdPattern.hasMatch(from) || !_unitIdPattern.hasMatch(to)) { return null; @@ -126,16 +127,20 @@ final class CustomUnitData { throw const FormatException('Custom unit formula is invalid.'); } final parsedScale = ExactDecimal.parse(scale); + final parsedOffset = ExactDecimal.parse(offset); if (parsedScale.compareTo(ExactDecimal.zero) <= 0) { throw const FormatException('Custom unit scale must be greater than zero.'); } + if (!parsedScale.isRustDecimalCompatible || !parsedOffset.isRustDecimalCompatible) { + throw const FormatException('Custom unit formula is outside the supported decimal range.'); + } return UnitDefinition( id: id, category: category, name: normalizedName, symbol: normalizedSymbol, scale: parsedScale, - offset: ExactDecimal.parse(offset), + offset: parsedOffset, aliases: List.unmodifiable(normalizedAliases), description: normalizedDescription, isBuiltIn: false, From ae14a745feb7210da7c528a44cffaf7bbb1b1127 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:21:59 +0530 Subject: [PATCH 219/241] test: cover Rust decimal compatibility bounds --- .../test/core/exact_decimal_test.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/unitflow_app/test/core/exact_decimal_test.dart b/apps/unitflow_app/test/core/exact_decimal_test.dart index eaa52943..61136434 100644 --- a/apps/unitflow_app/test/core/exact_decimal_test.dart +++ b/apps/unitflow_app/test/core/exact_decimal_test.dart @@ -17,6 +17,22 @@ void main() { expect(() => ExactDecimal.parse('1.2.3'), throwsFormatException); expect(() => ExactDecimal.parse('1e1001'), throwsFormatException); }); + + test('reports Rust decimal compatibility bounds', () { + expect( + ExactDecimal.parse('79228162514264337593543950335') + .isRustDecimalCompatible, + isTrue, + ); + expect( + ExactDecimal.parse('79228162514264337593543950336') + .isRustDecimalCompatible, + isFalse, + ); + expect(ExactDecimal.parse('1e-28').isRustDecimalCompatible, isTrue); + expect(ExactDecimal.parse('1e-29').isRustDecimalCompatible, isFalse); + expect(ExactDecimal.parse('10e-29').isRustDecimalCompatible, isTrue); + }); }); group('ExactDecimal arithmetic', () { From cfe6b63910200c4fe40424cfc21c590c0a770642 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:22:26 +0530 Subject: [PATCH 220/241] test: reject fallback values beyond Rust decimal limits --- .../test/features/conversion_engine_test.dart | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/unitflow_app/test/features/conversion_engine_test.dart b/apps/unitflow_app/test/features/conversion_engine_test.dart index fd386f39..99d57b5c 100644 --- a/apps/unitflow_app/test/features/conversion_engine_test.dart +++ b/apps/unitflow_app/test/features/conversion_engine_test.dart @@ -42,6 +42,35 @@ void main() { ); }); + test('rejects values outside the Rust decimal input domain', () { + for (final value in [ + '79228162514264337593543950336', + '-79228162514264337593543950336', + '1e-29', + '1e1000', + ]) { + expect( + () => engine.convert( + value: ExactDecimal.parse(value), + fromUnitId: 'meter', + toUnitId: 'kilometer', + ), + throwsA(isA()), + reason: value, + ); + } + }); + + test('accepts Rust decimal boundary values', () { + final result = engine.convert( + value: ExactDecimal.parse('79228162514264337593543950335'), + fromUnitId: 'meter', + toUnitId: 'meter', + decimalPlaces: 0, + ); + expect(result.output.toCanonicalString(), '79228162514264337593543950335'); + }); + test('batch conversion preserves target order', () { final results = engine.batchConvert( value: ExactDecimal.parse('1'), From b882cdddd5854708145a6ace64607456044dcb41 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:22:48 +0530 Subject: [PATCH 221/241] test: align oversized history case with Rust bounds --- apps/unitflow_app/test/features/converter_history_test.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/unitflow_app/test/features/converter_history_test.dart b/apps/unitflow_app/test/features/converter_history_test.dart index 1b474535..545c8587 100644 --- a/apps/unitflow_app/test/features/converter_history_test.dart +++ b/apps/unitflow_app/test/features/converter_history_test.dart @@ -35,10 +35,11 @@ void main() { expect(recent.toUnitId, 'kilometer'); }); - test('oversized canonical exact input is not persisted to history', () async { + test('out-of-domain exact input cannot enter history', () async { final hugeInput = '${''.padRight(1018, '9')}e1000'; converter.setInput(hugeInput); - expect(converter.result, isNotNull); + expect(converter.result, isNull); + expect(converter.error, isNotNull); await converter.recordCurrentConversion(); From 6e0dc6431bc66d553f0742a74ae0260e11d5e377 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:23:13 +0530 Subject: [PATCH 222/241] test: reject custom formulas outside Rust decimal bounds --- .../test/app/custom_unit_limits_test.dart | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/unitflow_app/test/app/custom_unit_limits_test.dart b/apps/unitflow_app/test/app/custom_unit_limits_test.dart index 0c5d0f7c..ecc7ba4c 100644 --- a/apps/unitflow_app/test/app/custom_unit_limits_test.dart +++ b/apps/unitflow_app/test/app/custom_unit_limits_test.dart @@ -70,4 +70,35 @@ void main() { expect(persisted.aliases, ['double', 'two meters']); expect(persisted.description, 'Example unit.'); }); + + test('custom formulas must fit the Rust decimal domain', () { + const oversizedScale = CustomUnitData( + id: 'oversized_scale', + category: UnitCategory.length, + name: 'Oversized Scale', + symbol: 'os', + scale: '79228162514264337593543950336', + offset: '0', + ); + const excessiveScalePrecision = CustomUnitData( + id: 'tiny_scale', + category: UnitCategory.length, + name: 'Tiny Scale', + symbol: 'ts', + scale: '1e-29', + offset: '0', + ); + const oversizedOffset = CustomUnitData( + id: 'oversized_offset', + category: UnitCategory.temperature, + name: 'Oversized Offset', + symbol: 'oo', + scale: '1', + offset: '79228162514264337593543950336', + ); + + expect(oversizedScale.toUnitDefinition, throwsFormatException); + expect(excessiveScalePrecision.toUnitDefinition, throwsFormatException); + expect(oversizedOffset.toUnitDefinition, throwsFormatException); + }); } From 25b0e514a06afea95a28498f6dcc8319293be9f9 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:24:56 +0530 Subject: [PATCH 223/241] fix: avoid false reset success and release-link errors --- .../settings/presentation/settings_screen.dart | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart index 577cad9a..c274017d 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart @@ -341,13 +341,21 @@ final class SettingsScreen extends StatelessWidget { } Future _openReleases(BuildContext context) async { - if (await launchUrl(_releasesUri)) { - return; + final strings = AppLocalizations.of(context); + try { + if (await launchUrl(_releasesUri)) { + return; + } + } on Object catch (error) { + userSafeFailure( + error, + event: 'release_page_open_failed', + fallback: strings.releaseOpenFailed, + ); } if (!context.mounted) { return; } - final strings = AppLocalizations.of(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(strings.releaseOpenFailed)), ); @@ -376,7 +384,7 @@ final class SettingsScreen extends StatelessWidget { return; } await appController.resetLocalData(); - if (!context.mounted) { + if (!context.mounted || appController.warning != null) { return; } ScaffoldMessenger.of(context).showSnackBar( From ba4cf4d75b25dff58054be9a249282265220d578 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:26:00 +0530 Subject: [PATCH 224/241] feat: localize external link failure feedback --- apps/unitflow_app/lib/l10n/app_en.arb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/l10n/app_en.arb b/apps/unitflow_app/lib/l10n/app_en.arb index aa4dd1ce..2e1dfa60 100644 --- a/apps/unitflow_app/lib/l10n/app_en.arb +++ b/apps/unitflow_app/lib/l10n/app_en.arb @@ -38,7 +38,7 @@ "unitLibrary": "Unit library", "unitLibrarySubtitle": "Search built-in units, favorites, and your own validated custom units.", "searchUnits": "Search units", - "searchUnitsHint": "Name, symbol, or alias", + "searchUnitsHint": "Name, symbol, alias, or description", "clearSearch": "Clear search", "all": "All", "customUnit": "Custom unit", @@ -130,6 +130,14 @@ "supportEmail": "Support email", "businessEmail": "Business email", "alternateBusinessEmail": "Business email (alternate)", + "externalLinkOpenFailed": "Could not open {title}.", + "@externalLinkOpenFailed": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "privacy": "Privacy", "aboutPrivacyBody": "Static conversions work offline and do not require an account. Preferences, favorites, history, pinned pairs, and custom units are designed to remain on this device unless you explicitly export them.", "aboutTagline": "A precise, offline-first unit converter with a Rust domain core and Flutter interface.", From de54b07e1e0421ef13af1cea99f4beb683ba991e Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:26:26 +0530 Subject: [PATCH 225/241] fix: safely handle About external link failures --- .../settings/presentation/about_screen.dart | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart b/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart index 700c7e5f..58722d93 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart @@ -3,6 +3,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../app/branding/unitflow_mark.dart'; import '../../../app/theme/app_theme.dart'; +import '../../../core/errors/user_safe_error.dart'; import '../../../l10n/app_localizations.dart'; final class AboutScreen extends StatelessWidget { @@ -182,13 +183,25 @@ final class _ExternalTile extends StatelessWidget { ); Future _open(BuildContext context) async { - if (!await launchUrl(uri)) { - if (!context.mounted) { + final strings = AppLocalizations.of(context); + String? message; + try { + if (await launchUrl(uri)) { return; } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Could not open $title.')), + message = strings.externalLinkOpenFailed(title); + } on Object catch (error) { + message = userSafeFailure( + error, + event: 'about_external_link_open_failed', + fallback: strings.externalLinkOpenFailed(title), ); } + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); } } From f538a8f421de6d8521662453fa732b86e826bf90 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:26:59 +0530 Subject: [PATCH 226/241] build: validate About version without Python TOML dependency --- tool/check_versions.py | 60 +++++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/tool/check_versions.py b/tool/check_versions.py index 755df72c..683ac9d4 100644 --- a/tool/check_versions.py +++ b/tool/check_versions.py @@ -1,21 +1,25 @@ #!/usr/bin/env python3 -"""Validate UnitFlow release, Flutter bundle, and bridge version consistency.""" +"""Validate UnitFlow release, Flutter bundle, UI, and bridge version consistency.""" from __future__ import annotations import re import sys -import tomllib from pathlib import Path ROOT = Path(__file__).resolve().parents[1] CARGO_TOML = ROOT / "Cargo.toml" PUBSPEC = ROOT / "apps/unitflow_app/pubspec.yaml" +ABOUT_SCREEN = ( + ROOT + / "apps/unitflow_app/lib/features/settings/presentation/about_screen.dart" +) PINNED_FILES = ( ROOT / ".github/workflows/ci.yml", ROOT / ".github/workflows/format-audit.yml", ROOT / ".github/workflows/release.yml", ROOT / "docs/bridge.md", + ROOT / "docs/release.md", ROOT / "docs/testing.md", ROOT / "docs/verification.md", ROOT / "tool/generate_bridge.sh", @@ -25,20 +29,39 @@ PUBSPEC_VERSION_RE = re.compile(r"(?m)^version:\s*([^\s#]+)\s*$") PUBSPEC_FRB_RE = re.compile(r"(?m)^\s{2}flutter_rust_bridge:\s*([^\s#]+)\s*$") +ABOUT_VERSION_RE = re.compile(r"static const appVersion\s*=\s*'([^']+)'\s*;") FLUTTER_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)\+(\d+)$") CODEGEN_PIN_RE = re.compile(r"flutter_rust_bridge_codegen\s+--version\s+([^\s`]+)") +def _toml_section(text: str, name: str) -> str: + pattern = re.compile( + rf"(?ms)^\[{re.escape(name)}\]\s*(.*?)(?=^\[|\Z)", + ) + match = pattern.search(text) + if match is None: + raise ValueError(f"Cargo.toml is missing [{name}]") + return match.group(1) + + +def _toml_string(section: str, key: str) -> str: + match = re.search( + rf'(?m)^{re.escape(key)}\s*=\s*"([^"]+)"\s*$', + section, + ) + if match is None: + raise ValueError(f"Cargo.toml is missing string value {key}") + return match.group(1) + + def load_workspace_versions() -> tuple[str, str]: - with CARGO_TOML.open("rb") as handle: - cargo = tomllib.load(handle) - package = cargo.get("workspace", {}).get("package", {}) - dependencies = cargo.get("workspace", {}).get("dependencies", {}) - version = package.get("version") - frb = dependencies.get("flutter_rust_bridge") - if not isinstance(version, str) or not isinstance(frb, str): - raise ValueError("Cargo.toml must define workspace package and FRB versions") - return version, frb + text = CARGO_TOML.read_text(encoding="utf-8") + package = _toml_section(text, "workspace.package") + dependencies = _toml_section(text, "workspace.dependencies") + return ( + _toml_string(package, "version"), + _toml_string(dependencies, "flutter_rust_bridge"), + ) def load_pubspec_versions() -> tuple[str, str]: @@ -50,6 +73,14 @@ def load_pubspec_versions() -> tuple[str, str]: return version_match.group(1), frb_match.group(1) +def load_about_version() -> str: + text = ABOUT_SCREEN.read_text(encoding="utf-8") + match = ABOUT_VERSION_RE.search(text) + if match is None: + raise ValueError("AboutScreen must define appVersion") + return match.group(1) + + def release_core_version(release_version: str) -> str: return release_version.split("-", 1)[0].split("+", 1)[0] @@ -84,8 +115,9 @@ def main() -> int: try: workspace_version, workspace_frb = load_workspace_versions() flutter_version, flutter_frb = load_pubspec_versions() + about_version = load_about_version() flutter_name = flutter_build_name(flutter_version) - except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, ValueError) as error: + except (OSError, UnicodeDecodeError, ValueError) as error: print(f"Version consistency check failed to parse configuration: {error}", file=sys.stderr) return 2 @@ -95,6 +127,10 @@ def main() -> int: f"Flutter build name {flutter_name} does not match release core version {workspace_core} " f"from Rust workspace version {workspace_version}" ) + if about_version != workspace_version: + failures.append( + f"About version {about_version} does not match Rust workspace release {workspace_version}" + ) if flutter_frb != workspace_frb: failures.append( f"Flutter FRB dependency {flutter_frb} does not match Rust workspace FRB dependency {workspace_frb}" From 26fedce1e33de4f01bea574ad03d8b4e07fc89d7 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:27:20 +0530 Subject: [PATCH 227/241] test: keep About release version synchronized --- tool/test_check_versions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tool/test_check_versions.py b/tool/test_check_versions.py index fdadb868..1a9229f0 100644 --- a/tool/test_check_versions.py +++ b/tool/test_check_versions.py @@ -8,6 +8,7 @@ from check_versions import ( check_codegen_pins, flutter_build_name, + load_about_version, load_pubspec_versions, load_workspace_versions, release_core_version, @@ -34,6 +35,7 @@ def test_repository_release_versions_match_platform_policy(self) -> None: flutter_build_name(flutter_version), release_core_version(workspace_version), ) + self.assertEqual(load_about_version(), workspace_version) self.assertEqual(flutter_frb, workspace_frb) def test_codegen_install_pins_match_workspace_dependency(self) -> None: From 608bd025b5d401465baf87130c29a6b091bbafc2 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:27:51 +0530 Subject: [PATCH 228/241] docs: define release and platform bundle version policy --- docs/release.md | 61 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/docs/release.md b/docs/release.md index b1117f4d..b72415b2 100644 --- a/docs/release.md +++ b/docs/release.md @@ -2,26 +2,50 @@ ## Versioning -UnitFlow uses SemVer-style versions. During `0.x`, breaking changes may occur but must be documented. Stable releases should preserve stored-data and bridge compatibility or include explicit migration notes. +UnitFlow uses SemVer-style repository/Rust release versions. During `0.x`, breaking changes may occur but must be documented. Stable releases should preserve stored-data and bridge compatibility or include explicit migration notes. -The current development target is `0.1.0-alpha.1`. A version string in source is not a release declaration; release status is tied to an audited commit/tag and its evidence. +The current development release target is `0.1.0-alpha.1`. A version string in source is not a release declaration; release status is tied to an audited commit/tag and its evidence. + +UnitFlow intentionally separates the repository prerelease identity from Flutter platform bundle metadata: + +- Cargo workspace release: `0.1.0-alpha.1`; +- About-screen release label: `0.1.0-alpha.1`; +- Git tag: `v0.1.0-alpha.1`; +- Flutter `pubspec.yaml` version: `0.1.0+1`. + +The Flutter build name stays three numeric components so generated Apple bundles can use it as `CFBundleShortVersionString`; prerelease state remains represented by the audited repository/Cargo version, About label, Git tag, and GitHub prerelease status. The `+1` component is the platform build iteration and can advance independently when rebuilding the same numeric app version. + +Run: + +```bash +python3 tool/check_versions.py +``` + +to ensure Cargo, Flutter, About, and pinned Flutter Rust Bridge versions follow this policy. For a tagged release, also run: + +```bash +python3 tool/check_release_tag.py v0.1.0-alpha.1 +``` + +The tagged release workflow performs both checks before starting expensive platform builds. ## Release checklist 1. Ensure the intended release commit is on the protected release branch and has no unreviewed local/generated changes. 2. Confirm `CHANGELOG.md`, `ROADMAP.md`, and `what_changed.md` match the exact candidate. -3. Run the strict host-independent release-candidate verifier. -4. Confirm CI, CodeQL, dependency review, bridge generation, and repository-safety checks are green for the exact candidate. -5. Build every advertised native/web platform in its supported CI/host environment. -6. Install/run primary user journeys on each release platform class rather than relying only on compilation. -7. Verify Rust-backed native conversion on native targets and deterministic Dart fallback behavior on web. -8. Verify backup import/export, migration behavior, custom units, favorites/pins/history, rounding, themes, and reduced motion. -9. Perform keyboard, text-scaling, contrast, and screen-reader-oriented manual accessibility review. -10. Verify final launcher/splash branding, About/version/license/support/funding links, and required **Made by the Sanskar** credit. -11. Capture real release screenshots from validated builds. Never substitute mock/placeholder images as release evidence. -12. Verify no secrets, signing material, private endpoints, or real user data are present in Git or release artifacts. -13. Generate checksums for distributable archives/binaries. -14. Tag the exact audited commit and let the release workflow package only that tag. +3. Run the version-consistency check and confirm the intended tag exactly matches the Cargo workspace release version. +4. Run the strict host-independent release-candidate verifier. +5. Confirm CI, CodeQL, dependency review, bridge generation, and repository-safety checks are green for the exact candidate. +6. Build every advertised native/web platform in its supported CI/host environment. +7. Install/run primary user journeys on each release platform class rather than relying only on compilation. +8. Verify Rust-backed native conversion on native targets and deterministic Dart fallback behavior on web. +9. Verify backup import/export, migration behavior, custom units, favorites/pins/history, rounding, themes, and reduced motion. +10. Perform keyboard, text-scaling, contrast, and screen-reader-oriented manual accessibility review. +11. Verify final launcher/splash branding, About/version/license/support/funding links, and required **Made by the Sanskar** credit. +12. Capture real release screenshots from validated builds. Never substitute mock/placeholder images as release evidence. +13. Verify no secrets, signing material, private endpoints, or real user data are present in Git or release artifacts. +14. Generate checksums for distributable archives/binaries and verify the checksum manifest is non-empty. +15. Tag the exact audited commit and let the release workflow package only that tag. ## Strict local verification @@ -37,13 +61,15 @@ Then run: bash tool/verify_release_candidate.sh ``` -This verifies repository safety/data/docs, Rust formatting/lint/tests/release build, Flutter localization/format/analyze/tests, bridge regeneration, generated-source cleanliness, web release build, and the core profiling harness. It intentionally fails if binding generation or formatting changes tracked files, because generated sources must be normalized and committed before a release candidate is considered reproducible. +This verifies repository utility tests, version consistency, repository safety/data/docs, Rust formatting/lint/tests/release build, Flutter localization/format/analyze/tests, bridge regeneration, generated-source cleanliness including untracked files, web release build, and the core profiling harness. It intentionally fails if binding generation or formatting changes repository files, because generated sources must be normalized and committed before a release candidate is considered reproducible. `tool/check.sh` is the faster development-quality command. It may skip bridge regeneration when the code generator is not installed; therefore it is not a substitute for `tool/verify_release_candidate.sh`. ## Platform builds -The release workflow validates multiple targets on compatible GitHub-hosted operating systems. Typical manual build commands include: +The release workflow validates multiple targets on compatible GitHub-hosted operating systems. Tagged runs use the exact tag in artifact names. Manual workflow dispatches use a sanitized `run-` label rather than a branch name, so branch separators cannot create invalid artifact paths. + +Typical manual build commands include: ```bash cd apps/unitflow_app @@ -74,6 +100,7 @@ Example: ```bash git tag -a v0.1.0-alpha.1 -m "UnitFlow 0.1.0-alpha.1" +python3 tool/check_release_tag.py v0.1.0-alpha.1 git push origin v0.1.0-alpha.1 ``` @@ -83,6 +110,8 @@ Do not move or rewrite a published release tag to hide a defect. Publish a corre Release artifacts must be generated from source through documented commands. Signing keys remain outside this public repository. For downloadable archives, publish a checksum manifest generated from the exact artifacts produced by the release run. +Apple artifact packaging discovers the produced `.app` bundle instead of assuming a hard-coded product-directory case/name. This keeps packaging tied to the build output while still failing when no application bundle exists. + ## Store releases Mobile store publication has additional signing, privacy, screenshots, content-rating, metadata, and listing requirements. Store credentials remain outside the repository and must never be placed in backup/environment examples. From 904823d201a10c764e338b80b62a04280169f4a4 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:28:34 +0530 Subject: [PATCH 229/241] docs: document strict JSON and decimal portability bounds --- docs/data-format.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/data-format.md b/docs/data-format.md index 707f609c..653844d3 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -24,12 +24,16 @@ The current schema version is **2**. Its machine-readable contract is published A backup is a UTF-8 JSON object. The root `schemaVersion` field is mandatory. UnitFlow validates the complete object before replacing the in-memory state; malformed or unsupported imports must not partially overwrite an existing profile. -The decoder intentionally rejects unknown object properties instead of silently discarding them. This keeps runtime behavior aligned with the checked-in JSON Schemas, which use `additionalProperties: false`, and prevents misspelled or future fields from appearing to import successfully when their meaning was actually ignored. +The runtime decoder performs a bounded structural pass before normal JSON decoding. It rejects duplicate object keys, including keys that become equal after JSON escape decoding, and rejects nesting deeper than 64 containers. This prevents ambiguous last-value-wins parsing and unbounded recursive input structures from entering state validation. + +The decoder also rejects unknown object properties instead of silently discarding them. This keeps runtime behavior aligned with the checked-in JSON Schemas, which use `additionalProperties: false`, and prevents misspelled or future fields from appearing to import successfully when their meaning was actually ignored. Current safety bounds include: -- file/import text size: at most 1,000,000 characters; +- file/import text size: at most 1,000,000 characters through the state decoder and at most 1,000,000 bytes through file import/export; +- JSON nesting: at most 64 containers; - recent conversions: at most 100 accepted from an imported document, with the app normally retaining at most 50 active recents; +- recent input text: at most 1024 characters; - pinned pairs: at most 20 active pairs; - custom units: at most 200 accepted from an imported document and at most 200 created locally; - custom aliases: at most 32 per unit; @@ -39,15 +43,26 @@ Current safety bounds include: Collection bounds are validated before iterating imported entries. Oversized arrays are rejected; their tail is never silently discarded during parsing. +## Decimal portability domain + +The native Rust core uses `rust_decimal::Decimal`. To keep the deterministic Dart fallback from accepting values that the authoritative native core cannot represent, fallback conversion input and custom-unit scale/offset values are restricted to the same normalized value domain: + +- scale from 0 through 28 decimal places; +- absolute 96-bit coefficient no greater than `79228162514264337593543950335`. + +The Dart `ExactDecimal` type remains arbitrary precision internally because it is useful for deterministic intermediate formatting and tests, but product conversion entry points enforce the Rust-compatible boundary. This avoids a web/test-only acceptance path for values that would be rejected by the native bridge. + ## Canonicalization Custom-unit text is normalized at the trust boundary before it becomes durable state: - names, symbols, descriptions, and aliases are trimmed; - aliases are deduplicated case-insensitively while preserving first-occurrence order; -- scale and offset are parsed through UnitFlow's exact decimal implementation and persisted in canonical decimal form; +- scale and offset are parsed through UnitFlow's exact decimal implementation, checked against the Rust-compatible decimal domain, and persisted in canonical decimal form; - stable identifiers are validated rather than rewritten. +Recent conversions created by the current application store the input value in locale-independent canonical decimal form. When a recent conversion is reopened, the canonical value is parsed first and then formatted for the current input locale before converter parsing. Older history rows that contain non-canonical localized input are not reinterpreted as canonical numeric data; their unit pair can still be restored without silently changing the numeric meaning. + Canonicalization ensures a unit created interactively and the same unit restored from backup have equivalent durable representation. ## Rounding modes @@ -75,7 +90,7 @@ Custom units use an affine relationship instead of evaluating arbitrary executab base_value = input_value * scale + offset ``` -The scale must be strictly positive. This design covers ordinary multiplicative units and temperature-like offsets without introducing an expression interpreter into imported user data. +The scale must be strictly positive, and both scale and offset must fit the shared Rust-compatible decimal domain. This design covers ordinary multiplicative units and temperature-like offsets without introducing an expression interpreter into imported user data. ## Referential normalization @@ -98,13 +113,15 @@ Normalization diagnostics record only removed item counts, not unit names, value An import is rejected when, among other validation failures: - JSON is malformed; +- duplicate JSON object keys are present; +- JSON nesting exceeds the configured depth bound; - the root is not an object; - the schema version is unsupported; - an object contains unsupported properties; - required settings have invalid types or ranges; - favorite or pinned identifiers do not match the stable-ID grammar; - duplicate favorite, pinned-pair, or custom-unit identifiers are present where uniqueness is required; -- a custom-unit identifier or formula is invalid; +- a custom-unit identifier or formula is invalid or outside the portable decimal domain; - duplicate identifiers would collide with built-in or imported custom units; - the import exceeds configured size/count limits. @@ -139,6 +156,8 @@ For future schema versions: `tool/check_data_files.py` parses every tracked JSON and ARB file as UTF-8 JSON and rejects duplicate object keys. Duplicate keys are forbidden because ordinary JSON parsers may silently keep one value and discard another, creating ambiguous configuration or schema evidence. +Runtime backup parsing has its own duplicate-key/depth enforcement in `apps/unitflow_app/lib/core/persistence/strict_json.dart`; repository validation is not relied on for untrusted user imports. + ## Privacy See `PRIVACY.md` for the user-facing privacy policy and `SECURITY.md` for vulnerability reporting. From 052674c2a4529f786f363d10c1c083294f7d2267 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:31:33 +0530 Subject: [PATCH 230/241] feat: expose ordered batch conversion through bridge --- crates/unitflow_bridge/src/api/converter.rs | 66 +++++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/crates/unitflow_bridge/src/api/converter.rs b/crates/unitflow_bridge/src/api/converter.rs index 64a64486..f73b6d6d 100644 --- a/crates/unitflow_bridge/src/api/converter.rs +++ b/crates/unitflow_bridge/src/api/converter.rs @@ -2,7 +2,8 @@ use std::str::FromStr; use rust_decimal::Decimal; use unitflow_core::{ - ConversionRequest, Converter, Notation, RoundMode, UnitCatalog, UnitDefinition, + ConversionRequest, ConversionResult, Converter, Notation, RoundMode, UnitCatalog, + UnitDefinition, }; #[derive(Debug, Clone)] @@ -90,7 +91,11 @@ pub fn search_units( None => None, }; Ok(catalog - .search(&query, category, usize::try_from(limit).unwrap_or(usize::MAX)) + .search( + &query, + category, + usize::try_from(limit).unwrap_or(usize::MAX), + ) .into_iter() .map(BridgeUnit::from) .collect()) @@ -104,7 +109,7 @@ pub fn convert_value( decimal_places: Option, round_mode: BridgeRoundMode, ) -> Result { - let value = Decimal::from_str(input.trim()).map_err(|_| "invalid decimal input".to_owned())?; + let value = parse_decimal_input(&input)?; let converter = Converter::with_built_in_catalog().map_err(|error| error.to_string())?; let result = converter .convert(&ConversionRequest { @@ -116,13 +121,29 @@ pub fn convert_value( }) .map_err(|error| error.to_string())?; - Ok(BridgeConversionResult { - input: result.input.normalize().to_string(), - output: result.output.normalize().to_string(), - from_unit_id: result.from_unit_id, - to_unit_id: result.to_unit_id, - category: result.category.to_string(), - }) + Ok(result.into()) +} + +#[flutter_rust_bridge::frb(sync)] +pub fn batch_convert_value( + input: String, + from_unit_id: String, + to_unit_ids: Vec, + decimal_places: Option, + round_mode: BridgeRoundMode, +) -> Result, String> { + let value = parse_decimal_input(&input)?; + let converter = Converter::with_built_in_catalog().map_err(|error| error.to_string())?; + converter + .batch_convert( + value, + &from_unit_id, + &to_unit_ids, + decimal_places, + round_mode.into(), + ) + .map(|results| results.into_iter().map(BridgeConversionResult::from).collect()) + .map_err(|error| error.to_string()) } #[flutter_rust_bridge::frb(sync)] @@ -132,13 +153,22 @@ pub fn format_value( decimal_places: Option, round_mode: BridgeRoundMode, ) -> Result { - let value = Decimal::from_str(input.trim()).map_err(|_| "invalid decimal input".to_owned())?; + let value = parse_decimal_input(&input)?; unitflow_core::format_decimal(value, notation.into(), decimal_places, round_mode.into()) .map_err(|error| error.to_string()) } +fn parse_decimal_input(input: &str) -> Result { + Decimal::from_str(input.trim()).map_err(|_| "invalid decimal input".to_owned()) +} + fn parse_category(value: &str) -> Result { - match value.trim().to_ascii_lowercase().replace([' ', '-'], "_").as_str() { + match value + .trim() + .to_ascii_lowercase() + .replace([' ', '-'], "_") + .as_str() + { "length" => Ok(unitflow_core::Category::Length), "area" => Ok(unitflow_core::Category::Area), "volume" => Ok(unitflow_core::Category::Volume), @@ -171,3 +201,15 @@ impl From<&UnitDefinition> for BridgeUnit { } } } + +impl From for BridgeConversionResult { + fn from(result: ConversionResult) -> Self { + Self { + input: result.input.normalize().to_string(), + output: result.output.normalize().to_string(), + from_unit_id: result.from_unit_id, + to_unit_id: result.to_unit_id, + category: result.category.to_string(), + } + } +} From 40511241567907a2612a535cd961626ee7733c81 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:31:53 +0530 Subject: [PATCH 231/241] test: cover ordered bridge batch conversion --- crates/unitflow_bridge/tests/bridge_api.rs | 92 +++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/unitflow_bridge/tests/bridge_api.rs b/crates/unitflow_bridge/tests/bridge_api.rs index 873dd685..ab00d345 100644 --- a/crates/unitflow_bridge/tests/bridge_api.rs +++ b/crates/unitflow_bridge/tests/bridge_api.rs @@ -1,5 +1,6 @@ use unitflow_bridge::api::converter::{ - bridge_version, convert_value, list_units, search_units, BridgeRoundMode, + batch_convert_value, bridge_version, convert_value, format_value, list_units, search_units, + BridgeNotation, BridgeRoundMode, }; #[test] @@ -25,7 +26,75 @@ fn converts_through_bridge_using_decimal_strings() { ) .expect("conversion"); + assert_eq!(result.input, "1000"); assert_eq!(result.output, "1"); + assert_eq!(result.from_unit_id, "meter"); + assert_eq!(result.to_unit_id, "kilometer"); + assert_eq!(result.category, "length"); +} + +#[test] +fn batch_conversion_preserves_requested_target_order() { + let results = batch_convert_value( + "1".to_owned(), + "meter".to_owned(), + vec![ + "centimeter".to_owned(), + "kilometer".to_owned(), + "inch".to_owned(), + ], + Some(6), + BridgeRoundMode::NearestEven, + ) + .expect("batch conversion"); + + assert_eq!(results.len(), 3); + assert_eq!(results[0].to_unit_id, "centimeter"); + assert_eq!(results[0].output, "100"); + assert_eq!(results[1].to_unit_id, "kilometer"); + assert_eq!(results[1].output, "0.001"); + assert_eq!(results[2].to_unit_id, "inch"); +} + +#[test] +fn empty_batch_returns_no_results() { + let results = batch_convert_value( + "1".to_owned(), + "meter".to_owned(), + Vec::new(), + Some(6), + BridgeRoundMode::NearestEven, + ) + .expect("empty batch conversion"); + + assert!(results.is_empty()); +} + +#[test] +fn batch_conversion_rejects_unknown_target_without_partial_results() { + let error = batch_convert_value( + "1".to_owned(), + "meter".to_owned(), + vec!["kilometer".to_owned(), "missing_unit".to_owned()], + Some(6), + BridgeRoundMode::NearestEven, + ) + .expect_err("unknown target should reject the whole batch"); + + assert!(error.contains("missing_unit")); +} + +#[test] +fn formats_through_bridge_with_explicit_notation_and_rounding() { + let formatted = format_value( + "1234.5".to_owned(), + BridgeNotation::Scientific, + Some(2), + BridgeRoundMode::NearestEven, + ) + .expect("formatting"); + + assert_eq!(formatted, "1.23e3"); } #[test] @@ -34,3 +103,24 @@ fn searches_by_category_string() { .expect("search"); assert!(results.iter().all(|unit| unit.category == "length")); } + +#[test] +fn rejects_invalid_decimal_input_consistently() { + let single = convert_value( + "not-a-number".to_owned(), + "meter".to_owned(), + "kilometer".to_owned(), + Some(6), + BridgeRoundMode::NearestEven, + ); + let batch = batch_convert_value( + "not-a-number".to_owned(), + "meter".to_owned(), + vec!["kilometer".to_owned()], + Some(6), + BridgeRoundMode::NearestEven, + ); + + assert_eq!(single.expect_err("single invalid decimal"), "invalid decimal input"); + assert_eq!(batch.expect_err("batch invalid decimal"), "invalid decimal input"); +} From f2e9fdcbd77ad718ec0baee76f7b61dcfd2d3b8a Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:32:16 +0530 Subject: [PATCH 232/241] test: match bridge scientific formatter output --- crates/unitflow_bridge/tests/bridge_api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/unitflow_bridge/tests/bridge_api.rs b/crates/unitflow_bridge/tests/bridge_api.rs index ab00d345..08f29df2 100644 --- a/crates/unitflow_bridge/tests/bridge_api.rs +++ b/crates/unitflow_bridge/tests/bridge_api.rs @@ -94,7 +94,7 @@ fn formats_through_bridge_with_explicit_notation_and_rounding() { ) .expect("formatting"); - assert_eq!(formatted, "1.23e3"); + assert_eq!(formatted, "1.23e+3"); } #[test] From 6ed038e31d2c2d779441c4ea91fa44cfb5ffbc06 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:32:40 +0530 Subject: [PATCH 233/241] test: run shared parity corpus through bridge --- .../unitflow_bridge/tests/parity_vectors.rs | 74 ++++++++++--------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/crates/unitflow_bridge/tests/parity_vectors.rs b/crates/unitflow_bridge/tests/parity_vectors.rs index f04ec47c..4ac1cedc 100644 --- a/crates/unitflow_bridge/tests/parity_vectors.rs +++ b/crates/unitflow_bridge/tests/parity_vectors.rs @@ -1,45 +1,39 @@ -use std::str::FromStr; - -use rust_decimal::Decimal; +use serde::Deserialize; use unitflow_bridge::api::converter::{convert_value, BridgeRoundMode}; -use unitflow_core::{ConversionRequest, Converter, RoundMode}; -#[test] -fn representative_bridge_vectors_match_core_results() { - let core = Converter::with_built_in_catalog().expect("catalog"); - let vectors = [ - ("123.456", "meter", "foot", 12_u32), - ("-40", "celsius", "fahrenheit", 8_u32), - ("1", "gallon_us", "liter", 12_u32), - ("1024", "byte", "kibibyte", 12_u32), - ("60", "revolution_per_minute", "hertz", 12_u32), - ("180", "degree", "radian", 18_u32), - ]; +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Vector { + name: String, + input: String, + from: String, + to: String, + decimal_places: u32, + rounding_mode: String, + expected: String, +} - for (input, from, to, places) in vectors { - let core_result = core - .convert(&ConversionRequest { - value: Decimal::from_str(input).expect("test decimal"), - from_unit_id: from.to_owned(), - to_unit_id: to.to_owned(), - decimal_places: Some(places), - round_mode: RoundMode::NearestEven, - }) - .expect("core conversion"); +#[test] +fn bridge_matches_shared_conversion_vectors() { + let vectors: Vec = serde_json::from_str(include_str!( + "../../../test_vectors/conversions.json" + )) + .expect("shared conversion vectors must be valid JSON"); + for vector in vectors { let bridge_result = convert_value( - input.to_owned(), - from.to_owned(), - to.to_owned(), - Some(places), - BridgeRoundMode::NearestEven, + vector.input.clone(), + vector.from.clone(), + vector.to.clone(), + Some(vector.decimal_places), + bridge_round_mode(&vector.rounding_mode), ) - .expect("bridge conversion"); + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.name)); assert_eq!( - bridge_result.output, - core_result.output.normalize().to_string(), - "bridge parity failed for {input} {from} -> {to}" + bridge_result.output, vector.expected, + "shared bridge vector mismatch: {}", + vector.name ); } } @@ -57,3 +51,15 @@ fn bridge_rejects_invalid_decimal_text() { assert_eq!(error, "invalid decimal input"); } + +fn bridge_round_mode(value: &str) -> BridgeRoundMode { + match value { + "nearestEven" => BridgeRoundMode::NearestEven, + "halfAwayFromZero" => BridgeRoundMode::HalfAwayFromZero, + "towardZero" => BridgeRoundMode::TowardZero, + "awayFromZero" => BridgeRoundMode::AwayFromZero, + "floor" => BridgeRoundMode::Floor, + "ceiling" => BridgeRoundMode::Ceiling, + other => panic!("unsupported shared vector round mode: {other}"), + } +} From f21611c26031244a44580695fb143204a19363cb Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:32:55 +0530 Subject: [PATCH 234/241] test: add bridge parity fixture dependencies --- crates/unitflow_bridge/Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/unitflow_bridge/Cargo.toml b/crates/unitflow_bridge/Cargo.toml index 4319c5ae..d611cd3c 100644 --- a/crates/unitflow_bridge/Cargo.toml +++ b/crates/unitflow_bridge/Cargo.toml @@ -15,3 +15,7 @@ crate-type = ["cdylib", "staticlib", "rlib"] flutter_rust_bridge.workspace = true rust_decimal.workspace = true unitflow_core = { path = "../unitflow_core" } + +[dev-dependencies] +serde.workspace = true +serde_json = "1.0" From ea58469fa8aa2ebab95da5dae7d434910bfb7f1c Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:34:00 +0530 Subject: [PATCH 235/241] fix: enforce canonical recent input at state boundary --- apps/unitflow_app/lib/app/app_controller.dart | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/unitflow_app/lib/app/app_controller.dart b/apps/unitflow_app/lib/app/app_controller.dart index f3fc0234..ed1b46fd 100644 --- a/apps/unitflow_app/lib/app/app_controller.dart +++ b/apps/unitflow_app/lib/app/app_controller.dart @@ -143,9 +143,11 @@ final class AppController extends ChangeNotifier { if (from == null || to == null || from.category != to.category) { throw ArgumentError('Recent conversion references invalid units.'); } + + final canonicalInput = _canonicalRecentInput(input); final next = _state.recents.toList(); if (next.isNotEmpty && - next.first.input == input && + next.first.input == canonicalInput && next.first.fromUnitId == fromUnitId && next.first.toUnitId == toUnitId) { return Future.value(); @@ -153,7 +155,7 @@ final class AppController extends ChangeNotifier { next.insert( 0, RecentConversion( - input: input, + input: canonicalInput, fromUnitId: fromUnitId, toUnitId: toUnitId, createdAt: DateTime.now(), @@ -379,6 +381,27 @@ final class AppController extends ChangeNotifier { ); } + String _canonicalRecentInput(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty || trimmed.length > RecentConversion.maxInputLength) { + throw ArgumentError.value(input, 'input', 'invalid recent conversion input'); + } + final ExactDecimal parsed; + try { + parsed = ExactDecimal.parse(trimmed); + } on FormatException { + throw ArgumentError.value(input, 'input', 'invalid recent conversion input'); + } + if (!parsed.isRustDecimalCompatible) { + throw ArgumentError.value(input, 'input', 'recent conversion input is out of range'); + } + final canonical = parsed.toCanonicalString(); + if (canonical.length > RecentConversion.maxInputLength) { + throw ArgumentError.value(input, 'input', 'recent conversion input is too long'); + } + return canonical; + } + Future _update(UserState state, {ConversionEngine? engine}) { _state = state; if (engine != null) { From a1b46cefc51c2e54e8db62c20fc0cc2a83c4ccdc Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:34:35 +0530 Subject: [PATCH 236/241] test: enforce canonical recent history at controller boundary --- .../test/app/app_controller_test.dart | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/unitflow_app/test/app/app_controller_test.dart b/apps/unitflow_app/test/app/app_controller_test.dart index 0bf295da..eb8ddf08 100644 --- a/apps/unitflow_app/test/app/app_controller_test.dart +++ b/apps/unitflow_app/test/app/app_controller_test.dart @@ -70,6 +70,37 @@ void main() { expect(controller.state.recents, hasLength(50)); }); + test('recordRecent canonicalizes exact decimal input before persistence', () async { + await controller.recordRecent( + input: ' 001.2500 ', + fromUnitId: 'meter', + toUnitId: 'kilometer', + ); + + expect(controller.state.recents.single.input, '1.25'); + expect((await repository.load()).recents.single.input, '1.25'); + }); + + test('recordRecent rejects malformed and out-of-range decimal input', () { + expect( + () => controller.recordRecent( + input: '1,25', + fromUnitId: 'meter', + toUnitId: 'kilometer', + ), + throwsArgumentError, + ); + expect( + () => controller.recordRecent( + input: '79228162514264337593543950336', + fromUnitId: 'meter', + toUnitId: 'kilometer', + ), + throwsArgumentError, + ); + expect(controller.state.recents, isEmpty); + }); + test('conversion settings persist through the repository', () async { await controller.setRoundingMode(DecimalRoundingMode.ceiling); await controller.setDecimalPlaces(4); From 1bb67c9e7298a9742e449f51b2f1699be5958026 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:35:06 +0530 Subject: [PATCH 237/241] fix: keep About link failure message non-null --- .../lib/features/settings/presentation/about_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart b/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart index 58722d93..0ed3584e 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart @@ -184,7 +184,7 @@ final class _ExternalTile extends StatelessWidget { Future _open(BuildContext context) async { final strings = AppLocalizations.of(context); - String? message; + final String message; try { if (await launchUrl(uri)) { return; From 1c84a6d15e0893907a500e9d4b9c48926f0421a4 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:36:04 +0530 Subject: [PATCH 238/241] fix: enforce Rust-compatible fallback intermediates --- .../converter/domain/conversion_engine.dart | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/unitflow_app/lib/features/converter/domain/conversion_engine.dart b/apps/unitflow_app/lib/features/converter/domain/conversion_engine.dart index a40aada2..ea5d5643 100644 --- a/apps/unitflow_app/lib/features/converter/domain/conversion_engine.dart +++ b/apps/unitflow_app/lib/features/converter/domain/conversion_engine.dart @@ -42,9 +42,7 @@ final class ExactConversionEngine implements ConversionEngine { if (decimalPlaces < 0 || decimalPlaces > 28) { throw ConversionFailure('Decimal places must be between 0 and 28.'); } - if (!value.isRustDecimalCompatible) { - throw ConversionFailure('Value is outside the supported decimal range.'); - } + _requireRustCompatible(value); final from = catalog.byId(fromUnitId); final to = catalog.byId(toUnitId); @@ -61,8 +59,13 @@ final class ExactConversionEngine implements ConversionEngine { throw ConversionFailure('Target unit has an invalid zero scale.'); } - final base = (value * from.scale) + from.offset; - final output = (base - to.offset) + final scaled = value * from.scale; + _requireRustCompatible(scaled); + final base = scaled + from.offset; + _requireRustCompatible(base); + final shifted = base - to.offset; + _requireRustCompatible(shifted); + final output = shifted .divide(to.scale, precision: 28, rounding: rounding) .round(decimalPlaces, mode: rounding); @@ -87,6 +90,12 @@ final class ExactConversionEngine implements ConversionEngine { ), ) .toList(growable: false); + + void _requireRustCompatible(ExactDecimal value) { + if (!value.isRustDecimalCompatible) { + throw ConversionFailure('Value is outside the supported decimal range.'); + } + } } final class ConversionFailure implements Exception { From eb9a3744cf010b581b4a906add9ce4efe73b2a23 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:36:37 +0530 Subject: [PATCH 239/241] test: reject fallback intermediate overflow --- .../test/features/conversion_engine_test.dart | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/apps/unitflow_app/test/features/conversion_engine_test.dart b/apps/unitflow_app/test/features/conversion_engine_test.dart index 99d57b5c..645fce2b 100644 --- a/apps/unitflow_app/test/features/conversion_engine_test.dart +++ b/apps/unitflow_app/test/features/conversion_engine_test.dart @@ -71,6 +71,50 @@ void main() { expect(result.output.toCanonicalString(), '79228162514264337593543950335'); }); + test('rejects a multiplication intermediate that would overflow Rust', () { + expect( + () => engine.convert( + value: ExactDecimal.parse('79228162514264337593543950335'), + fromUnitId: 'kilometer', + toUnitId: 'meter', + decimalPlaces: 0, + ), + throwsA(isA()), + ); + }); + + test('rejects an affine intermediate that would overflow Rust', () { + final affineEngine = ExactConversionEngine( + catalog: UnitCatalog([ + UnitDefinition( + id: 'shifted', + category: UnitCategory.temperature, + name: 'Shifted', + symbol: 's', + scale: ExactDecimal.parse('1'), + offset: ExactDecimal.parse('1'), + ), + UnitDefinition( + id: 'base', + category: UnitCategory.temperature, + name: 'Base', + symbol: 'b', + scale: ExactDecimal.parse('1'), + ), + ]), + ); + + expect( + () => affineEngine.convert( + value: ExactDecimal.parse('79228162514264337593543950335'), + fromUnitId: 'shifted', + toUnitId: 'base', + decimalPlaces: 0, + ), + throwsA(isA()), + ); + }); + test('batch conversion preserves target order', () { final results = engine.batchConvert( value: ExactDecimal.parse('1'), From 084fbf777d87800a947f504e657f5d917286e6b7 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:38:12 +0530 Subject: [PATCH 240/241] docs: align bridge contract with implemented batch API --- docs/bridge.md | 44 +++++++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/bridge.md b/docs/bridge.md index 8c9b9985..63257bf3 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -29,16 +29,35 @@ bash tool/generate_bridge.sh ## Bridge API -The bridge exposes safe string/primitive DTOs for: +The Rust bridge source currently exposes synchronous FRB functions for: -- single conversion; -- batch conversion; -- built-in unit listing; -- catalog search; -- explicit rounding-mode selection. +- `bridge_version` — reports the authoritative core release version; +- `list_units` — returns built-in unit DTOs; +- `search_units` — searches the built-in catalog with an optional category and bounded result count; +- `convert_value` — converts one decimal-string value between two built-in units; +- `batch_convert_value` — converts one decimal-string source value to an ordered target-ID list and preserves target order; +- `format_value` — formats one decimal string using explicit notation, precision, and rounding. + +Batch conversion is all-or-error at the Rust core boundary: if a requested target is invalid, the bridge does not return a misleading partial batch. Decimal values cross the FFI boundary as strings. Rust parses and validates them before executing domain behavior. This avoids silently converting high-precision decimal input through a binary floating-point representation. +Bridge regression tests exercise single conversion, ordered batch conversion, empty batches, invalid batch targets, explicit notation formatting, category-scoped search, invalid decimal input, and the shared conversion parity corpus covering all categories and rounding modes represented by `test_vectors/conversions.json`. + +## Custom units and native adapter strategy + +The current bridge catalog is intentionally the built-in Rust catalog. Flutter user-created units are stored locally and merged into the Dart-side application catalog. Therefore a native production adapter must not blindly route every pair to the built-in-only Rust bridge. + +The intended adapter boundary is hybrid: + +1. initialize the generated native Rust library on supported native platforms; +2. use Rust bridge conversion for pairs where both unit IDs are built-in and the native bridge is available; +3. retain the deterministic exact-decimal engine for custom-unit pairs and for web/fallback operation; +4. preserve the same `ConversionEngine` interface so presentation code does not depend on generated FRB classes/functions directly; +5. fail back safely when native initialization/loading is unavailable rather than crashing startup. + +Do **not** implement this adapter by guessing generated Dart identifiers. First let the pinned generator produce/normalize `apps/unitflow_app/lib/src/rust`, inspect the exact generated API, and only then add the adapter imports/calls. This rule prevents hand-written source from depending on code-generator names that have not actually been produced for the pinned version. + ## Generated sources Generated bindings are treated as derived **but intentionally tracked** sources. The audit-branch normalization workflow installs the pinned generator, regenerates bindings, runs formatting, and commits generated changes when needed. Generated files must still pass Rust and Flutter analysis before merge. @@ -56,11 +75,13 @@ The release checklist therefore distinguishes: 1. Rust core compiles/tests; 2. FRB bindings generate; 3. generated Rust/Dart analyze; -4. native application builds; -5. installed app executes a conversion through the intended native boundary; -6. web fallback executes deterministic Dart conversion without a native library. +4. the application adapter initializes and routes built-in conversion through the generated native API; +5. native application builds; +6. installed app executes a built-in conversion through the intended native boundary; +7. custom units continue through the deterministic application fallback path; +8. web fallback executes deterministic Dart conversion without requiring a native library. -Until steps 4–5 have platform evidence, native bridge packaging is not considered release-verified. +Until native adapter and platform evidence exist for steps 4–6, native bridge runtime use is not considered release-verified. ## API change policy @@ -71,7 +92,8 @@ When bridge-visible Rust types/functions change: 3. run `cargo fmt`, `cargo clippy`, and workspace tests; 4. run Flutter generation, formatting, analysis, and tests; 5. confirm no modified or untracked generated source remains; -6. update this document and `what_changed.md` when integration behavior changes. +6. inspect adapter compilation against the generated API; +7. update this document and `what_changed.md` when integration behavior changes. ## Troubleshooting From c09493eb47ceb30f8408a37c9e670cdecd330176 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Wed, 19 Aug 2026 12:40:17 +0530 Subject: [PATCH 241/241] docs: document parity and strict import regression coverage --- docs/testing.md | 70 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index a20e56f2..ea8960e8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,7 +10,7 @@ From the repository root: bash tool/check.sh ``` -The script runs the same primary Rust and Flutter quality gates used by CI. +The script runs the same primary repository, Rust, and Flutter quality gates used by CI. When the pinned bridge generator is installed, it also checks generated bridge cleanliness. ## Repository safety tests @@ -21,7 +21,9 @@ cd tool python3 -m unittest discover -p 'test_*.py' ``` -CI also validates shell/Python syntax, scans tracked files for common credential signatures, validates tracked JSON/ARB files as UTF-8 JSON with unique object keys, and verifies internal Markdown targets. +The utility suite covers duplicate JSON-key detection, release/source version consistency, Flutter numeric bundle-version policy, About-version synchronization, Flutter Rust Bridge codegen pin consistency, and release-tag validation helpers. + +CI also validates shell/Python syntax, runs `python3 tool/check_versions.py`, scans tracked files for common credential signatures, validates tracked JSON/ARB files as UTF-8 JSON with unique object keys, and verifies internal Markdown targets. ## Rust quality gates @@ -39,15 +41,39 @@ Coverage priorities: - source/target category mismatch handling; - multiplicative and affine conversion accuracy; - zero/negative/large/small decimal values; +- checked arithmetic overflow behavior; - round-trip conversion invariants where exact decimal factors permit it; - search by name, symbol, alias, and descriptive metadata where supported; - custom-unit validation; - scientific/engineering notation edge cases; - batch conversion order and error behavior; -- Rust↔Flutter bridge DTO/end-point behavior. +- Rust↔Flutter bridge DTO/endpoint behavior. `crates/unitflow_core/tests/properties.rs` uses property-based tests for identity conversion, exact metric round trips, and batch target ordering across generated values. +`crates/unitflow_core/tests/parity_coverage.rs` verifies that the shared conversion-vector corpus covers every UnitFlow category and all six rounding modes. + +## Shared Rust/Dart/bridge parity vectors + +`test_vectors/conversions.json` is the language-boundary regression corpus. It includes representative conversions across: + +- length; +- area; +- volume; +- mass; +- speed; +- pressure; +- energy; +- power; +- angle; +- data size; +- frequency; +- time; +- temperature; +- all six explicit rounding modes. + +The Rust core, Dart fallback, and Rust bridge consume the same expected-output records. Adding a category or changing conversion/rounding behavior should update the shared vectors instead of creating unrelated language-specific expectations. + ## Flutter quality gates Run: @@ -64,21 +90,38 @@ flutter test Coverage priorities: - exact-decimal parsing/arithmetic without binary floating point; +- Rust-compatible decimal coefficient/scale boundaries; +- rejection of fallback input and exact arithmetic intermediates that exceed the native decimal domain; - converter input validation; - source/target selection and swap; +- canonical recent-history persistence and locale-safe reopening; - primary app/onboarding journey; - responsive layout at representative widths; - theme switching; - favorites/pin/history state behavior; - settings and About page content; - semantics for major controls; -- custom-unit form validation; +- custom-unit form validation and Rust-compatible formula bounds; - backup schema round trips and rejected imports; +- runtime duplicate-key and JSON-nesting rejection; - strict backup collection/property/identifier validation; - custom-unit normalization and collection limits; - batch CSV escaping. -## Rust–Flutter bridge generation +## Rust–Flutter bridge tests and generation + +Bridge source tests cover: + +- core version exposure; +- built-in catalog DTOs; +- single conversion through decimal strings; +- ordered batch conversion; +- empty batch behavior; +- all-or-error invalid batch targets; +- explicit notation/rounding formatting; +- category-scoped search; +- invalid decimal input; +- the complete shared conversion-vector corpus. The bridge is generated from checked-in Rust API source: @@ -90,7 +133,9 @@ cd apps/unitflow_app flutter analyze --fatal-infos --fatal-warnings ``` -CI runs this as an independent job so bridge generation cannot silently drift from the source API. +CI runs generation as an independent job so generated APIs cannot silently drift from bridge source. Required generated files must be committed; modified and untracked generated-source drift is a failure. + +The final native application adapter is intentionally gated on inspecting the actual generated Dart API. Do not substitute guessed generated identifiers for this evidence-producing step. ## Integration and end-to-end journeys @@ -103,11 +148,12 @@ Primary journeys are tracked as layered widget/integration coverage: 5. observe a correct conversion; 6. swap units; 7. favorite or pin the pair; -8. restart and verify persisted state; -9. create a valid custom unit and use it; -10. reject an invalid imported backup without corrupting local state; -11. export user data and restore it into a clean profile; -12. copy deterministic batch CSV results. +8. submit a conversion and reopen the exact recorded value/pair from history; +9. restart and verify persisted state; +10. create a valid custom unit and use it; +11. reject an invalid imported backup without corrupting local state; +12. export user data and restore it into a clean profile; +13. copy deterministic batch CSV results. Device-level integration tests are added when a platform runner is available; widget/domain tests remain deterministic and do not require production credentials. @@ -132,4 +178,4 @@ Every confirmed defect should receive a failing regression test before or with t ## CI policy -CI fails on formatting, lint, analysis, tests, generated bridge verification, security checks, or build failures. A skipped platform check must be explicit rather than silently treated as success. +CI fails on repository utility/version checks, formatting, lint, analysis, tests, generated bridge verification, security checks, or build failures. A skipped platform check must be explicit rather than silently treated as success.