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 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" 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 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. 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 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)" 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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec5f84e..41629a82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,12 +15,41 @@ concurrency: cancel-in-progress: true jobs: + repository-safety: + name: Repository safety + runs-on: ubuntu-latest + steps: + - 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: Test repository utilities + 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 + + - 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 + rust: name: Rust quality 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 +76,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 +87,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 +98,48 @@ 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: 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 + + - name: Verify generated Dart + working-directory: apps/unitflow_app + run: flutter analyze --fatal-infos --fatal-warnings + + - name: Verify generated integration is committed + 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 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" 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 diff --git a/.github/workflows/format-audit.yml b/.github/workflows/format-audit.yml new file mode 100644 index 00000000..799fb082 --- /dev/null +++ b/.github/workflows/format-audit.yml @@ -0,0 +1,83 @@ +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@v6 + 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: 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 + + - name: Resolve Flutter dependencies + 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 + + - name: Format Dart + working-directory: apps/unitflow_app + run: dart format lib test + + - name: Commit normalized generated sources when needed + shell: bash + run: | + 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 + git config user.name "Sanskar" + git config user.email "sanskarin@outlook.in" + git add Cargo.lock apps/unitflow_app crates + if git diff --cached --quiet; then + 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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..e1e856a0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,346 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +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 + 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 + uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + 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 + needs: release-metadata + 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: 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 + shell: bash + 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-${{ env.RELEASE_LABEL }} + path: unitflow-web-${{ env.RELEASE_LABEL }}.tar.gz + if-no-files-found: error + + flutter-android: + name: Flutter Android + needs: release-metadata + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/unitflow_app + 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: 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: Build Android release app bundle + run: flutter build appbundle --release + - name: Package Android validation artifacts + shell: bash + run: | + 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-${{ env.RELEASE_LABEL }} + path: unitflow-android-${{ env.RELEASE_LABEL }}/* + if-no-files-found: error + + flutter-linux: + name: Flutter Linux + needs: release-metadata + 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 Rust + uses: dtolnay/rust-toolchain@stable + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - 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 + shell: bash + 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-${{ env.RELEASE_LABEL }} + path: unitflow-linux-${{ env.RELEASE_LABEL }}.tar.gz + if-no-files-found: error + + flutter-windows: + name: Flutter Windows + needs: release-metadata + runs-on: windows-latest + defaults: + run: + working-directory: apps/unitflow_app + 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: 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 "$env:GITHUB_WORKSPACE/unitflow-windows-$env:RELEASE_LABEL.zip" + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: unitflow-windows-${{ env.RELEASE_LABEL }} + path: unitflow-windows-${{ env.RELEASE_LABEL }}.zip + if-no-files-found: error + + flutter-macos-ios: + name: Flutter macOS and iOS validation + needs: release-metadata + runs-on: macos-latest + defaults: + run: + working-directory: apps/unitflow_app + 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: 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 + 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 + 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-${{ 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-${{ env.RELEASE_LABEL }} + path: unitflow-ios-nosign-${{ env.RELEASE_LABEL }}.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 + test -s SHA256SUMS + cat SHA256SUMS + - name: Upload checksum manifest + uses: actions/upload-artifact@v4 + with: + name: unitflow-checksums-${{ env.RELEASE_LABEL }} + 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 + test -s 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 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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ec68f8..4bb93c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,22 +6,65 @@ 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. +- 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. +- 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 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 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. +- Rust fuzz targets for catalog search and decimal bridge inputs. +- Bridge, platform-support, branding, accessibility, performance, data-format, and release verification documentation. ### 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. +- 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 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. ### Fixed -- Nothing yet. +- 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. +- 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 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. ## [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`. 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" diff --git a/README.md b/README.md index 6407f254..662b2637 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 | +| Web | Supported Flutter fallback target | +| iOS | iOS-ready target pending release validation/signing | -## 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. - -## 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,75 @@ 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: +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 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. Imports reject unsupported object fields and collection counts outside the documented schema bounds rather than silently discarding them. -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). +See [`docs/data-format.md`](docs/data-format.md) and [`schemas/unitflow-backup-v2.schema.json`](schemas/unitflow-backup-v2.schema.json). + +## 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 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). + +## 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 diff --git a/ROADMAP.md b/ROADMAP.md index 7311206e..9d4411d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,76 +1,119 @@ # 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] 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. +- [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] 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. +- [ ] 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. 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 diff --git a/apps/unitflow_app/lib/app/app_controller.dart b/apps/unitflow_app/lib/app/app_controller.dart index 49f316cf..ed1b46fd 100644 --- a/apps/unitflow_app/lib/app/app_controller.dart +++ b/apps/unitflow_app/lib/app/app_controller.dart @@ -1,14 +1,35 @@ 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'; 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; + 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(); @@ -25,11 +46,17 @@ 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) { - _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 { @@ -44,6 +71,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'); @@ -54,6 +84,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)); @@ -76,7 +109,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(); @@ -90,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)); @@ -102,9 +138,16 @@ 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 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(); @@ -112,45 +155,140 @@ final class AppController extends ChangeNotifier { next.insert( 0, RecentConversion( - input: input, + input: canonicalInput, fromUnitId: fromUnitId, toUnitId: toUnitId, 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)); } + Future clearHistory() => + _update(_state.copyWith(recents: [])); + + Future restoreHistory(List recents) { + 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(definition.id, 'id', 'unit identifier already exists'); + throw ArgumentError.value( + definition.id, + 'id', + '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); } - Future removeCustomUnit(String id) { + Future removeCustomUnit(String id) async { final existing = _state.customUnits.where((item) => item.id == id).toList(); if (existing.isEmpty) { - return Future.value(); + return null; } - final nextCustom = _state.customUnits.where((item) => item.id != id).toList(); - final nextFavorites = _state.favoriteUnitIds.where((item) => item != id).toSet(); + 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(); + final nextFavorites = _state.favoriteUnitIds + .where((item) => item != id) + .toSet(); 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, + ); + await _update(newState, engine: _buildEngine(newState)); + return snapshot; + } + + 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, + '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(UserState.maxPinnedPairs).toList(), + recents: recents.take(UserState.maxActiveRecents).toList(), + ); + return _update( + _normalizeStateReferences(restored, restoredEngine), + engine: restoredEngine, ); - return _update(newState, engine: _buildEngine(newState)); } String exportState() => _repository.exportJson(_state); @@ -158,15 +296,29 @@ 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 { - 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; notifyListeners(); + AppLog.write(LogLevel.info, 'local_data_reset'); } void clearWarning() { @@ -187,6 +339,69 @@ 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(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(UserState.maxActiveRecents).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, + ); + } + + 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) { @@ -196,9 +411,16 @@ 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'); + 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; } } diff --git a/apps/unitflow_app/lib/app/app_shell.dart b/apps/unitflow_app/lib/app/app_shell.dart index ce384976..5196ebb4 100644 --- a/apps/unitflow_app/lib/app/app_shell.dart +++ b/apps/unitflow_app/lib/app/app_shell.dart @@ -1,13 +1,17 @@ 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'; +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 'branding/unitflow_mark.dart'; import 'theme/app_theme.dart'; final class AppShell extends StatefulWidget { @@ -32,118 +36,141 @@ 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.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), - }, - 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.keyK, control: true): () => + _select(1), + 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), + const SingleActivator(LogicalKeyboardKey.keyK, meta: true): () => + _select(1), + }, + 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: [ + UnitFlowMark(size: 32, semanticLabel: strings.appName), + 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'), - ), - ], + 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), + ), + ) + .toList(growable: false), + ), + const VerticalDivider(width: 1), + Expanded(child: content), + ], + ) + : content, ), - 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), - selectedIcon: Icon(Icons.library_books), - label: Text('Library'), - ), - NavigationRailDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: Text('Settings'), - ), - ], + ], + ), + bottomNavigationBar: useRail + ? null + : NavigationBar( + selectedIndex: _selectedIndex, + onDestinationSelected: _select, + destinations: destinations + .map( + (item) => NavigationDestination( + icon: Icon(item.icon), + selectedIcon: Icon(item.selectedIcon), + label: item.label, ), - 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.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', - ), - ], - ), - ); - }, + ) + .toList(growable: false), + ), + ); + }, + ), ), ), - ), - ); + ); + } Widget _content() => IndexedStack( index: _selectedIndex, @@ -153,6 +180,10 @@ final class _AppShellState extends State { appController: widget.appController, onOpenPair: _openPair, ), + HistoryScreen( + appController: widget.appController, + onOpenRecent: _openRecent, + ), SettingsScreen( appController: widget.appController, onOpenAbout: _openAbout, @@ -172,7 +203,26 @@ 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()))), + MaterialPageRoute( + builder: (_) => const Scaffold(body: SafeArea(child: AboutScreen())), + ), ); } + +final class _NavigationItem { + const _NavigationItem({ + required this.icon, + required this.selectedIcon, + required this.label, + }); + + final IconData icon; + final IconData selectedIcon; + final String label; +} 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); + } +} diff --git a/apps/unitflow_app/lib/app/unitflow_app.dart b/apps/unitflow_app/lib/app/unitflow_app.dart index e388b519..d1b814e1 100644 --- a/apps/unitflow_app/lib/app/unitflow_app.dart +++ b/apps/unitflow_app/lib/app/unitflow_app.dart @@ -1,10 +1,11 @@ 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 'branding/unitflow_mark.dart'; import 'theme/app_theme.dart'; final class UnitFlowApp extends StatefulWidget { @@ -30,16 +31,15 @@ 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')], + themeAnimationDuration: widget.appController.state.reduceMotion + ? Duration.zero + : const Duration(milliseconds: 200), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: _home(), ), ); @@ -65,28 +65,30 @@ 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: [ + UnitFlowMark(size: 76, semanticLabel: strings.appName), + 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), + ], + ), ), - ), - ); + ); + } } 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; +} 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; +} 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(); + } +} diff --git a/apps/unitflow_app/lib/core/math/exact_decimal.dart b/apps/unitflow_app/lib/core/math/exact_decimal.dart index b8a02c0e..a48b2540 100644 --- a/apps/unitflow_app/lib/core/math/exact_decimal.dart +++ b/apps/unitflow_app/lib/core/math/exact_decimal.dart @@ -59,9 +59,12 @@ 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); + 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); 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..798201b5 --- /dev/null +++ b/apps/unitflow_app/lib/core/persistence/strict_json.dart @@ -0,0 +1,172 @@ +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) { + _checkDepth(depth); + _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) { + _checkDepth(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) { + _checkDepth(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.'); + } + } + + void _checkDepth(int depth) { + if (depth > maxNesting) { + throw FormatException('JSON nesting exceeds the limit of $maxNesting.'); + } + } + + bool _isWhitespace(String character) => + character == ' ' || + character == '\n' || + character == '\r' || + character == '\t'; +} diff --git a/apps/unitflow_app/lib/core/persistence/user_state.dart b/apps/unitflow_app/lib/core/persistence/user_state.dart index 34373cbb..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,15 @@ final class RecentConversion { required this.createdAt, }); + static const maxInputLength = 1024; + 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 +34,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 +45,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 > maxInputLength || + !_unitIdPattern.hasMatch(from) || + !_unitIdPattern.hasMatch(to)) { return null; } return RecentConversion( @@ -60,6 +73,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,31 +97,52 @@ 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); + 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: name.trim(), - symbol: symbol.trim(), + name: normalizedName, + symbol: normalizedSymbol, scale: parsedScale, - offset: ExactDecimal.parse(offset), - aliases: List.unmodifiable(aliases), - description: description.trim(), + offset: parsedOffset, + aliases: List.unmodifiable(normalizedAliases), + description: normalizedDescription, isBuiltIn: false, ); } @@ -114,7 +159,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 +202,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; } @@ -169,8 +223,10 @@ final class UserState { UserState({ this.theme = ThemePreference.system, this.notation = DecimalNotation.plain, + this.roundingMode = DecimalRoundingMode.nearestEven, this.decimalPlaces = 12, this.useGrouping = true, + this.reduceMotion = false, this.onboardingComplete = false, Set? favoriteUnitIds, List? pinnedPairs, @@ -181,12 +237,36 @@ final class UserState { recents = List.unmodifiable(recents ?? const []), customUnits = List.unmodifiable(customUnits ?? const []); - static const schemaVersion = 1; + 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; + final DecimalRoundingMode roundingMode; final int decimalPlaces; final bool useGrouping; + final bool reduceMotion; final bool onboardingComplete; final Set favoriteUnitIds; final List pinnedPairs; @@ -196,8 +276,10 @@ final class UserState { UserState copyWith({ ThemePreference? theme, DecimalNotation? notation, + DecimalRoundingMode? roundingMode, int? decimalPlaces, bool? useGrouping, + bool? reduceMotion, bool? onboardingComplete, Set? favoriteUnitIds, List? pinnedPairs, @@ -206,8 +288,10 @@ final class UserState { }) => UserState( theme: theme ?? this.theme, notation: notation ?? this.notation, + 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, @@ -219,8 +303,10 @@ final class UserState { 'schemaVersion': schemaVersion, 'theme': theme.name, 'notation': notation.name, + '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), @@ -230,25 +316,36 @@ 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.'); } + 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']; + 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.'); } 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']; @@ -261,29 +358,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) { @@ -293,10 +398,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); @@ -305,8 +411,10 @@ final class UserState { return UserState( theme: theme, notation: notation, + roundingMode: roundingMode, decimalPlaces: decimalPlaces, useGrouping: useGrouping, + reduceMotion: reduceMotionValue as bool? ?? false, onboardingComplete: onboardingComplete, favoriteUnitIds: favorites, pinnedPairs: pins, @@ -330,6 +438,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; 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..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 { @@ -21,7 +22,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 +55,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 +73,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 +84,28 @@ 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 decoded = decodeStrictJson(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); +} + final class StatePersistenceException implements Exception { const StatePersistenceException(this.message, [this.cause]); 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('"', '""')}"'; +} 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..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,6 +42,7 @@ final class ExactConversionEngine implements ConversionEngine { if (decimalPlaces < 0 || decimalPlaces > 28) { throw ConversionFailure('Decimal places must be between 0 and 28.'); } + _requireRustCompatible(value); final from = catalog.byId(fromUnitId); final to = catalog.byId(toUnitId); @@ -58,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); @@ -84,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 { 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..328c8ad5 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; @@ -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)); } } @@ -151,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; @@ -158,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; @@ -169,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( 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..2dd8036a 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'; @@ -14,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(); @@ -126,6 +129,7 @@ final class ConverterController extends ChangeNotifier { fromUnitId: _fromUnitId, toUnitId: _toUnitId, decimalPlaces: _appController.state.decimalPlaces, + rounding: _appController.state.roundingMode, ); _error = null; } on FormatException { @@ -153,6 +157,7 @@ final class ConverterController extends ChangeNotifier { .where((unit) => unit.id != _fromUnitId) .map((unit) => unit.id), decimalPlaces: _appController.state.decimalPlaces, + rounding: _appController.state.roundingMode, ); } @@ -167,11 +172,16 @@ final class ConverterController extends ChangeNotifier { _appController.togglePinnedPair(currentPair); Future recordCurrentConversion() { - if (_result == null) { + final currentResult = _result; + if (currentResult == null) { + return Future.value(); + } + final canonicalInput = currentResult.input.toCanonicalString(); + if (canonicalInput.length > _maxPersistedRecentInputLength) { return Future.value(); } return _appController.recordRecent( - input: _input, + input: canonicalInput, fromUnitId: _fromUnitId, toUnitId: _toUnitId, ); @@ -180,7 +190,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; @@ -189,6 +202,36 @@ 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; + } + + 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 = _formatter.format( + canonicalInput, + localeName: _localeName, + notation: DecimalNotation.plain, + useGrouping: false, + ); + recompute(); + } + void _selectDefaults(UnitCategory category) { final units = _appController.engine.catalog.forCategory(category); if (units.isEmpty) { 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..889fc2cc 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'; @@ -15,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() { @@ -25,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(); } @@ -88,48 +118,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 +219,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 +233,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 +248,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 +262,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 +281,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 +293,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 +309,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 +330,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 +347,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 +365,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 +381,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 +405,7 @@ final class _ConverterCard extends StatelessWidget { ), ), IconButton( - tooltip: 'Copy result', + tooltip: strings.copyResult, onPressed: controller.result == null ? null : () => _copyResult(context), @@ -336,7 +421,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 +431,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 +451,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 +462,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 +490,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 +503,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 +544,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), 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..86c82486 --- /dev/null +++ b/apps/unitflow_app/lib/features/history/presentation/history_screen.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +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'; + +final class HistoryScreen extends StatelessWidget { + const HistoryScreen({ + required this.appController, + required this.onOpenRecent, + super.key, + }); + + final AppController appController; + final ValueChanged onOpenRecent; + + @override + 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(); + } + 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), + 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) { + 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.name} • ${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).add_jm().format(recent.createdAt.toLocal())}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => onOpenRecent(recent), + ), + ), + ); + }), + const SizedBox(height: AppSpacing.xxl), + ], + ), + ), + ), + ], + ); + }, + ); + + 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 { + const _EmptyHistory(); + + @override + 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, + ), + ], + ), + ), + ), + ); + } +} 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); 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..58aa8188 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'; @@ -34,14 +36,19 @@ 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, 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; } @@ -74,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 = ''); @@ -97,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, ), ], @@ -130,7 +137,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,42 +164,62 @@ 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; } - if (!mounted) { + } + + Future _deleteCustomUnit(UnitDefinition unit) async { + 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('${data.name} added.')), + SnackBar( + content: Text(strings.customUnitRemoved), + action: SnackBarAction( + label: strings.undo, + onPressed: () => _restoreCustomUnit(snapshot), + ), + ), ); } - Future _deleteCustomUnit(UnitDefinition unit) async { - final data = widget.appController.state.customUnits - .where((candidate) => candidate.id == unit.id) - .firstOrNull; - if (data == null) { + 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; } - await widget.appController.removeCustomUnit(unit.id); if (!mounted) { return; } - ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${unit.name} removed.'), - action: SnackBarAction( - label: 'Undo', - onPressed: () => widget.appController.addCustomUnit(data), - ), - ), + SnackBar(content: Text(strings.customUnitRestored)), ); } } @@ -201,30 +230,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 { @@ -239,13 +274,17 @@ 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), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Pinned pairs', style: Theme.of(context).textTheme.titleMedium), + Text( + strings.pinnedPairs, + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: AppSpacing.xs), Wrap( spacing: AppSpacing.xs, @@ -277,31 +316,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 { @@ -318,61 +360,69 @@ 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 { - T? get firstOrNull { - final iterator = this.iterator; - return iterator.moveNext() ? iterator.current : null; + ); } } 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..ccfc155d 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,26 @@ final class _OnboardingScreenState extends State { @override 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, + 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 +56,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 +82,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 +127,14 @@ 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), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 180), + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.xxs, + ), width: index == _page ? 28 : 8, height: 8, decoration: BoxDecoration( @@ -140,14 +151,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,10 +172,16 @@ final class _OnboardingScreenState extends State { } Future _next() async { - if (_page == _pages.length - 1) { + if (_page == 2) { 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, 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..0ed3584e 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/about_screen.dart @@ -1,7 +1,10 @@ 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 '../../../core/errors/user_safe_error.dart'; +import '../../../l10n/app_localizations.dart'; final class AboutScreen extends StatelessWidget { const AboutScreen({super.key}); @@ -9,115 +12,115 @@ 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), 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('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 +148,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, ], @@ -177,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); + final 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)), + ); } } 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..c274017d 100644 --- a/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart +++ b/apps/unitflow_app/lib/features/settings/presentation/settings_screen.dart @@ -1,10 +1,15 @@ 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'; +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'; import '../../../core/persistence/user_state.dart'; +import '../../../l10n/app_localizations.dart'; final class SettingsScreen extends StatelessWidget { const SettingsScreen({ @@ -13,159 +18,292 @@ final class SettingsScreen extends StatelessWidget { super.key, }); + static const _backupFiles = BackupFileService(); + static final Uri _releasesUri = Uri.parse( + 'https://github.com/sanskarIN/unitflow/releases', + ); + final AppController appController; 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); + } + }, + ), + 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), + subtitle: Text(strings.digitGroupingSubtitle), + value: appController.state.useGrouping, + onChanged: appController.setUseGrouping, + ), + ], + ), + 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, + children: [ + Text(strings.privacyLocalDataSubtitle), + const SizedBox(height: AppSpacing.md), + Wrap( + 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), + 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.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, + 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 _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) { + final message = userSafeFailure( + error, + event: 'backup_export_failed', + fallback: strings.backupExportFailed, + ); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } + + 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) { + final message = userSafeFailure( + error, + event: 'backup_file_import_rejected', + fallback: strings.backupImportRejected, + ); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } 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 +311,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; } @@ -181,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('Import rejected: $error')), + SnackBar(content: Text(message)), ); return; } @@ -193,26 +336,46 @@ final class SettingsScreen extends StatelessWidget { return; } ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('UnitFlow backup imported.')), + SnackBar(content: Text(strings.backupImported)), + ); + } + + Future _openReleases(BuildContext context) async { + 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; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(strings.releaseOpenFailed)), ); } 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), ), ], ), @@ -221,11 +384,11 @@ final class SettingsScreen extends StatelessWidget { return; } await appController.resetLocalData(); - if (!context.mounted) { + if (!context.mounted || appController.warning != null) { return; } ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Local UnitFlow data cleared.')), + SnackBar(content: Text(strings.localDataCleared)), ); } } @@ -270,9 +433,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; @@ -280,7 +445,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, 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..2e1dfa60 --- /dev/null +++ b/apps/unitflow_app/lib/l10n/app_en.arb @@ -0,0 +1,153 @@ +{ + "@@locale": "en", + "appName": "UnitFlow", + "madeBySanskar": "Made by the Sanskar", + "dismiss": "Dismiss", + "cancel": "Cancel", + "undo": "Undo", + "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.", + "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.", + "searchUnits": "Search units", + "searchUnitsHint": "Name, symbol, alias, or description", + "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", + "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", + "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.", + "clearHistory": "Clear history", + "historyCleared": "Conversion history cleared.", + "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", + "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.", + "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.", + "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.", + "importClipboard": "Import from clipboard", + "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.", + "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)", + "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.", + "startConverting": "Start converting", + "next": "Next", + "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." +} diff --git a/apps/unitflow_app/pubspec.yaml b/apps/unitflow_app/pubspec.yaml index 42d36f93..c79199f9 100644 --- a/apps/unitflow_app/pubspec.yaml +++ b/apps/unitflow_app/pubspec.yaml @@ -12,10 +12,12 @@ environment: sdk: ">=3.9.0 <4.0.0" dependencies: + file_selector: ^1.1.0 flutter: 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 +28,5 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter: + generate: true uses-material-design: true 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..eb8ddf08 --- /dev/null +++ b/apps/unitflow_app/test/app/app_controller_test.dart @@ -0,0 +1,248 @@ +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() { + 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('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); + 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', + 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('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('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, unorderedEquals(['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', + 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); + }); +} 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); +} 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..ecc7ba4c --- /dev/null +++ b/apps/unitflow_app/test/app/custom_unit_limits_test.dart @@ -0,0 +1,104 @@ +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.'); + }); + + 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); + }); +} 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'); + }); +} 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..371469cd --- /dev/null +++ b/apps/unitflow_app/test/app/primary_journey_test.dart @@ -0,0 +1,51 @@ +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.input, '1000'); + 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); + + final reopenedField = tester.widget(find.byType(TextField)); + expect(reopenedField.controller?.text, '1000'); + expect(find.text('1'), findsWidgets); + }); +} 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..7998b54d --- /dev/null +++ b/apps/unitflow_app/test/app/unitflow_app_test.dart @@ -0,0 +1,83 @@ +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 { + final controller = AppController( + repository: MemoryUserStateRepository( + UserState(onboardingComplete: true), + ), + ); + + await tester.pumpWidget(UnitFlowApp(appController: controller)); + 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); + }); + + 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); + }); + + 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); + }); +} 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); + }); +} 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', + ); + }); + }); +} 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', () { 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..10955ba1 --- /dev/null +++ b/apps/unitflow_app/test/core/strict_json_test.dart @@ -0,0 +1,51 @@ +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, + ); + expect( + () => decodeStrictJson('[[[]]]', maxNesting: 2), + throwsFormatException, + ); + }); +} 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'))); + }); +} diff --git a/apps/unitflow_app/test/core/user_state_test.dart b/apps/unitflow_app/test/core/user_state_test.dart index 793d7e38..18ed31f6 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,7 +11,9 @@ void main() { final state = UserState( theme: ThemePreference.dark, notation: DecimalNotation.engineering, + roundingMode: DecimalRoundingMode.halfAwayFromZero, decimalPlaces: 8, + reduceMotion: true, onboardingComplete: true, favoriteUnitIds: {'meter'}, pinnedPairs: const [ @@ -37,12 +40,64 @@ void main() { expect(restored.theme, ThemePreference.dark); 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'); }); + test('memory repository enforces production import size bound', () { + final repository = MemoryUserStateRepository(); + final oversized = ''.padRight(1_000_001, ' '); + + expect(() => repository.importJson(oversized), throwsFormatException); + }); + + 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.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( @@ -51,6 +106,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', 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')); + }); +} diff --git a/apps/unitflow_app/test/features/conversion_engine_test.dart b/apps/unitflow_app/test/features/conversion_engine_test.dart index 5125f78f..645fce2b 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; @@ -40,6 +42,79 @@ 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('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'), @@ -47,6 +122,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'); }); } 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..545c8587 --- /dev/null +++ b/apps/unitflow_app/test/features/converter_history_test.dart @@ -0,0 +1,100 @@ +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('out-of-domain exact input cannot enter history', () async { + final hugeInput = '${''.padRight(1018, '9')}e1000'; + converter.setInput(hugeInput); + expect(converter.result, isNull); + expect(converter.error, 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', + 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'); + }); + + 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'); + }); +} 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.'), +}; 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')); + }); +} 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. + + + + + + + + + + + diff --git a/crates/unitflow_bridge/Cargo.toml b/crates/unitflow_bridge/Cargo.toml new file mode 100644 index 00000000..d611cd3c --- /dev/null +++ b/crates/unitflow_bridge/Cargo.toml @@ -0,0 +1,21 @@ +[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" } + +[dev-dependencies] +serde.workspace = true +serde_json = "1.0" diff --git a/crates/unitflow_bridge/src/api/converter.rs b/crates/unitflow_bridge/src/api/converter.rs new file mode 100644 index 00000000..f73b6d6d --- /dev/null +++ b/crates/unitflow_bridge/src/api/converter.rs @@ -0,0 +1,215 @@ +use std::str::FromStr; + +use rust_decimal::Decimal; +use unitflow_core::{ + ConversionRequest, ConversionResult, 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 = parse_decimal_input(&input)?; + 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(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)] +pub fn format_value( + input: String, + notation: BridgeNotation, + decimal_places: Option, + round_mode: BridgeRoundMode, +) -> Result { + 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() + { + "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, + } + } +} + +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(), + } + } +} 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; 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. diff --git a/crates/unitflow_bridge/src/lib.rs b/crates/unitflow_bridge/src/lib.rs new file mode 100644 index 00000000..a804bb52 --- /dev/null +++ b/crates/unitflow_bridge/src/lib.rs @@ -0,0 +1,5 @@ +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; diff --git a/crates/unitflow_bridge/tests/bridge_api.rs b/crates/unitflow_bridge/tests/bridge_api.rs new file mode 100644 index 00000000..08f29df2 --- /dev/null +++ b/crates/unitflow_bridge/tests/bridge_api.rs @@ -0,0 +1,126 @@ +use unitflow_bridge::api::converter::{ + batch_convert_value, bridge_version, convert_value, format_value, list_units, search_units, + BridgeNotation, 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.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.23e+3"); +} + +#[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")); +} + +#[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"); +} diff --git a/crates/unitflow_bridge/tests/parity_vectors.rs b/crates/unitflow_bridge/tests/parity_vectors.rs new file mode 100644 index 00000000..4ac1cedc --- /dev/null +++ b/crates/unitflow_bridge/tests/parity_vectors.rs @@ -0,0 +1,65 @@ +use serde::Deserialize; +use unitflow_bridge::api::converter::{convert_value, BridgeRoundMode}; + +#[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 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( + vector.input.clone(), + vector.from.clone(), + vector.to.clone(), + Some(vector.decimal_places), + bridge_round_mode(&vector.rounding_mode), + ) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.name)); + + assert_eq!( + bridge_result.output, vector.expected, + "shared bridge vector mismatch: {}", + vector.name + ); + } +} + +#[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"); +} + +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}"), + } +} 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" 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() + ); +} 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() 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}" + ); + } +} 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}"), + } +} 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, + ); + } +} 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); +} 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")); +} 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 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. 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. 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. 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. + + + + + + + + + + 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 + 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**. diff --git a/docs/bridge.md b/docs/bridge.md new file mode 100644 index 00000000..63257bf3 --- /dev/null +++ b/docs/bridge.md @@ -0,0 +1,100 @@ +# 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 Rust bridge source currently exposes synchronous FRB functions for: + +- `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. + +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. + +## 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. 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 native adapter and platform evidence exist for steps 4–6, native bridge runtime use 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. confirm no modified or untracked generated source remains; +6. inspect adapter compilation against the generated API; +7. 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. diff --git a/docs/data-format.md b/docs/data-format.md new file mode 100644 index 00000000..653844d3 --- /dev/null +++ b/docs/data-format.md @@ -0,0 +1,163 @@ +# 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; +- explicit decimal rounding mode; +- decimal-place preference; +- grouping preference; +- reduced-motion accessibility preference; +- onboarding completion state; +- favorite unit identifiers; +- pinned unit pairs; +- bounded recent-conversion history; +- validated custom affine units. + +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 + +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 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 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; +- custom scale/offset text: at most 1024 characters each; +- decimal precision preference: 0–28 places; +- 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. + +## 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, 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 + +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. + +## 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: + +```text +base_value = input_value * scale + offset +``` + +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 + +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: + +- 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 or outside the portable decimal domain; +- 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. + +## Migration policy + +### 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. 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: + +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. 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. + +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. 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. 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. 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. 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. diff --git a/docs/release.md b/docs/release.md index 7794e535..b72415b2 100644 --- a/docs/release.md +++ b/docs/release.md @@ -2,43 +2,97 @@ ## 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. -## Release checklist +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: -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. +- 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`. -## Suggested local commands +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 -cargo fmt --all -- --check -cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo test --workspace +python3 tool/check_versions.py +``` -cd apps/unitflow_app -flutter pub get -flutter analyze -flutter test +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 ``` -Then run platform builds required for the release, for example: +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 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 + +Install the pinned Flutter Rust Bridge generator first: ```bash +cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked +``` + +Then run: + +```bash +bash tool/verify_release_candidate.sh +``` + +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. 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 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 @@ -46,19 +100,22 @@ 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 ``` -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 and checksums -## Artifacts +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. -Release artifacts must be generated from source through documented commands. Do not commit signing keys. Checksums should be generated for distributable binaries when practical. +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, 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. 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. diff --git a/docs/testing.md b/docs/testing.md index a25d31cd..ea8960e8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2,6 +2,29 @@ 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 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 + +Run the dependency-free repository utility tests with: + +```bash +cd tool +python3 -m unittest discover -p 'test_*.py' +``` + +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 Run: @@ -9,7 +32,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 +41,38 @@ 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; -- search by name, symbol, and alias; +- 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. +- batch conversion order and error 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 @@ -31,47 +81,96 @@ 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; +- 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; -- import/export failure states. +- 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 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: + +```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 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. -## Integration and end-to-end tests +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. -Primary journeys should eventually cover: +## Integration and end-to-end journeys + +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. 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. -## Property/fuzz testing +## Fuzz testing -Useful invariants include: +Cargo-fuzz harnesses live under `fuzz/` and are intentionally outside the normal workspace so release builds do not pull fuzz dependencies. -- 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. +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 +178,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 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. diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 00000000..c5def782 --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,113 @@ +# Verification Record + +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. + +## Active audit target + +Branch: `audit/phase-1-quality` + +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 -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 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 + +```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 +cd ../.. +git status --short +``` + +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 + +```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 including untracked files, 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 exact check is rerun. +- Never convert an unavailable toolchain into a passing result. +- 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, tracked where required, and leave no modified/untracked drift for release. +- Security/release/accessibility/platform checks are additive to core compiler/test success. 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 = ["."] 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); +}); 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, + ); + } +}); 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 + } + } + } + } +} diff --git a/schemas/unitflow-backup-v2.schema.json b/schemas/unitflow-backup-v2.schema.json new file mode 100644 index 00000000..ec733bda --- /dev/null +++ b/schemas/unitflow-backup-v2.schema.json @@ -0,0 +1,184 @@ +{ + "$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, explicit decimal rounding, and accessibility preferences.", + "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" }, + "reduceMotion": { "type": "boolean", "default": false }, + "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 + } + } + } + } +} diff --git a/test_vectors/conversions.json b/test_vectors/conversions.json new file mode 100644 index 00000000..237a8b5b --- /dev/null +++ b/test_vectors/conversions.json @@ -0,0 +1,227 @@ +[ + { + "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": "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", + "from": "celsius", + "to": "fahrenheit", + "decimalPlaces": 8, + "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", + "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": "nearest even midpoint rounds to even digit", + "input": "2.345", + "from": "meter", + "to": "meter", + "decimalPlaces": 2, + "roundingMode": "nearestEven", + "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" + } +] diff --git a/tool/bootstrap_platforms.sh b/tool/bootstrap_platforms.sh new file mode 100644 index 00000000..e9734b72 --- /dev/null +++ b/tool/bootstrap_platforms.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +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="$PLATFORMS" \ + --project-name unitflow \ + --org in.sanskar.unitflow \ + . + +flutter pub get + +echo "Flutter platform shells are ready for: $PLATFORMS" +echo "Run ../../tool/check.sh before committing generated changes." diff --git a/tool/check.sh b/tool/check.sh new file mode 100644 index 00000000..965444e6 --- /dev/null +++ b/tool/check.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +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_versions.py +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 + 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 +fi diff --git a/tool/check_data_files.py b/tool/check_data_files.py new file mode 100644 index 00000000..5e867993 --- /dev/null +++ b/tool/check_data_files.py @@ -0,0 +1,66 @@ +#!/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 +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( + ["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, object_pairs_hook=reject_duplicate_keys) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, DuplicateKeyError) 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 with unique object keys.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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()) 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()) 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()) diff --git a/tool/check_versions.py b/tool/check_versions.py new file mode 100644 index 00000000..683ac9d4 --- /dev/null +++ b/tool/check_versions.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Validate UnitFlow release, Flutter bundle, UI, and bridge version consistency.""" + +from __future__ import annotations + +import re +import sys +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", + 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*$") +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]: + 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]: + 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 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] + + +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]: + 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") + 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}" + ) + return failures + + +def main() -> int: + failures: list[str] = [] + 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, ValueError) as error: + print(f"Version consistency check failed to parse configuration: {error}", file=sys.stderr) + return 2 + + workspace_core = release_core_version(workspace_version) + if flutter_name != workspace_core: + failures.append( + 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}" + ) + 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"release {workspace_version}, Flutter {flutter_version}, flutter_rust_bridge {workspace_frb}." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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." diff --git a/tool/integrate_native_bridge.sh b/tool/integrate_native_bridge.sh new file mode 100644 index 00000000..c6901fd1 --- /dev/null +++ b/tool/integrate_native_bridge.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +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 + 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 "$PLATFORMS" \ + --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 for: $PLATFORMS" 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 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() 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() diff --git a/tool/test_check_versions.py b/tool/test_check_versions.py new file mode 100644 index 00000000..1a9229f0 --- /dev/null +++ b/tool/test_check_versions.py @@ -0,0 +1,47 @@ +#!/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_build_name, + load_about_version, + load_pubspec_versions, + load_workspace_versions, + release_core_version, +) + + +class VersionConsistencyTests(unittest.TestCase): + 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_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: + _, workspace_frb = load_workspace_versions() + self.assertEqual(check_codegen_pins(workspace_frb), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tool/verify_release_candidate.sh b/tool/verify_release_candidate.sh new file mode 100644 index 00000000..3d060814 --- /dev/null +++ b/tool/verify_release_candidate.sh @@ -0,0 +1,57 @@ +#!/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 -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 + +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 [[ -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 +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." diff --git a/what_changed.md b/what_changed.md index 1080611f..141fb6dc 100644 --- a/what_changed.md +++ b/what_changed.md @@ -1,76 +1,526 @@ -# 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` +Always read the live PR head before using a SHA as release evidence. The branch advances frequently while hardening is active. -## 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 unit-definition matching now includes descriptions as well as IDs, names, symbols, and aliases. A dedicated regression test covers case-insensitive description matching. -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. +### Rust ↔ Flutter bridge + +`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. + +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 + +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. + +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: + +- 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; +- 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 and Rust description matching. + +### 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; +- description-aware unit-definition matching; +- 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; +- 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; +- 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 and duplicate-object-key rejection; +- `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. + +### 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 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 + +```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 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. + +## GitHub Actions + +Configured workflows include: + +- CI: + - repository safety and repository utility regression tests; + - Rust quality; + - Flutter quality; + - Rust/Flutter bridge generation/check including untracked generated-source drift detection; +- CodeQL; +- dependency review; +- audit-branch generated-source/format normalization including untracked generated files; +- 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. + +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; +- 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: + +```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 +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 +crates/unitflow_core/tests/unit_definition_search.rs +docs/bridge.md +docs/platform-support.md +docs/branding.md +docs/data-format.md +docs/verification.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` +- `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` +- `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. + +## 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; +- 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. -## Known limitations +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. -- 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 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. +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 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. -## 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, reproducible tracked bridge generation, strict bounded backup validation, 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. diff --git a/what_changed_phase1.md b/what_changed_phase1.md new file mode 100644 index 00000000..d1ee3868 --- /dev/null +++ b/what_changed_phase1.md @@ -0,0 +1,15 @@ +# UnitFlow — Superseded Phase 1 Handoff + +This file is retained only as a historical filename from the initial audit branch work. + +The canonical, current development checkpoint is now: + +- [`what_changed.md`](what_changed.md) + +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. + +Future development sessions must update `what_changed.md` instead of creating another parallel handoff file. + +--- + +**Made by the Sanskar**