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
-[](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-appimage.yml)
-[](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-exe.yml)
[](https://flutter.dev)
[](LICENSE)
-
+

-
+
+
+
+
+[](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-appimage.yml)
+[](https://github.com/o-murphy/ebalistyka-app/actions/workflows/build-exe.yml)
+[](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 |
-|------|-----------|-------------------|
-|  |  |  |
-
-| Convertors | Settings |
-|------------|----------|
-|  |  |
+
+
+ | Home |
+ Conditions |
+ Trajectory Tables |
+
+
+  |
+  |
+  |
+
+
+ | Convertors |
+ My Profiles |
+ Reticle |
+
+
+  |
+  |
+  |
+
+
---
@@ -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 @@
-
\ 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" <
+
+