diff --git a/.github/actions/version/action.yml b/.github/actions/version/action.yml index f081424b..508d21ea 100644 --- a/.github/actions/version/action.yml +++ b/.github/actions/version/action.yml @@ -1,11 +1,5 @@ name: "Resolve version" -description: "Extracts version from tag (v1.2.3 → 1.2.3) or returns a default for non-tag runs" - -inputs: - default: - description: "Version to use when not running on a tag" - required: false - default: "0.1.0-dev" +description: "Extracts version from tag (v1.2.3 → 1.2.3) or reads base version from pubspec.yaml" outputs: version: @@ -22,7 +16,7 @@ runs: VERSION="${{ github.ref_name }}" [[ "${VERSION}" == v* ]] && VERSION="${VERSION:1}" else - VERSION="${{ inputs.default }}" + VERSION=$(grep '^version:' pubspec.yaml | sed 's/version:[[:space:]]*//' | sed 's/+.*//') fi echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "Resolved version: ${VERSION}" diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 00000000..81cdcbdd --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,187 @@ +name: Build Android APK + +on: + workflow_call: + inputs: + build_name: + description: "Version name (e.g. v1.2.3)" + required: false + type: string + default: "" + build_type: + description: "Build type" + required: false + type: string + default: "release" + retention_days: + description: "Artifact retention in days" + required: false + type: number + default: 30 + secrets: + ANDROID_KEYSTORE_BASE64: + description: "Base64-encoded Android keystore (.jks / .p12)" + required: false + ANDROID_KEYSTORE_PASSWORD: + description: "Keystore store password" + required: false + ANDROID_KEY_ALIAS: + description: "Key alias inside the keystore" + required: false + ANDROID_KEY_PASSWORD: + description: "Key password" + required: false + outputs: + artifact_name: + description: "Name of the uploaded artifact" + value: ${{ jobs.build.outputs.artifact_name }} + artifact_url: + description: "URL to the artifact" + value: ${{ jobs.build.outputs.artifact_url }} + + workflow_dispatch: + inputs: + build_name: + description: "Version name override (leave empty to use pubspec.yaml)" + required: false + default: "" + type: string + build_type: + description: "Build type" + required: false + default: "release" + type: choice + options: [ release, debug ] + + pull_request: + branches: [ main, develop ] + paths: + - "lib/**" + - "android/**" + - "external/**" + - "packages/bclibc_ffi/**" + - "pubspec.yaml" + - "scripts/build-android.sh" + - ".github/workflows/build-apk.yml" + +env: + FLUTTER_VERSION: "3.41.6" + +jobs: + prepare-version: + runs-on: ubuntu-latest + if: github.event_name != 'workflow_call' + outputs: + version: ${{ steps.v.outputs.version }} + steps: + - uses: actions/checkout@v4 + - id: v + uses: ./.github/actions/version + + build: + needs: [ prepare-version ] + if: always() + name: Android APK (${{ inputs.build_type || 'release' }}) + runs-on: ubuntu-latest + outputs: + artifact_name: ${{ steps.meta.outputs.artifact_name }} + artifact_url: ${{ steps.upload.outputs.artifact-url }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Flutter + uses: ./.github/actions/flutter + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - name: Accept Android licenses + run: yes | flutter doctor --android-licenses || true + + - name: Set build metadata + id: meta + shell: bash + run: | + BUILD_NAME="${{ inputs.build_name || needs.prepare-version.outputs.version }}" + BUILD_NAME="${BUILD_NAME#v}" + BUILD_NUMBER="${{ github.run_number }}" + BCLIBC_HASH=$(git -C external/bclibc rev-parse HEAD) + BCLIBC_FFI_HASH=$(git log -1 --format='%H' -- packages/bclibc_ffi 2>/dev/null || echo "unknown") + echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT + echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT + echo "bclibc_hash=$BCLIBC_HASH" >> $GITHUB_OUTPUT + echo "bclibc_ffi_hash=$BCLIBC_FFI_HASH" >> $GITHUB_OUTPUT + echo "artifact_name=ebalistyka-android-${{ inputs.build_type || 'release' }}-${BUILD_NAME}-${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - name: Cache pub packages + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/pub-cache + key: pub-android-${{ hashFiles('pubspec.lock') }} + restore-keys: pub-android- + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('android/app/build.gradle.kts', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle- + + - name: Cache bclibc NDK build + uses: actions/cache@v4 + with: + path: packages/bclibc_ffi/android/.cxx + key: bclibc-android-${{ steps.meta.outputs.bclibc_hash }}-${{ steps.meta.outputs.bclibc_ffi_hash }} + restore-keys: | + bclibc-android-${{ steps.meta.outputs.bclibc_hash }}- + bclibc-android- + + - name: Install Flutter dependencies + run: | + flutter clean + flutter pub get + + - name: Build & sign APKs + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + bash scripts/build-android.sh \ + "${{ steps.meta.outputs.build_name }}" \ + "${{ steps.meta.outputs.build_number }}" + + - name: Upload artifact + id: upload + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.meta.outputs.artifact_name }} + path: artifacts/ + retention-days: ${{ inputs.retention_days || 30 }} + if-no-files-found: error + + + pr-summary: + needs: [ build ] + if: always() && github.event_name == 'pull_request' + permissions: + pull-requests: write + issues: write + uses: ./.github/workflows/pr-summary.yml + with: + platform: Android + build_result: ${{ needs.build.result }} + artifact_name: ${{ needs.build.outputs.artifact_name }} + artifact_url: ${{ needs.build.outputs.artifact_url }} diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml index e9529620..7b684e71 100644 --- a/.github/workflows/build-appimage.yml +++ b/.github/workflows/build-appimage.yml @@ -1,4 +1,4 @@ -name: PR Build (Linux AppImage) +name: Build Linux AppImage + Portable on: pull_request: @@ -45,92 +45,28 @@ jobs: build_name: ${{ needs.prepare-version.outputs.version }} retention_days: 30 - pr-summary: - name: PR Build Summary - runs-on: ubuntu-latest - needs: [ build-amd64, build-arm64 ] + pr-summary-amd64: + needs: [ build-amd64 ] if: always() && github.event_name == 'pull_request' permissions: pull-requests: write issues: write + uses: ./.github/workflows/pr-summary.yml + with: + platform: Linux amd64 + build_result: ${{ needs.build-amd64.result }} + artifact_name: ${{ needs.build-amd64.outputs.artifact_name }} + artifact_url: ${{ needs.build-amd64.outputs.artifact_url }} - steps: - - name: Get PR number - id: pr - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT - else - echo "number=" >> $GITHUB_OUTPUT - fi - - - name: Comment on PR - if: steps.pr.outputs.number != '' - uses: actions/github-script@v7 - with: - script: | - const prNumber = parseInt('${{ steps.pr.outputs.number }}'); - const amd64Success = '${{ needs.build-amd64.result }}' === 'success'; - const arm64Success = '${{ needs.build-arm64.result }}' === 'success'; - const artifactUrl = '${{ needs.build-amd64.outputs.artifact_url || needs.build-arm64.outputs.artifact_url }}'; - const artifactName = '${{ needs.build-amd64.outputs.artifact_name || needs.build-arm64.outputs.artifact_name }}'; - const runUrl = `https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}`; - - let body = `## Linux AppImage Build\n\n`; - - if (amd64Success || arm64Success) { - body += `✅ **Build successful!**\n\n`; - body += `- **amd64**: ${amd64Success ? '✅' : '❌'}\n`; - body += `- **arm64**: ${arm64Success ? '✅' : '❌'}\n\n`; - - if (artifactUrl) { - body += `📦 **[Download AppImage](${artifactUrl})**\n`; - body += `\`${artifactName}\` — available for 7 days\n\n`; - } - - body += `**PR**: #${prNumber} · **Commit**: \`${{ github.sha }}\`\n\n`; - body += `### Test Instructions:\n`; - body += `\`\`\`bash\n# Download and run\nchmod +x ebalistyka-x86_64.AppImage\n./ebalistyka-x86_64.AppImage\n\`\`\`\n`; - } else { - body += `❌ **Build failed!**\n\n[View logs](${runUrl})\n`; - } - - try { - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - }); - - const existing = comments.find(c => - c.user.type === 'Bot' && c.body.includes('## Linux AppImage Build') - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body, - }); - } - } catch (error) { - console.log('Error posting comment:', error.message); - } - - - name: Display build status - if: steps.pr.outputs.number == '' - run: | - echo "=========================================" - echo "Build completed with status: ${{ needs.build-amd64.result }} / ${{ needs.build-arm64.result }}" - echo "=========================================" - echo "Skipping PR comment because this is not a pull request event" - echo "Triggered by: ${{ github.event_name }}" - echo "Run URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + pr-summary-arm64: + needs: [ build-arm64 ] + if: always() && github.event_name == 'pull_request' + permissions: + pull-requests: write + issues: write + uses: ./.github/workflows/pr-summary.yml + with: + platform: Linux arm64 + build_result: ${{ needs.build-arm64.result }} + artifact_name: ${{ needs.build-arm64.outputs.artifact_name }} + artifact_url: ${{ needs.build-arm64.outputs.artifact_url }} diff --git a/.github/workflows/build-exe.yml b/.github/workflows/build-exe.yml index 3bf734ed..092d7210 100644 --- a/.github/workflows/build-exe.yml +++ b/.github/workflows/build-exe.yml @@ -1,4 +1,4 @@ -name: PR Build (Windows EXE) +name: Build Windows MSIX + Portable on: pull_request: @@ -38,70 +38,14 @@ jobs: CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} pr-summary: - name: PR Build Summary - runs-on: ubuntu-latest needs: [ build-amd64 ] if: always() && github.event_name == 'pull_request' permissions: pull-requests: write issues: write - - steps: - - name: Comment on PR - uses: actions/github-script@v7 - with: - script: | - const prNumber = context.issue.number; - const amd64Success = '${{ needs.build-amd64.result }}' === 'success'; - const artifactUrl = '${{ needs.build-amd64.outputs.artifact_url }}'; - const artifactName = '${{ needs.build-amd64.outputs.artifact_name }}'; - const runUrl = `https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}`; - - let body = `## Windows EXE Build\n\n`; - - if (amd64Success) { - body += `✅ **Build successful!**\n\n`; - body += `- **amd64**: ${amd64Success ? '✅' : '❌'}\n`; - - if (artifactUrl) { - body += `📦 **[Download EXE (amd64)](${artifactUrl})**\n`; - body += `\`${artifactName}\` — available for 7 days\n\n`; - } - - body += `**PR**: #${prNumber} · **Commit**: \`${{ github.sha }}\`\n\n`; - body += `### Test Instructions:\n`; - body += `\`\`\`powershell\n# Download and extract\nExpand-Archive ${artifactName}.zip\ncd ${artifactName}\n\n# Check if bclibc_ffi.dll exists\nls *.dll | findstr bclibc\n\n# Run the app\n.\\ebalistyka.exe\n\`\`\`\n`; - } else { - body += `❌ **Build failed!**\n\n[View logs](${runUrl})\n`; - } - - try { - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - }); - - const existing = comments.find(c => - c.user.type === 'Bot' && c.body.includes('## Windows EXE Build') - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body, - }); - } - } catch (error) { - console.log('Error posting comment:', error.message); - // Don't fail the workflow due to a comment error - } + uses: ./.github/workflows/pr-summary.yml + with: + platform: Windows amd64 + build_result: ${{ needs.build-amd64.result }} + artifact_name: ${{ needs.build-amd64.outputs.artifact_name }} + artifact_url: ${{ needs.build-amd64.outputs.artifact_url }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2290c24..9dc75e60 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,10 +16,10 @@ on: required: true type: string # release | debug build_name: - description: "Version name (e.g. 0.1.0-dev)" + description: "Version name override (leave empty to use pubspec.yaml)" required: false type: string - default: "0.1.0-dev" + default: "" retention_days: description: "Artifact retention in days" required: false @@ -61,9 +61,9 @@ on: type: choice options: [ release, debug ] build_name: - description: "Version name (e.g. 0.1.0-dev)" + description: "Version name override (leave empty to use pubspec.yaml)" required: false - default: "0.1.0-dev" + default: "" type: string env: @@ -80,7 +80,7 @@ jobs: outputs: artifact_name: ${{ steps.meta.outputs.artifact_name }} - artifact_url: ${{ steps.artifact_url.outputs.url }} + artifact_url: ${{ steps.upload.outputs.artifact-url }} steps: - uses: actions/checkout@v4 @@ -115,7 +115,9 @@ jobs: echo "bclibc_ffi_hash=$BCLIBC_FFI_HASH" >> $GITHUB_OUTPUT BUILD_NAME="${{ inputs.build_name }}" - # Remove prefix 'v' + if [ -z "$BUILD_NAME" ]; then + BUILD_NAME=$(grep '^version:' pubspec.yaml | sed 's/version:[[:space:]]*//' | sed 's/+.*//') + fi BUILD_NAME="${BUILD_NAME#v}" BUILD_NUMBER="${{ github.run_number }}" echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT @@ -234,7 +236,8 @@ jobs: } $buildName = "${{ steps.meta.outputs.build_name }}" if ($buildName -match '^(\d+\.\d+\.\d+)') { $base = $Matches[1] } else { $base = "0.1.0" } - $msixVersion = "$base.${{ steps.meta.outputs.build_number }}" + $revision = if ('${{ github.ref_type }}' -eq 'tag' -and '${{ github.ref_name }}' -match '^v\d+\.\d+\.\d+') { '0' } else { '${{ steps.meta.outputs.build_number }}' } + $msixVersion = "$base.$revision" .\scripts\package-msix.ps1 ` -BuildName $buildName ` @@ -255,9 +258,3 @@ jobs: retention-days: ${{ inputs.retention_days || 30 }} if-no-files-found: error - - name: Generate artifact URL - id: artifact_url - shell: bash - run: | - RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - echo "url=${RUN_URL}#artifacts" >> $GITHUB_OUTPUT diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml new file mode 100644 index 00000000..fe9848b3 --- /dev/null +++ b/.github/workflows/pr-summary.yml @@ -0,0 +1,78 @@ +name: PR Build Summary + +on: + workflow_call: + inputs: + platform: + description: "Platform label shown in the comment (e.g. Android, Linux amd64)" + required: true + type: string + build_result: + description: "Build result: success | failure | cancelled | skipped" + required: true + type: string + artifact_name: + required: false + type: string + default: "" + artifact_url: + required: false + type: string + default: "" + +jobs: + comment: + name: Post PR comment (${{ inputs.platform }}) + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + steps: + - name: Post or update PR comment + uses: actions/github-script@v7 + with: + script: | + const platform = '${{ inputs.platform }}'; + const success = '${{ inputs.build_result }}' === 'success'; + const artifactUrl = '${{ inputs.artifact_url }}'; + const artifactName = '${{ inputs.artifact_name }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const marker = ``; + + let body = `${marker}\n## ${platform} Build\n\n`; + if (success) { + body += `✅ **Build successful!**\n\n`; + if (artifactUrl) { + body += `📦 **[Download](${artifactUrl})** \n`; + body += `\`${artifactName}\`\n\n`; + } + body += `**PR**: #${context.issue.number} · **Commit**: \`${context.sha}\`\n`; + } else { + body += `❌ **Build failed!**\n\n[View logs](${runUrl})\n`; + } + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.type === 'Bot' && c.body.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01a61507..c2ef6ae7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,9 @@ on: - 'v[0-9]+.[0-9]+.[0-9]+-*' # v1.2.3-beta → prerelease workflow_dispatch: +env: + FLUTTER_VERSION: "3.41.6" + jobs: prepare-version: runs-on: ubuntu-latest @@ -50,8 +53,24 @@ jobs: CERTIFICATE_BASE64: ${{ secrets.CERTIFICATE_BASE64 }} CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} + build-android: + needs: prepare-version + permissions: + issues: write + pull-requests: write + uses: ./.github/workflows/build-apk.yml + with: + build_name: ${{ needs.prepare-version.outputs.version }} + build_type: release + retention_days: 1 + secrets: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + release: - needs: [prepare-version, build-linux-amd64, build-linux-arm64, build-windows-amd64] + needs: [prepare-version, build-linux-amd64, build-linux-arm64, build-windows-amd64, build-android] runs-on: ubuntu-latest permissions: contents: write @@ -72,7 +91,9 @@ jobs: -name "*.zsync" -o \ -name "*.zip" -o \ -name "*.msix" -o \ - -name "*.appinstaller" \ + -name "*.appinstaller" -o \ + -name "*.cer" -o \ + -name "*.apk" \ \) -exec cp {} release/ \; echo "=== Release assets ===" diff --git a/.gitignore b/.gitignore index b1709b3a..48f72ddf 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,10 @@ app.*.map.json # Local certificates (never commit) /certs/ certificate_base64.txt + +# Android signing — never commit keystore or key.properties +android/key.properties +android/*.keystore +android/*.jks +android/*.p12 +android/keystore_base64.txt diff --git a/.metadata b/.metadata index 89db5de5..a8104ff2 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "ff37bef603469fb030f2b72995ab929ccfc227f0" + revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a" channel: "stable" project_type: app @@ -13,26 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a - platform: android - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: ios - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: linux - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: macos - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: web - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: windows - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a # User provided section diff --git a/CHANGELOG.md b/CHANGELOG.md index f4dc3354..e72911ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,102 @@ # Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/). + +--- + ## [Unreleased] +## [0.1.2] - 2026-04-26 + +### Added + +#### Android +- Initial Android support — application builds and runs on Android +- CI integration for Android builds, including FFI and submodules +- File import support via `file_picker` with Android fallback (`FileType.any`) +- FileProvider configuration for `share_plus` +- `` configuration for `file_picker` and `url_launcher` (Android 11+) + +#### CI / Build +- Reusable `build-apk.yml` workflow: + - supports `workflow_call` + - accepts `build_name`, `build_type`, `retention_days` + - supports signing via secrets +- `scripts/build-android.sh`: + - sets app version from CI + - decodes keystore from `ANDROID_KEYSTORE_BASE64` + - builds split-per-ABI APKs + - outputs artifacts to `artifacts/` +- `scripts/generate-android-keystore.sh`: + - generates JKS keystore + - creates `android/key.properties` + - exports base64 + metadata to `certs/` + +### Changed + +#### Android +- Impeller renderer disabled (`EnableImpeller=false`) due to incorrect SVG circle tessellation (temporary workaround until upstream fix) +- `AndroidManifest.xml` updated: + - added storage permissions (`READ_EXTERNAL_STORAGE`, `READ_MEDIA_*`) + - enabled `requestLegacyExternalStorage` + - added URL visibility queries (`http`, `https`) + +#### CI / Build +- `release.yml` now uses reusable `build-apk.yml` instead of inline Android job +- APK files (`*.apk`) are now included as release assets +- `build.gradle.kts`: + - reads signing config from `android/key.properties` + - falls back to debug signing if missing +- Reusable `pr-summary.yml` workflow — posts/updates per-platform build result comment on PRs; replaces duplicated inline scripts in `build-apk.yml`, `build-exe.yml`, `build-appimage.yml` +- PR artifact links now use `upload-artifact@v4` direct URL instead of a generic run page link +- Version resolution unified across all workflows via `.github/actions/version`: + - tag builds → version from tag + - PR / `workflow_dispatch` → base version from `pubspec.yaml` (no suffix) +- `build-apk.yml`: added `prepare-version` job for direct PR and dispatch triggers (previously fell back to hardcoded `0.1.0-dev`) +- MSIX version revision set to `0` for release tags (`v*.*.*`, `v*.*.*-*`) per Microsoft Store requirement; non-release builds keep `run_number` as revision + +#### Reticle gen +- Updated reticles generator + +### Fixed + +#### UI +- Window scaling now respects system scale on startup +- Fixed `RenderFlex` overflow on Home screen +- Fixed `PageDotsIndicator` overflow (tap target size mismatch) +- `AdjustmentDisplay` now correctly applies zero offsets and adjustments + +#### SVG / Rendering +- Fixed SVG circles rendered as polygons: + - `reticle_gen` now uses `` instead of arc `` + - regenerated all reticle and target assets + +#### Navigation +- Fixed missing `await` in `HomeScreen → AmmoWizard` route + +#### Code Quality +- Enabled `discarded_futures: true` +- Fixed all related lint issues + +### Reliability +- Improved database resilience: + - ObjectBox open failure is now handled + - corrupted `data.mdb` / `lock.mdb` are deleted automatically + - store is reinitialized safely + - user is notified via SnackBar only if data previously existed + +### Docs +- README updated: + - added **Android notes** section + - documented Impeller workaround + - documented file import limitations on Android + + ## [0.1.1] - 2026-04-23 ### CI / Build diff --git a/README.md b/README.md index 0e86fdae..49be53a7 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,17 @@ # ebalistyka -[![Build (Linux AppImage)](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-appimage.yml/badge.svg)](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-appimage.yml) -[![Build (Windows EXE)](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-exe.yml/badge.svg)](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-exe.yml) [![Flutter](https://img.shields.io/badge/Flutter-3.41.6-02569B?logo=flutter)](https://flutter.dev) [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](LICENSE) -![Version](https://img.shields.io/badge/version-0.1.0--alpha-orange) +![Version](https://img.shields.io/badge/version-0.1.2-orange) ![Status: Alpha](https://img.shields.io/badge/status-alpha-orange) -![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows-lightgrey) +![Linux](https://img.shields.io/badge/Linux-x86__64%20%7C%20arm64-grey?logo=linux&logoColor=black&labelColor=FCC624) +![Windows](https://img.shields.io/badge/x86__64-grey?logo=windows&logoColor=black&label=Windows&labelColor=0078D4) +![Android](https://img.shields.io/badge/Android-arm64%20%7C%20armv7%20%7C%20x86__64-grey?logo=android&logoColor=white&labelColor=3DDC84) + +[![Build (Linux)](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-appimage.yml/badge.svg)](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-appimage.yml) +[![Build (Windows)](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-exe.yml/badge.svg)](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-exe.yml) +[![Build (Android)](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-apk.yml/badge.svg)](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-apk.yml) + > [!WARNING] > **Alpha software.** Expect breaking changes, incomplete features, and rough edges. @@ -19,16 +24,28 @@ _UI/UX inspired by the [**Strilets**](https://download.strilets.tech/) ballistic ## Screenshots -> [!NOTE] -> Screenshots can be updated on changes. - -| Home | Conditions | Trajectory Tables | -|------|-----------|-------------------| -| ![Home](docs/screenshots/home.png) | ![Conditions](docs/screenshots/conditions.png) | ![Tables](docs/screenshots/tables.png) | - -| Convertors | Settings | -|------------|----------| -| ![Convertors](docs/screenshots/convertors.png) | ![Settings](docs/screenshots/settings.png) | + + + + + + + + + + + + + + + + + + + + + +
HomeConditionsTrajectory Tables
ConvertorsMy ProfilesReticle
--- @@ -140,6 +157,26 @@ GitHub Actions workflows are provided for automated builds: --- +## Android notes + +### Impeller disabled + +Flutter's Impeller renderer (enabled by default on Android since Flutter 3.16) tessellates SVG paths — including circles — into coarse polygons, which makes reticle and target SVGs look jagged. Until Flutter/Impeller resolves path tessellation quality for small shapes, Impeller is **explicitly disabled** for Android in `android/app/src/main/AndroidManifest.xml`: + +```xml + +``` + +This forces the app to use **Skia**, which renders SVG circles smoothly. Re-enable Impeller only after verifying that circle/arc quality is acceptable on your target Android version. + +### File import + +On Android, `file_picker` cannot filter by custom extensions (`.ebcp`, `.a7p`) because Android does not know their MIME types. The import dialogs open with `FileType.any` and validate the extension after the user selects a file. Selecting a wrong file type shows an error message. + +--- + ## Dependencies | Package | Role | @@ -164,3 +201,8 @@ See [LICENSE](LICENSE) for the full text. See [CHANGELOG](CHANGELOG.md) for rele > [!NOTE] > `bclibc` (the ballistic solver engine, located in `external/bclibc`) is licensed separately under the **GNU Lesser General Public License v3.0**. See [`external/bclibc/LICENSE`](external/bclibc/LICENSE). + +> [!WARNING] +> **Risk notice.** This application performs approximate simulations of complex physical processes. Calculation results must not be considered as completely or reliably reflecting actual projectile behaviour. Results may be used for educational purposes only and must not be relied upon in any context where an incorrect calculation could cause financial harm or put a human life at risk. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index c067f962..734c0c24 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -27,3 +27,4 @@ linter: # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options + discarded_futures: true diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3b7e9dd9..530c302f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +7,14 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +// Read signing properties from android/key.properties (generated by CI or developer). +// If the file does not exist the release build falls back to the debug key. +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(keystorePropertiesFile.inputStream()) +} + android { namespace = "com.o.murphy.ebalistyka" compileSdk = flutter.compileSdkVersion @@ -20,21 +30,30 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.o.murphy.ebalistyka" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = if (keystorePropertiesFile.exists()) + signingConfigs.getByName("release") + else + signingConfigs.getByName("debug") } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c2a2dcc1..9e2aa7de 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,19 @@ + + + + + + + + + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true"> + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/android/app/src/main/kotlin/com/example/test_app/MainActivity.kt b/android/app/src/main/kotlin/com/o/murphy/ebalistyka/MainActivity.kt similarity index 74% rename from android/app/src/main/kotlin/com/example/test_app/MainActivity.kt rename to android/app/src/main/kotlin/com/o/murphy/ebalistyka/MainActivity.kt index 845adb2b..c1b23a16 100644 --- a/android/app/src/main/kotlin/com/example/test_app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/o/murphy/ebalistyka/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.test_app +package com.o.murphy.ebalistyka import io.flutter.embedding.android.FlutterActivity diff --git a/android/app/src/main/res/xml/file_provider_paths.xml b/android/app/src/main/res/xml/file_provider_paths.xml new file mode 100644 index 00000000..a4a9b567 --- /dev/null +++ b/android/app/src/main/res/xml/file_provider_paths.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/svg/reticles/DDR-2.svg b/assets/svg/reticles/DDR-2.svg index 851cdf1e..75c1472a 100644 --- a/assets/svg/reticles/DDR-2.svg +++ b/assets/svg/reticles/DDR-2.svg @@ -1,12 +1,12 @@ - - + + - - + + diff --git a/assets/svg/reticles/MIL-C-F1.svg b/assets/svg/reticles/MIL-C-F1.svg index 6ca622e8..040318b0 100644 --- a/assets/svg/reticles/MIL-C-F1.svg +++ b/assets/svg/reticles/MIL-C-F1.svg @@ -1,4 +1,4 @@ - + @@ -33,56 +33,56 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -91,15 +91,15 @@ - - - + + + - - + + \ No newline at end of file diff --git a/assets/svg/reticles/MIL-R-F1.svg b/assets/svg/reticles/MIL-R-F1.svg index 4ba416a6..6f485128 100644 --- a/assets/svg/reticles/MIL-R-F1.svg +++ b/assets/svg/reticles/MIL-R-F1.svg @@ -1,4 +1,4 @@ - + @@ -31,15 +31,15 @@ - - - - - - - - - + + + + + + + + + @@ -58,6 +58,6 @@ - + \ No newline at end of file diff --git a/assets/svg/reticles/MIL-XT.svg b/assets/svg/reticles/MIL-XT.svg index ded881ed..f529898d 100644 --- a/assets/svg/reticles/MIL-XT.svg +++ b/assets/svg/reticles/MIL-XT.svg @@ -1,4 +1,4 @@ - + @@ -59,158 +59,1553 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/svg/reticles/MOAR-T.svg b/assets/svg/reticles/MOAR-T.svg index 0321e85e..563be9f4 100644 --- a/assets/svg/reticles/MOAR-T.svg +++ b/assets/svg/reticles/MOAR-T.svg @@ -1,42 +1,42 @@ - - + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - + + - + - + diff --git a/assets/svg/reticles/MOAR.svg b/assets/svg/reticles/MOAR.svg index 1bafb92a..d5272471 100644 --- a/assets/svg/reticles/MOAR.svg +++ b/assets/svg/reticles/MOAR.svg @@ -1,5 +1,5 @@ - - + + @@ -30,14 +30,14 @@ - - - - - - + + + + + + - + \ No newline at end of file diff --git a/assets/svg/reticles/default.svg b/assets/svg/reticles/default.svg index db185697..980931ca 100644 --- a/assets/svg/reticles/default.svg +++ b/assets/svg/reticles/default.svg @@ -1,4 +1,4 @@ - + @@ -9,27 +9,27 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/svg/targets/20cmPlate.svg b/assets/svg/targets/20cmPlate.svg index 54e15178..83a6edf2 100644 --- a/assets/svg/targets/20cmPlate.svg +++ b/assets/svg/targets/20cmPlate.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/assets/svg/targets/Body.svg b/assets/svg/targets/Body.svg index f03e45bb..860b5db3 100644 --- a/assets/svg/targets/Body.svg +++ b/assets/svg/targets/Body.svg @@ -1,4 +1,4 @@ - + @@ -6,14 +6,14 @@ - + - + - + - + - + \ No newline at end of file diff --git a/assets/svg/targets/default.svg b/assets/svg/targets/default.svg index 31f912e6..b0f62ca7 100644 --- a/assets/svg/targets/default.svg +++ b/assets/svg/targets/default.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/docs/7.BETA_UX.md b/docs/7.BETA_UX.md index b17ac655..ac9da851 100644 --- a/docs/7.BETA_UX.md +++ b/docs/7.BETA_UX.md @@ -19,9 +19,10 @@ ## Ballistics & UI - [x] **Display corrections in clicks** — Home Page 1 (AdjPanel) ✅, Tables screen ✅, ReticleViewScreen ✅ -- [ ] **Enter corrections in clicks** — ReticleViewScreen - allow to enter corrections in clicks -- [ ] **ammo.zeroOffset** — fields `zeroOffsetX` / `zeroOffsetY` / `zeroOffsetUnit` in `Ammo`, UI in `AmmoWizardScreen`, taking into account in calculations +- [x] **Enter corrections in clicks** — ReticleViewScreen ✅, AmmoWizardScreen - allow to enter corrections in clicks +- [x] **ammo.zeroOffset** — fields `zeroOffsetX` / `zeroOffsetY` / `zeroOffsetUnitX` / `zeroOffsetUnitY` in `Ammo`, UI in `AmmoWizardScreen`, taking into account in calculations - [ ] **Custom drag function editor** — complete custom drag function editor (`CustomDragTableEditor`): now read-only by default; add full editing, validation, saving +- [ ] **Known bug** - overlap on android on keyboard open --- @@ -68,8 +69,12 @@ - [x] **Windows** — package with msix and sign the application for distribution - [x] **CI/CD** — release workflow: builds all platforms, publishes GitHub Release with all assets - [ ] **Auto-update** — auto-update (desktop and mobile) - - [x] **Linux AppImage** — appimageupdate with zsync via github releases (untested end-to-end) - - [x] **Windows msix** — `.appinstaller` native auto-update via GitHub Releases latest + - [ ] **Linux AppImage** — appimageupdate with zsync via github releases + > [!WARNING] + > autoupdate is untested end-to-end + - [ ] **Windows msix** — `.appinstaller` native auto-update via GitHub Releases latest + > [!WARNING] + > windows says that `.appinstaller` is invalid - [ ] **Android apk** — ota_update - [ ] **macOS** — flutter auto_updater or modern desktop_updater - [ ] **iOS ipa** — only app-store, no autoupdates via sideload @@ -82,7 +87,7 @@ ## Reliability -- [ ] **Database resilience** — special behavior in case of database OB corruption: open backup path / notify user / restore from backup +- [x] **Database resilience** — `_openStore` catches open failures, deletes `data.mdb`/`lock.mdb`, re-inits fresh store; SnackBar warning shown only when prior data existed --- diff --git a/docs/screenshots/ammo_wizard.png b/docs/screenshots/ammo_wizard.png new file mode 100644 index 00000000..9d269d47 Binary files /dev/null and b/docs/screenshots/ammo_wizard.png differ diff --git a/docs/screenshots/conditions.png b/docs/screenshots/conditions.png index 8899ba6e..9514e216 100644 Binary files a/docs/screenshots/conditions.png and b/docs/screenshots/conditions.png differ diff --git a/docs/screenshots/convertors.png b/docs/screenshots/convertors.png index 6bfb2f13..1468598f 100644 Binary files a/docs/screenshots/convertors.png and b/docs/screenshots/convertors.png differ diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png index 83b4b87c..f8deaa6e 100644 Binary files a/docs/screenshots/home.png and b/docs/screenshots/home.png differ diff --git a/docs/screenshots/html_report.png b/docs/screenshots/html_report.png new file mode 100644 index 00000000..aeae29b5 Binary files /dev/null and b/docs/screenshots/html_report.png differ diff --git a/docs/screenshots/my_profiles.png b/docs/screenshots/my_profiles.png new file mode 100644 index 00000000..3f07e0b6 Binary files /dev/null and b/docs/screenshots/my_profiles.png differ diff --git a/docs/screenshots/my_sights.png b/docs/screenshots/my_sights.png new file mode 100644 index 00000000..e5a84f25 Binary files /dev/null and b/docs/screenshots/my_sights.png differ diff --git a/docs/screenshots/reticle.png b/docs/screenshots/reticle.png new file mode 100644 index 00000000..74840d7a Binary files /dev/null and b/docs/screenshots/reticle.png differ diff --git a/docs/screenshots/reticle_picker.png b/docs/screenshots/reticle_picker.png new file mode 100644 index 00000000..bf9eaa6f Binary files /dev/null and b/docs/screenshots/reticle_picker.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index fb0ca1ca..7f3f4579 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/shot_info.png b/docs/screenshots/shot_info.png new file mode 100644 index 00000000..14e8bd10 Binary files /dev/null and b/docs/screenshots/shot_info.png differ diff --git a/docs/screenshots/sight_wizard.png b/docs/screenshots/sight_wizard.png new file mode 100644 index 00000000..ab1f7c70 Binary files /dev/null and b/docs/screenshots/sight_wizard.png differ diff --git a/docs/screenshots/tables.png b/docs/screenshots/tables.png deleted file mode 100644 index 7c7e0f8e..00000000 Binary files a/docs/screenshots/tables.png and /dev/null differ diff --git a/docs/screenshots/tables_details.png b/docs/screenshots/tables_details.png new file mode 100644 index 00000000..14974d2f Binary files /dev/null and b/docs/screenshots/tables_details.png differ diff --git a/docs/screenshots/tables_trajectory.png b/docs/screenshots/tables_trajectory.png new file mode 100644 index 00000000..d5929418 Binary files /dev/null and b/docs/screenshots/tables_trajectory.png differ diff --git a/docs/screenshots/weapon_collection.png b/docs/screenshots/weapon_collection.png new file mode 100644 index 00000000..35aedb4f Binary files /dev/null and b/docs/screenshots/weapon_collection.png differ diff --git a/docs/screenshots/weapon_wizard.png b/docs/screenshots/weapon_wizard.png new file mode 100644 index 00000000..02d43930 Binary files /dev/null and b/docs/screenshots/weapon_wizard.png differ diff --git a/lib/core/extensions/ammo_extensions.dart b/lib/core/extensions/ammo_extensions.dart index d78d9de9..14670b37 100644 --- a/lib/core/extensions/ammo_extensions.dart +++ b/lib/core/extensions/ammo_extensions.dart @@ -66,6 +66,18 @@ extension AmmoExtension on Ammo { Angular get zeroAzimuth => Angular.degree(zeroAzimuthDeg); set zeroAzimuth(Angular v) => zeroAzimuthDeg = v.in_(Unit.degree); + Unit get zeroOffsetXUnitValue => Unit.values.firstWhere( + (u) => u.name == zeroOffsetXUnit, + orElse: () => Unit.mil, + ); + set zeroOffsetXUnitValue(Unit v) => zeroOffsetXUnit = v.name; + + Unit get zeroOffsetYUnitValue => Unit.values.firstWhere( + (u) => u.name == zeroOffsetYUnit, + orElse: () => Unit.mil, + ); + set zeroOffsetYUnitValue(Unit v) => zeroOffsetYUnit = v.name; + // ── Drag model helpers ──────────────────────────────────────────────────────── /// True when G1/G7 with multiple BC breakpoints (velocity-dependent BC). diff --git a/lib/core/extensions/settings_extensions.dart b/lib/core/extensions/settings_extensions.dart index d366748f..8b3daf2b 100644 --- a/lib/core/extensions/settings_extensions.dart +++ b/lib/core/extensions/settings_extensions.dart @@ -137,4 +137,7 @@ extension ReticleSettingsExtension on ReticleSettings { ); set horizontalAdjustmentUnitValue(Unit v) => horizontalAdjustmentUnit = v.name; + + bool get verticalAdjInClicks => verticalAdjustmentUnit == 'clicks'; + bool get horizontalAdjInClicks => horizontalAdjustmentUnit == 'clicks'; } diff --git a/lib/core/providers/app_state_provider.dart b/lib/core/providers/app_state_provider.dart index 384d5d0e..4ee34efc 100644 --- a/lib/core/providers/app_state_provider.dart +++ b/lib/core/providers/app_state_provider.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:bclibc_ffi/unit.dart'; import 'package:ebalistyka/core/extensions/ammo_extensions.dart'; @@ -12,32 +13,32 @@ import 'package:riverpod/riverpod.dart'; class AppState { final List weapons; - final List cartridges; + final List ammo; final List sights; final List profiles; final Profile? activeProfile; const AppState({ required this.weapons, - required this.cartridges, + required this.ammo, required this.sights, required this.profiles, this.activeProfile, }); factory AppState.empty() => - const AppState(weapons: [], cartridges: [], sights: [], profiles: []); + const AppState(weapons: [], ammo: [], sights: [], profiles: []); AppState copyWith({ List? weapons, - List? cartridges, + List? ammo, List? sights, List? profiles, Profile? activeProfile, bool clearActiveProfile = false, }) => AppState( weapons: weapons ?? this.weapons, - cartridges: cartridges ?? this.cartridges, + ammo: ammo ?? this.ammo, sights: sights ?? this.sights, profiles: profiles ?? this.profiles, activeProfile: clearActiveProfile @@ -87,7 +88,7 @@ class AppStateNotifier extends AsyncNotifier { ]; ref.onDispose(() { for (final s in subs) { - s.cancel(); + unawaited(s.cancel()); } }); @@ -102,7 +103,7 @@ class AppStateNotifier extends AsyncNotifier { .query(Weapon_.owner.equals(owner.id)) .build() .find(); - var cartridges = _store + var ammo = _store .box() .query(Ammo_.owner.equals(owner.id)) .build() @@ -119,10 +120,7 @@ class AppStateNotifier extends AsyncNotifier { .find(); // ── Seed on first run ────────────────────────────────────────────────────── - if (weapons.isEmpty && - cartridges.isEmpty && - sights.isEmpty && - profiles.isEmpty) { + if (weapons.isEmpty && ammo.isEmpty && sights.isEmpty && profiles.isEmpty) { debugPrint('AppStateNotifier: seeding initial data...'); _seed(owner); weapons = _store @@ -130,7 +128,7 @@ class AppStateNotifier extends AsyncNotifier { .query(Weapon_.owner.equals(owner.id)) .build() .find(); - cartridges = _store + ammo = _store .box() .query(Ammo_.owner.equals(owner.id)) .build() @@ -156,13 +154,13 @@ class AppStateNotifier extends AsyncNotifier { : (profiles.isNotEmpty ? profiles.first : null); debugPrint( - 'AppStateNotifier: ${weapons.length} weapons, ${cartridges.length} ammo, ' + 'AppStateNotifier: ${weapons.length} weapons, ${ammo.length} ammo, ' '${sights.length} sights, ${profiles.length} profiles', ); return AppState( weapons: weapons, - cartridges: cartridges, + ammo: ammo, sights: sights, profiles: profiles, activeProfile: activeProfile, @@ -283,7 +281,8 @@ class AppStateNotifier extends AsyncNotifier { ..zeroAzimuthDeg = original.zeroAzimuthDeg ..zeroOffsetX = original.zeroOffsetX ..zeroOffsetY = original.zeroOffsetY - ..zeroOffsetUnit = original.zeroOffsetUnit + ..zeroOffsetXUnit = original.zeroOffsetXUnit + ..zeroOffsetYUnit = original.zeroOffsetYUnit ..projectileName = original.projectileName ..vendor = original.vendor ..owner.target = _owner; @@ -511,23 +510,3 @@ class AppStateNotifier extends AsyncNotifier { final appStateProvider = AsyncNotifierProvider( AppStateNotifier.new, ); - -final weaponsProvider = Provider>((ref) { - return ref.watch(appStateProvider).value?.weapons ?? []; -}); - -final cartridgesProvider = Provider>((ref) { - return ref.watch(appStateProvider).value?.cartridges ?? []; -}); - -final sightsProvider = Provider>((ref) { - return ref.watch(appStateProvider).value?.sights ?? []; -}); - -final profilesProvider = Provider>((ref) { - return ref.watch(appStateProvider).value?.profiles ?? []; -}); - -final activeProfileProvider = Provider((ref) { - return ref.watch(appStateProvider).value?.activeProfile; -}); diff --git a/lib/core/providers/db_provider.dart b/lib/core/providers/db_provider.dart index 17c93f6e..e20901ec 100644 --- a/lib/core/providers/db_provider.dart +++ b/lib/core/providers/db_provider.dart @@ -15,6 +15,9 @@ final dbProvider = Provider((ref) { throw UnimplementedError('dbProvider must be overridden with an open Store'); }); +/// Set to [true] when the store was corrupted on startup and had to be reset. +final dbWasResetProvider = Provider((_) => false); + /// Returns the singleton local [Owner] (token = "local"). /// /// Creates it on first run. All local entities are linked to this owner — diff --git a/lib/core/providers/settings_provider.dart b/lib/core/providers/settings_provider.dart index fac7b190..b93e77bf 100644 --- a/lib/core/providers/settings_provider.dart +++ b/lib/core/providers/settings_provider.dart @@ -41,7 +41,10 @@ class SettingsNotifier extends AsyncNotifier { if (existing != null) return existing; final s = GeneralSettings() ..owner.target = owner - ..homeShowMil = true; + ..homeShowMil = true + ..homeShowMoa = true + ..homeShowCmPer100m = true + ..homeShowInClicks = true; _store.box().put(s); // put() returns int, no await needed return s; } @@ -240,7 +243,10 @@ class TablesSettingsNotifier extends AsyncNotifier { final s = TablesSettings() ..owner.target = owner ..distanceEndMeter = 1000.0 - ..showMil = true; + ..showMil = true + ..showMoa = true + ..showCmPer100m = true + ..showInClicks = true; _store.box().put(s); // put() returns int, no await needed return s; } @@ -346,6 +352,18 @@ class ReticleSettingsNotifier extends AsyncNotifier { s.horizontalAdjustmentUnit = unit.name; _store.box().put(s); // remove await } + + Future setVerticalAdjustmentUnitRaw(String name) async { + final s = _loadOrCreate(_owner); + s.verticalAdjustmentUnit = name; + _store.box().put(s); + } + + Future setHorizontalAdjustmentUnitRaw(String name) async { + final s = _loadOrCreate(_owner); + s.horizontalAdjustmentUnit = name; + _store.box().put(s); + } } // ── Providers ───────────────────────────────────────────────────────────────── diff --git a/lib/core/services/a7p_service.dart b/lib/core/services/a7p_service.dart index 3aedbc84..ebd47801 100644 --- a/lib/core/services/a7p_service.dart +++ b/lib/core/services/a7p_service.dart @@ -43,13 +43,17 @@ abstract final class A7pService { /// Throws [A7pParseException] if the file is invalid. static Future pickAndParse() async { final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['a7p'], + type: Platform.isAndroid ? FileType.any : FileType.custom, + allowedExtensions: Platform.isAndroid ? null : ['a7p'], withData: true, ); if (result == null || result.files.isEmpty) return null; final file = result.files.single; + if (!file.name.toLowerCase().endsWith('.a7p')) { + throw FormatException('Expected an .a7p file, got: ${file.name}'); + } + final Uint8List bytes; if (file.bytes != null) { bytes = file.bytes!; @@ -69,8 +73,8 @@ abstract final class A7pService { /// Throws [A7pParseException] on invalid .a7p files or [Exception] on other errors. static Future?> pickAndParseProfiles() async { final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['ebcp', 'a7p'], + type: Platform.isAndroid ? FileType.any : FileType.custom, + allowedExtensions: Platform.isAndroid ? null : ['ebcp', 'a7p'], withData: true, ); if (result == null || result.files.isEmpty) return null; @@ -85,17 +89,21 @@ abstract final class A7pService { throw const A7pParseException('cannot read file bytes'); } - final name = (file.name).toLowerCase(); + final name = file.name.toLowerCase(); if (name.endsWith('.a7p')) { final payload = A7pFile.decode(bytes); return [A7pConverter.fromPayload(payload, validate: false)]; - } else { + } else if (name.endsWith('.ebcp')) { final ebcp = EbcpFile.fromEbcp(bytes); if (ebcp == null) return []; return ebcp.items .map((i) => i.asProfile()) .whereType() .toList(); + } else { + throw FormatException( + 'Expected an .a7p or .ebcp file, got: ${file.name}', + ); } } } diff --git a/lib/core/services/ebcp_service.dart b/lib/core/services/ebcp_service.dart index 0cfca33d..22655a34 100644 --- a/lib/core/services/ebcp_service.dart +++ b/lib/core/services/ebcp_service.dart @@ -44,13 +44,17 @@ abstract final class EbcpService { /// Returns `null` if the user cancels or the file is invalid. static Future pickAndParse() async { final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['ebcp'], + type: Platform.isAndroid ? FileType.any : FileType.custom, + allowedExtensions: Platform.isAndroid ? null : ['ebcp'], withData: true, ); if (result == null || result.files.isEmpty) return null; final file = result.files.single; + if (!file.name.toLowerCase().endsWith('.ebcp')) { + throw FormatException('Expected an .ebcp file, got: ${file.name}'); + } + final Uint8List bytes; if (file.bytes != null) { bytes = file.bytes!; @@ -94,7 +98,7 @@ abstract final class EbcpService { .where((w) => w.id == profile.weapon.targetId) .firstOrNull; if (weapon == null) continue; - final ammo = appState.cartridges + final ammo = appState.ammo .where((a) => a.id == profile.ammo.targetId) .firstOrNull; final sight = appState.sights @@ -107,7 +111,7 @@ abstract final class EbcpService { ); } - for (final ammo in appState.cartridges) { + for (final ammo in appState.ammo) { if (!linkedAmmoIds.contains(ammo.id)) { items.add(EbcpItem.fromAmmo(AmmoExport.fromEntity(ammo))); } diff --git a/lib/features/convertors/angular_convertor_vm.dart b/lib/features/convertors/angular_convertor_vm.dart index 94d61376..6b8a0cb3 100644 --- a/lib/features/convertors/angular_convertor_vm.dart +++ b/lib/features/convertors/angular_convertor_vm.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ebalistyka/core/extensions/convertors_extensions.dart'; import 'package:ebalistyka/core/providers/convertors_notifier.dart'; import 'package:riverpod/riverpod.dart'; @@ -82,7 +84,11 @@ class AnglesConvertorViewModel extends Notifier { void updateDistanceValue(double? rawValueInInputUnit) { final convertorsState = ref.read(convertorStateProvider); if (rawValueInInputUnit == null) { - ref.read(convertorsProvider.notifier).updateAnglesConvDistanceValue(null); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateAnglesConvDistanceValue(null), + ); return; } final metersValue = rawValueInInputUnit.convert( @@ -90,16 +96,22 @@ class AnglesConvertorViewModel extends Notifier { Unit.meter, ); if (metersValue >= 0) { - ref - .read(convertorsProvider.notifier) - .updateAnglesConvDistanceValue(metersValue); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateAnglesConvDistanceValue(metersValue), + ); } } void updateAngularValue(double? rawValueInInputUnit) { final convertorsState = ref.read(convertorStateProvider); if (rawValueInInputUnit == null) { - ref.read(convertorsProvider.notifier).updateAnglesConvAngularValue(null); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateAnglesConvAngularValue(null), + ); return; } final milValue = rawValueInInputUnit.convert( @@ -107,22 +119,34 @@ class AnglesConvertorViewModel extends Notifier { Unit.mil, ); if (milValue >= 0) { - ref - .read(convertorsProvider.notifier) - .updateAnglesConvAngularValue(milValue); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateAnglesConvAngularValue(milValue), + ); } } void changeDistanceUnit(Unit newUnit) { - ref.read(convertorsProvider.notifier).updateAnglesConvDistanceUnit(newUnit); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateAnglesConvDistanceUnit(newUnit), + ); } void changeAngularUnit(Unit newUnit) { - ref.read(convertorsProvider.notifier).updateAnglesConvAngularUnit(newUnit); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateAnglesConvAngularUnit(newUnit), + ); } void changeOutputUnit(Unit newUnit) { - ref.read(convertorsProvider.notifier).updateAnglesConvOutputUnit(newUnit); + unawaited( + ref.read(convertorsProvider.notifier).updateAnglesConvOutputUnit(newUnit), + ); } FieldConstraints getDistanceConstraintsForUnit(Unit unit) { diff --git a/lib/features/convertors/length_convertor_vm.dart b/lib/features/convertors/length_convertor_vm.dart index 36377f12..fc962ec5 100644 --- a/lib/features/convertors/length_convertor_vm.dart +++ b/lib/features/convertors/length_convertor_vm.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ebalistyka/core/extensions/convertors_extensions.dart'; import 'package:ebalistyka/core/providers/convertors_notifier.dart'; import 'package:ebalistyka/features/convertors/generic_convertor_vm_field.dart'; @@ -43,7 +45,7 @@ class LengthConvertorViewModel extends Notifier { final convertorsState = ref.read(convertorStateProvider); if (rawValueInInputUnit == null) { - ref.read(convertorsProvider.notifier).updateLengthValue(null); + unawaited(ref.read(convertorsProvider.notifier).updateLengthValue(null)); return; } @@ -52,12 +54,14 @@ class LengthConvertorViewModel extends Notifier { Unit.inch, ); if (inchesValue >= 0) { - ref.read(convertorsProvider.notifier).updateLengthValue(inchesValue); + unawaited( + ref.read(convertorsProvider.notifier).updateLengthValue(inchesValue), + ); } } void changeInputUnit(Unit newUnit) { - ref.read(convertorsProvider.notifier).updateLengthUnit(newUnit); + unawaited(ref.read(convertorsProvider.notifier).updateLengthUnit(newUnit)); } double? _getDisplayValue(double? rawInches, Unit inputUnit) { diff --git a/lib/features/convertors/pressure_convertor_vm.dart b/lib/features/convertors/pressure_convertor_vm.dart index 14d2481b..8f37b90f 100644 --- a/lib/features/convertors/pressure_convertor_vm.dart +++ b/lib/features/convertors/pressure_convertor_vm.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ebalistyka/core/extensions/convertors_extensions.dart'; import 'package:ebalistyka/core/providers/convertors_notifier.dart'; import 'package:ebalistyka/features/convertors/generic_convertor_vm_field.dart'; @@ -41,7 +43,9 @@ class PressureConvertorViewModel extends Notifier { final convertorsState = ref.read(convertorStateProvider); if (rawValueInInputUnit == null) { - ref.read(convertorsProvider.notifier).updatePressureValue(null); + unawaited( + ref.read(convertorsProvider.notifier).updatePressureValue(null), + ); return; } @@ -50,12 +54,16 @@ class PressureConvertorViewModel extends Notifier { Unit.mmHg, ); if (mmHgValue >= 0) { - ref.read(convertorsProvider.notifier).updatePressureValue(mmHgValue); + unawaited( + ref.read(convertorsProvider.notifier).updatePressureValue(mmHgValue), + ); } } void changeInputUnit(Unit newUnit) { - ref.read(convertorsProvider.notifier).updatePressureUnit(newUnit); + unawaited( + ref.read(convertorsProvider.notifier).updatePressureUnit(newUnit), + ); } double? _getDisplayValue(double? rawMmHg, Unit inputUnit) { diff --git a/lib/features/convertors/target_distance_convertor_vm.dart b/lib/features/convertors/target_distance_convertor_vm.dart index 922b2f54..fa9456f3 100644 --- a/lib/features/convertors/target_distance_convertor_vm.dart +++ b/lib/features/convertors/target_distance_convertor_vm.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bclibc_ffi/unit.dart'; import 'package:ebalistyka/core/extensions/convertors_extensions.dart'; import 'package:ebalistyka/core/models/field_constraints.dart'; @@ -79,16 +81,20 @@ class TargetAtDistanceConvertorViewModel Unit.inch, ); if (inchValue >= 0) { - ref - .read(convertorsProvider.notifier) - .updateDistanceConvTargetSize(inchValue); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateDistanceConvTargetSize(inchValue), + ); } } void changeSizeUnit(Unit newUnit) { - ref - .read(convertorsProvider.notifier) - .updateDistanceConvTargetSizeUnit(newUnit); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateDistanceConvTargetSizeUnit(newUnit), + ); } void updateAngularValue(double? rawInInputUnit) { @@ -99,16 +105,20 @@ class TargetAtDistanceConvertorViewModel Unit.mil, ); if (milValue > 0) { - ref - .read(convertorsProvider.notifier) - .updateDistanceConvTargetSizeAngular(milValue); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateDistanceConvTargetSizeAngular(milValue), + ); } } void changeAngularUnit(Unit newUnit) { - ref - .read(convertorsProvider.notifier) - .updateDistanceConvTargetSizeAngularUnit(newUnit); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateDistanceConvTargetSizeAngularUnit(newUnit), + ); } FieldConstraints getSizeConstraintsForUnit(Unit unit) { diff --git a/lib/features/convertors/temperature_convertor_vm.dart b/lib/features/convertors/temperature_convertor_vm.dart index 7172c714..c88c0ba6 100644 --- a/lib/features/convertors/temperature_convertor_vm.dart +++ b/lib/features/convertors/temperature_convertor_vm.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ebalistyka/core/extensions/convertors_extensions.dart'; import 'package:ebalistyka/core/providers/convertors_notifier.dart'; import 'package:ebalistyka/features/convertors/generic_convertor_vm_field.dart'; @@ -34,7 +36,9 @@ class TemperatureConvertorViewModel final convertorsState = ref.read(convertorStateProvider); if (rawValueInInputUnit == null) { - ref.read(convertorsProvider.notifier).updateTemperatureValue(null); + unawaited( + ref.read(convertorsProvider.notifier).updateTemperatureValue(null), + ); return; } @@ -42,13 +46,17 @@ class TemperatureConvertorViewModel convertorsState.temperatureUnit, Unit.fahrenheit, ); - ref - .read(convertorsProvider.notifier) - .updateTemperatureValue(fahrenheitValue); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateTemperatureValue(fahrenheitValue), + ); } void changeInputUnit(Unit newUnit) { - ref.read(convertorsProvider.notifier).updateTemperatureUnit(newUnit); + unawaited( + ref.read(convertorsProvider.notifier).updateTemperatureUnit(newUnit), + ); } double? _getDisplayValue(double? rawFahrenheit, Unit inputUnit) { diff --git a/lib/features/convertors/torque_convertor_vm.dart b/lib/features/convertors/torque_convertor_vm.dart index f99be3f7..f18f953c 100644 --- a/lib/features/convertors/torque_convertor_vm.dart +++ b/lib/features/convertors/torque_convertor_vm.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ebalistyka/core/extensions/convertors_extensions.dart'; import 'package:ebalistyka/core/providers/convertors_notifier.dart'; import 'package:ebalistyka/features/convertors/generic_convertor_vm_field.dart'; @@ -35,7 +37,7 @@ class TorqueConvertorViewModel extends Notifier { final convertorsState = ref.read(convertorStateProvider); if (rawValueInInputUnit == null) { - ref.read(convertorsProvider.notifier).updateTorqueValue(null); + unawaited(ref.read(convertorsProvider.notifier).updateTorqueValue(null)); return; } @@ -44,12 +46,16 @@ class TorqueConvertorViewModel extends Notifier { Unit.newtonMeter, ); if (newtonMeterValue >= 0) { - ref.read(convertorsProvider.notifier).updateTorqueValue(newtonMeterValue); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateTorqueValue(newtonMeterValue), + ); } } void changeInputUnit(Unit newUnit) { - ref.read(convertorsProvider.notifier).updateTorqueUnit(newUnit); + unawaited(ref.read(convertorsProvider.notifier).updateTorqueUnit(newUnit)); } double? _getDisplayValue(double? rawNewtonMeter, Unit inputUnit) { diff --git a/lib/features/convertors/velocity_convertor_vm.dart b/lib/features/convertors/velocity_convertor_vm.dart index 684f624c..ec1c3fc1 100644 --- a/lib/features/convertors/velocity_convertor_vm.dart +++ b/lib/features/convertors/velocity_convertor_vm.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bclibc_ffi/bclibc.dart'; import 'package:ebalistyka/core/extensions/convertors_extensions.dart'; import 'package:ebalistyka/core/extensions/settings_extensions.dart'; @@ -49,18 +51,24 @@ class VelocityConvertorViewModel extends Notifier { void updateRawValue(double? rawValueInInputUnit) { final s = ref.read(convertorStateProvider); if (rawValueInInputUnit == null) { - ref.read(convertorsProvider.notifier).updateVelocityValue(null); + unawaited( + ref.read(convertorsProvider.notifier).updateVelocityValue(null), + ); return; } if (s.velocityUnit == Unit.mach) { // Store the mach number; mps is derived on the fly from mach + atmo. - ref - .read(convertorsProvider.notifier) - .updateVelocityMachInputValue(rawValueInInputUnit); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateVelocityMachInputValue(rawValueInInputUnit), + ); } else { final mpsValue = rawValueInInputUnit.convert(s.velocityUnit, Unit.mps); if (mpsValue >= 0) { - ref.read(convertorsProvider.notifier).updateVelocityValue(mpsValue); + unawaited( + ref.read(convertorsProvider.notifier).updateVelocityValue(mpsValue), + ); } } } @@ -74,54 +82,70 @@ class VelocityConvertorViewModel extends Notifier { if (newUnit == Unit.mach && s.velocityUnit != Unit.mach) { // Switching TO mach: sync mach from current stored mps. final machValue = s.velocityValue.inMach(atmo); - ref - .read(convertorsProvider.notifier) - .updateVelocityMachInputValue(machValue); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateVelocityMachInputValue(machValue), + ); } else if (newUnit != Unit.mach && s.velocityUnit == Unit.mach) { // Switching FROM mach: sync mps from stored mach + current atmo. final mpsValue = s.velocityMachInputValue .toVelocityFromMach(atmo) .in_(Unit.mps); - ref.read(convertorsProvider.notifier).updateVelocityValue(mpsValue); + unawaited( + ref.read(convertorsProvider.notifier).updateVelocityValue(mpsValue), + ); } - ref.read(convertorsProvider.notifier).updateVelocityUnit(newUnit); + unawaited( + ref.read(convertorsProvider.notifier).updateVelocityUnit(newUnit), + ); } void toggleCustomAtmo(bool value) { - ref - .read(convertorsProvider.notifier) - .updateVelocityMachUseCustomAtmo(value); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateVelocityMachUseCustomAtmo(value), + ); } void updateAtmoTemperature(double rawValue) { final units = ref.read(unitSettingsProvider); final celsiusValue = rawValue.convert(units.temperatureUnit, Unit.celsius); - ref - .read(convertorsProvider.notifier) - .updateVelocityAtmoTemperature(Temperature.celsius(celsiusValue)); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateVelocityAtmoTemperature(Temperature.celsius(celsiusValue)), + ); } void updateAtmoPressure(double rawValue) { final units = ref.read(unitSettingsProvider); final hPaValue = rawValue.convert(units.pressureUnit, Unit.hPa); - ref - .read(convertorsProvider.notifier) - .updateVelocityAtmoPressure(Pressure.hPa(hPaValue)); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateVelocityAtmoPressure(Pressure.hPa(hPaValue)), + ); } void updateAtmoHumidity(double rawFrac) { - ref - .read(convertorsProvider.notifier) - .updateVelocityAtmoHumidityFrac(rawFrac); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateVelocityAtmoHumidityFrac(rawFrac), + ); } void updateAtmoAltitude(double rawValue) { final units = ref.read(unitSettingsProvider); final meterValue = rawValue.convert(units.distanceUnit, Unit.meter); - ref - .read(convertorsProvider.notifier) - .updateVelocityAtmoAltitude(Distance.meter(meterValue)); + unawaited( + ref + .read(convertorsProvider.notifier) + .updateVelocityAtmoAltitude(Distance.meter(meterValue)), + ); } Atmo _buildAtmo(ConvertorsState s) => s.velocityMachUseCustomAtmo diff --git a/lib/features/convertors/weight_convertor_vm.dart b/lib/features/convertors/weight_convertor_vm.dart index 70b246cd..ae75aa7e 100644 --- a/lib/features/convertors/weight_convertor_vm.dart +++ b/lib/features/convertors/weight_convertor_vm.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ebalistyka/core/extensions/convertors_extensions.dart'; import 'package:ebalistyka/core/providers/convertors_notifier.dart'; import 'package:ebalistyka/features/convertors/generic_convertor_vm_field.dart'; @@ -39,7 +41,7 @@ class WeightConvertorViewModel extends Notifier { final convertorsState = ref.read(convertorStateProvider); if (rawValueInInputUnit == null) { - ref.read(convertorsProvider.notifier).updateWeightValue(null); + unawaited(ref.read(convertorsProvider.notifier).updateWeightValue(null)); return; } @@ -48,12 +50,14 @@ class WeightConvertorViewModel extends Notifier { Unit.grain, ); if (grainsValue >= 0) { - ref.read(convertorsProvider.notifier).updateWeightValue(grainsValue); + unawaited( + ref.read(convertorsProvider.notifier).updateWeightValue(grainsValue), + ); } } void changeInputUnit(Unit newUnit) { - ref.read(convertorsProvider.notifier).updateWeightUnit(newUnit); + unawaited(ref.read(convertorsProvider.notifier).updateWeightUnit(newUnit)); } double? _getDisplayValue(double? rawGrains, Unit inputUnit) { diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 4e28c9e6..f35f5680 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:ebalistyka/shared/consts.dart'; @@ -11,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ebalistyka/router.dart'; +import 'package:ebalistyka_db/ebalistyka_db.dart'; import 'package:ebalistyka/core/models/field_constraints.dart'; import 'package:ebalistyka/core/providers/app_state_provider.dart'; import 'package:bclibc_ffi/unit.dart'; @@ -60,7 +62,7 @@ class _HomeScreenState extends ConsumerState ref.listen>(homeVmProvider, (prev, next) { final wasLoading = prev?.isLoading == true; final isReady = next.value is HomeUiReady; - if (wasLoading && isReady) _calcDoneCtrl.forward(from: 0); + if (wasLoading && isReady) unawaited(_calcDoneCtrl.forward(from: 0)); }); final vmAsync = ref.watch(homeVmProvider); @@ -111,7 +113,9 @@ class _HomeScreenState extends ConsumerState displayUnit: Unit.degree, onChanged: (v) { final normalized = (((v! % 360) + 360) % 360); - ref.read(homeVmProvider.notifier).updateWindDirection(normalized); + unawaited( + ref.read(homeVmProvider.notifier).updateWindDirection(normalized), + ); }, ); @@ -173,12 +177,12 @@ class _HomeScreenState extends ConsumerState const SizedBox(width: 8), if (vmState is HomeUiReady) IconButton.filledTonal( - onPressed: () { + onPressed: () async { final appState = ref .read(appStateProvider) .value; final profile = appState?.activeProfile; - final ammo = appState?.cartridges + final ammo = appState?.ammo .where( (a) => a.id == profile?.ammo.targetId, @@ -191,11 +195,15 @@ class _HomeScreenState extends ConsumerState profile?.weapon.targetId, ) .firstOrNull; - if (ammo != null) { - context.push( - Routes.profileEditAmmo, - extra: (ammo, weapon?.caliberInch), - ); + if (ammo == null) return; + final result = await context.push( + Routes.profileEditAmmo, + extra: (ammo, weapon?.caliberInch), + ); + if (result != null && context.mounted) { + await ref + .read(appStateProvider.notifier) + .saveAmmo(result); } }, icon: const Icon(IconDef.ammo), @@ -253,9 +261,11 @@ class _HomeScreenState extends ConsumerState child: WindIndicator( initialAngle: windInitialAngle, onAngleChanged: (degrees, _) { - ref - .read(homeVmProvider.notifier) - .updateWindDirection(degrees); + unawaited( + ref + .read(homeVmProvider.notifier) + .updateWindDirection(degrees), + ); }, onDirectionTap: (deg) => showUnitEditDialog( @@ -378,17 +388,19 @@ class _HomeScreenState extends ConsumerState alignment: Alignment.center, color: cs.surface, child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - const SizedBox(height: 8), Text(pageName), PageDotsIndicator( current: _currentPage, count: 3, onPageChanged: (page) { - _pageController.animateToPage( - page, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, + unawaited( + _pageController.animateToPage( + page, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ), ); setState(() => _currentPage = page); }, diff --git a/lib/features/home/home_vm.dart b/lib/features/home/home_vm.dart index 9b4bcfaf..56306a4a 100644 --- a/lib/features/home/home_vm.dart +++ b/lib/features/home/home_vm.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:ebalistyka/core/providers/reticle_provider.dart'; @@ -55,6 +56,7 @@ class ReticleUiState { final String? targetId; final double targetSizeMilAtDistance; final String? adjustedMessageLine; + final String? zeroOffsetMessageLine; final String cartridgeInfoLine; final AdjustmentData adjustment; final AdjustmentDisplayFormat adjustmentFormat; @@ -66,6 +68,7 @@ class ReticleUiState { this.targetId, this.targetSizeMilAtDistance = 0.0, this.adjustedMessageLine, + this.zeroOffsetMessageLine, this.cartridgeInfoLine = '', required this.adjustment, this.adjustmentFormat = AdjustmentDisplayFormat.arrows, @@ -157,17 +160,18 @@ class HomeViewModel extends AsyncNotifier { @override Future build() async { ref.listen>(shotContextProvider, (_, next) { - if (next.hasValue) _recalculate(); + if (next.hasValue) unawaited(_recalculate()); }, fireImmediately: true); ref.listen>(settingsProvider, (prev, next) { if (!next.hasValue) return; - if (_generalNeedsRecalc(prev?.value, next.value!)) _recalculate(); + if (_generalNeedsRecalc(prev?.value, next.value!)) + unawaited(_recalculate()); }, fireImmediately: true); ref.listen(unitSettingsProvider, (prev, next) { - if (prev != null) _recalculate(); + if (prev != null) unawaited(_recalculate()); }, fireImmediately: true); ref.listen(reticleSettingsProvider, (prev, next) { - if (prev != null) _recalculate(); + if (prev != null) unawaited(_recalculate()); }, fireImmediately: true); return const HomeUiNoData(type: EmptyStateType.noProfile); } @@ -291,19 +295,29 @@ class HomeViewModel extends AsyncNotifier { Future updateReticleAdjustments({ required double vRaw, - required Unit vUnit, + required Unit? vUnit, required double hRaw, - required Unit hUnit, + required Unit? hUnit, }) async { final notifier = ref.read(reticleSettingsNotifierProvider.notifier); - notifier.setVerticalAdjustmentUnit(vUnit); - notifier.setVerticalAdjustment( - Angular(vRaw, FC.adjustment.rawUnit).in_(vUnit), - ); - notifier.setHorizontalAdjustmentUnit(hUnit); - notifier.setHorizontalAdjustment( - Angular(hRaw, FC.adjustment.rawUnit).in_(hUnit), - ); + if (vUnit == null) { + notifier.setVerticalAdjustmentUnitRaw('clicks'); + notifier.setVerticalAdjustment(vRaw); + } else { + notifier.setVerticalAdjustmentUnit(vUnit); + notifier.setVerticalAdjustment( + Angular(vRaw, FC.adjustment.rawUnit).in_(vUnit), + ); + } + if (hUnit == null) { + notifier.setHorizontalAdjustmentUnitRaw('clicks'); + notifier.setHorizontalAdjustment(hRaw); + } else { + notifier.setHorizontalAdjustmentUnit(hUnit); + notifier.setHorizontalAdjustment( + Angular(hRaw, FC.adjustment.rawUnit).in_(hUnit), + ); + } } Future updateTargetImage(String? imageId) async { @@ -363,24 +377,29 @@ class HomeViewModel extends AsyncNotifier { targetDistanceM: targetM, ); - final adjustedMessageLine = _buildAdjustedMessageLine(reticle); final cartridgeInfoLine = _buildCartridgeInfoLine( profile, conditions, formatter, ); - final vAdjMil = reticle.verticalAdjustment.convert( - reticle.verticalAdjustmentUnitValue, - Unit.mil, - ); - final hAdjMil = reticle.horizontalAdjustment.convert( - reticle.horizontalAdjustmentUnitValue, - Unit.mil, - ); + final vAdjMil = reticle.verticalAdjInClicks + ? reticle.verticalAdjustment + : reticle.verticalAdjustment.convert( + reticle.verticalAdjustmentUnitValue, + Unit.mil, + ); + final hAdjMil = reticle.horizontalAdjInClicks + ? reticle.horizontalAdjustment + : reticle.horizontalAdjustment.convert( + reticle.horizontalAdjustmentUnitValue, + Unit.mil, + ); double horizontalClickSizeMil = 0.0; double verticalClickSizeMil = 0.0; + String? adjustedMessageLine; + final sight = profile.sight.target; if (sight != null) { horizontalClickSizeMil = Angular( @@ -391,25 +410,54 @@ class HomeViewModel extends AsyncNotifier { sight.verticalClick, sight.verticalClickUnitValue, ).in_(Unit.mil); + adjustedMessageLine = _buildAdjustedMessageLine( + reticle, + vClickSizeMil: verticalClickSizeMil, + hClickSizeMil: horizontalClickSizeMil, + ); } - final adjustment = _buildAdjustment( + double zeroOffsetYMil = 0.0; + double zeroOffsetXMil = 0.0; + String? zeroOffsetMessageLine; + + final ammo = profile.ammo.target; + if (ammo != null) { + zeroOffsetYMil = Angular( + ammo.zeroOffsetY, + ammo.zeroOffsetYUnitValue, + ).in_(Unit.mil); + zeroOffsetXMil = Angular( + ammo.zeroOffsetX, + ammo.zeroOffsetXUnitValue, + ).in_(Unit.mil); + zeroOffsetMessageLine = _buildZeroOffsetMessageLine( + zeroOffsetYMil: zeroOffsetYMil, + zeroOffsetXMil: zeroOffsetXMil, + zeroOffsetYUnit: ammo.zeroOffsetYUnitValue, + zeroOffsetXUnit: ammo.zeroOffsetXUnitValue, + ); + } + + final elevMil = + Angular.radian(result.holdRad).in_(Unit.mil) + vAdjMil + zeroOffsetYMil; + final targetPoint = hit.trajectory.isNotEmpty + ? hit.getAtDistance(Distance.meter(targetM)) + : null; + final windMil = + (targetPoint != null ? targetPoint.windageAngle.in_(Unit.mil) : 0.0) + + hAdjMil + + zeroOffsetXMil; + + final adjustmentData = _buildAdjustment( hit, targetM, - result.holdRad, + Angular(elevMil, Unit.mil), + Angular(windMil, Unit.mil), horizontalClickSizeMil, verticalClickSizeMil, settings, - elevOffsetMil: vAdjMil, - windOffsetMil: hAdjMil, ); - final elevMil = Angular.radian(result.holdRad).in_(Unit.mil) + vAdjMil; - final targetPoint = hit.trajectory.isNotEmpty - ? hit.getAtDistance(Distance.meter(targetM)) - : null; - final windMil = - (targetPoint != null ? targetPoint.windageAngle.in_(Unit.mil) : 0.0) + - hAdjMil; final targetSvg = await ref .read(targetSvgProvider(reticle.targetImage).future) @@ -424,8 +472,9 @@ class HomeViewModel extends AsyncNotifier { targetId: reticle.targetImage, targetSizeMilAtDistance: targetSizeMilAtDistance, adjustedMessageLine: adjustedMessageLine, + zeroOffsetMessageLine: zeroOffsetMessageLine, cartridgeInfoLine: cartridgeInfoLine, - adjustment: adjustment, + adjustment: adjustmentData, adjustmentFormat: settings.adjustmentDisplayFormat, adjustmentElevMil: elevMil, adjustmentWindMil: windMil, @@ -468,30 +517,69 @@ class HomeViewModel extends AsyncNotifier { return m != null ? double.tryParse(m.group(1)!) ?? 0.5 : 0.0; } - String? _buildAdjustedMessageLine(ReticleSettings reticle) { + String? _buildZeroOffsetMessageLine({ + required Unit zeroOffsetYUnit, + required Unit zeroOffsetXUnit, + required double zeroOffsetYMil, + required double zeroOffsetXMil, + }) { + if (zeroOffsetYMil == 0.0 && zeroOffsetXMil == 0.0) return null; + + final parts = []; + + if (zeroOffsetYMil != 0.0) { + parts.add(_angularPart(zeroOffsetYMil, zeroOffsetYUnit, 'vertical')); + } + + if (zeroOffsetXMil != 0.0) { + parts.add(_angularPart(zeroOffsetXMil, zeroOffsetXUnit, 'horizontal')); + } + + return 'Zero offset: ${parts.join(' / ')}'; + } + + String? _buildAdjustedMessageLine( + ReticleSettings reticle, { + required double vClickSizeMil, + required double hClickSizeMil, + }) { final vAdj = reticle.verticalAdjustment; final hAdj = reticle.horizontalAdjustment; + if (vAdj == 0.0 && hAdj == 0.0) return null; - final vUnit = reticle.verticalAdjustmentUnitValue; - final hUnit = reticle.horizontalAdjustmentUnitValue; final parts = []; if (vAdj != 0.0) { - final acc = FC.adjustment.accuracyFor(vUnit); - parts.add( - '${vAdj > 0 ? '+' : ''}${vAdj.toFixedSafe(acc)} ${_unitLabel(vUnit)} vertical', - ); + final part = reticle.verticalAdjInClicks && vClickSizeMil > 0 + ? _clicksPart(vAdj, vClickSizeMil, 'vertical') + : _angularPart(vAdj, reticle.verticalAdjustmentUnitValue, 'vertical'); + parts.add(part); } if (hAdj != 0.0) { - final acc = FC.adjustment.accuracyFor(hUnit); - parts.add( - '${hAdj > 0 ? '+' : ''}${hAdj.toFixedSafe(acc)} ${_unitLabel(hUnit)} horizontal', - ); + final part = reticle.horizontalAdjInClicks && hClickSizeMil > 0 + ? _clicksPart(hAdj, hClickSizeMil, 'horizontal') + : _angularPart( + hAdj, + reticle.horizontalAdjustmentUnitValue, + 'horizontal', + ); + parts.add(part); } return 'Drum adjustment: ${parts.join(' / ')}'; } + static String _clicksPart(double rawMil, double clickSizeMil, String dir) { + final clicks = rawMil / clickSizeMil; + final v = clicks.toStringAsFixed(0); + return '${clicks > 0 ? '+' : ''}$v click $dir'; + } + + static String _angularPart(double adjInUnit, Unit unit, String dir) { + final acc = FC.adjustment.accuracyFor(unit); + return '${adjInUnit > 0 ? '+' : ''}${adjInUnit.toFixedSafe(acc)} ${_unitLabel(unit)} $dir'; + } + static String _unitLabel(Unit u) => switch (u) { Unit.mRad => 'MRAD', Unit.moa => 'MOA', @@ -530,24 +618,12 @@ class HomeViewModel extends AsyncNotifier { AdjustmentData _buildAdjustment( bclibc.HitResult hit, double targetM, - double holdRad, + Angular elevAngle, + Angular windAngle, double horizontalClickSizeMil, double verticalClickSizeMil, - GeneralSettings settings, { - double elevOffsetMil = 0.0, - double windOffsetMil = 0.0, - }) { - final elevAngle = Angular( - Angular.radian(holdRad).in_(Unit.mil) + elevOffsetMil, - Unit.mil, - ); - final point = hit.trajectory.isNotEmpty - ? hit.getAtDistance(Distance.meter(targetM)) - : null; - final windAngle = point != null - ? Angular(point.windageAngle.in_(Unit.mil) + windOffsetMil, Unit.mil) - : null; - + GeneralSettings settings, + ) { final dispUnits = <(Unit, String)>[ if (settings.homeShowMrad) (Unit.mRad, 'MRAD'), if (settings.homeShowMoa) (Unit.moa, 'MOA'), @@ -581,19 +657,17 @@ class HomeViewModel extends AsyncNotifier { ); } - final windValues = windAngle != null - ? dispUnits.map((u) { - final corr = windAngle.in_(u.$1); - return AdjustmentValue( - absValue: corr.abs(), - isPositive: corr >= 0, - symbol: u.$2, - decimals: FC.adjustment.accuracyFor(u.$1), - ); - }).toList() - : []; + final windValues = dispUnits.map((u) { + final corr = windAngle.in_(u.$1); + return AdjustmentValue( + absValue: corr.abs(), + isPositive: corr >= 0, + symbol: u.$2, + decimals: FC.adjustment.accuracyFor(u.$1), + ); + }).toList(); - if (settings.homeShowInClicks && windAngle != null) { + if (settings.homeShowInClicks) { final val = windAngle.in_(Unit.mil); final clicks = horizontalClickSizeMil > 0.0 ? val / horizontalClickSizeMil diff --git a/lib/features/home/profiles_vm.dart b/lib/features/home/profiles_vm.dart index 84249003..42aefc92 100644 --- a/lib/features/home/profiles_vm.dart +++ b/lib/features/home/profiles_vm.dart @@ -176,7 +176,7 @@ ProfileCardData _buildCardData( final weapon = appState.weapons .where((w) => w.id == profile.weapon.targetId) .firstOrNull; - final ammo = appState.cartridges + final ammo = appState.ammo .where((a) => a.id == profile.ammo.targetId) .firstOrNull; final sight = appState.sights @@ -275,7 +275,8 @@ int _ammoFingerprint(Ammo? a) { a.zeroAzimuthDeg, a.zeroOffsetX, a.zeroOffsetY, - a.zeroOffsetUnit, + a.zeroOffsetXUnit, + a.zeroOffsetYUnit, a.bcG1, a.bcG7, a.dragTypeValue, diff --git a/lib/features/home/shot_details_vm.dart b/lib/features/home/shot_details_vm.dart index 1200ccc7..30781b55 100644 --- a/lib/features/home/shot_details_vm.dart +++ b/lib/features/home/shot_details_vm.dart @@ -71,14 +71,14 @@ class ShotDetailsViewModel extends AsyncNotifier { @override Future build() async { ref.listen>(shotContextProvider, (_, next) { - if (next.hasValue) _recalculate(); + if (next.hasValue) unawaited(_recalculate()); }, fireImmediately: true); ref.listen>(settingsProvider, (prev, next) { if (!next.hasValue) return; - if (prev?.value != null) _recalculate(); + if (prev?.value != null) unawaited(_recalculate()); }, fireImmediately: true); ref.listen(unitSettingsProvider, (prev, next) { - if (prev != null) _recalculate(); + if (prev != null) unawaited(_recalculate()); }, fireImmediately: true); return _calculate(); } diff --git a/lib/features/home/sub_screens/ammo_wizard_screen.dart b/lib/features/home/sub_screens/ammo_wizard_screen.dart index 5b1d7c42..c261d701 100644 --- a/lib/features/home/sub_screens/ammo_wizard_screen.dart +++ b/lib/features/home/sub_screens/ammo_wizard_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:bclibc_ffi/unit.dart'; @@ -13,6 +14,7 @@ import 'package:ebalistyka/shared/widgets/base_screen.dart'; import 'package:ebalistyka/shared/widgets/coriolis_section.dart'; import 'package:ebalistyka/shared/widgets/info_tile.dart'; import 'package:ebalistyka/shared/widgets/list_section_tile.dart'; +import 'package:ebalistyka/shared/widgets/offsets_edit.dart'; import 'package:ebalistyka/shared/widgets/powder_sens_section.dart'; import 'package:ebalistyka/features/home/sub_screens/powder_sens_table_editor_screen.dart'; import 'package:ebalistyka/shared/widgets/unit_constrained_input_tile.dart'; @@ -76,6 +78,11 @@ class _AmmoWizardScreenState extends ConsumerState { final _powderSensKey = GlobalKey(); final _coriolisKey = GlobalKey(); + late double _offsetXRaw; + late Unit _offsetXUnit; + late double _offsetYRaw; + late Unit _offsetYUnit; + @override void initState() { super.initState(); @@ -128,6 +135,16 @@ class _AmmoWizardScreenState extends ConsumerState { _zeroUseCoriolis = a?.zeroUseCoriolis ?? false; _zeroLatitudeRaw = a?.zeroLatitudeDeg ?? 0.0; _zeroAzimuthRaw = a?.zeroAzimuthDeg ?? 0.0; + + _offsetYUnit = a?.zeroOffsetYUnitValue ?? Unit.mil; + _offsetYRaw = a != null + ? Angular(a.zeroOffsetY, _offsetYUnit).in_(FC.adjustment.rawUnit) + : Angular.mil(0.1).in_(FC.adjustment.rawUnit); + + _offsetXUnit = a?.zeroOffsetXUnitValue ?? Unit.mil; + _offsetXRaw = a != null + ? Angular(a.zeroOffsetX, _offsetXUnit).in_(FC.adjustment.rawUnit) + : Angular.mil(0.1).in_(FC.adjustment.rawUnit); } @override @@ -192,10 +209,12 @@ class _AmmoWizardScreenState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { final ctx = key.currentContext; if (ctx != null) { - Scrollable.ensureVisible( - ctx, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, + unawaited( + Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ), ); } }); @@ -321,6 +340,17 @@ class _AmmoWizardScreenState extends ConsumerState { ammo.zeroUseCoriolis = _zeroUseCoriolis; ammo.zeroLatitudeDeg = _zeroLatitudeRaw; ammo.zeroAzimuthDeg = _zeroAzimuthRaw; + + ammo.zeroOffsetYUnitValue = _offsetYUnit; + ammo.zeroOffsetY = Angular( + _offsetYRaw, + FC.adjustment.rawUnit, + ).in_(_offsetYUnit); + ammo.zeroOffsetXUnitValue = _offsetXUnit; + ammo.zeroOffsetX = Angular( + _offsetXRaw, + FC.adjustment.rawUnit, + ).in_(_offsetXUnit); return ammo; } @@ -697,7 +727,6 @@ class _AmmoWizardScreenState extends ConsumerState { icon: IconDef.altitude, onChanged: (v) => setState(() => _zeroAltRaw = v), ), - // TODO: Zeroing atmo params // ── Powder sensitivity ────────────────────────────────────────────── if (_usePowderSensitivity) ...[ @@ -742,6 +771,22 @@ class _AmmoWizardScreenState extends ConsumerState { ), ], + // ── Zeroing offset ──────────────────────────────────────── + offsetsTile( + context: context, + yLabel: 'Vertical offset', + xLabel: 'Horizontal offset', + unitLabel: 'Click unit', + yRaw: _offsetYRaw, + xRaw: _offsetXRaw, + yUnits: _offsetYUnit, + xUnits: _offsetXUnit, + onYChanged: (v) => setState(() => _offsetYRaw = v), + onXChanged: (v) => setState(() => _offsetXRaw = v), + onYUnitChanged: (u) => setState(() => _offsetYUnit = u), + onXUnitChanged: (u) => setState(() => _offsetXUnit = u), + ), + // ── Zeroing coriolis ──────────────────────────────────────── const Divider(height: 1), CoriolisSection( diff --git a/lib/features/home/sub_screens/my_ammo_screen.dart b/lib/features/home/sub_screens/my_ammo_screen.dart index 77c27c0a..05c76f0b 100644 --- a/lib/features/home/sub_screens/my_ammo_screen.dart +++ b/lib/features/home/sub_screens/my_ammo_screen.dart @@ -94,18 +94,25 @@ class MyAmmoScreen extends ConsumerWidget { icon: IconDef.import, title: 'Import from file', onTap: () async { - final ebcp = await EbcpService.pickAndParse(); - if (ebcp == null || !context.mounted) return; - final ammos = ebcp.items - .map((i) => i.asAmmo()) - .whereType() - .toList(); - if (ammos.isEmpty) { - showNotAvailableSnackBar(context, 'No ammo found in file'); - return; - } - for (final a in ammos) { - await ref.read(appStateProvider.notifier).importAmmo(a); + try { + final ebcp = await EbcpService.pickAndParse(); + if (ebcp == null || !context.mounted) return; + final ammos = ebcp.items + .map((i) => i.asAmmo()) + .whereType() + .toList(); + if (ammos.isEmpty) { + showNotAvailableSnackBar(context, 'No ammo found in file'); + return; + } + for (final a in ammos) { + await ref.read(appStateProvider.notifier).importAmmo(a); + } + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Import failed: $e'))); } }, ), @@ -125,8 +132,8 @@ class MyAmmoScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final appStateAsync = ref.watch(appStateProvider); - final cartridges = ref.watch(cartridgesProvider); final appState = ref.watch(appStateProvider).value; + final cartridges = appState?.ammo ?? []; final profile = profileId != null ? appState?.profiles .where((p) => p.id.toString() == profileId) diff --git a/lib/features/home/sub_screens/my_profiles_screen.dart b/lib/features/home/sub_screens/my_profiles_screen.dart index c5622c2f..782e6b9c 100644 --- a/lib/features/home/sub_screens/my_profiles_screen.dart +++ b/lib/features/home/sub_screens/my_profiles_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:a7p/a7p.dart'; import 'package:ebalistyka/core/services/a7p_service.dart'; import 'package:ebalistyka/core/services/ebcp_service.dart'; @@ -52,10 +54,12 @@ class _ProfilesScreenState extends ConsumerState { }); if (_pageController.hasClients) { if (animate) { - _pageController.animateToPage( - page, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, + unawaited( + _pageController.animateToPage( + page, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ), ); } else { _pageController.jumpToPage(page); @@ -171,7 +175,7 @@ class _ProfilesScreenState extends ConsumerState { .where((w) => w.id == profile.weapon.targetId) .firstOrNull; if (weapon == null) return; - final ammo = appState.cartridges + final ammo = appState.ammo .where((a) => a.id == profile.ammo.targetId) .firstOrNull; final sight = appState.sights @@ -288,7 +292,7 @@ class _ProfilesScreenState extends ConsumerState { final weapon = appState.weapons .where((w) => w.id == profile.weapon.targetId) .firstOrNull; - final ammo = appState.cartridges + final ammo = appState.ammo .where((a) => a.id == profile.ammo.targetId) .firstOrNull; @@ -483,10 +487,12 @@ class _ProfilePageView extends StatelessWidget { current: currentPage, count: orderedIds.length, onPageChanged: (page) { - pageController.animateToPage( - page, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, + unawaited( + pageController.animateToPage( + page, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ), ); }, ), diff --git a/lib/features/home/sub_screens/my_sights_screen.dart b/lib/features/home/sub_screens/my_sights_screen.dart index e0e5a6b8..4ed8a759 100644 --- a/lib/features/home/sub_screens/my_sights_screen.dart +++ b/lib/features/home/sub_screens/my_sights_screen.dart @@ -49,18 +49,25 @@ class MySightsCollectionScreen extends ConsumerWidget { icon: IconDef.import, title: 'Import from file', onTap: () async { - final ebcp = await EbcpService.pickAndParse(); - if (ebcp == null || !context.mounted) return; - final sights = ebcp.items - .map((i) => i.asSight()) - .whereType() - .toList(); - if (sights.isEmpty) { - showNotAvailableSnackBar(context, 'No sights found in file'); - return; - } - for (final s in sights) { - await ref.read(appStateProvider.notifier).importSight(s); + try { + final ebcp = await EbcpService.pickAndParse(); + if (ebcp == null || !context.mounted) return; + final sights = ebcp.items + .map((i) => i.asSight()) + .whereType() + .toList(); + if (sights.isEmpty) { + showNotAvailableSnackBar(context, 'No sights found in file'); + return; + } + for (final s in sights) { + await ref.read(appStateProvider.notifier).importSight(s); + } + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Import failed: $e'))); } }, ), @@ -84,8 +91,8 @@ class MySightsCollectionScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final appStateAsync = ref.watch(appStateProvider); - final sights = ref.watch(sightsProvider); final appState = ref.watch(appStateProvider).value; + final sights = appState?.sights ?? []; final profile = profileId != null ? appState?.profiles .where((p) => p.id.toString() == profileId) diff --git a/lib/features/home/sub_screens/reticle_view_screen.dart b/lib/features/home/sub_screens/reticle_view_screen.dart index f52e2fe3..e7067aeb 100644 --- a/lib/features/home/sub_screens/reticle_view_screen.dart +++ b/lib/features/home/sub_screens/reticle_view_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:bclibc_ffi/unit.dart' show Angular, Unit; @@ -14,10 +15,13 @@ import 'package:ebalistyka/router.dart'; import 'package:ebalistyka/shared/consts.dart'; import 'package:ebalistyka/shared/icons_definitions.dart'; import 'package:ebalistyka/shared/widgets/base_screen.dart'; +import 'package:ebalistyka/shared/widgets/click_label.dart'; import 'package:ebalistyka/shared/widgets/empty_state.dart'; import 'package:ebalistyka/shared/widgets/info_tile.dart'; import 'package:ebalistyka/shared/widgets/list_section_tile.dart'; +import 'package:ebalistyka/shared/widgets/offsets_edit.dart'; import 'package:ebalistyka/shared/widgets/reticle_view.dart'; +import 'package:ebalistyka/shared/widgets/adjustment_input_with_clicks.dart'; import 'package:ebalistyka/shared/widgets/unit_constrained_input_with_unit_picker_tile.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -34,10 +38,11 @@ class _ReticleViewScreenState extends ConsumerState { final _zoomKey = GlobalKey<_ZoomableViewState>(); // Barrel drums (ReticleSettings) — raw in FC.adjustment.rawUnit (mil) + // null unit = clicks mode late double _vAdjRaw; - late Unit _vAdjUnit; + Unit? _vAdjUnit; late double _hAdjRaw; - late Unit _hAdjUnit; + Unit? _hAdjUnit; // ReticleSettings String? _targetImage; @@ -49,28 +54,23 @@ class _ReticleViewScreenState extends ConsumerState { late double _hClickRaw; late Unit _hClickUnit; - static const _adjUnits = [ - Unit.mil, - Unit.moa, - Unit.mRad, - Unit.cmPer100m, - Unit.inPer100Yd, - ]; - @override void initState() { super.initState(); final reticle = ref.read(reticleSettingsProvider); - _vAdjUnit = reticle.verticalAdjustmentUnitValue; - _vAdjRaw = Angular( - reticle.verticalAdjustment, - _vAdjUnit, - ).in_(FC.adjustment.rawUnit); - _hAdjUnit = reticle.horizontalAdjustmentUnitValue; - _hAdjRaw = Angular( - reticle.horizontalAdjustment, - _hAdjUnit, - ).in_(FC.adjustment.rawUnit); + final vUnit = reticle.verticalAdjustmentUnitValue; + _vAdjUnit = reticle.verticalAdjInClicks ? null : vUnit; + _vAdjRaw = reticle.verticalAdjInClicks + ? reticle.verticalAdjustment + : Angular(reticle.verticalAdjustment, vUnit).in_(FC.adjustment.rawUnit); + final hUnit = reticle.horizontalAdjustmentUnitValue; + _hAdjUnit = reticle.horizontalAdjInClicks ? null : hUnit; + _hAdjRaw = reticle.horizontalAdjInClicks + ? reticle.horizontalAdjustment + : Angular( + reticle.horizontalAdjustment, + hUnit, + ).in_(FC.adjustment.rawUnit); _targetImage = reticle.targetImage; final ctx = ref.read(shotContextProvider).value; @@ -137,13 +137,12 @@ class _ReticleViewScreenState extends ConsumerState { return LayoutBuilder( builder: (context, constraints) { - const double maxTopHeightRatio = 0.50; // 35% від загальної висоти + const double maxTopHeightRatio = 0.50; final double fullHeight = constraints.maxHeight; final double maxAllowedHeight = fullHeight * maxTopHeightRatio; - // Верхній блок має бути квадратним (1:1), але не більше ніж 35% висоти final double topBlockSize = math.min( - constraints.maxWidth, // ширина = висота для ratio 1:1 + constraints.maxWidth, maxAllowedHeight, ); @@ -177,40 +176,42 @@ class _ReticleViewScreenState extends ConsumerState { const SizedBox(height: 8), const Divider(height: 1), const ListSectionTile('Barrel drums'), - _clickLabel(context, 'Vertical adjustment'), - UnitInputWithPicker( - value: _vAdjRaw, + listInputLabel(context, 'Vertical adjustment'), + AdjustmentInputWithClicks( + rawValue: _vAdjRaw, constraints: FC.adjustment, displayUnit: _vAdjUnit, - options: _adjUnits, + clickSizeRaw: _vClickRaw, + options: offsetUnits, unitLabel: 'Adjustment unit', onChanged: (v) { if (v != null) { setState(() => _vAdjRaw = v); - _saveAdj(); + unawaited(_saveAdj()); } }, onUnitChanged: (u) { setState(() => _vAdjUnit = u); - _saveAdj(); + unawaited(_saveAdj()); }, ), - _clickLabel(context, 'Horizontal adjustment'), - UnitInputWithPicker( - value: _hAdjRaw, + listInputLabel(context, 'Horizontal adjustment'), + AdjustmentInputWithClicks( + rawValue: _hAdjRaw, constraints: FC.adjustment, displayUnit: _hAdjUnit, - options: _adjUnits, + clickSizeRaw: _hClickRaw, + options: offsetUnits, unitLabel: 'Adjustment unit', onChanged: (v) { if (v != null) { setState(() => _hAdjRaw = v); - _saveAdj(); + unawaited(_saveAdj()); } }, onUnitChanged: (u) { setState(() => _hAdjUnit = u); - _saveAdj(); + unawaited(_saveAdj()); }, ), const Divider(height: 1), @@ -262,40 +263,40 @@ class _ReticleViewScreenState extends ConsumerState { ), const Divider(height: 1), const ListSectionTile('Clicks'), - _clickLabel(context, 'Vertical click'), + listInputLabel(context, 'Vertical click'), UnitInputWithPicker( value: _vClickRaw, constraints: FC.adjustment, displayUnit: _vClickUnit, - options: _adjUnits, + options: offsetUnits, unitLabel: 'Click unit', onChanged: (v) { if (v != null) { setState(() => _vClickRaw = v); - _saveSight(); + unawaited(_saveSight()); } }, onUnitChanged: (u) { setState(() => _vClickUnit = u); - _saveSight(); + unawaited(_saveSight()); }, ), - _clickLabel(context, 'Horizontal click'), + listInputLabel(context, 'Horizontal click'), UnitInputWithPicker( value: _hClickRaw, constraints: FC.adjustment, displayUnit: _hClickUnit, - options: _adjUnits, + options: offsetUnits, unitLabel: 'Click unit', onChanged: (v) { if (v != null) { setState(() => _hClickRaw = v); - _saveSight(); + unawaited(_saveSight()); } }, onUnitChanged: (u) { setState(() => _hClickUnit = u); - _saveSight(); + unawaited(_saveSight()); }, ), ], @@ -311,13 +312,13 @@ class _ReticleViewScreenState extends ConsumerState { Widget _buildTopBlock( BuildContext context, - double size, // тепер це і ширина і висота + double size, HomeUiReady vmState, double targetSizeMil, ) { return SizedBox( - width: double.infinity, // займає всю ширину - height: size, // висота дорівнює size (квадрат) + width: double.infinity, + height: size, child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( @@ -371,18 +372,6 @@ class _ReticleViewScreenState extends ConsumerState { ).firstMatch(svg); return m != null ? double.tryParse(m.group(1)!) ?? 0.5 : 0.0; } - - Widget _clickLabel(BuildContext context, String label) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 0, 0), - child: Text( - label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ); - } } // ── ZoomableView ────────────────────────────────────────────────────────────── @@ -449,7 +438,7 @@ class _ZoomableViewState extends State curve: Curves.easeInOut, ), ); - _animationController.forward(from: 0); + unawaited(_animationController.forward(from: 0)); } void _handleDoubleTap() { diff --git a/lib/features/home/sub_screens/sight_wizard_screen.dart b/lib/features/home/sub_screens/sight_wizard_screen.dart index a5f3c9f3..d511a120 100644 --- a/lib/features/home/sub_screens/sight_wizard_screen.dart +++ b/lib/features/home/sub_screens/sight_wizard_screen.dart @@ -6,8 +6,8 @@ import 'package:ebalistyka/core/providers/settings_provider.dart'; import 'package:ebalistyka/shared/icons_definitions.dart'; import 'package:ebalistyka/shared/widgets/base_screen.dart'; import 'package:ebalistyka/shared/widgets/list_section_tile.dart'; +import 'package:ebalistyka/shared/widgets/offsets_edit.dart'; import 'package:ebalistyka/shared/widgets/unit_constrained_input_tile.dart'; -import 'package:ebalistyka/shared/widgets/unit_constrained_input_with_unit_picker_tile.dart'; import 'package:ebalistyka_db/ebalistyka_db.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -50,14 +50,6 @@ class _SightWizardScreenState extends ConsumerState { late String? _reticleImage; - static const _clickUnits = [ - Unit.mil, - Unit.moa, - Unit.mRad, - Unit.cmPer100m, - Unit.inPer100Yd, - ]; - @override void initState() { super.initState(); @@ -279,46 +271,24 @@ class _SightWizardScreenState extends ConsumerState { // ── Clicks ──────────────────────────────────────────────── const Divider(height: 1), const ListSectionTile('Clicks'), - _clickLabel(context, 'Vertical click'), - UnitInputWithPicker( - value: _vClickRaw, - constraints: FC.adjustment, - displayUnit: _vClickUnit, - options: _clickUnits, - unitLabel: 'Click unit', - onChanged: (v) { - if (v != null) setState(() => _vClickRaw = v); - }, - onUnitChanged: (u) => setState(() => _vClickUnit = u), - ), - _clickLabel(context, 'Horizontal click'), - UnitInputWithPicker( - value: _hClickRaw, - constraints: FC.adjustment, - displayUnit: _hClickUnit, - options: _clickUnits, + offsetsTile( + context: context, + yLabel: 'Vertical click', + xLabel: 'Horizontal click', unitLabel: 'Click unit', - onChanged: (v) { - if (v != null) setState(() => _hClickRaw = v); - }, - onUnitChanged: (u) => setState(() => _hClickUnit = u), + yRaw: _vClickRaw, + xRaw: _hClickRaw, + yUnits: _vClickUnit, + xUnits: _hClickUnit, + onYChanged: (v) => setState(() => _vClickRaw = v), + onXChanged: (v) => setState(() => _hClickRaw = v), + onYUnitChanged: (u) => setState(() => _vClickUnit = u), + onXUnitChanged: (u) => setState(() => _hClickUnit = u), ), ], ), ); } - - Widget _clickLabel(BuildContext context, String label) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 0, 0), - child: Text( - label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ); - } } // ── Widgets ─────────────────────────────────────────────────────────────────── diff --git a/lib/features/home/sub_screens/svg_asset_picker_screen.dart b/lib/features/home/sub_screens/svg_asset_picker_screen.dart index 2f93479b..f348303d 100644 --- a/lib/features/home/sub_screens/svg_asset_picker_screen.dart +++ b/lib/features/home/sub_screens/svg_asset_picker_screen.dart @@ -184,7 +184,7 @@ class _SvgAssetTile extends ConsumerWidget { // Add shape-rendering for better quality result = result.replaceFirst( RegExp(r' Function(String) onSelect, ) { const langs = [('en', 'English'), ('uk', 'Українська')]; - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Language'), - content: RadioGroup( - groupValue: current, - onChanged: (v) { - if (v != null) { - onSelect(v); - Navigator.pop(ctx); - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: langs - .map((l) => RadioListTile(value: l.$1, title: Text(l.$2))) - .toList(), + unawaited( + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Language'), + content: RadioGroup( + groupValue: current, + onChanged: (v) { + if (v != null) { + unawaited(onSelect(v)); + Navigator.pop(ctx); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: langs + .map( + (l) => RadioListTile(value: l.$1, title: Text(l.$2)), + ) + .toList(), + ), ), ), ), @@ -281,11 +294,7 @@ class _ThemeSelector extends StatelessWidget { } Future _launchUrl(String url) async { - final Uri uri = Uri.parse(url); - if (!await launchUrl( - uri, - mode: LaunchMode.externalApplication, // Opens in an external browser - )) { - throw Exception('Could not launch $url'); - } + final uri = Uri.parse(url); + if (await launchUrl(uri, mode: LaunchMode.externalApplication)) return; + await launchUrl(uri, mode: LaunchMode.platformDefault); } diff --git a/lib/features/tables/sub_screens/tables_config_screen.dart b/lib/features/tables/sub_screens/tables_config_screen.dart index 02f1a425..c603204b 100644 --- a/lib/features/tables/sub_screens/tables_config_screen.dart +++ b/lib/features/tables/sub_screens/tables_config_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ebalistyka/shared/icons_definitions.dart'; import 'package:ebalistyka/shared/widgets/base_screen.dart'; import 'package:ebalistyka/shared/widgets/list_section_tile.dart'; @@ -40,7 +42,7 @@ class TableConfigScreen extends ConsumerWidget { ..showInClicks = cfg.showInClicks; mutate(updated); - notifier.saveSettings(updated); + unawaited(notifier.saveSettings(updated)); } void toggleCol(String colId, bool visible) { diff --git a/lib/features/tables/trajectory_tables_vm.dart b/lib/features/tables/trajectory_tables_vm.dart index a2e5dac4..e9fd5e27 100644 --- a/lib/features/tables/trajectory_tables_vm.dart +++ b/lib/features/tables/trajectory_tables_vm.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ebalistyka/core/extensions/num_extensions.dart'; import 'package:ebalistyka/core/extensions/profile_extensions.dart'; import 'package:ebalistyka/core/extensions/settings_extensions.dart'; @@ -70,7 +72,7 @@ class TrajectoryTablesViewModel extends AsyncNotifier { TrajectoryTablesUiEmpty(type: EmptyStateType.noProfile), ); } else { - _recalculate(); + unawaited(_recalculate()); } }, fireImmediately: true); ref.listen(tablesSettingsProvider, (prev, next) { diff --git a/lib/main.dart b/lib/main.dart index 43d4e06e..fc373f99 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,7 @@ +import 'dart:io'; +import 'dart:ui'; + import 'package:ebalistyka_db/ebalistyka_db.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ebalistyka/shared/helpers/is_desktop.dart'; @@ -22,18 +24,43 @@ const _windowInitialHeight = 812.0; // const _contentMaxWidth = _windowMaxWidth; // const _contentMaxHeight = _windowMaxHeight; +Future<(Store, bool)> _openStore(String directory) async { + final hadData = await File('$directory/data.mdb').exists(); + try { + return (await initObjectBox(directory: directory), false); + } catch (e) { + debugPrint('ObjectBox open failed — resetting DB: $e'); + for (final name in const ['data.mdb', 'lock.mdb']) { + final f = File('$directory/$name'); + if (await f.exists()) await f.delete(); + } + return (await initObjectBox(directory: directory), hadData); + } +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); if (isDesktop) { await windowManager.ensureInitialized(); - const windowOptions = WindowOptions( - size: Size(_windowInitialWidth, _windowInitialHeight), - minimumSize: Size(_windowMinWidth, _windowMinHeight), - // maximumSize: Size(_windowMaxWidth, _windowMaxHeight), + final double ratio = + PlatformDispatcher.instance.views.first.devicePixelRatio; + + final size = Size( + _windowInitialWidth * ratio, + _windowInitialHeight * ratio, + ); + final minSize = Size(_windowMinWidth * ratio, _windowMinHeight * ratio); + + WindowOptions windowOptions = WindowOptions( + size: size, + minimumSize: minSize, center: true, title: 'eBalistyka', + backgroundColor: Colors.transparent, + skipTaskbar: false, + titleBarStyle: TitleBarStyle.normal, ); windowManager.waitUntilReadyToShow(windowOptions, () async { @@ -41,23 +68,21 @@ void main() async { await windowManager.setIcon('assets/icon.png'); await windowManager.focus(); - await windowManager.setMinimumSize( - const Size(_windowMinWidth, _windowMinHeight), - ); - // await windowManager.setMaximumSize( - // const Size(_windowMaxWidth, _windowMaxHeight), - // ); + await windowManager.setMinimumSize(minSize); await windowManager.setMaximizable(false); }); } final appSupport = await getApplicationSupportDirectory(); - final store = await initObjectBox(directory: appSupport.path); + final (store, dbWasReset) = await _openStore(appSupport.path); debugPrint("DB path: ${appSupport.path}"); runApp( ProviderScope( - overrides: [dbProvider.overrideWithValue(store)], + overrides: [ + dbProvider.overrideWithValue(store), + dbWasResetProvider.overrideWithValue(dbWasReset), + ], child: const MyApp(), ), ); @@ -73,6 +98,37 @@ class _AppScrollBehavior extends MaterialScrollBehavior { }; } +class _DbResetBanner extends ConsumerStatefulWidget { + const _DbResetBanner({required this.child}); + final Widget child; + + @override + ConsumerState<_DbResetBanner> createState() => _DbResetBannerState(); +} + +class _DbResetBannerState extends ConsumerState<_DbResetBanner> { + @override + void initState() { + super.initState(); + if (ref.read(dbWasResetProvider)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Database was corrupted and has been reset. All data has been cleared.', + ), + duration: Duration(seconds: 6), + ), + ); + }); + } + } + + @override + Widget build(BuildContext context) => widget.child; +} + class MyApp extends ConsumerWidget { const MyApp({super.key}); @@ -103,18 +159,11 @@ class MyApp extends ConsumerWidget { themeMode: themeMode, scrollBehavior: _AppScrollBehavior(), builder: (context, child) { + final inner = _DbResetBanner(child: child!); if (isDesktop) { - return Center( - child: Container( - // constraints: const BoxConstraints( - // maxWidth: _contentMaxWidth, - // maxHeight: _contentMaxHeight, - // ), - child: child, - ), - ); + return Center(child: Container(child: inner)); } - return child!; + return inner; }, ); } diff --git a/lib/shared/widgets/adjustment_input_with_clicks.dart b/lib/shared/widgets/adjustment_input_with_clicks.dart new file mode 100644 index 00000000..36d847cb --- /dev/null +++ b/lib/shared/widgets/adjustment_input_with_clicks.dart @@ -0,0 +1,235 @@ +import 'dart:async'; + +import 'package:bclibc_ffi/unit.dart'; +import 'package:ebalistyka/core/models/field_constraints.dart'; +import 'package:ebalistyka/shared/icons_definitions.dart'; +import 'package:ebalistyka/shared/widgets/unit_constrained_input_field.dart'; +import 'package:flutter/material.dart'; + +const _clicksSymbol = 'click'; +const _clicksLabel = 'Clicks'; + +/// Like [UnitInputWithPicker] but also exposes a "Clicks" pseudo-unit. +/// +/// [displayUnit] == null means clicks mode; the input field shows the +/// adjustment value divided by [clickSizeRaw] (one click in the raw unit). +/// [onUnitChanged] receives null when the user picks "Clicks". +class AdjustmentInputWithClicks extends StatelessWidget { + const AdjustmentInputWithClicks({ + required this.rawValue, + required this.constraints, + required this.displayUnit, + required this.clickSizeRaw, + required this.options, + required this.onChanged, + required this.onUnitChanged, + this.unitLabel = 'Select Unit', + super.key, + }); + + final double? rawValue; + final FieldConstraints constraints; + final Unit? displayUnit; + final double clickSizeRaw; + final List options; + final ValueChanged onChanged; + final ValueChanged onUnitChanged; + final String unitLabel; + + @override + Widget build(BuildContext context) { + return ListTile( + title: displayUnit == null + ? _ClicksInputField( + rawValue: rawValue, + clickSizeRaw: clickSizeRaw, + onChanged: onChanged, + ) + : ConstrainedUnitInputField( + rawValue: rawValue, + constraints: constraints, + displayUnit: displayUnit!, + onChanged: onChanged, + hideSymbol: true, + ), + trailing: _AdjUnitPickerButton( + current: displayUnit, + options: options, + label: unitLabel, + onChanged: onUnitChanged, + ), + dense: true, + ); + } +} + +// ── Clicks input ───────────────────────────────────────────────────────────── + +class _ClicksInputField extends StatefulWidget { + const _ClicksInputField({ + required this.rawValue, + required this.clickSizeRaw, + required this.onChanged, + }); + + final double? rawValue; + final double clickSizeRaw; + final ValueChanged onChanged; + + @override + State<_ClicksInputField> createState() => _ClicksInputFieldState(); +} + +class _ClicksInputFieldState extends State<_ClicksInputField> { + late final TextEditingController _controller; + late final FocusNode _focusNode; + + double get _clicks => (widget.rawValue != null && widget.clickSizeRaw != 0) + ? widget.rawValue! / widget.clickSizeRaw + : 0; + + String _format(double v) => v.toStringAsFixed(0); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: _format(_clicks)); + _focusNode = FocusNode() + ..addListener(() { + if (!_focusNode.hasFocus && mounted) _submit(); + }); + } + + @override + void didUpdateWidget(_ClicksInputField old) { + super.didUpdateWidget(old); + if ((widget.rawValue != old.rawValue || + widget.clickSizeRaw != old.clickSizeRaw) && + !_focusNode.hasFocus) { + _controller.text = _format(_clicks); + } + } + + void _submit() { + final v = double.tryParse(_controller.text.replaceAll(',', '.')); + if (v != null) widget.onChanged(v * widget.clickSizeRaw); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + focusNode: _focusNode, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + textInputAction: TextInputAction.done, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + decoration: const InputDecoration( + suffixText: _clicksSymbol, + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + onSubmitted: (_) => _submit(), + ); + } +} + +// ── Unit picker ─────────────────────────────────────────────────────────────── + +class _AdjUnitPickerButton extends StatelessWidget { + const _AdjUnitPickerButton({ + required this.current, + required this.options, + required this.label, + required this.onChanged, + }); + + final Unit? current; + final List options; + final String label; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final symbol = current?.symbol ?? _clicksSymbol; + return SizedBox( + width: 60, + child: InkWell( + onTap: () => _showPicker(context), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Text( + symbol, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(IconDef.dropDown, size: 20), + ], + ), + ), + ), + ); + } + + void _showPicker(BuildContext context) { + unawaited( + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text(label, style: Theme.of(ctx).textTheme.titleMedium), + ), + const Divider(height: 1), + ListTile( + title: const Text('$_clicksLabel ($_clicksSymbol)'), + trailing: current == null ? const Icon(IconDef.apply) : null, + onTap: () { + onChanged(null); + Navigator.pop(ctx); + }, + ), + ...options.map( + (unit) => ListTile( + title: Text('${unit.label} (${unit.symbol})'), + trailing: current == unit ? const Icon(IconDef.apply) : null, + onTap: () { + onChanged(unit); + Navigator.pop(ctx); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/click_label.dart b/lib/shared/widgets/click_label.dart new file mode 100644 index 00000000..51393775 --- /dev/null +++ b/lib/shared/widgets/click_label.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +Widget listInputLabel(BuildContext context, String label) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 0, 0), + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); +} diff --git a/lib/shared/widgets/offsets_edit.dart b/lib/shared/widgets/offsets_edit.dart new file mode 100644 index 00000000..0bf00981 --- /dev/null +++ b/lib/shared/widgets/offsets_edit.dart @@ -0,0 +1,59 @@ +import 'package:bclibc_ffi/unit.dart'; +import 'package:ebalistyka/core/models/field_constraints.dart'; +import 'package:ebalistyka/shared/widgets/click_label.dart'; +import 'package:ebalistyka/shared/widgets/unit_constrained_input_with_unit_picker_tile.dart'; +import 'package:flutter/material.dart'; + +const offsetUnits = [ + Unit.mil, + Unit.moa, + Unit.mRad, + Unit.cmPer100m, + Unit.inPer100Yd, +]; + +Widget offsetsTile({ + required BuildContext context, + required String yLabel, + required String xLabel, + required String unitLabel, + required double yRaw, + required double xRaw, + required Unit yUnits, + required Unit xUnits, + required void Function(double) onYChanged, + required void Function(double) onXChanged, + required void Function(Unit) onYUnitChanged, + required void Function(Unit) onXUnitChanged, +}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Zeroing offset ──────────────────────────────────────── + listInputLabel(context, yLabel), + UnitInputWithPicker( + value: yRaw, + constraints: FC.adjustment, + displayUnit: yUnits, + options: offsetUnits, + unitLabel: unitLabel, + onChanged: (v) { + if (v != null) onYChanged(v); + }, + onUnitChanged: onYUnitChanged, + ), + listInputLabel(context, xLabel), + UnitInputWithPicker( + value: xRaw, + constraints: FC.adjustment, + displayUnit: xUnits, + options: offsetUnits, + unitLabel: unitLabel, + onChanged: (v) { + if (v != null) onXChanged(v); + }, + onUnitChanged: onXUnitChanged, + ), + ], + ); +} diff --git a/lib/shared/widgets/pages_dots_indicator.dart b/lib/shared/widgets/pages_dots_indicator.dart index 4cce243c..ce852cc3 100644 --- a/lib/shared/widgets/pages_dots_indicator.dart +++ b/lib/shared/widgets/pages_dots_indicator.dart @@ -38,6 +38,9 @@ class PageDotsIndicator extends StatelessWidget { icon: Icon(IconDef.chevronLeft, size: 24), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), ), // Dots @@ -61,6 +64,9 @@ class PageDotsIndicator extends StatelessWidget { icon: Icon(IconDef.chevronRight, size: 24), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), ), ], ); diff --git a/lib/shared/widgets/reticle_view.dart b/lib/shared/widgets/reticle_view.dart index af357dd8..2846d4e5 100644 --- a/lib/shared/widgets/reticle_view.dart +++ b/lib/shared/widgets/reticle_view.dart @@ -136,7 +136,7 @@ class ReticleView extends ConsumerWidget { // Build SVG elements for underlay final buffer = StringBuffer(); buffer.writeln('', ); diff --git a/lib/shared/widgets/unit_constrained_input_dialog.dart b/lib/shared/widgets/unit_constrained_input_dialog.dart index 406c88e1..939c96da 100644 --- a/lib/shared/widgets/unit_constrained_input_dialog.dart +++ b/lib/shared/widgets/unit_constrained_input_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ebalistyka/shared/helpers/unit_constrained_convertion_helper.dart'; import 'package:ebalistyka/shared/icons_definitions.dart'; import 'package:ebalistyka/shared/models/unit_picker_context.dart'; @@ -185,14 +187,16 @@ class _UnitEditDialogContentState extends State<_UnitEditDialogContent> { // ── Public dialog functions ───────────────────────────────────────────────── void showUnitEditDialog(UnitPickerContext pickerContext) { - showDialog( - context: pickerContext.buildContext, - builder: (ctx) => Dialog( - backgroundColor: Colors.transparent, - elevation: 0, - child: _UnitEditDialogContent( - pickerContext: pickerContext, - initialRawValue: pickerContext.rawValue, + unawaited( + showDialog( + context: pickerContext.buildContext, + builder: (ctx) => Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + child: _UnitEditDialogContent( + pickerContext: pickerContext, + initialRawValue: pickerContext.rawValue, + ), ), ), ); diff --git a/lib/shared/widgets/unit_hybrid_picker_dialog.dart b/lib/shared/widgets/unit_hybrid_picker_dialog.dart index 9c4eb3a5..98750542 100644 --- a/lib/shared/widgets/unit_hybrid_picker_dialog.dart +++ b/lib/shared/widgets/unit_hybrid_picker_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bclibc_ffi/unit.dart'; import 'package:ebalistyka/core/models/field_constraints.dart'; import 'package:ebalistyka/shared/helpers/unit_constrained_convertion_helper.dart'; @@ -224,14 +226,16 @@ class _UnitHybridPickerState extends State { } void showUnitHybridPickerDialog(UnitPickerContext pickerContext) { - showDialog( - context: pickerContext.buildContext, - builder: (context) => Dialog( - backgroundColor: Colors.transparent, - insetPadding: const EdgeInsets.symmetric(horizontal: 40), - child: UnitHybridPicker( - pickerContext: pickerContext, - initialRawValue: pickerContext.rawValue, + unawaited( + showDialog( + context: pickerContext.buildContext, + builder: (context) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 40), + child: UnitHybridPicker( + pickerContext: pickerContext, + initialRawValue: pickerContext.rawValue, + ), ), ), ); diff --git a/lib/shared/widgets/unit_picker_button.dart b/lib/shared/widgets/unit_picker_button.dart index 4e3b3df3..40ba0346 100644 --- a/lib/shared/widgets/unit_picker_button.dart +++ b/lib/shared/widgets/unit_picker_button.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bclibc_ffi/unit.dart'; import 'package:ebalistyka/shared/icons_definitions.dart'; import 'package:flutter/material.dart'; @@ -50,32 +52,34 @@ class UnitPickerButton extends StatelessWidget { } void _showPicker(BuildContext context) { - showModalBottomSheet( - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text(label, style: Theme.of(ctx).textTheme.titleMedium), - ), - const Divider(height: 1), - ...options.map( - (unit) => ListTile( - title: Text("${unit.label} (${unit.symbol})"), - trailing: current == unit ? const Icon(IconDef.apply) : null, - onTap: () { - onChanged(unit); - Navigator.pop(ctx); - }, + unawaited( + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text(label, style: Theme.of(ctx).textTheme.titleMedium), ), - ), - const SizedBox(height: 8), - ], + const Divider(height: 1), + ...options.map( + (unit) => ListTile( + title: Text("${unit.label} (${unit.symbol})"), + trailing: current == unit ? const Icon(IconDef.apply) : null, + onTap: () { + onChanged(unit); + Navigator.pop(ctx); + }, + ), + ), + const SizedBox(height: 8), + ], + ), ), ), ); diff --git a/lib/shared/widgets/unit_wheel_picker_dialog.dart b/lib/shared/widgets/unit_wheel_picker_dialog.dart index a5cc42b8..f1a190df 100644 --- a/lib/shared/widgets/unit_wheel_picker_dialog.dart +++ b/lib/shared/widgets/unit_wheel_picker_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bclibc_ffi/unit.dart'; import 'package:ebalistyka/core/models/field_constraints.dart' as fc; import 'package:ebalistyka/shared/helpers/unit_constrained_convertion_helper.dart'; @@ -105,16 +107,18 @@ class _UnitWheelPickerState extends State { } void showUnitWheelPickerDialog(UnitPickerContext pickerContext) { - showDialog( - context: pickerContext.buildContext, - builder: (context) => Dialog( - backgroundColor: Colors.transparent, - child: UnitWheelPicker( - label: pickerContext.label, - constraints: pickerContext.constraints, - initialRawValue: pickerContext.rawValue, - displayUnit: pickerContext.displayUnit, - onSave: pickerContext.onChanged, + unawaited( + showDialog( + context: pickerContext.buildContext, + builder: (context) => Dialog( + backgroundColor: Colors.transparent, + child: UnitWheelPicker( + label: pickerContext.label, + constraints: pickerContext.constraints, + initialRawValue: pickerContext.rawValue, + displayUnit: pickerContext.displayUnit, + onSave: pickerContext.onChanged, + ), ), ), ); diff --git a/lib/shared/widgets/unit_wheel_picker_widget.dart b/lib/shared/widgets/unit_wheel_picker_widget.dart index f5bf9712..93b816a2 100644 --- a/lib/shared/widgets/unit_wheel_picker_widget.dart +++ b/lib/shared/widgets/unit_wheel_picker_widget.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:bclibc_ffi/unit.dart'; import 'package:ebalistyka/core/models/field_constraints.dart' as fc; @@ -146,7 +147,7 @@ class _UnitWheelPickerWidgetState extends State { .clamp(widget.constraints.minRaw, widget.constraints.maxRaw); }); widget.onChanged(_currentRawValue); - HapticFeedback.selectionClick(); + unawaited(HapticFeedback.selectionClick()); } @override diff --git a/packages/a7p/lib/src/a7p_converter.dart b/packages/a7p/lib/src/a7p_converter.dart index 7c539084..a0c83466 100644 --- a/packages/a7p/lib/src/a7p_converter.dart +++ b/packages/a7p/lib/src/a7p_converter.dart @@ -154,7 +154,8 @@ abstract final class A7pConverter { zeroAzimuthDeg: 0.0, zeroOffsetX: 0.0, zeroOffsetY: 0.0, - zeroOffsetUnit: "mil", + zeroOffsetXUnit: "mil", + zeroOffsetYUnit: "mil", ); } diff --git a/packages/bclibc_ffi/lib/ffi/bclibc_ffi.dart b/packages/bclibc_ffi/lib/ffi/bclibc_ffi.dart index 99cc2bee..197bfd18 100644 --- a/packages/bclibc_ffi/lib/ffi/bclibc_ffi.dart +++ b/packages/bclibc_ffi/lib/ffi/bclibc_ffi.dart @@ -30,6 +30,7 @@ ffi.DynamicLibrary _openLibrary() { } if (Platform.isLinux) return ffi.DynamicLibrary.open(lib('libbclibc_ffi.so')); + if (Platform.isAndroid) return ffi.DynamicLibrary.open('libbclibc_ffi.so'); if (Platform.isWindows) return ffi.DynamicLibrary.open(lib('bclibc_ffi.dll')); if (Platform.isMacOS) { return ffi.DynamicLibrary.open(lib('libbclibc_ffi.dylib')); diff --git a/packages/bclibc_ffi/src/CMakeLists.txt b/packages/bclibc_ffi/src/CMakeLists.txt new file mode 100644 index 00000000..66ce0597 --- /dev/null +++ b/packages/bclibc_ffi/src/CMakeLists.txt @@ -0,0 +1,24 @@ +cmake_minimum_required(VERSION 3.13) + +set(PROJECT_NAME "bclibc_ffi") +project(${PROJECT_NAME} LANGUAGES C CXX) + +# packages/bclibc_ffi/src/ → ../../.. → project root +get_filename_component(PROJECT_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../.." ABSOLUTE) +set(BCLIBC_ROOT "${PROJECT_ROOT}/external/bclibc") + +message(STATUS "[bclibc_ffi/android] Project root: ${PROJECT_ROOT}") +message(STATUS "[bclibc_ffi/android] bclibc path: ${BCLIBC_ROOT}") + +if(NOT EXISTS "${BCLIBC_ROOT}") + message(FATAL_ERROR "bclibc not found at ${BCLIBC_ROOT}") +endif() + +if(NOT EXISTS "${BCLIBC_ROOT}/CMakeLists.txt") + message(FATAL_ERROR "bclibc CMakeLists.txt not found at ${BCLIBC_ROOT}") +endif() + +add_subdirectory( + "${BCLIBC_ROOT}" + "${CMAKE_CURRENT_BINARY_DIR}/bclibc_ffi_build" +) diff --git a/packages/ebalistyka_db/lib/objectbox-model.json b/packages/ebalistyka_db/lib/objectbox-model.json index 4f4f933c..3c47a0e6 100644 --- a/packages/ebalistyka_db/lib/objectbox-model.json +++ b/packages/ebalistyka_db/lib/objectbox-model.json @@ -5,7 +5,7 @@ "entities": [ { "id": "1:481047032094263803", - "lastPropertyId": "64:8581311930876680754", + "lastPropertyId": "66:5976187693795951477", "name": "Ammo", "properties": [ { @@ -210,8 +210,13 @@ "type": 8 }, { - "id": "64:8581311930876680754", - "name": "zeroOffsetUnit", + "id": "65:3662876034954873277", + "name": "zeroOffsetXUnit", + "type": 9 + }, + { + "id": "66:5976187693795951477", + "name": "zeroOffsetYUnit", "type": 9 } ], @@ -1124,7 +1129,8 @@ 968810543137024596, 4141946973460114136, 2449350094904024515, - 3182962183369396844 + 3182962183369396844, + 8581311930876680754 ], "retiredRelationUids": [], "version": 1 diff --git a/packages/ebalistyka_db/lib/objectbox.g.dart b/packages/ebalistyka_db/lib/objectbox.g.dart index ae31433d..e9d1a288 100644 --- a/packages/ebalistyka_db/lib/objectbox.g.dart +++ b/packages/ebalistyka_db/lib/objectbox.g.dart @@ -22,7 +22,7 @@ final _entities = [ obx_int.ModelEntity( id: const obx_int.IdUid(1, 481047032094263803), name: 'Ammo', - lastPropertyId: const obx_int.IdUid(64, 8581311930876680754), + lastPropertyId: const obx_int.IdUid(66, 5976187693795951477), flags: 0, properties: [ obx_int.ModelProperty( @@ -264,8 +264,14 @@ final _entities = [ flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(64, 8581311930876680754), - name: 'zeroOffsetUnit', + id: const obx_int.IdUid(65, 3662876034954873277), + name: 'zeroOffsetXUnit', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(66, 5976187693795951477), + name: 'zeroOffsetYUnit', type: 9, flags: 0, ), @@ -1396,6 +1402,7 @@ obx_int.ModelDefinition getObjectBoxModel() { 4141946973460114136, 2449350094904024515, 3182962183369396844, + 8581311930876680754, ], retiredRelationUids: const [], modelVersion: 5, @@ -1454,8 +1461,9 @@ obx_int.ModelDefinition getObjectBoxModel() { final customDragTableMachOffset = object.customDragTableMach == null ? null : fbb.writeListFloat64(object.customDragTableMach!); - final zeroOffsetUnitOffset = fbb.writeString(object.zeroOffsetUnit); - fbb.startTable(65); + final zeroOffsetXUnitOffset = fbb.writeString(object.zeroOffsetXUnit); + final zeroOffsetYUnitOffset = fbb.writeString(object.zeroOffsetYUnit); + fbb.startTable(67); fbb.addInt64(0, object.id); fbb.addOffset(1, nameOffset); fbb.addOffset(5, dragTypeValueOffset); @@ -1495,7 +1503,8 @@ obx_int.ModelDefinition getObjectBoxModel() { fbb.addFloat64(59, object.zeroAltitudeMeter); fbb.addFloat64(61, object.zeroOffsetX); fbb.addFloat64(62, object.zeroOffsetY); - fbb.addOffset(63, zeroOffsetUnitOffset); + fbb.addOffset(64, zeroOffsetXUnitOffset); + fbb.addOffset(65, zeroOffsetYUnitOffset); fbb.finish(fbb.endTable()); return object.id; }, @@ -1670,9 +1679,12 @@ obx_int.ModelDefinition getObjectBoxModel() { 128, 0, ) - ..zeroOffsetUnit = const fb.StringReader( + ..zeroOffsetXUnit = const fb.StringReader( asciiOptimization: true, - ).vTableGet(buffer, rootOffset, 130, ''); + ).vTableGet(buffer, rootOffset, 132, '') + ..zeroOffsetYUnit = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 134, ''); object.owner.targetId = const fb.Int64Reader().vTableGet( buffer, rootOffset, @@ -3027,11 +3039,16 @@ class Ammo_ { _entities[0].properties[38], ); - /// See [Ammo.zeroOffsetUnit]. - static final zeroOffsetUnit = obx.QueryStringProperty( + /// See [Ammo.zeroOffsetXUnit]. + static final zeroOffsetXUnit = obx.QueryStringProperty( _entities[0].properties[39], ); + /// See [Ammo.zeroOffsetYUnit]. + static final zeroOffsetYUnit = obx.QueryStringProperty( + _entities[0].properties[40], + ); + /// see [Ammo.profiles] static final profiles = obx.QueryBacklinkToMany(Profile_.ammo); } diff --git a/packages/ebalistyka_db/lib/src/entities.dart b/packages/ebalistyka_db/lib/src/entities.dart index 689ea8f6..fc2b87ad 100644 --- a/packages/ebalistyka_db/lib/src/entities.dart +++ b/packages/ebalistyka_db/lib/src/entities.dart @@ -204,7 +204,8 @@ class Ammo with Cloneable { double zeroOffsetX = 0.0; double zeroOffsetY = 0.0; - String zeroOffsetUnit = "mil"; + String zeroOffsetXUnit = "mil"; + String zeroOffsetYUnit = "mil"; String? projectileName; String? vendor; @@ -252,7 +253,8 @@ class Ammo with Cloneable { double? zeroAzimuthDeg, double? zeroOffsetX, double? zeroOffsetY, - String? zeroOffsetUnit, + String? zeroOffsetXUnit, + String? zeroOffsetYUnit, String? projectileName, String? vendor, String? image, @@ -298,7 +300,8 @@ class Ammo with Cloneable { ..zeroAzimuthDeg = zeroAzimuthDeg ?? this.zeroAzimuthDeg ..zeroOffsetX = zeroOffsetX ?? this.zeroOffsetX ..zeroOffsetY = zeroOffsetY ?? this.zeroOffsetY - ..zeroOffsetUnit = zeroOffsetUnit ?? this.zeroOffsetUnit + ..zeroOffsetXUnit = zeroOffsetXUnit ?? this.zeroOffsetXUnit + ..zeroOffsetYUnit = zeroOffsetYUnit ?? this.zeroOffsetYUnit ..projectileName = projectileName ?? this.projectileName ..vendor = vendor ?? this.vendor ..image = image ?? this.image; diff --git a/packages/ebalistyka_db/lib/src/export/ammo_export.dart b/packages/ebalistyka_db/lib/src/export/ammo_export.dart index 81b43fde..af641cb1 100644 --- a/packages/ebalistyka_db/lib/src/export/ammo_export.dart +++ b/packages/ebalistyka_db/lib/src/export/ammo_export.dart @@ -37,7 +37,8 @@ class AmmoExport { required this.zeroAzimuthDeg, required this.zeroOffsetX, required this.zeroOffsetY, - required this.zeroOffsetUnit, + required this.zeroOffsetXUnit, + required this.zeroOffsetYUnit, this.powderSensitivityTC, this.powderSensitivityVMps, this.multiBcTableG1VMps, @@ -77,7 +78,8 @@ class AmmoExport { final double zeroAzimuthDeg; final double zeroOffsetX; final double zeroOffsetY; - final String zeroOffsetUnit; + final String zeroOffsetXUnit; + final String zeroOffsetYUnit; final Float64List? powderSensitivityTC; final Float64List? powderSensitivityVMps; final Float64List? multiBcTableG1VMps; @@ -122,7 +124,8 @@ class AmmoExport { zeroAzimuthDeg: a.zeroAzimuthDeg, zeroOffsetX: a.zeroOffsetX, zeroOffsetY: a.zeroOffsetY, - zeroOffsetUnit: a.zeroOffsetUnit, + zeroOffsetXUnit: a.zeroOffsetXUnit, + zeroOffsetYUnit: a.zeroOffsetYUnit, powderSensitivityTC: a.powderSensitivityTC, powderSensitivityVMps: a.powderSensitivityVMps, multiBcTableG1VMps: a.multiBcTableG1VMps, @@ -163,7 +166,8 @@ class AmmoExport { ..zeroAzimuthDeg = zeroAzimuthDeg ..zeroOffsetX = zeroOffsetX ..zeroOffsetY = zeroOffsetY - ..zeroOffsetUnit = zeroOffsetUnit + ..zeroOffsetXUnit = zeroOffsetXUnit + ..zeroOffsetYUnit = zeroOffsetYUnit ..powderSensitivityTC = powderSensitivityTC ..powderSensitivityVMps = powderSensitivityVMps ..multiBcTableG1VMps = multiBcTableG1VMps diff --git a/packages/ebalistyka_db/lib/src/export/ammo_export.g.dart b/packages/ebalistyka_db/lib/src/export/ammo_export.g.dart index 3472b4f7..a21a0fd9 100644 --- a/packages/ebalistyka_db/lib/src/export/ammo_export.g.dart +++ b/packages/ebalistyka_db/lib/src/export/ammo_export.g.dart @@ -34,7 +34,8 @@ AmmoExport _$AmmoExportFromJson(Map json) => AmmoExport( zeroAzimuthDeg: (json['zeroAzimuthDeg'] as num).toDouble(), zeroOffsetX: (json['zeroOffsetX'] as num).toDouble(), zeroOffsetY: (json['zeroOffsetY'] as num).toDouble(), - zeroOffsetUnit: json['zeroOffsetUnit'] as String, + zeroOffsetXUnit: json['zeroOffsetXUnit'] as String, + zeroOffsetYUnit: json['zeroOffsetYUnit'] as String, powderSensitivityTC: const Float64ListConverter().fromJson( json['powderSensitivityTC'] as List?, ), @@ -92,7 +93,8 @@ Map _$AmmoExportToJson(AmmoExport instance) => 'zeroAzimuthDeg': instance.zeroAzimuthDeg, 'zeroOffsetX': instance.zeroOffsetX, 'zeroOffsetY': instance.zeroOffsetY, - 'zeroOffsetUnit': instance.zeroOffsetUnit, + 'zeroOffsetXUnit': instance.zeroOffsetXUnit, + 'zeroOffsetYUnit': instance.zeroOffsetYUnit, 'powderSensitivityTC': ?const Float64ListConverter().toJson( instance.powderSensitivityTC, ), diff --git a/packages/reticle_gen/lib/gen_reticles.sh b/packages/reticle_gen/lib/gen_reticles.sh deleted file mode 100755 index 90ef2d4e..00000000 --- a/packages/reticle_gen/lib/gen_reticles.sh +++ /dev/null @@ -1,17 +0,0 @@ -dart packages/reticle_gen/lib/target_default.dart assets/svg/targets/default.svg -dart packages/reticle_gen/lib/target_plate20sm.dart assets/svg/targets/plate20sm.svg - -dart packages/reticle_gen/lib/ddr_2.dart assets/svg/reticles/DDR-2.svg -dart packages/reticle_gen/lib/moar.dart "ATACR 35x" assets/svg/reticles/MOAR.svg -dart packages/reticle_gen/lib/mil_r_f1.dart "ATACR 35x F1" assets/svg/reticles/MIL-R-F1.svg -dart packages/reticle_gen/lib/moar_t.dart "NXS 22x & 32x" assets/svg/reticles/MOAR-T.svg -dart packages/reticle_gen/lib/mil_c_f1.dart "NX8 20x" assets/svg/reticles/MIL-C-F1.svg -dart packages/reticle_gen/lib/mil_xt.dart "ATACR 7-35" assets/svg/reticles/MIL-XT.svg -dart packages/reticle_gen/lib/default.dart assets/svg/reticles/default.svg -# dart packages/reticle_gen/lib/mil_xt.dart "ATACR 7-35" assets/reticles/mil-xt_atacr_7-35.svg -# dart packages/reticle_gen/lib/mil_xt.dart "ATACR 5-25" assets/reticles/mil-xt_atacr_5-25.svg -# dart packages/reticle_gen/lib/mil_xt.dart "ATACR 4-20" assets/reticles/mil-xt_atacr_4-20.svg -# dart packages/reticle_gen/lib/mil_xt.dart "ATACR 4-16" assets/reticles/mil-xt_atacr_4-16.svg -# dart packages/reticle_gen/lib/mil_xt.dart "NX8 4-32" assets/reticles/mil-xt_nx8_4-32.svg -# dart packages/reticle_gen/lib/mil_xt.dart "NX8 2.5-20" assets/reticles/mil-xt_nx8_2.5-20.svg -# dart packages/reticle_gen/lib/mil_xt.dart "SHV 4-14" assets/reticles/mil-xt_shv_4-14.svg diff --git a/packages/reticle_gen/lib/mil_c_f1.dart b/packages/reticle_gen/lib/mil_c_f1.dart index 0ceb7ee4..cb951966 100644 --- a/packages/reticle_gen/lib/mil_c_f1.dart +++ b/packages/reticle_gen/lib/mil_c_f1.dart @@ -199,7 +199,6 @@ class MilCf1ReticleDrawer implements SVGDrawerInterface { ..moveTo(-(A / 2 + I), 0) ..lineTo(-(A / 2 + 1), -D / 2)) .d, - 'none', stroke: color, strokeWidth: C, ) diff --git a/packages/reticle_gen/lib/mil_r_f1.dart b/packages/reticle_gen/lib/mil_r_f1.dart index 746539bb..f54d47e4 100644 --- a/packages/reticle_gen/lib/mil_r_f1.dart +++ b/packages/reticle_gen/lib/mil_r_f1.dart @@ -174,7 +174,6 @@ class MilRF1ReticleDrawer implements SVGDrawerInterface { ..moveTo(-30, 0) ..lineTo(-(A / 2 + I), 0)) .d, - 'none', stroke: color, strokeWidth: B, ) @@ -191,7 +190,6 @@ class MilRF1ReticleDrawer implements SVGDrawerInterface { ..lineTo(-(A / 2 + 1), -D / 2) ..lineTo(-30, -D / 2)) .d, - 'none', stroke: color, strokeWidth: C, ) diff --git a/packages/reticle_gen/lib/mil_xt.dart b/packages/reticle_gen/lib/mil_xt.dart index 3f39ca69..b63ec607 100644 --- a/packages/reticle_gen/lib/mil_xt.dart +++ b/packages/reticle_gen/lib/mil_xt.dart @@ -146,7 +146,6 @@ class MilXtReticleDrawer implements SVGDrawerInterface { ..moveTo(-11, minHalfI) ..lineTo(-10.2, 0)) .d, - 'none', stroke: color, strokeWidth: A, ) diff --git a/packages/reticle_gen/lib/moar.dart b/packages/reticle_gen/lib/moar.dart index 719082a2..8d7011a9 100644 --- a/packages/reticle_gen/lib/moar.dart +++ b/packages/reticle_gen/lib/moar.dart @@ -192,7 +192,6 @@ class MoarReticleDrawer implements SVGDrawerInterface { ..moveTo(-40, 0) ..lineTo(-(A / 2 + M), 0)) .d, - 'none', stroke: color, strokeWidth: H, ) @@ -211,7 +210,6 @@ class MoarReticleDrawer implements SVGDrawerInterface { ..lineTo(-40, -B / 2) ..close()) .d, - 'none', stroke: color, strokeWidth: I, ); diff --git a/packages/reticle_gen/lib/moar_t.dart b/packages/reticle_gen/lib/moar_t.dart index c8938752..b3c90553 100644 --- a/packages/reticle_gen/lib/moar_t.dart +++ b/packages/reticle_gen/lib/moar_t.dart @@ -149,7 +149,6 @@ class MoarTReticleDrawer implements SVGDrawerInterface { ..moveTo(-35, 0) ..lineTo(-(A / 2 + M), 0)) .d, - 'none', stroke: color, strokeWidth: J, ) @@ -166,7 +165,6 @@ class MoarTReticleDrawer implements SVGDrawerInterface { ..lineTo(-(A / 2 + 3), -B / 2) ..lineTo(-35, -B / 2)) .d, - 'none', stroke: color, strokeWidth: I, ); @@ -177,7 +175,6 @@ class MoarTReticleDrawer implements SVGDrawerInterface { ..moveTo(0, 35) ..lineTo(0, A / 2 + M)) .d, - 'none', stroke: color, strokeWidth: J, ) @@ -189,7 +186,6 @@ class MoarTReticleDrawer implements SVGDrawerInterface { ..lineTo(-B / 2, A / 2 + 3) ..lineTo(-B / 2, 35)) .d, - 'none', stroke: color, strokeWidth: I, ) diff --git a/packages/reticle_gen/lib/reticle_gen.dart b/packages/reticle_gen/lib/reticle_gen.dart index d9e2be13..7f0ad068 100644 --- a/packages/reticle_gen/lib/reticle_gen.dart +++ b/packages/reticle_gen/lib/reticle_gen.dart @@ -4,23 +4,35 @@ import 'dart:math' as math; import 'package:xml/xml.dart'; import 'dart:io'; +enum CirclesBatchMode { none, path, approx } + +const circlesBatchMode = CirclesBatchMode.none; + extension SvgExport on XmlElement { void export([String? filePath]) { File(filePath ?? 'temp.svg').writeAsStringSync(toXmlString(pretty: true)); } } -/// Formats [v] as a compact SVG number, rounded to 3 decimal places. +/// Formats [v] as a compact SVG number, rounded to 4 decimal places. /// Trailing zeros after the decimal point are stripped (e.g. 1.500 → "1.5"). String _fmtNum(double v) { - final rounded = (v * 1000).roundToDouble() / 1000; - if (rounded == rounded.truncateToDouble()) { - return rounded.toInt().toString(); - } - return rounded - .toStringAsFixed(3) + // better approach + return v + .toStringAsFixed(4) .replaceAll(RegExp(r'0+$'), '') .replaceAll(RegExp(r'\.$'), ''); + + // // can increese error + // final rounded = (v * 1000).roundToDouble() / 1000; + + // if (rounded == rounded.truncateToDouble()) { + // return rounded.toInt().toString(); + // } + // return rounded + // .toStringAsFixed(3) + // .replaceAll(RegExp(r'0+$'), '') + // .replaceAll(RegExp(r'\.$'), ''); } /// Accumulates SVG path commands into a `d` attribute string. @@ -30,8 +42,10 @@ String _fmtNum(double v) { class PathBuilder { final StringBuffer _buffer = StringBuffer(); - void moveTo(double x, double y) => _buffer.write('M ${_n(x)} ${_n(y)} '); - void lineTo(double x, double y) => _buffer.write('L ${_n(x)} ${_n(y)} '); + void moveTo(double x, double y) => + _buffer.write('M ${_fmtNum(x)} ${_fmtNum(y)} '); + void lineTo(double x, double y) => + _buffer.write('L ${_fmtNum(x)} ${_fmtNum(y)} '); void close() => _buffer.write('Z '); void arcTo( double rx, @@ -42,13 +56,13 @@ class PathBuilder { double x, double y, ) => _buffer.write( - 'A ${_n(rx)} ${_n(ry)} ${_n(rotation)} ' - '${largeArc ? 1 : 0} ${sweep ? 1 : 0} ${_n(x)} ${_n(y)} ', + 'A ${_fmtNum(rx)} ${_fmtNum(ry)} ${_fmtNum(rotation)} ' + '${largeArc ? 1 : 0} ${sweep ? 1 : 0} ${_fmtNum(x)} ${_fmtNum(y)} ', ); /// Appends a full circle as four clockwise quarter-arcs. /// Four arcs avoid the 180° ambiguity of two-arc approaches. - void dotCircle(double cx, double cy, double r) { + void arcCircle(double cx, double cy, double r) { moveTo(cx - r, cy); arcTo(r, r, 0, false, true, cx, cy + r); arcTo(r, r, 0, false, true, cx + r, cy); @@ -57,11 +71,27 @@ class PathBuilder { close(); } + void approxCircle(double cx, double cy, double r, {int segments = 12}) { + final step = 2 * math.pi / segments; + + for (int i = 0; i <= segments; i++) { + final a = i * step; + final x = cx + r * math.cos(a); + final y = cy + r * math.sin(a); + + if (i == 0) { + moveTo(x, y); + } else { + lineTo(x, y); + } + } + + close(); + } + bool get isEmpty => _buffer.isEmpty; String get d => _buffer.toString().trimRight(); void clear() => _buffer.clear(); - - static String _n(double v) => _fmtNum(v); } abstract interface class SVGDrawerInterface { @@ -115,34 +145,42 @@ class _StrokeFont { pb ..moveTo(ox, oy) ..lineTo(ox + w, oy); + break; case _segM: pb ..moveTo(ox, oy + hh) ..lineTo(ox + w, oy + hh); + break; case _segB: pb ..moveTo(ox, oy + h) ..lineTo(ox + w, oy + h); + break; case _segUl: pb ..moveTo(ox, oy) ..lineTo(ox, oy + hh); + break; case _segUr: pb ..moveTo(ox + w, oy) ..lineTo(ox + w, oy + hh); + break; case _segLl: pb ..moveTo(ox, oy + hh) ..lineTo(ox, oy + h); + break; case _segLr: pb ..moveTo(ox + w, oy + hh) ..lineTo(ox + w, oy + h); + break; case _segCv: pb ..moveTo(ox + w / 2, oy) ..lineTo(ox + w / 2, oy + h); + break; } } } @@ -209,6 +247,10 @@ class MilReticleSVGCanvas { _idHint ??= h; } + int _circleSegments(double r) { + return math.min(64, (2 * math.pi * r * factor / 4).ceil()); + } + static void _warn(String method, String reason) => log('$method: $reason', name: 'reticle_gen', level: 900); @@ -224,7 +266,7 @@ class MilReticleSVGCanvas { XmlName('viewBox'), '${_fmtNum(minX)} ${_fmtNum(minY)} ${_fmtNum(milWidth)} ${_fmtNum(milHeight)}', ), - XmlAttribute(XmlName('shape-rendering'), 'crispEdges'), + XmlAttribute(XmlName('shape-rendering'), 'geometricPrecision'), ]); _idCounters.clear(); _clipCounter = 0; @@ -301,27 +343,51 @@ class MilReticleSVGCanvas { double cx, double cy, double r, { - String? fill, + String? fill = "none", String? stroke, double? strokeWidth, }) { - _target.children.add( - XmlElement(XmlName('circle'), [ - XmlAttribute(XmlName('id'), nextId('circle')), - XmlAttribute(XmlName('cx'), _fmtNum(cx)), - XmlAttribute(XmlName('cy'), _fmtNum(cy)), - XmlAttribute(XmlName('r'), _fmtNum(r)), - XmlAttribute(XmlName('fill'), fill ?? "none"), - if (stroke != null) XmlAttribute(XmlName('stroke'), stroke), - if (strokeWidth != null) - XmlAttribute(XmlName('stroke-width'), _fmtNum(strokeWidth)), - ]), - ); + switch (circlesBatchMode) { + case CirclesBatchMode.path: + final pb = PathBuilder(); + pb.arcCircle(cx, cy, r); + if (!pb.isEmpty) { + path(pb.d, fill: fill, stroke: stroke, strokeWidth: strokeWidth); + } + break; + case CirclesBatchMode.approx: + final pb = PathBuilder(); + pb.approxCircle(cx, cy, r, segments: _circleSegments(r)); + if (!pb.isEmpty) { + path( + pb.d, + fill: fill, + stroke: stroke, + strokeWidth: strokeWidth, + strokeLineJoin: 'round', + strokeLineCap: 'round', + ); + } + break; + case CirclesBatchMode.none: + _target.children.add( + XmlElement(XmlName('circle'), [ + XmlAttribute(XmlName('id'), nextId('circle')), + XmlAttribute(XmlName('cx'), _fmtNum(cx)), + XmlAttribute(XmlName('cy'), _fmtNum(cy)), + XmlAttribute(XmlName('r'), _fmtNum(r)), + XmlAttribute(XmlName('fill'), fill ?? "none"), + if (stroke != null) XmlAttribute(XmlName('stroke'), stroke), + if (strokeWidth != null) + XmlAttribute(XmlName('stroke-width'), _fmtNum(strokeWidth)), + ]), + ); + } } void path( - String d, - String fill, { + String d, { + String? fill = "none", String? stroke, double? strokeWidth, String? strokeLineJoin = 'miter', @@ -331,7 +397,7 @@ class MilReticleSVGCanvas { XmlElement(XmlName('path'), [ XmlAttribute(XmlName('id'), nextId('path')), XmlAttribute(XmlName('d'), d), - XmlAttribute(XmlName('fill'), fill), + if (fill != null) XmlAttribute(XmlName('fill'), fill), if (stroke != null) XmlAttribute(XmlName('stroke'), stroke), if (strokeWidth != null) XmlAttribute(XmlName('stroke-width'), _fmtNum(strokeWidth)), @@ -507,7 +573,7 @@ class MilReticleSVGCanvas { } if (!pb.isEmpty) { _hint('hruler'); - path(pb.d, 'none', stroke: stroke, strokeWidth: strokeWidth); + path(pb.d, stroke: stroke, strokeWidth: strokeWidth); } } @@ -534,7 +600,7 @@ class MilReticleSVGCanvas { } if (!pb.isEmpty) { _hint('vruler'); - path(pb.d, 'none', stroke: stroke, strokeWidth: strokeWidth); + path(pb.d, stroke: stroke, strokeWidth: strokeWidth); } } @@ -552,7 +618,7 @@ class MilReticleSVGCanvas { ..moveTo(cx, cy - half) ..lineTo(cx, cy + half); _hint('cross'); - path(pb.d, 'none', stroke: stroke, strokeWidth: strokeWidth); + path(pb.d, stroke: stroke, strokeWidth: strokeWidth); } void dashLine( @@ -590,7 +656,7 @@ class MilReticleSVGCanvas { } if (!pb.isEmpty) { _hint('dashline'); - path(pb.d, 'none', stroke: stroke, strokeWidth: strokeWidth); + path(pb.d, stroke: stroke, strokeWidth: strokeWidth); } } @@ -646,12 +712,49 @@ class MilReticleSVGCanvas { } final ux = dx / length; final uy = dy / length; - final pb = PathBuilder(); - for (var t = 0.0; t <= length + 1e-9; t += spacing) { - pb.dotCircle(x1 + t * ux, y1 + t * uy, r); - } - if (!pb.isEmpty) { - path(pb.d, fill, stroke: stroke, strokeWidth: strokeWidth); + + switch (circlesBatchMode) { + case CirclesBatchMode.path: + final pb = PathBuilder(); + for (var t = 0.0; t <= length + 1e-9; t += spacing) { + pb.arcCircle(x1 + t * ux, y1 + t * uy, r); + } + if (!pb.isEmpty) { + path(pb.d, fill: fill, stroke: stroke, strokeWidth: strokeWidth); + } + break; + case CirclesBatchMode.approx: + final pb = PathBuilder(); + for (var t = 0.0; t <= length + 1e-9; t += spacing) { + pb.approxCircle( + x1 + t * ux, + y1 + t * uy, + r, + segments: _circleSegments(r), + ); + } + if (!pb.isEmpty) { + path( + pb.d, + fill: fill, + stroke: stroke, + strokeWidth: strokeWidth, + strokeLineJoin: 'round', + strokeLineCap: 'round', + ); + } + break; + case CirclesBatchMode.none: + for (var t = 0.0; t <= length + 1e-9; t += spacing) { + circle( + x1 + t * ux, + y1 + t * uy, + r, + fill: fill, + stroke: stroke, + strokeWidth: strokeWidth, + ); + } } } @@ -709,16 +812,56 @@ class MilReticleSVGCanvas { String? stroke, double? strokeWidth, }) { - if (xStep <= 0 || yStep <= 0) return; - _hint('dotgrid'); - final pb = PathBuilder(); - for (double y = y1; y <= y2 + 1e-9; y += yStep) { - for (double x = x1; x <= x2 + 1e-9; x += xStep) { - pb.dotCircle(x, y, r); - } - } - if (!pb.isEmpty) { - path(pb.d, fill, stroke: stroke, strokeWidth: strokeWidth); + switch (circlesBatchMode) { + case CirclesBatchMode.path: + if (xStep <= 0 || yStep <= 0) return; + _hint('dotgrid'); + final pb = PathBuilder(); + for (double y = y1; y <= y2 + 1e-9; y += yStep) { + for (double x = x1; x <= x2 + 1e-9; x += xStep) { + pb.arcCircle(x, y, r); + } + } + if (!pb.isEmpty) { + path(pb.d, fill: fill, stroke: stroke, strokeWidth: strokeWidth); + } + break; + case CirclesBatchMode.approx: + if (xStep <= 0 || yStep <= 0) return; + _hint('dotgrid'); + final pb = PathBuilder(); + for (double y = y1; y <= y2 + 1e-9; y += yStep) { + for (double x = x1; x <= x2 + 1e-9; x += xStep) { + pb.approxCircle(x, y, r, segments: _circleSegments(r)); + } + } + if (!pb.isEmpty) { + path( + pb.d, + fill: fill, + stroke: stroke, + strokeWidth: strokeWidth, + strokeLineJoin: 'round', + strokeLineCap: 'round', + ); + } + break; + case CirclesBatchMode.none: + if (xStep <= 0 || yStep <= 0) return; + _hint('dotgrid'); + for (double y = y1; y <= y2 + 1e-9; y += yStep) { + for (double x = x1; x <= x2 + 1e-9; x += xStep) { + circle( + x, + y, + r, + fill: fill, + stroke: stroke, + strokeWidth: strokeWidth, + ); + } + } + break; } } @@ -731,7 +874,10 @@ class MilReticleSVGCanvas { double yStep, void Function(double x, double y) draw, ) { - if (xStep == 0 || yStep == 0) return; + if (xStep == 0 || yStep == 0) { + _warn('repeat', 'step must not be zero'); + return; + } for ( double y = y1; yStep > 0 ? y <= y2 + 1e-9 : y >= y2 - 1e-9; @@ -751,7 +897,7 @@ class MilReticleSVGCanvas { String stroke, double strokeWidth, void Function(PathBuilder pb) build, { - String fill = 'none', + String? fill = "none", String? strokeLineJoin = 'miter', String? strokeLineCap = 'miter', }) { @@ -761,7 +907,7 @@ class MilReticleSVGCanvas { _hint('batch'); path( pb.d, - fill, + fill: fill, stroke: stroke, strokeWidth: strokeWidth, strokeLineJoin: strokeLineJoin, diff --git a/pubspec.yaml b/pubspec.yaml index 3cf7a935..91b80bde 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.1.1 +version: 0.1.2 environment: sdk: ^3.11.1 diff --git a/scripts/build-android.sh b/scripts/build-android.sh new file mode 100755 index 00000000..6a338937 --- /dev/null +++ b/scripts/build-android.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Build Flutter Android APKs and place them in artifacts/. +# +# Usage: +# build-android.sh [--fat] +# +# Arguments: +# build_name Version string, e.g. "1.2.3" or "v1.2.3-beta". "v" prefix is stripped. +# build_number Integer build number (github.run_number). +# --fat Build a single fat APK instead of per-ABI split (optional). +# +# Signing (optional — falls back to debug key if not set): +# ANDROID_KEYSTORE_BASE64 Base64-encoded .jks/.p12 keystore file. +# ANDROID_KEYSTORE_PASSWORD Keystore (store) password. +# ANDROID_KEY_ALIAS Key alias inside the keystore. +# ANDROID_KEY_PASSWORD Key password. +# +# Outputs: +# artifacts/ebalistyka_android_arm64.apk +# artifacts/ebalistyka_android_armeabi_v7a.apk +# artifacts/ebalistyka_android_x86_64.apk +# — or — +# artifacts/ebalistyka_android.apk (when --fat) + +set -euo pipefail + +BUILD_NAME="${1:-0.1.0-dev}" +BUILD_NUMBER="${2:-0}" +FAT="${3:-}" + +# Strip leading 'v' +BUILD_NAME="${BUILD_NAME#v}" +# Strip pre-release suffix for versionCode compatibility: "1.2.3-beta" → "1.2.3" +BASE=$(echo "$BUILD_NAME" | sed 's/-.*//') + +# ── Pubspec version ────────────────────────────────────────────────────────── +sed -i "s/^version:.*/version: ${BASE}+${BUILD_NUMBER}/" pubspec.yaml +echo "pubspec version → ${BASE}+${BUILD_NUMBER}" + +# ── Android signing ────────────────────────────────────────────────────────── +if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then + echo "Setting up Android release signing…" + echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/ebalistyka.keystore + cat > android/key.properties <] [--alias ] [--dn ] +# +# Options: +# --password Keystore + key password (default: prompted interactively) +# --alias Key alias (default: ebalistyka) +# --dn Distinguished Name (default: CN=o-murphy) +# +# Outputs (all gitignored): +# android/ebalistyka.keystore — keep this safe, never lose it +# android/key.properties — used by build.gradle.kts for local builds +# certs/android_keystore_base64.txt — paste into ANDROID_KEYSTORE_BASE64 CI secret +# certs/android_secrets.txt — all four secrets ready to copy-paste + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# ── Defaults ───────────────────────────────────────────────────────────────── +PASSWORD="" +ALIAS="ebalistyka" +DN="CN=o-murphy" + +# ── Parse args ──────────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --password) PASSWORD="$2"; shift 2 ;; + --alias) ALIAS="$2"; shift 2 ;; + --dn) DN="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# ── Prompt for password if not provided ────────────────────────────────────── +if [ -z "$PASSWORD" ]; then + read -rsp "Keystore password: " PASSWORD; echo + read -rsp "Confirm password: " PASSWORD2; echo + if [ "$PASSWORD" != "$PASSWORD2" ]; then + echo "Passwords do not match." >&2 + exit 1 + fi +fi + +if [ ${#PASSWORD} -lt 6 ]; then + echo "Password must be at least 6 characters." >&2 + exit 1 +fi + +# ── Check keytool ───────────────────────────────────────────────────────────── +if ! command -v keytool &>/dev/null; then + echo "keytool not found. Install a JDK (e.g. openjdk-17-jdk)." >&2 + exit 1 +fi + +# ── Paths ───────────────────────────────────────────────────────────────────── +CERTS_DIR="$ROOT_DIR/certs" +KEYSTORE="$ROOT_DIR/android/ebalistyka.keystore" +KEY_PROPS="$ROOT_DIR/android/key.properties" +BASE64_OUT="$CERTS_DIR/android_keystore_base64.txt" +SECRETS_OUT="$CERTS_DIR/android_secrets.txt" + +mkdir -p "$CERTS_DIR" + +# ── Generate keystore ───────────────────────────────────────────────────────── +echo "Generating keystore…" +keytool -genkey -v \ + -keystore "$KEYSTORE" \ + -alias "$ALIAS" \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -dname "$DN" \ + -storepass "$PASSWORD" \ + -keypass "$PASSWORD" \ + -storetype JKS 2>/dev/null + +echo "Keystore → $KEYSTORE" + +# ── Write key.properties for local gradle builds ────────────────────────────── +cat > "$KEY_PROPS" < "$BASE64_OUT" +echo "Keystore copy → $CERTS_DIR/ebalistyka.keystore" +echo "Base64 → $BASE64_OUT" + +# ── Write secrets summary file ──────────────────────────────────────────────── +cat > "$SECRETS_OUT" < + +