diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 6140a56..d598b06 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -1,19 +1,32 @@ # Release automation for git.comunes.org (Forgejo Actions). # -# Password-free: pushing a tag `v*` builds a signed AAB + per-ABI APKs, uploads -# the AAB to Google Play's internal track via fastlane, and attaches the signed -# per-ABI APKs to the Forgejo release (the reference binaries F-Droid verifies -# for reproducible, developer-signed publishing). All credentials come from repo -# secrets (Settings > Actions > Secrets), never typed: +# Pushing a tag `v*` runs two independent jobs: +# play - build the signed AAB and ship it to Google Play (production, 100%). +# fdroid_reference- build the per-ABI reference APKs the SAME way F-Droid will +# (fdroid build inside the fdroidserver buildserver image), +# sign them with the tane-upload key, and attach them to the +# Forgejo release. F-Droid re-runs `fdroid build` on the same +# commit+recipe+image, gets byte-identical output, verifies our +# signature (AllowedAPKSigningKeys) and publishes OUR APK — so +# F-Droid and Play share the same signing key. +# +# All credentials come from repo secrets (Settings > Actions > Secrets): # TANE_KEYSTORE_BASE64 base64 of the dedicated tane-upload.jks # TANE_KEYSTORE_PASSWORD store password # TANE_KEY_ALIAS tane-upload # TANE_KEY_PASSWORD key password -# SUPPLY_JSON_KEY_DATA Google Play service-account JSON (reused from Ğ1nkgo) +# SUPPLY_JSON_KEY_DATA Google Play service-account JSON # -# Build + deploy live in ONE job so the AAB never has to cross a job boundary. -# NOTE: `runs-on` must match a label your Forgejo runner registered with; adjust -# if the Comunes runner uses something other than `docker`. +# NOTE: `runs-on` must match a label your Forgejo runner registered with. +# fdroid_reference is 3 separate jobs chained with `needs:` (armeabi-v7a -> +# arm64-v8a -> x86_64), NOT a matrix: this runner's act_runner does not honor +# `strategy.max-parallel`, so a matrix always ran all 3 concurrently — 2-3 +# simultaneous cold NDK/CMake/Gradle builds contend for RAM and get OOM-killed +# (silent "Gradle build daemon disappeared", no error message). `needs:` is +# core Actions syntax with no such gap, so it actually serializes them. Each +# needs its own full cold NDK+tesseract-native+flutter compile (~30-35min +# alone), which is why they're chained rather than run in one job (that alone +# was too slow even for a 90m timeout). name: release @@ -21,9 +34,89 @@ on: push: tags: - 'v*' + # Manual trigger to test the fdroid_reference job on a branch without cutting a + # tag or deploying to Play (the play job below is guarded to tags only). + # Pass ref_tag (e.g. v0.1.10) to rebuild the reference APKs for an EXISTING + # release and re-upload them (idempotently), without cutting a new tag — used + # to iterate on build-reproducibility fixes without re-deploying to Play. + workflow_dispatch: + inputs: + ref_tag: + description: 'Existing tag to rebuild+re-upload reference APKs for (e.g. v0.1.10). Empty = build-only, no upload.' + required: false + default: '' jobs: - android: + # Test gate. Forgejo/Actions workflows are independent — there is no cross-workflow + # `needs:`, and ci.yml does NOT trigger on tag pushes (its `branches` filter never + # matches refs/tags/*). So the CI test jobs are duplicated here (verbatim from + # ci.yml) to gate the deploy: nothing ships unless analyze + both test suites pass. + # Keep these in sync with ci.yml by hand. + analyze: + runs-on: docker + container: + image: ghcr.io/cirruslabs/flutter:3.41.9 + steps: + - name: Checkout + env: + TOKEN: ${{ github.token }} + run: | + git config --global --add safe.directory '*' + git init -q . + git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git" + git fetch -q --depth 1 origin "${GITHUB_SHA}" + git checkout -q FETCH_HEAD + - run: flutter pub get + - run: flutter analyze + + test-commons-core: + runs-on: docker + container: + image: ghcr.io/cirruslabs/flutter:3.41.9 + steps: + - name: Checkout + env: + TOKEN: ${{ github.token }} + run: | + git config --global --add safe.directory '*' + git init -q . + git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git" + git fetch -q --depth 1 origin "${GITHUB_SHA}" + git checkout -q FETCH_HEAD + - run: flutter pub get + - name: dart test + working-directory: packages/commons_core + run: dart test + + test-app-seeds: + runs-on: docker + container: + image: ghcr.io/cirruslabs/flutter:3.41.9 + steps: + - name: Checkout + env: + TOKEN: ${{ github.token }} + run: | + git config --global --add safe.directory '*' + git init -q . + git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git" + git fetch -q --depth 1 origin "${GITHUB_SHA}" + git checkout -q FETCH_HEAD + # SQLCipher so the "no plaintext at rest" security test actually runs. + - run: apt-get update -qq && apt-get install -y -qq libsqlcipher-dev + - run: flutter pub get + - name: Generate code + test + working-directory: apps/app_seeds + run: | + dart run slang + dart run build_runner build --delete-conflicting-outputs + flutter test --coverage + + play: + # Gate on the tests: with no status-check function in this `if`, the implicit + # success() on `needs` still applies — play runs only on a tag AND green tests. + needs: [analyze, test-commons-core, test-app-seeds] + if: ${{ startsWith(github.ref, 'refs/tags/') }} runs-on: docker container: image: ghcr.io/cirruslabs/flutter:3.41.9 @@ -44,6 +137,13 @@ jobs: working-directory: apps/app_seeds run: | flutter pub get + # Same two hardenings the fdroid recipe applies (this job builds the AAB + # directly, not via the recipe, so a cold cache is still exposed): + # 1) strip flutter_tesseract_ocr's dead-jcenter buildscript (AGP 7.1.2) so + # it inherits the app's AGP instead of hitting jcenter.bintray.com; + # 2) cap the Gradle heap so the build fits the shared CI host's RAM. + find "${PUB_CACHE:-$HOME/.pub-cache}" -path '*flutter_tesseract_ocr-*/android/build.gradle' -exec sed -i '/^buildscript {/,/^}/d' {} + + printf 'org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g\norg.gradle.daemon=false\n' >> android/gradle.properties dart run slang dart run build_runner build --delete-conflicting-outputs @@ -63,33 +163,9 @@ jobs: keyPassword=$TANE_KEY_PASSWORD EOF - - name: Build signed AAB + per-ABI APKs + - name: Build signed AAB working-directory: apps/app_seeds - run: | - flutter build appbundle --release - # Per-ABI splits: these are the reference binaries F-Droid rebuilds and - # verifies against (see docs/fdroid/org.comunes.tane.yml, binary:). - flutter build apk --release --split-per-abi - - - name: Publish signed per-ABI APKs to the Forgejo release - working-directory: apps/app_seeds - env: - TOKEN: ${{ github.token }} - run: | - apt-get update -qq && apt-get install -y -qq curl jq - api="http://forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}" - tag="${GITHUB_REF_NAME}" - # Create the release for this tag if it does not exist yet, then get its id. - curl -sf -X POST "$api/releases" \ - -H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \ - -d "{\"tag_name\":\"${tag}\",\"name\":\"${tag}\"}" >/dev/null || true - rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${tag}" | jq -r .id) - for abi in armeabi-v7a arm64-v8a x86_64; do - f="build/app/outputs/flutter-apk/app-${abi}-release.apk" - curl -sf -X POST "$api/releases/${rid}/assets?name=app-${abi}-release.apk" \ - -H "Authorization: token ${TOKEN}" \ - -F "attachment=@${f};type=application/vnd.android.package-archive" - done + run: flutter build appbundle --release - name: Install fastlane working-directory: apps/app_seeds @@ -99,7 +175,7 @@ jobs: gem install bundler --no-document bundle install - - name: Publish to Google Play (internal track) + - name: Publish to Google Play (production track, 100%) working-directory: apps/app_seeds env: SUPPLY_JSON_KEY_DATA: ${{ secrets.SUPPLY_JSON_KEY_DATA }} @@ -109,3 +185,423 @@ jobs: if: always() working-directory: apps/app_seeds run: rm -f android/key.properties "$GITHUB_WORKSPACE/tane-upload.jks" + + fdroid_reference_armeabi_v7a: + # Serialize after play so two heavy Android builds never run concurrently on + # the shared host (capacity:2) — that contention OOM-killed play's Gradle + # daemon. Gated on the test jobs, but kept INDEPENDENT of play's result: + # `!cancelled()` is a status function, which drops the implicit success() on + # `needs`, so play failing/skipping (e.g. on workflow_dispatch, where play is + # tag-only) does not block the fdroid chain — while the explicit result checks + # still hard-gate on green tests. Nothing gets built/uploaded on a red suite. + needs: [analyze, test-commons-core, test-app-seeds, play] + if: ${{ !cancelled() + && needs.analyze.result == 'success' + && needs.test-commons-core.result == 'success' + && needs.test-app-seeds.result == 'success' }} + runs-on: docker + container: + image: registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie + steps: + - name: Build the armeabi-v7a reference APK with fdroid, sign, and attach it + env: + TOKEN: ${{ github.token }} + TANE_KEYSTORE_BASE64: ${{ secrets.TANE_KEYSTORE_BASE64 }} + TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }} + TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }} + TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }} + REF_TAG: ${{ github.event.inputs.ref_tag }} + ABI: armeabi-v7a + OFFSET: 1 + run: | + set -eu + + # 1) Reproduce F-Droid's build environment (ANDROID_HOME, PATH, NDK...). + sh /opt/buildserver/setup-env-vars /opt/android-sdk + . /etc/profile.d/bsenv.sh + + # 2) fdroidserver from master, exactly like the fdroiddata CI. + fds=/opt/fdroidserver-src + mkdir -p "$fds" + # Download to a file with retries: a mid-transfer vSwitch blip used to + # pipe an empty stream straight into tar ("gzip: unexpected end of + # file"). -f fails on HTTP errors; --retry-all-errors covers resets. + for a in 1 2 3 4 5; do + curl -fsSL --retry 5 --retry-delay 3 --retry-all-errors \ + https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz \ + -o /tmp/fds.tgz && tar -tzf /tmp/fds.tgz >/dev/null 2>&1 && break + echo "fdroidserver download attempt $a failed; retrying in 5s" >&2; sleep 5 + done + tar -xz -C "$fds" --strip-components=1 -f /tmp/fds.tgz + export PATH="$fds:$PATH" + export PYTHONPATH="$fds:$fds/examples" + git config --global --add safe.directory '*' + # The job container reaches github.com but not the host's public git.comunes.org + # (NAT hairpin); redirect the app clone to the internal Forgejo host so + # `fdroid build` can fetch the source (and SOURCE_DATE_EPOCH from its commit). + git config --global url."http://x-access-token:${TOKEN}@forgejo:3000/".insteadOf "https://git.comunes.org/" + + # 3) Fetch our in-repo recipe (the single source of truth) at this commit, + # plus the pubspec versionCode base for this ABI's versionCode. + # Which app commit/tag to build: on a tag push, the pushed commit; on a + # manual dispatch with ref_tag set, that tag (to rebuild+re-upload an + # existing release's references without cutting a new tag / Play deploy). + BUILD_REF="${REF_TAG:-$GITHUB_SHA}" + work=/tmp/tane-src + mkdir -p "$work" && cd "$work" + git init -q . + git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git" + git fetch -q --depth 1 origin "$BUILD_REF" + git checkout -q FETCH_HEAD + base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml) + [ -n "$base" ] + vc=$((base * 10 + OFFSET)) + + # 4) Assemble a minimal fdroiddata (config.yml + srclibs + our recipe) from the + # in-repo files. No external host needed — the job container reaches + # git.comunes.org (via forgejo:3000) but not gitlab.com. + # Match F-Droid's buildserver path (~/build/) so the native .so + # embed identical build paths — required for the byte-for-byte repro check. + fdd=/home/vagrant + mkdir -p "$fdd/metadata" "$fdd/srclibs" + cp "$work/fdroid-ci/config.yml" "$fdd/config.yml" + cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/" + cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml" + cd "$fdd" + # Build THIS commit; drop binary: (we PRODUCE the reference here, not verify it). + sed -i "s#^\( *commit: \).*#\1${BUILD_REF}#" metadata/org.comunes.tane.yml + sed -i '/^ *binary: /d' metadata/org.comunes.tane.yml + + # Pre-clone the app into fdroid's build dir: fdroidserver computes + # SOURCE_DATE_EPOCH from this checkout BEFORE it clones anything — on a + # fresh dir it falls back to `git log` of the fdroiddata tree, which our + # assembled skeleton doesn't have, and crashes on None. With the clone in + # place the primary path pins SOURCE_DATE_EPOCH to the app commit + # timestamp (deterministic; the public URL is redirected to forgejo:3000 + # by the insteadOf rule above). + mkdir -p build + git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane + git -C build/org.comunes.tane checkout -q "${BUILD_REF}" + + # 5) Build ONLY this ABI's split with F-Droid's own toolchain. Splitting + # per-ABI into its own job/matrix-entry (rather than all 3 sequentially in + # one job) is what keeps each build inside the runner's max-job-runtime — + # each needs a full cold NDK+tesseract-native+flutter compile. + # Retry the build: a transient network blip mid-Gradle (maven/pub) used + # to kill it. `fdroid build` exits 0 even on failure, so gate the retry + # on the APK actually appearing. Up to 3 attempts fit the 90m timeout. + for a in 1 2 3; do + fdroid build --verbose --no-tarball "org.comunes.tane:${vc}" || true + if [ -f "unsigned/org.comunes.tane_${vc}.apk" ]; then break; fi + echo "fdroid build attempt $a produced no APK; retrying" >&2 + done + + # `fdroid build` exits 0 even when builds fail — demand the APK. + f="unsigned/org.comunes.tane_${vc}.apk" + if [ ! -f "$f" ]; then + echo "expected $f, not found" >&2 + tail -n 80 logs/*.log 2>/dev/null || true + exit 1 + fi + + # 6) Sign the unsigned APK with tane-upload and attach it to the Forgejo release. + apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1) + if [ -z "$apksigner" ]; then + yes | sdkmanager "build-tools;34.0.0" >/dev/null 2>&1 || true + apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1) + fi + echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks + out="/tmp/app-${ABI}-release.apk" + # v2/v3 only — NO v1 (JAR) signature. minSdk is 24, so v1 is never + # needed, and v1 adds META-INF/*.SF/*.RSA/MANIFEST.MF as zip ENTRIES + # that F-Droid's rebuild doesn't have. F-Droid's apksigcopier transplants + # only the v2/v3 signing block, so those extra v1 entries make the + # reference's signed content differ from F-Droid's rebuild (CHUNKED_SHA512 + # mismatch) even when every file is identical. Dropping v1 makes the + # reference byte-match F-Droid's build so the signature copy verifies. + "$apksigner" sign --ks /tmp/ks.jks --ks-key-alias "$TANE_KEY_ALIAS" \ + --ks-pass "pass:$TANE_KEYSTORE_PASSWORD" --key-pass "pass:$TANE_KEY_PASSWORD" \ + --v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true \ + --alignment-preserved true \ + --out "$out" "$f" + # Prove the signer cert matches AllowedAPKSigningKeys. + "$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true + rm -f /tmp/ks.jks + + # Upload target: the pushed tag, or (on a manual dispatch) the ref_tag + # input. A plain dispatch with no ref_tag just builds+signs, no upload. + api="http://forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}" + UPLOAD_TAG="" + if [ "${GITHUB_REF_TYPE:-}" = tag ]; then + UPLOAD_TAG="${GITHUB_REF_NAME}" + elif [ -n "${REF_TAG:-}" ]; then + UPLOAD_TAG="${REF_TAG}" + fi + if [ -n "$UPLOAD_TAG" ]; then + curl -sf -X POST "$api/releases" \ + -H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"${UPLOAD_TAG}\",\"name\":\"${UPLOAD_TAG}\"}" >/dev/null || true + rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${UPLOAD_TAG}" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])') + # Idempotent re-upload: delete any existing asset of this name first + # (the assets POST rejects a duplicate name). + curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets" \ + | python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name']=='app-${ABI}-release.apk']" 2>/dev/null \ + | while read -r aid; do [ -n "$aid" ] && curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets/${aid}" >/dev/null || true; done + curl -sf -X POST "$api/releases/${rid}/assets?name=app-${ABI}-release.apk" \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${out};type=application/vnd.android.package-archive" + echo "uploaded app-${ABI}-release.apk to ${UPLOAD_TAG} (from ${f##*/})" + else + echo "built+signed app-${ABI}-release.apk (dispatch: no ref_tag, upload skipped)" + fi + + fdroid_reference_arm64_v8a: + needs: fdroid_reference_armeabi_v7a + runs-on: docker + container: + image: registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie + steps: + - name: Build the arm64-v8a reference APK with fdroid, sign, and attach it + env: + TOKEN: ${{ github.token }} + TANE_KEYSTORE_BASE64: ${{ secrets.TANE_KEYSTORE_BASE64 }} + TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }} + TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }} + TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }} + REF_TAG: ${{ github.event.inputs.ref_tag }} + ABI: arm64-v8a + OFFSET: 2 + run: | + set -eu + sh /opt/buildserver/setup-env-vars /opt/android-sdk + . /etc/profile.d/bsenv.sh + fds=/opt/fdroidserver-src + mkdir -p "$fds" + # Download to a file with retries: a mid-transfer vSwitch blip used to + # pipe an empty stream straight into tar ("gzip: unexpected end of + # file"). -f fails on HTTP errors; --retry-all-errors covers resets. + for a in 1 2 3 4 5; do + curl -fsSL --retry 5 --retry-delay 3 --retry-all-errors \ + https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz \ + -o /tmp/fds.tgz && tar -tzf /tmp/fds.tgz >/dev/null 2>&1 && break + echo "fdroidserver download attempt $a failed; retrying in 5s" >&2; sleep 5 + done + tar -xz -C "$fds" --strip-components=1 -f /tmp/fds.tgz + export PATH="$fds:$PATH" + export PYTHONPATH="$fds:$fds/examples" + git config --global --add safe.directory '*' + git config --global url."http://x-access-token:${TOKEN}@forgejo:3000/".insteadOf "https://git.comunes.org/" + # Which app commit/tag to build: on a tag push, the pushed commit; on a + # manual dispatch with ref_tag set, that tag (to rebuild+re-upload an + # existing release's references without cutting a new tag / Play deploy). + BUILD_REF="${REF_TAG:-$GITHUB_SHA}" + work=/tmp/tane-src + mkdir -p "$work" && cd "$work" + git init -q . + git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git" + git fetch -q --depth 1 origin "$BUILD_REF" + git checkout -q FETCH_HEAD + base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml) + [ -n "$base" ] + vc=$((base * 10 + OFFSET)) + # Match F-Droid's buildserver path (~/build/) so the native .so + # embed identical build paths — required for the byte-for-byte repro check. + fdd=/home/vagrant + mkdir -p "$fdd/metadata" "$fdd/srclibs" + cp "$work/fdroid-ci/config.yml" "$fdd/config.yml" + cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/" + cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml" + cd "$fdd" + sed -i "s#^\( *commit: \).*#\1${BUILD_REF}#" metadata/org.comunes.tane.yml + sed -i '/^ *binary: /d' metadata/org.comunes.tane.yml + mkdir -p build + git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane + git -C build/org.comunes.tane checkout -q "${BUILD_REF}" + # Retry the build: a transient network blip mid-Gradle (maven/pub) used + # to kill it. `fdroid build` exits 0 even on failure, so gate the retry + # on the APK actually appearing. Up to 3 attempts fit the 90m timeout. + for a in 1 2 3; do + fdroid build --verbose --no-tarball "org.comunes.tane:${vc}" || true + if [ -f "unsigned/org.comunes.tane_${vc}.apk" ]; then break; fi + echo "fdroid build attempt $a produced no APK; retrying" >&2 + done + f="unsigned/org.comunes.tane_${vc}.apk" + if [ ! -f "$f" ]; then + echo "expected $f, not found" >&2 + tail -n 80 logs/*.log 2>/dev/null || true + exit 1 + fi + apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1) + if [ -z "$apksigner" ]; then + yes | sdkmanager "build-tools;34.0.0" >/dev/null 2>&1 || true + apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1) + fi + echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks + out="/tmp/app-${ABI}-release.apk" + # v2/v3 only — NO v1 (JAR) signature. minSdk is 24, so v1 is never + # needed, and v1 adds META-INF/*.SF/*.RSA/MANIFEST.MF as zip ENTRIES + # that F-Droid's rebuild doesn't have. F-Droid's apksigcopier transplants + # only the v2/v3 signing block, so those extra v1 entries make the + # reference's signed content differ from F-Droid's rebuild (CHUNKED_SHA512 + # mismatch) even when every file is identical. Dropping v1 makes the + # reference byte-match F-Droid's build so the signature copy verifies. + "$apksigner" sign --ks /tmp/ks.jks --ks-key-alias "$TANE_KEY_ALIAS" \ + --ks-pass "pass:$TANE_KEYSTORE_PASSWORD" --key-pass "pass:$TANE_KEY_PASSWORD" \ + --v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true \ + --alignment-preserved true \ + --out "$out" "$f" + "$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true + rm -f /tmp/ks.jks + # Upload target: the pushed tag, or (on a manual dispatch) the ref_tag + # input. A plain dispatch with no ref_tag just builds+signs, no upload. + api="http://forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}" + UPLOAD_TAG="" + if [ "${GITHUB_REF_TYPE:-}" = tag ]; then + UPLOAD_TAG="${GITHUB_REF_NAME}" + elif [ -n "${REF_TAG:-}" ]; then + UPLOAD_TAG="${REF_TAG}" + fi + if [ -n "$UPLOAD_TAG" ]; then + curl -sf -X POST "$api/releases" \ + -H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"${UPLOAD_TAG}\",\"name\":\"${UPLOAD_TAG}\"}" >/dev/null || true + rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${UPLOAD_TAG}" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])') + # Idempotent re-upload: delete any existing asset of this name first + # (the assets POST rejects a duplicate name). + curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets" \ + | python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name']=='app-${ABI}-release.apk']" 2>/dev/null \ + | while read -r aid; do [ -n "$aid" ] && curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets/${aid}" >/dev/null || true; done + curl -sf -X POST "$api/releases/${rid}/assets?name=app-${ABI}-release.apk" \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${out};type=application/vnd.android.package-archive" + echo "uploaded app-${ABI}-release.apk to ${UPLOAD_TAG} (from ${f##*/})" + else + echo "built+signed app-${ABI}-release.apk (dispatch: no ref_tag, upload skipped)" + fi + + fdroid_reference_x86_64: + needs: fdroid_reference_arm64_v8a + runs-on: docker + container: + image: registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie + steps: + - name: Build the x86_64 reference APK with fdroid, sign, and attach it + env: + TOKEN: ${{ github.token }} + TANE_KEYSTORE_BASE64: ${{ secrets.TANE_KEYSTORE_BASE64 }} + TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }} + TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }} + TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }} + REF_TAG: ${{ github.event.inputs.ref_tag }} + ABI: x86_64 + OFFSET: 3 + run: | + set -eu + sh /opt/buildserver/setup-env-vars /opt/android-sdk + . /etc/profile.d/bsenv.sh + fds=/opt/fdroidserver-src + mkdir -p "$fds" + # Download to a file with retries: a mid-transfer vSwitch blip used to + # pipe an empty stream straight into tar ("gzip: unexpected end of + # file"). -f fails on HTTP errors; --retry-all-errors covers resets. + for a in 1 2 3 4 5; do + curl -fsSL --retry 5 --retry-delay 3 --retry-all-errors \ + https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz \ + -o /tmp/fds.tgz && tar -tzf /tmp/fds.tgz >/dev/null 2>&1 && break + echo "fdroidserver download attempt $a failed; retrying in 5s" >&2; sleep 5 + done + tar -xz -C "$fds" --strip-components=1 -f /tmp/fds.tgz + export PATH="$fds:$PATH" + export PYTHONPATH="$fds:$fds/examples" + git config --global --add safe.directory '*' + git config --global url."http://x-access-token:${TOKEN}@forgejo:3000/".insteadOf "https://git.comunes.org/" + # Which app commit/tag to build: on a tag push, the pushed commit; on a + # manual dispatch with ref_tag set, that tag (to rebuild+re-upload an + # existing release's references without cutting a new tag / Play deploy). + BUILD_REF="${REF_TAG:-$GITHUB_SHA}" + work=/tmp/tane-src + mkdir -p "$work" && cd "$work" + git init -q . + git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git" + git fetch -q --depth 1 origin "$BUILD_REF" + git checkout -q FETCH_HEAD + base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml) + [ -n "$base" ] + vc=$((base * 10 + OFFSET)) + # Match F-Droid's buildserver path (~/build/) so the native .so + # embed identical build paths — required for the byte-for-byte repro check. + fdd=/home/vagrant + mkdir -p "$fdd/metadata" "$fdd/srclibs" + cp "$work/fdroid-ci/config.yml" "$fdd/config.yml" + cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/" + cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml" + cd "$fdd" + sed -i "s#^\( *commit: \).*#\1${BUILD_REF}#" metadata/org.comunes.tane.yml + sed -i '/^ *binary: /d' metadata/org.comunes.tane.yml + mkdir -p build + git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane + git -C build/org.comunes.tane checkout -q "${BUILD_REF}" + # Retry the build: a transient network blip mid-Gradle (maven/pub) used + # to kill it. `fdroid build` exits 0 even on failure, so gate the retry + # on the APK actually appearing. Up to 3 attempts fit the 90m timeout. + for a in 1 2 3; do + fdroid build --verbose --no-tarball "org.comunes.tane:${vc}" || true + if [ -f "unsigned/org.comunes.tane_${vc}.apk" ]; then break; fi + echo "fdroid build attempt $a produced no APK; retrying" >&2 + done + f="unsigned/org.comunes.tane_${vc}.apk" + if [ ! -f "$f" ]; then + echo "expected $f, not found" >&2 + tail -n 80 logs/*.log 2>/dev/null || true + exit 1 + fi + apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1) + if [ -z "$apksigner" ]; then + yes | sdkmanager "build-tools;34.0.0" >/dev/null 2>&1 || true + apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1) + fi + echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks + out="/tmp/app-${ABI}-release.apk" + # v2/v3 only — NO v1 (JAR) signature. minSdk is 24, so v1 is never + # needed, and v1 adds META-INF/*.SF/*.RSA/MANIFEST.MF as zip ENTRIES + # that F-Droid's rebuild doesn't have. F-Droid's apksigcopier transplants + # only the v2/v3 signing block, so those extra v1 entries make the + # reference's signed content differ from F-Droid's rebuild (CHUNKED_SHA512 + # mismatch) even when every file is identical. Dropping v1 makes the + # reference byte-match F-Droid's build so the signature copy verifies. + "$apksigner" sign --ks /tmp/ks.jks --ks-key-alias "$TANE_KEY_ALIAS" \ + --ks-pass "pass:$TANE_KEYSTORE_PASSWORD" --key-pass "pass:$TANE_KEY_PASSWORD" \ + --v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true \ + --alignment-preserved true \ + --out "$out" "$f" + "$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true + rm -f /tmp/ks.jks + # Upload target: the pushed tag, or (on a manual dispatch) the ref_tag + # input. A plain dispatch with no ref_tag just builds+signs, no upload. + api="http://forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}" + UPLOAD_TAG="" + if [ "${GITHUB_REF_TYPE:-}" = tag ]; then + UPLOAD_TAG="${GITHUB_REF_NAME}" + elif [ -n "${REF_TAG:-}" ]; then + UPLOAD_TAG="${REF_TAG}" + fi + if [ -n "$UPLOAD_TAG" ]; then + curl -sf -X POST "$api/releases" \ + -H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"${UPLOAD_TAG}\",\"name\":\"${UPLOAD_TAG}\"}" >/dev/null || true + rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${UPLOAD_TAG}" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])') + # Idempotent re-upload: delete any existing asset of this name first + # (the assets POST rejects a duplicate name). + curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets" \ + | python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name']=='app-${ABI}-release.apk']" 2>/dev/null \ + | while read -r aid; do [ -n "$aid" ] && curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets/${aid}" >/dev/null || true; done + curl -sf -X POST "$api/releases/${rid}/assets?name=app-${ABI}-release.apk" \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${out};type=application/vnd.android.package-archive" + echo "uploaded app-${ABI}-release.apk to ${UPLOAD_TAG} (from ${f##*/})" + else + echo "built+signed app-${ABI}-release.apk (dispatch: no ref_tag, upload skipped)" + fi diff --git a/.gitignore b/.gitignore index 3aea187..d5889a2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ node_modules/ Thumbs.db apps/app_seeds/fastlane/README.md apps/app_seeds/TODO.org + +# Internal working notes — live in the private repo (tane-private) +docs/notes/ diff --git a/CLAUDE.md b/CLAUDE.md index d335a1a..a494ed2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,4 +74,4 @@ Social layer uses: Nostr (offers NIP-99, DMs NIP-17, via the pure-Dart `nostr` p - Live decision log: [`docs/design/open-decisions.md`](docs/design/open-decisions.md) — check before deciding anything. - First code steps: [`docs/design/first-sprint.md`](docs/design/first-sprint.md). - Prior art / valuable mockups: [`docs/mockups/`](docs/mockups/) (inventory, item, search, profile, chat — the UI spec). -- Git: bare at `~/repos/tane.git`; working clone here. Commit messages in English. +- Git: `origin` is the forge at `git.comunes.org/comunes/tane.git` (authoritative; push here). Working clone here. A pre-split backup of the old (pre-`filter-repo`) history is archived at `~/repos/tane-pre-split-2026-07-15.git` — not a remote, do not push to it. Commit messages in English. diff --git a/README.md b/README.md index 12ca078..67f2b07 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,16 @@ The name honors the old Japanese mutual-aid traditions around rice — *yui* (sh - Web: https://tane.comunes.org - Package id: `org.comunes.tane` +## Install + +Android, same signature in both stores — you can switch between them without reinstalling: + +- **F-Droid**: https://f-droid.org/packages/org.comunes.tane/ (reproducible, developer-signed build) +- **Google Play**: https://play.google.com/store/apps/details?id=org.comunes.tane + ## Status -Early design. See [`PLAN.md`](PLAN.md) for the full analysis and roadmap (in Spanish). The valuable prior work lives in [`docs/mockups/`](docs/mockups/). +Block 1 (inventory) is shipped and in beta; Block 2 (the social layer) is under way. See [`PLAN.md`](PLAN.md) for the full analysis and roadmap (in Spanish) and [`docs/design/open-decisions.md`](docs/design/open-decisions.md) for the live decision log. The valuable prior work lives in [`docs/mockups/`](docs/mockups/). ## Principles diff --git a/apps/app_seeds/android/app/build.gradle.kts b/apps/app_seeds/android/app/build.gradle.kts index b0e4713..fd8100f 100644 --- a/apps/app_seeds/android/app/build.gradle.kts +++ b/apps/app_seeds/android/app/build.gradle.kts @@ -73,6 +73,17 @@ android { } else { signingConfigs.getByName("debug") } + // R8: shrink + optimize + obfuscate the Java/Kotlin bytecode and strip + // unused resources — Play's "app optimization" recommendation (smaller + // download, less memory). Native .so libs are untouched, so this does + // not change device/ABI compatibility. Keep rules for reflection/JNI + // heavy deps (OCR, SQLCipher, notifications) live in proguard-rules.pro. + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) } } } @@ -86,6 +97,17 @@ dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } +// F-Droid: Flutter's embedding transitively pulls Google Play Core (deferred +// components / split install — com.google.android.play.core.*), which F-Droid's +// scanner rejects as a proprietary Google dependency. Tane does not use deferred +// components, so drop the whole com.google.android.play group; those split-install +// code paths are never reached. R8 keeps the referenced classes otherwise, so the +// exclusion (not shrinking) is what removes them; -dontwarn in proguard-rules.pro +// silences the now-dangling compile-time references. +configurations.all { + exclude(group = "com.google.android.play") +} + // F-Droid builds one split APK per ABI (smaller downloads than a universal // APK) and needs each split to carry a distinct versionCode so its repo can // tell them apart; `flutter build apk --split-per-abi` alone gives every diff --git a/apps/app_seeds/android/app/proguard-rules.pro b/apps/app_seeds/android/app/proguard-rules.pro new file mode 100644 index 0000000..ad9ccdd --- /dev/null +++ b/apps/app_seeds/android/app/proguard-rules.pro @@ -0,0 +1,52 @@ +# R8/ProGuard keep rules for Tane (org.comunes.tane). +# +# R8 is enabled for release builds (see build.gradle.kts). It shrinks/optimizes +# the Java/Kotlin bytecode and can break code reached only via reflection or JNI +# — the native plugins below load their Java classes from C, so R8 can't see the +# references and would strip/rename them. Keep them explicitly. Start +# conservative; trim only after a release smoke test proves a class is unused. + +# --- Flutter engine & embedding --- +# Do NOT blanket-keep io.flutter.** : that pins io.flutter.embedding.engine. +# deferredcomponents.PlayStoreDeferredComponentManager, which references the +# proprietary com.google.android.play.core.* (SplitInstall/SplitCompat) API and +# makes F-Droid's scanner reject the APK. Tane doesn't use deferred components, +# so let R8 shrink that unused manager away (removing the Play Core references). +# JNI-critical embedding classes (FlutterJNI, etc.) are kept by the Flutter +# embedding AAR's own bundled consumer ProGuard rules, so this stays safe. +-keep class io.flutter.plugins.** { *; } +-dontwarn io.flutter.embedding.** + +# --- On-device OCR: tesseract4android (JNI) --- +# libtesseract/libleptonica call back into these Java classes by name. +-keep class com.googlecode.tesseract.android.** { *; } +-keep class com.googlecode.leptonica.android.** { *; } +-keep class io.paratoner.flutter_tesseract_ocr.** { *; } + +# --- QR scanning: zxing_barcode_scanner (pure ZXing, platform view) --- +-keep class com.shirisharyal.zxing_barcode_scanner.** { *; } + +# --- Encrypted DB: SQLCipher / sqlite3 native loader --- +# sqlite3 is reached over FFI (dlopen), but keep the loader plugin classes. +-keep class eu.simonbinder.sqlite3_flutter_libs.** { *; } +-keep class net.zetetic.** { *; } +-dontwarn net.zetetic.** + +# --- Local notifications --- +# Published keep rules for flutter_local_notifications' Gson-serialized models. +-keep class com.dexterous.** { *; } +-keep class com.google.gson.** { *; } +-keep class * extends com.google.gson.TypeAdapter +-keepattributes Signature +-keepattributes *Annotation* +-dontwarn com.google.errorprone.annotations.** + +# --- Core library desugaring --- +-dontwarn java.lang.invoke.** +-dontwarn build.IgnoreJava8API + +# --- F-Droid: Google Play Core excluded (see build.gradle.kts) --- +# The Flutter embedding references Play Core split-install classes for deferred +# components, which Tane doesn't use; the com.google.android.play group is +# excluded from the build, so tell R8 not to warn about the absent references. +-dontwarn com.google.android.play.** diff --git a/apps/app_seeds/android/app/src/main/AndroidManifest.xml b/apps/app_seeds/android/app/src/main/AndroidManifest.xml index 677d645..4b9e56b 100644 --- a/apps/app_seeds/android/app/src/main/AndroidManifest.xml +++ b/apps/app_seeds/android/app/src/main/AndroidManifest.xml @@ -1,10 +1,29 @@ + + + + + + + + + when (call.method) { "getCoarseLatLon" -> getCoarseLatLon(result) + "hasCamera" -> result.success(hasCameraHardware()) else -> result.notImplemented() } } @@ -72,6 +73,15 @@ class MainActivity : FlutterActivity() { } } + /** + * Whether this device has any camera. Camera-less devices (Chromebooks, + * Android Automotive, many TVs) are supported — see AndroidManifest's + * uses-feature required="false" — so the QR scan and camera-capture UI + * hide themselves when this is false instead of offering broken actions. + */ + private fun hasCameraHardware(): Boolean = + packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) + private fun hasLocationPermission(): Boolean = ContextCompat.checkSelfPermission( this, diff --git a/apps/app_seeds/drift_schemas/drift_schema_v14.json b/apps/app_seeds/drift_schemas/drift_schema_v14.json new file mode 100644 index 0000000..623b534 --- /dev/null +++ b/apps/app_seeds/drift_schemas/drift_schema_v14.json @@ -0,0 +1,2509 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "varieties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "label", + "getter_name": "label", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "cultivar_name", + "getter_name": "cultivarName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "category", + "getter_name": "category", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_draft", + "getter_name": "isDraft", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_draft\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_draft\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_organic", + "getter_name": "isOrganic", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_organic\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_organic\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "needs_reproduction", + "getter_name": "needsReproduction", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"needs_reproduction\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"needs_reproduction\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sow_months", + "getter_name": "sowMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "transplant_months", + "getter_name": "transplantMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "flowering_months", + "getter_name": "floweringMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fruiting_months", + "getter_name": "fruitingMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seed_harvest_months", + "getter_name": "seedHarvestMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "variety_vernacular_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "region", + "getter_name": "region", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "species", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "scientific_name", + "getter_name": "scientificName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "wikidata_qid", + "getter_name": "wikidataQid", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "gbif_key", + "getter_name": "gbifKey", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "family", + "getter_name": "family", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_bundled", + "getter_name": "isBundled", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_bundled\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_bundled\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "viability_years", + "getter_name": "viabilityYears", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 3, + "references": [], + "type": "table", + "data": { + "name": "species_common_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 4, + "references": [], + "type": "table", + "data": { + "name": "lots", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'seed\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(LotType.values)", + "dart_type_name": "LotType" + } + }, + { + "name": "harvest_year", + "getter_name": "harvestYear", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "harvest_month", + "getter_name": "harvestMonth", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "presentation", + "getter_name": "presentation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Presentation.values)", + "dart_type_name": "Presentation" + } + }, + { + "name": "storage_location", + "getter_name": "storageLocation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "offer_status", + "getter_name": "offerStatus", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'private\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(OfferStatus.values)", + "dart_type_name": "OfferStatus" + } + }, + { + "name": "seedbank_id", + "getter_name": "seedbankId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_name", + "getter_name": "originName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_place", + "getter_name": "originPlace", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "abundance", + "getter_name": "abundance", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Abundance.values)", + "dart_type_name": "Abundance" + } + }, + { + "name": "preservation_format", + "getter_name": "preservationFormat", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PreservationFormat.values)", + "dart_type_name": "PreservationFormat" + } + }, + { + "name": "price_amount", + "getter_name": "priceAmount", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "price_currency", + "getter_name": "priceCurrency", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 5, + "references": [], + "type": "table", + "data": { + "name": "germination_tests", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tested_on", + "getter_name": "testedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sample_size", + "getter_name": "sampleSize", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "germinated_count", + "getter_name": "germinatedCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 6, + "references": [], + "type": "table", + "data": { + "name": "condition_checks", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "checked_on", + "getter_name": "checkedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_count", + "getter_name": "containerCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "desiccant_state", + "getter_name": "desiccantState", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(DesiccantState.values)", + "dart_type_name": "DesiccantState" + } + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 7, + "references": [], + "type": "table", + "data": { + "name": "garden_outcomes", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "year", + "getter_name": "year", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "rating", + "getter_name": "rating", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(GardenOutcomeRating.values)", + "dart_type_name": "GardenOutcomeRating" + } + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 8, + "references": [], + "type": "table", + "data": { + "name": "movements", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(MovementType.values)", + "dart_type_name": "MovementType" + } + }, + { + "name": "occurred_on", + "getter_name": "occurredOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "counterparty_id", + "getter_name": "counterpartyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_movement_id", + "getter_name": "parentMovementId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "plantare_id", + "getter_name": "plantareId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 9, + "references": [], + "type": "table", + "data": { + "name": "parties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "public_key", + "getter_name": "publicKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'person\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PartyKind.values)", + "dart_type_name": "PartyKind" + } + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 10, + "references": [], + "type": "table", + "data": { + "name": "attachments", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(AttachmentKind.values)", + "dart_type_name": "AttachmentKind" + } + }, + { + "name": "uri", + "getter_name": "uri", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "bytes", + "getter_name": "bytes", + "moor_type": "blob", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "thumbnail", + "getter_name": "thumbnail", + "moor_type": "blob", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "mime_type", + "getter_name": "mimeType", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sort_order", + "getter_name": "sortOrder", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 11, + "references": [], + "type": "table", + "data": { + "name": "external_links", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 12, + "references": [], + "type": "table", + "data": { + "name": "plantares", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "direction", + "getter_name": "direction", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareDirection.values)", + "dart_type_name": "PlantareDirection" + } + }, + { + "name": "counterparty", + "getter_name": "counterparty", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owed_description", + "getter_name": "owedDescription", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "made_on", + "getter_name": "madeOn", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "due_by", + "getter_name": "dueBy", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "status", + "getter_name": "status", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'open\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareStatus.values)", + "dart_type_name": "PlantareStatus" + } + }, + { + "name": "settled_on", + "getter_name": "settledOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "pledge_id", + "getter_name": "pledgeId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "debtor_key", + "getter_name": "debtorKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "creditor_key", + "getter_name": "creditorKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "debtor_signature", + "getter_name": "debtorSignature", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "creditor_signature", + "getter_name": "creditorSignature", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "movement_id", + "getter_name": "movementId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "remote_state", + "getter_name": "remoteState", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareRemoteState.values)", + "dart_type_name": "PlantareRemoteState" + } + }, + { + "name": "return_kind", + "getter_name": "returnKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'similar\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareReturnKind.values)", + "dart_type_name": "PlantareReturnKind" + } + }, + { + "name": "work_hours", + "getter_name": "workHours", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 13, + "references": [], + "type": "table", + "data": { + "name": "sales", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "direction", + "getter_name": "direction", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(SaleDirection.values)", + "dart_type_name": "SaleDirection" + } + }, + { + "name": "counterparty", + "getter_name": "counterparty", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "amount", + "getter_name": "amount", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "currency", + "getter_name": "currency", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sold_on", + "getter_name": "soldOn", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 14, + "references": [ + 0 + ], + "type": "index", + "data": { + "on": 0, + "name": "idx_varieties_deleted_draft", + "sql": null, + "unique": false, + "columns": [ + { + "column": "is_deleted", + "order_by": null + }, + { + "column": "is_draft", + "order_by": null + } + ] + } + }, + { + "id": 15, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_lots_variety", + "sql": null, + "unique": false, + "columns": [ + { + "column": "variety_id", + "order_by": null + } + ] + } + }, + { + "id": 16, + "references": [ + 10 + ], + "type": "index", + "data": { + "on": 10, + "name": "idx_attachments_parent", + "sql": null, + "unique": false, + "columns": [ + { + "column": "parent_type", + "order_by": null + }, + { + "column": "parent_id", + "order_by": null + }, + { + "column": "kind", + "order_by": null + } + ] + } + } + ], + "fixed_sql": [ + { + "name": "varieties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"varieties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"label\" TEXT NOT NULL, \"species_id\" TEXT NULL, \"cultivar_name\" TEXT NULL, \"category\" TEXT NULL, \"notes\" TEXT NULL, \"is_draft\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_draft\" IN (0, 1)), \"is_organic\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_organic\" IN (0, 1)), \"needs_reproduction\" INTEGER NOT NULL DEFAULT 0 CHECK (\"needs_reproduction\" IN (0, 1)), \"sow_months\" INTEGER NULL, \"transplant_months\" INTEGER NULL, \"flowering_months\" INTEGER NULL, \"fruiting_months\" INTEGER NULL, \"seed_harvest_months\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "variety_vernacular_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"variety_vernacular_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, \"region\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"scientific_name\" TEXT NOT NULL, \"wikidata_qid\" TEXT NULL, \"gbif_key\" INTEGER NULL, \"family\" TEXT NULL, \"is_bundled\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_bundled\" IN (0, 1)), \"viability_years\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species_common_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species_common_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"species_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "lots", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"lots\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL DEFAULT 'seed', \"harvest_year\" INTEGER NULL, \"harvest_month\" INTEGER NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"presentation\" TEXT NULL, \"storage_location\" TEXT NULL, \"offer_status\" TEXT NOT NULL DEFAULT 'private', \"seedbank_id\" TEXT NULL, \"origin_name\" TEXT NULL, \"origin_place\" TEXT NULL, \"abundance\" TEXT NULL, \"preservation_format\" TEXT NULL, \"price_amount\" REAL NULL, \"price_currency\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "germination_tests", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"germination_tests\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"tested_on\" INTEGER NULL, \"sample_size\" INTEGER NULL, \"germinated_count\" INTEGER NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "condition_checks", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"condition_checks\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"checked_on\" INTEGER NULL, \"container_count\" INTEGER NULL, \"desiccant_state\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "garden_outcomes", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"garden_outcomes\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"year\" INTEGER NULL, \"rating\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "movements", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"movements\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"last_author\" TEXT NOT NULL, \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL, \"occurred_on\" INTEGER NULL, \"counterparty_id\" TEXT NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"parent_movement_id\" TEXT NULL, \"plantare_id\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "parties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"parties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"display_name\" TEXT NOT NULL, \"public_key\" TEXT NULL, \"kind\" TEXT NOT NULL DEFAULT 'person', \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "attachments", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"attachments\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"kind\" TEXT NOT NULL, \"uri\" TEXT NULL, \"bytes\" BLOB NULL, \"thumbnail\" BLOB NULL, \"mime_type\" TEXT NULL, \"sort_order\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "external_links", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"external_links\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"url\" TEXT NOT NULL, \"title\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "plantares", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"plantares\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NULL, \"direction\" TEXT NOT NULL, \"counterparty\" TEXT NULL, \"owed_description\" TEXT NULL, \"made_on\" INTEGER NOT NULL, \"due_by\" INTEGER NULL, \"status\" TEXT NOT NULL DEFAULT 'open', \"settled_on\" INTEGER NULL, \"note\" TEXT NULL, \"pledge_id\" TEXT NULL, \"debtor_key\" TEXT NULL, \"creditor_key\" TEXT NULL, \"debtor_signature\" TEXT NULL, \"creditor_signature\" TEXT NULL, \"movement_id\" TEXT NULL, \"remote_state\" TEXT NULL, \"return_kind\" TEXT NOT NULL DEFAULT 'similar', \"work_hours\" REAL NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "sales", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"sales\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NULL, \"direction\" TEXT NOT NULL, \"counterparty\" TEXT NULL, \"amount\" REAL NULL, \"currency\" TEXT NULL, \"sold_on\" INTEGER NOT NULL, \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "idx_varieties_deleted_draft", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_varieties_deleted_draft ON varieties (is_deleted, is_draft)" + } + ] + }, + { + "name": "idx_lots_variety", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_lots_variety ON lots (variety_id)" + } + ] + }, + { + "name": "idx_attachments_parent", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_attachments_parent ON attachments (parent_type, parent_id, kind)" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/app_seeds/fastlane/Fastfile b/apps/app_seeds/fastlane/Fastfile index 6a52523..5f3437a 100644 --- a/apps/app_seeds/fastlane/Fastfile +++ b/apps/app_seeds/fastlane/Fastfile @@ -16,13 +16,24 @@ default_platform(:android) AAB_PATH = "build/app/outputs/bundle/release/app-release.aab".freeze platform :android do - desc "Upload the signed AAB + store listing to Google Play (internal track)" + desc "Upload the signed AAB + store listing to Google Play (production, 100%)" lane :deploy_play do + supply( + track: "production", + aab: AAB_PATH, + release_status: "completed", # publish to 100% of users (subject to review) + skip_upload_apk: true, # we ship the AAB, not a raw APK + skip_upload_changelogs: false, + ) + end + + desc "Upload the signed AAB to the internal test track (manual QA safety net)" + lane :deploy_internal do supply( track: "internal", aab: AAB_PATH, release_status: "completed", # internal testing has no review delay - skip_upload_apk: true, # we ship the AAB, not a raw APK + skip_upload_apk: true, skip_upload_changelogs: false, ) end diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/changelogs/18.txt b/apps/app_seeds/fastlane/metadata/android/en-US/changelogs/18.txt new file mode 100644 index 0000000..d4934e0 --- /dev/null +++ b/apps/app_seeds/fastlane/metadata/android/en-US/changelogs/18.txt @@ -0,0 +1 @@ +Tane now stays completely offline until you join the sharing part — it no longer contacts any server on its own. The store description finally says which community servers it talks to, what they can see, and how to change them or switch them all off. diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt b/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt index c6adfc2..71e51df 100644 --- a/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt +++ b/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt @@ -13,8 +13,11 @@ MANAGE YOUR BANK • Search and filter; snap a photo now and name it later. YOURS, AND ONLY YOURS -• Works fully offline. No account, no server, no trackers. +• Your seed book works with no internet at all. What you write stays on your + phone; nothing goes out unless you put some of your seeds up for sharing, or + write to someone. • Everything is encrypted at rest on your device. +• No account, no sign-up, no ads, no trackers. • Save an encrypted backup and keep a printed recovery sheet — restore your whole bank on a new device. @@ -22,6 +25,26 @@ SHARE, THE WAY IT'S ALWAYS BEEN DONE • Mark what you have spare to give away, swap or sell. • Print a catalog of what you share to take to a seed fair. +SHARING NEEDS THE INTERNET +Keeping your seed book needs nothing but your phone. Sharing does need a +connection: to get an offer or a message across to someone, Tane leaves it on +community servers — machines run by people, not by a company. Tane comes with +four: relay.comunes.org, which Asociación Comunes runs (the same people who make +Tane), plus three public ones that others already use — nos.lol, relay.damus.io +and relay.primal.net. + +Tane only talks to them once you join the sharing part and agree to the +community rules. Before that it doesn't connect to anything, and messaging stays +switched off. Once you join, those servers see the key that stands for you and +the address your phone connects from — no name, no email, no account. Your seed +book itself is never sent anywhere. + +You stay in charge of that list of servers. In the sharing setup you can switch +off any server on it, switch off every one of them — Tane then goes back to +being a seed book that never connects — or add the address of a different +server. Anyone can run a server of their own, and Tane works just as well with +it. + Tane is free software (AGPL-3.0). No ads, no commissions, no business model — it exists to support traditional varieties and push back against the seed monopoly. diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/changelogs/18.txt b/apps/app_seeds/fastlane/metadata/android/es-ES/changelogs/18.txt new file mode 100644 index 0000000..df31c73 --- /dev/null +++ b/apps/app_seeds/fastlane/metadata/android/es-ES/changelogs/18.txt @@ -0,0 +1 @@ +Tane ya no se conecta a ningún servidor por su cuenta: se queda completamente sin conexión hasta que entras en la parte de compartir. Y la descripción de la tienda por fin dice con qué servidores comunitarios habla, qué pueden ver y cómo cambiarlos o desactivarlos todos. diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt b/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt index d25105b..d014862 100644 --- a/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt +++ b/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt @@ -13,8 +13,11 @@ GESTIONA TU BANCO • Busca y filtra; haz una foto ahora y ponle nombre después. TUYO, Y SOLO TUYO -• Funciona totalmente sin conexión. Sin cuenta, sin servidor, sin rastreadores. +• Tu cuaderno de semillas funciona sin nada de internet. Lo que escribes se + queda en el móvil; no sale nada salvo que pongas a compartir parte de tus + semillas o le escribas a alguien. • Todo va cifrado en tu dispositivo. +• Sin cuenta, sin registro, sin anuncios, sin rastreadores. • Guarda una copia cifrada y ten una hoja de recuperación impresa: recupera todo tu banco en un dispositivo nuevo. @@ -22,6 +25,25 @@ COMPARTIR, COMO SE HA HECHO SIEMPRE • Marca lo que te sobra para regalar, intercambiar o vender. • Imprime un catálogo de lo que compartes para llevar a una feria de semillas. +COMPARTIR SÍ NECESITA INTERNET +Para llevar tu cuaderno de semillas no hace falta más que el móvil. Compartir sí +necesita conexión: para que una oferta o un mensaje le lleguen a otra persona, +Tane los deja en servidores comunitarios, máquinas que lleva gente, no una +empresa. Tane viene con cuatro: relay.comunes.org, que lleva la Asociación +Comunes (la misma gente que hace Tane), y tres públicos que ya usa otra gente: +nos.lol, relay.damus.io y relay.primal.net. + +Tane solo habla con ellos cuando entras en la parte de compartir y aceptas las +normas de la comunidad. Antes de eso no se conecta a nada, y la mensajería está +apagada. Cuando entras, esos servidores ven la clave que te representa y la +dirección desde la que se conecta tu móvil: ni nombre, ni correo, ni cuenta. Tu +cuaderno de semillas no se envía nunca a ningún sitio. + +Tú mandas sobre esa lista de servidores. En la configuración de compartir puedes +desactivar el servidor que quieras, desactivarlos todos —y Tane vuelve a ser un +cuaderno que no se conecta nunca— o añadir la dirección de otro servidor. +Cualquiera puede montar su propio servidor, y Tane funciona igual de bien con él. + Tane es software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo de negocio: existe para apoyar las variedades tradicionales y plantar cara al monopolio de las semillas. diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index a6aec7b..d02af0a 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -23,6 +23,7 @@ import 'services/social_account_store.dart'; import 'services/social_connection.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; +import 'services/sharing_switch.dart'; import 'state/inventory_cubit.dart'; import 'state/variety_detail_cubit.dart'; import 'ui/about_screen.dart'; @@ -70,6 +71,7 @@ class TaneApp extends StatelessWidget { this.notifications, this.showIntro = false, this.autoBackup, + SharingSwitch? sharing, super.key, }) : _router = _buildRouter( repository, @@ -87,6 +89,17 @@ class TaneApp extends StatelessWidget { savedSearches, socialAccounts, inbox, + // `bootstrap` passes the real switch, holding the person's stored + // answer. A widget test that doesn't care gets one already on, so + // screens behave as they did before sharing became opt-in. + sharing ?? + (socialSettings == null + ? null + : SharingSwitch( + settings: socialSettings, + connection: connection, + enabled: true, + )), ) { // A tapped message notification opens that peer's chat. Wired here because // the router only exists now; taps only happen while the app is foreground, @@ -161,14 +174,17 @@ class TaneApp extends StatelessWidget { SavedSearchesStore? savedSearches, SocialAccountStore? socialAccounts, InboxService? inbox, + SharingSwitch? sharing, ) { return GoRouter( initialLocation: showIntro ? '/intro' : '/', routes: [ GoRoute( path: '/', + // A null switch means there is no social layer at all: the market card + // and the social drawer entries aren't drawn. builder: (context, state) => - HomeScreen(marketEnabled: social != null), + HomeScreen(sharing: sharing, onboarding: onboarding), ), if (social != null && socialSettings != null && connection != null) GoRoute( @@ -181,6 +197,7 @@ class TaneApp extends StatelessWidget { outbox: outbox, onboarding: onboarding, savedSearches: savedSearches, + sharing: sharing, ), ), if (social != null && connection != null) diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index 57ab427..62a4c93 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -9,6 +9,7 @@ import 'data/variety_repository.dart'; import 'di/injector.dart'; import 'i18n/strings.g.dart'; import 'services/auto_backup_service.dart'; +import 'services/camera_availability.dart'; import 'services/coarse_location.dart'; import 'services/inbox_service.dart'; import 'services/locale_store.dart'; @@ -22,6 +23,7 @@ import 'services/profile_store.dart'; import 'services/saved_offers_store.dart'; import 'services/saved_search_alert_service.dart'; import 'services/saved_searches_store.dart'; +import 'services/sharing_switch.dart'; import 'services/social_account_store.dart'; import 'services/social_connection.dart'; import 'services/social_service.dart'; @@ -50,6 +52,11 @@ class _BootstrapState extends State { Future _boot() async { await configureDependencies(); + // Detect camera presence once, before the home screen builds, so camera-less + // devices (Chromebooks, Automotive, some TVs) hide the QR scan / camera UI + // instead of offering broken actions. Non-Android platforms keep the default. + await initCameraAvailability(); + // The saved language lives in the keystore, so it can only be applied once // DI is up. Until now the splash showed in the device language (it has no // text), so there is no visible flip. @@ -75,14 +82,24 @@ class _BootstrapState extends State { final savedSearchAlerts = getIt.isRegistered() ? getIt() : null; + // Sharing is opt-in: the app must not touch the network until the person + // has joined the sharing side. An install from before this setting keeps + // whatever it had (see `migrateSharingEnabled`) so nobody silently loses + // messaging on upgrade. + final introSeen = await onboarding.introSeen(); + final sharingOn = await getIt().migrateSharingEnabled( + introSeen: introSeen, + ); + // Subscribe the inbox + sync + plantaré + saved-search listeners BEFORE the // shared connection starts connecting, so the first session is caught; then - // bring the connection up. + // bring the connection up. The listeners are harmless while sharing is off: + // they only ever react to a session, and none arrives. inbox?.start(); sync?.start(); plantares?.start(); savedSearchAlerts?.start(); - connection?.start(); + if (sharingOn) connection?.start(); return TaneApp( repository: getIt(), @@ -102,7 +119,12 @@ class _BootstrapState extends State { socialAccounts: getIt(), inbox: inbox, notifications: notifications, - showIntro: !await onboarding.introSeen(), + showIntro: !introSeen, + sharing: SharingSwitch( + settings: getIt(), + connection: connection, + enabled: sharingOn, + ), autoBackup: getIt.isRegistered() ? getIt() : null, diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index dd5e877..da4b511 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:async/async.dart'; import 'package:commons_core/commons_core.dart'; import 'package:drift/drift.dart'; @@ -652,13 +654,27 @@ class VarietyRepository { required this.idGen, required this.nodeId, int Function()? nowMillis, + Uint8List? Function(Uint8List)? thumbnailBuilder, }) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch), + _thumbnailBuilder = thumbnailBuilder, _clock = Hlc.zero(nodeId); final AppDatabase _db; final IdGen idGen; final String nodeId; final int Function() _now; + + /// Builds the small list-avatar thumbnail from a full photo. Injected (from + /// `services/offer_thumbnail.dart` in production) so the data layer stays free + /// of the image codec and unit tests run without decoding. Null → no + /// thumbnail is stored and the list falls back to the full photo. + final Uint8List? Function(Uint8List)? _thumbnailBuilder; + + /// The thumbnail for [photoBytes], or null when no builder is wired or the + /// bytes aren't decodable. Wrapped in a Value for direct use in a companion. + Value _thumbValue(Uint8List photoBytes) => + Value(_thumbnailBuilder?.call(photoBytes)); + Hlc _clock; /// Emits the non-deleted inventory, ordered by category then label, each with @@ -696,7 +712,11 @@ class VarietyRepository { _db.select(_db.species).watch().map((_) {}), _db.select(_db.lots).watch().map((_) {}), ]); - return triggers.asyncMap( + // Coalesce bursts: a single quick-add or handover touches several of these + // tables at once, and each would otherwise re-run the full (7-query) load. + // Debouncing collapses the burst into one reload — decisive with a large + // inventory. + return _debounce(triggers, const Duration(milliseconds: 250)).asyncMap( (_) async => (items: await _loadInventory(), drafts: await _loadDrafts()), ); } @@ -929,7 +949,10 @@ class VarietyRepository { return rows.map((l) => l.varietyId).toSet(); } - /// Loads the first photo BLOB for each of [varietyIds] (one query). + /// Loads the first photo's small [thumbnail] for each of [varietyIds] (one + /// query) — for the inventory-list avatar. Falls back to the full-resolution + /// [bytes] only when a thumbnail hasn't been generated yet (older rows, + /// pending lazy backfill), so the list stays correct meanwhile. Future> _firstPhotosFor( List varietyIds, ) async { @@ -950,17 +973,69 @@ class VarietyRepository { .get(); final byVariety = {}; for (final row in rows) { - final bytes = row.bytes; - if (bytes != null) byVariety.putIfAbsent(row.parentId, () => bytes); + final image = row.thumbnail ?? row.bytes; + if (image != null) byVariety.putIfAbsent(row.parentId, () => image); } return byVariety; } + /// Generates the missing list thumbnails for photos that predate the + /// thumbnail column (or arrived via sync/backup restore, which never carry + /// one). Processes in small batches so decoding doesn't block the UI; safe to + /// call at startup as fire-and-forget. No-op when no thumbnail builder is + /// wired. Returns how many thumbnails were written. + Future backfillThumbnails({int batchSize = 20}) async { + final build = _thumbnailBuilder; + if (build == null) return 0; + var written = 0; + while (true) { + final batch = + await (_db.select(_db.attachments) + ..where( + (a) => + a.kind.equalsValue(AttachmentKind.photo) & + a.isDeleted.equals(false) & + a.thumbnail.isNull() & + a.bytes.isNotNull(), + ) + ..limit(batchSize)) + .get(); + if (batch.isEmpty) break; + for (final row in batch) { + final thumb = build(row.bytes!); + // No decodable image → store the full bytes as the "thumbnail" so this + // row isn't re-scanned forever. It's rare (corrupt photo) and still + // bounded by the avatar's cacheWidth at render time. + await (_db.update(_db.attachments)..where((a) => a.id.equals(row.id))) + .write(AttachmentsCompanion(thumbnail: Value(thumb ?? row.bytes))); + written++; + } + if (batch.length < batchSize) break; + } + return written; + } + /// The cover photo (lowest `sortOrder`) for a single variety, or null when it - /// has none. Used by the publish step to host an offer's image; reuses the - /// same "first photo" rule as the inventory avatar. - Future coverPhotoFor(String varietyId) async => - (await _firstPhotosFor([varietyId]))[varietyId]; + /// has none. Used by the publish step to host an offer's image, so it returns + /// the FULL-resolution [bytes] (not the list thumbnail). + Future coverPhotoFor(String varietyId) async { + final rows = + await (_db.select(_db.attachments) + ..where( + (a) => + a.parentId.equals(varietyId) & + a.parentType.equalsValue(ParentType.variety) & + a.kind.equalsValue(AttachmentKind.photo) & + a.isDeleted.equals(false), + ) + ..orderBy([ + (a) => OrderingTerm(expression: a.sortOrder), + (a) => OrderingTerm(expression: a.createdAt), + ]) + ..limit(1)) + .get(); + return rows.isEmpty ? null : rows.first.bytes; + } /// Maps each of [speciesIds] to its scientific name (one query). Future> _scientificNamesFor( @@ -1040,6 +1115,7 @@ class VarietyRepository { parentId: varietyId, kind: AttachmentKind.photo, bytes: Value(photoBytes), + thumbnail: _thumbValue(photoBytes), mimeType: const Value('image/jpeg'), ), ); @@ -1080,6 +1156,7 @@ class VarietyRepository { parentId: varietyId, kind: AttachmentKind.photo, bytes: Value(photoBytes), + thumbnail: _thumbValue(photoBytes), mimeType: const Value('image/jpeg'), ), ); @@ -1795,6 +1872,7 @@ class VarietyRepository { parentId: varietyId, kind: AttachmentKind.photo, bytes: Value(bytes), + thumbnail: _thumbValue(bytes), mimeType: const Value('image/jpeg'), ), ); @@ -3006,3 +3084,46 @@ class VarietyRepository { return maxPacked == null ? null : Hlc.parse(maxPacked); } } + +/// Emits the latest event from [source] only once [duration] has elapsed with +/// no newer event — collapsing a burst of rapid change-triggers into a single +/// downstream reload. Trailing-edge; single-subscription (matches the merged +/// Drift trigger stream it wraps). +Stream _debounce(Stream source, Duration duration) { + late StreamController controller; + StreamSubscription? sub; + Timer? timer; + T? pending; + var hasPending = false; + + void flush() { + if (hasPending) { + hasPending = false; + controller.add(pending as T); + } + } + + controller = StreamController( + onListen: () { + sub = source.listen( + (value) { + pending = value; + hasPending = true; + timer?.cancel(); + timer = Timer(duration, flush); + }, + onError: controller.addError, + onDone: () { + timer?.cancel(); + flush(); + controller.close(); + }, + ); + }, + onCancel: () async { + timer?.cancel(); + await sub?.cancel(); + }, + ); + return controller.stream; +} diff --git a/apps/app_seeds/lib/db/database.dart b/apps/app_seeds/lib/db/database.dart index 01c0d85..53c061f 100644 --- a/apps/app_seeds/lib/db/database.dart +++ b/apps/app_seeds/lib/db/database.dart @@ -32,7 +32,7 @@ class AppDatabase extends _$AppDatabase { /// Current schema version; also stamped into interchange exports so an /// importer knows which app generation wrote the file (data-model §7). - static const int currentSchemaVersion = 13; + static const int currentSchemaVersion = 14; @override int get schemaVersion => currentSchemaVersion; @@ -196,9 +196,40 @@ class AppDatabase extends _$AppDatabase { await m.createTable(gardenOutcomes); } } + // v14: scalability for large inventories. A local, regenerable [thumbnail] + // BLOB on attachments (so the list never decodes full-resolution photos) + // plus indexes on the columns the inventory list filters/joins on. All + // additive and guarded, so a half-migrated dev database re-runs cleanly + // (see the v7 note above). Existing thumbnails are backfilled lazily in + // the background, not here (image decoding in a migration is slow/fragile). + if (from < 14) { + if (!await _hasColumn('attachments', 'thumbnail')) { + await m.addColumn(attachments, attachments.thumbnail); + } + // Declared as `@TableIndex` on the tables, so a fresh install gets them + // via createAll(); existing databases get them here. Guarded against a + // half-migrated dev database (see the v7 note above). + for (final index in [ + idxVarietiesDeletedDraft, + idxAttachmentsParent, + idxLotsVariety, + ]) { + if (!await _hasIndex(index.entityName)) await m.create(index); + } + } }, ); + /// Whether an index named [name] already exists — keeps the additive v14 + /// index creation idempotent against partially-migrated databases. + Future _hasIndex(String name) async { + final rows = await customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?", + variables: [Variable.withString(name)], + ).get(); + return rows.isNotEmpty; + } + /// Whether a table named [table] already exists. Keeps the additive v8 /// table creation idempotent against partially-migrated databases. Future _hasTable(String table) async { diff --git a/apps/app_seeds/lib/db/database.g.dart b/apps/app_seeds/lib/db/database.g.dart index da165ef..17b35c8 100644 --- a/apps/app_seeds/lib/db/database.g.dart +++ b/apps/app_seeds/lib/db/database.g.dart @@ -7975,6 +7975,17 @@ class $AttachmentsTable extends Attachments type: DriftSqlType.blob, requiredDuringInsert: false, ); + static const VerificationMeta _thumbnailMeta = const VerificationMeta( + 'thumbnail', + ); + @override + late final GeneratedColumn thumbnail = GeneratedColumn( + 'thumbnail', + aliasedName, + true, + type: DriftSqlType.blob, + requiredDuringInsert: false, + ); static const VerificationMeta _mimeTypeMeta = const VerificationMeta( 'mimeType', ); @@ -8011,6 +8022,7 @@ class $AttachmentsTable extends Attachments kind, uri, bytes, + thumbnail, mimeType, sortOrder, ]; @@ -8090,6 +8102,12 @@ class $AttachmentsTable extends Attachments bytes.isAcceptableOrUnknown(data['bytes']!, _bytesMeta), ); } + if (data.containsKey('thumbnail')) { + context.handle( + _thumbnailMeta, + thumbnail.isAcceptableOrUnknown(data['thumbnail']!, _thumbnailMeta), + ); + } if (data.containsKey('mime_type')) { context.handle( _mimeTypeMeta, @@ -8159,6 +8177,10 @@ class $AttachmentsTable extends Attachments DriftSqlType.blob, data['${effectivePrefix}bytes'], ), + thumbnail: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}thumbnail'], + ), mimeType: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}mime_type'], @@ -8193,6 +8215,13 @@ class Attachment extends DataClass implements Insertable { final AttachmentKind kind; final String? uri; final Uint8List? bytes; + + /// A small JPEG thumbnail of [bytes] (photos only), decoded once on save so + /// the inventory list never has to decode the full-resolution photo for a + /// 48px avatar. Purely derived, local and regenerable — it is EXCLUDED from + /// CRDT sync payloads and backups (see [SyncColumns] usage); a peer or a + /// restored backup regenerates it lazily via `backfillThumbnails`. + final Uint8List? thumbnail; final String? mimeType; /// Display order among sibling attachments (lower first). The lowest-ordered @@ -8212,6 +8241,7 @@ class Attachment extends DataClass implements Insertable { required this.kind, this.uri, this.bytes, + this.thumbnail, this.mimeType, required this.sortOrder, }); @@ -8241,6 +8271,9 @@ class Attachment extends DataClass implements Insertable { if (!nullToAbsent || bytes != null) { map['bytes'] = Variable(bytes); } + if (!nullToAbsent || thumbnail != null) { + map['thumbnail'] = Variable(thumbnail); + } if (!nullToAbsent || mimeType != null) { map['mime_type'] = Variable(mimeType); } @@ -8263,6 +8296,9 @@ class Attachment extends DataClass implements Insertable { bytes: bytes == null && nullToAbsent ? const Value.absent() : Value(bytes), + thumbnail: thumbnail == null && nullToAbsent + ? const Value.absent() + : Value(thumbnail), mimeType: mimeType == null && nullToAbsent ? const Value.absent() : Value(mimeType), @@ -8291,6 +8327,7 @@ class Attachment extends DataClass implements Insertable { ), uri: serializer.fromJson(json['uri']), bytes: serializer.fromJson(json['bytes']), + thumbnail: serializer.fromJson(json['thumbnail']), mimeType: serializer.fromJson(json['mimeType']), sortOrder: serializer.fromJson(json['sortOrder']), ); @@ -8314,6 +8351,7 @@ class Attachment extends DataClass implements Insertable { ), 'uri': serializer.toJson(uri), 'bytes': serializer.toJson(bytes), + 'thumbnail': serializer.toJson(thumbnail), 'mimeType': serializer.toJson(mimeType), 'sortOrder': serializer.toJson(sortOrder), }; @@ -8331,6 +8369,7 @@ class Attachment extends DataClass implements Insertable { AttachmentKind? kind, Value uri = const Value.absent(), Value bytes = const Value.absent(), + Value thumbnail = const Value.absent(), Value mimeType = const Value.absent(), int? sortOrder, }) => Attachment( @@ -8345,6 +8384,7 @@ class Attachment extends DataClass implements Insertable { kind: kind ?? this.kind, uri: uri.present ? uri.value : this.uri, bytes: bytes.present ? bytes.value : this.bytes, + thumbnail: thumbnail.present ? thumbnail.value : this.thumbnail, mimeType: mimeType.present ? mimeType.value : this.mimeType, sortOrder: sortOrder ?? this.sortOrder, ); @@ -8367,6 +8407,7 @@ class Attachment extends DataClass implements Insertable { kind: data.kind.present ? data.kind.value : this.kind, uri: data.uri.present ? data.uri.value : this.uri, bytes: data.bytes.present ? data.bytes.value : this.bytes, + thumbnail: data.thumbnail.present ? data.thumbnail.value : this.thumbnail, mimeType: data.mimeType.present ? data.mimeType.value : this.mimeType, sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder, ); @@ -8386,6 +8427,7 @@ class Attachment extends DataClass implements Insertable { ..write('kind: $kind, ') ..write('uri: $uri, ') ..write('bytes: $bytes, ') + ..write('thumbnail: $thumbnail, ') ..write('mimeType: $mimeType, ') ..write('sortOrder: $sortOrder') ..write(')')) @@ -8405,6 +8447,7 @@ class Attachment extends DataClass implements Insertable { kind, uri, $driftBlobEquality.hash(bytes), + $driftBlobEquality.hash(thumbnail), mimeType, sortOrder, ); @@ -8423,6 +8466,7 @@ class Attachment extends DataClass implements Insertable { other.kind == this.kind && other.uri == this.uri && $driftBlobEquality.equals(other.bytes, this.bytes) && + $driftBlobEquality.equals(other.thumbnail, this.thumbnail) && other.mimeType == this.mimeType && other.sortOrder == this.sortOrder); } @@ -8439,6 +8483,7 @@ class AttachmentsCompanion extends UpdateCompanion { final Value kind; final Value uri; final Value bytes; + final Value thumbnail; final Value mimeType; final Value sortOrder; final Value rowid; @@ -8454,6 +8499,7 @@ class AttachmentsCompanion extends UpdateCompanion { this.kind = const Value.absent(), this.uri = const Value.absent(), this.bytes = const Value.absent(), + this.thumbnail = const Value.absent(), this.mimeType = const Value.absent(), this.sortOrder = const Value.absent(), this.rowid = const Value.absent(), @@ -8470,6 +8516,7 @@ class AttachmentsCompanion extends UpdateCompanion { required AttachmentKind kind, this.uri = const Value.absent(), this.bytes = const Value.absent(), + this.thumbnail = const Value.absent(), this.mimeType = const Value.absent(), this.sortOrder = const Value.absent(), this.rowid = const Value.absent(), @@ -8492,6 +8539,7 @@ class AttachmentsCompanion extends UpdateCompanion { Expression? kind, Expression? uri, Expression? bytes, + Expression? thumbnail, Expression? mimeType, Expression? sortOrder, Expression? rowid, @@ -8508,6 +8556,7 @@ class AttachmentsCompanion extends UpdateCompanion { if (kind != null) 'kind': kind, if (uri != null) 'uri': uri, if (bytes != null) 'bytes': bytes, + if (thumbnail != null) 'thumbnail': thumbnail, if (mimeType != null) 'mime_type': mimeType, if (sortOrder != null) 'sort_order': sortOrder, if (rowid != null) 'rowid': rowid, @@ -8526,6 +8575,7 @@ class AttachmentsCompanion extends UpdateCompanion { Value? kind, Value? uri, Value? bytes, + Value? thumbnail, Value? mimeType, Value? sortOrder, Value? rowid, @@ -8542,6 +8592,7 @@ class AttachmentsCompanion extends UpdateCompanion { kind: kind ?? this.kind, uri: uri ?? this.uri, bytes: bytes ?? this.bytes, + thumbnail: thumbnail ?? this.thumbnail, mimeType: mimeType ?? this.mimeType, sortOrder: sortOrder ?? this.sortOrder, rowid: rowid ?? this.rowid, @@ -8588,6 +8639,9 @@ class AttachmentsCompanion extends UpdateCompanion { if (bytes.present) { map['bytes'] = Variable(bytes.value); } + if (thumbnail.present) { + map['thumbnail'] = Variable(thumbnail.value); + } if (mimeType.present) { map['mime_type'] = Variable(mimeType.value); } @@ -8614,6 +8668,7 @@ class AttachmentsCompanion extends UpdateCompanion { ..write('kind: $kind, ') ..write('uri: $uri, ') ..write('bytes: $bytes, ') + ..write('thumbnail: $thumbnail, ') ..write('mimeType: $mimeType, ') ..write('sortOrder: $sortOrder, ') ..write('rowid: $rowid') @@ -11435,6 +11490,18 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ExternalLinksTable externalLinks = $ExternalLinksTable(this); late final $PlantaresTable plantares = $PlantaresTable(this); late final $SalesTable sales = $SalesTable(this); + late final Index idxVarietiesDeletedDraft = Index( + 'idx_varieties_deleted_draft', + 'CREATE INDEX idx_varieties_deleted_draft ON varieties (is_deleted, is_draft)', + ); + late final Index idxLotsVariety = Index( + 'idx_lots_variety', + 'CREATE INDEX idx_lots_variety ON lots (variety_id)', + ); + late final Index idxAttachmentsParent = Index( + 'idx_attachments_parent', + 'CREATE INDEX idx_attachments_parent ON attachments (parent_type, parent_id, kind)', + ); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -11454,6 +11521,9 @@ abstract class _$AppDatabase extends GeneratedDatabase { externalLinks, plantares, sales, + idxVarietiesDeletedDraft, + idxLotsVariety, + idxAttachmentsParent, ]; } @@ -15118,6 +15188,7 @@ typedef $$AttachmentsTableCreateCompanionBuilder = required AttachmentKind kind, Value uri, Value bytes, + Value thumbnail, Value mimeType, Value sortOrder, Value rowid, @@ -15135,6 +15206,7 @@ typedef $$AttachmentsTableUpdateCompanionBuilder = Value kind, Value uri, Value bytes, + Value thumbnail, Value mimeType, Value sortOrder, Value rowid, @@ -15206,6 +15278,11 @@ class $$AttachmentsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get thumbnail => $composableBuilder( + column: $table.thumbnail, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get mimeType => $composableBuilder( column: $table.mimeType, builder: (column) => ColumnFilters(column), @@ -15281,6 +15358,11 @@ class $$AttachmentsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get thumbnail => $composableBuilder( + column: $table.thumbnail, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get mimeType => $composableBuilder( column: $table.mimeType, builder: (column) => ColumnOrderings(column), @@ -15341,6 +15423,9 @@ class $$AttachmentsTableAnnotationComposer GeneratedColumn get bytes => $composableBuilder(column: $table.bytes, builder: (column) => column); + GeneratedColumn get thumbnail => + $composableBuilder(column: $table.thumbnail, builder: (column) => column); + GeneratedColumn get mimeType => $composableBuilder(column: $table.mimeType, builder: (column) => column); @@ -15390,6 +15475,7 @@ class $$AttachmentsTableTableManager Value kind = const Value.absent(), Value uri = const Value.absent(), Value bytes = const Value.absent(), + Value thumbnail = const Value.absent(), Value mimeType = const Value.absent(), Value sortOrder = const Value.absent(), Value rowid = const Value.absent(), @@ -15405,6 +15491,7 @@ class $$AttachmentsTableTableManager kind: kind, uri: uri, bytes: bytes, + thumbnail: thumbnail, mimeType: mimeType, sortOrder: sortOrder, rowid: rowid, @@ -15422,6 +15509,7 @@ class $$AttachmentsTableTableManager required AttachmentKind kind, Value uri = const Value.absent(), Value bytes = const Value.absent(), + Value thumbnail = const Value.absent(), Value mimeType = const Value.absent(), Value sortOrder = const Value.absent(), Value rowid = const Value.absent(), @@ -15437,6 +15525,7 @@ class $$AttachmentsTableTableManager kind: kind, uri: uri, bytes: bytes, + thumbnail: thumbnail, mimeType: mimeType, sortOrder: sortOrder, rowid: rowid, diff --git a/apps/app_seeds/lib/db/tables.dart b/apps/app_seeds/lib/db/tables.dart index 4d2c49f..125f418 100644 --- a/apps/app_seeds/lib/db/tables.dart +++ b/apps/app_seeds/lib/db/tables.dart @@ -5,6 +5,10 @@ import 'sync_columns.dart'; /// The identity/accession — one row per distinct thing in the inventory. /// Only [label] is mandatory (progressive disclosure). +/// +/// The index covers the inventory list's hot filter — non-deleted, non-draft +/// rows — so it stays fast with a large catalogue. +@TableIndex(name: 'idx_varieties_deleted_draft', columns: {#isDeleted, #isDraft}) class Varieties extends Table with SyncColumns { TextColumn get label => text()(); TextColumn get speciesId => text().nullable()(); // → Species @@ -77,6 +81,10 @@ class SpeciesCommonNames extends Table with SyncColumns { /// A homogeneous batch held for a Variety — its own year and its own unit. /// Quantity (commons_core value type) is flattened into columns here. +/// +/// Indexed by [varietyId] — the inventory list joins lots per variety (types, +/// shared status, viability), so this avoids a full scan per reload. +@TableIndex(name: 'idx_lots_variety', columns: {#varietyId}) class Lots extends Table with SyncColumns { TextColumn get varietyId => text()(); TextColumn get type => @@ -181,12 +189,26 @@ class Parties extends Table with SyncColumns { /// rest by SQLCipher) via [bytes]; external files use [uri]. Storing bytes here /// keeps the "no plaintext at rest" rule for photos in Block 1; an external /// encrypted file store is a later optimization. +/// +/// Indexed by (parentType, parentId, kind) — the list's "first photo per +/// variety" lookup filters on exactly these. +@TableIndex( + name: 'idx_attachments_parent', + columns: {#parentType, #parentId, #kind}, +) class Attachments extends Table with SyncColumns { TextColumn get parentType => textEnum()(); TextColumn get parentId => text()(); TextColumn get kind => textEnum()(); TextColumn get uri => text().nullable()(); BlobColumn get bytes => blob().nullable()(); + + /// A small JPEG thumbnail of [bytes] (photos only), decoded once on save so + /// the inventory list never has to decode the full-resolution photo for a + /// 48px avatar. Purely derived, local and regenerable — it is EXCLUDED from + /// CRDT sync payloads and backups (see [SyncColumns] usage); a peer or a + /// restored backup regenerates it lazily via `backfillThumbnails`. + BlobColumn get thumbnail => blob().nullable()(); TextColumn get mimeType => text().nullable()(); /// Display order among sibling attachments (lower first). The lowest-ordered diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 528baf5..844a4fb 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -28,6 +28,7 @@ import '../services/file_service.dart'; import '../services/inbox_service.dart'; import '../services/locale_store.dart'; import '../services/notification_service.dart'; +import '../services/offer_thumbnail.dart'; import '../services/ocr/label_text_extractor.dart'; import '../services/ocr/ocr_language.dart'; import '../services/ocr/tesseract_label_extractor.dart'; @@ -152,7 +153,11 @@ Future configureDependencies() async { database, idGen: IdGen(), nodeId: nodeId, + thumbnailBuilder: inventoryThumbnailBytes, ); + // Backfill list thumbnails for photos that predate the thumbnail column (or + // arrived via a restored backup). Fire-and-forget so startup isn't blocked. + unawaited(varietyRepository.backfillThumbnails()); const fileService = FilePickerFileService(); diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 80d39a1..c7d0454 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -58,8 +58,7 @@ "cancel": "Encaboxar", "delete": "Desaniciar", "edit": "Editar", - "type": "Triba", - "comingSoon": "Aína" + "type": "Triba" }, "home": { "tagline": "Comparte y cultiva simiente llocal", @@ -94,6 +93,7 @@ "langEs": "Español", "langEn": "English", "langPt": "Português", + "langPtBr": "Português (Brasil)", "langAst": "Asturianu", "langFr": "Français", "langDe": "Deutsch", @@ -146,9 +146,9 @@ "license": "Llicencia", "licenseValue": "AGPL-3.0", "website": "Sitiu web", - "sourceCode": "Códigu fonte", - "translate": "Ayuda a traducir", - "translateSubtitle": "Ayuda a traer Tane a la to llingua", + "sourceCode": "Códigu fonte", + "translate": "Ayuda a traducir", + "translateSubtitle": "Ayuda a traer Tane a la to llingua", "openSourceLicenses": "Llicencies de códigu abiertu", "openSourceLicensesSubtitle": "Biblioteques de terceros y les sos llicencies", "copyright": "© {years} Asociación Comunes, baxo AGPLv3" @@ -515,7 +515,7 @@ "contact": "Mensaxe", "mine": "Tu", "configTitle": "Configuración de compartir", - "setupIntro": "Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu'otres persones atopen lo qu'ufiertes, ensin nenguna empresa en mediu.", + "setupIntro": "Compartir con xente cercano ye opcional. Namás indica la to zona averada — lo qu'ufiertes viaxa per servidores comunitarios, calteníos por persones y coleutivos, non por una empresa, pa qu'otres persones cercanes puedan atopalo.", "areaLabel": "La to zona", "areaHelp": "Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.", "areaSet": "La to zona ta puesta — averada, enxamás el to puntu esactu", diff --git a/apps/app_seeds/lib/i18n/de.i18n.json b/apps/app_seeds/lib/i18n/de.i18n.json index 444b08b..45889cc 100644 --- a/apps/app_seeds/lib/i18n/de.i18n.json +++ b/apps/app_seeds/lib/i18n/de.i18n.json @@ -59,7 +59,6 @@ "delete": "Löschen", "edit": "Bearbeiten", "type": "Typ", - "comingSoon": "Bald", "offline": "Offline - Teilen ist unterbrochen" }, "home": { @@ -95,6 +94,7 @@ "langEs": "Español", "langEn": "English", "langPt": "Português", + "langPtBr": "Português (Brasil)", "langAst": "Asturianu", "about": "Über", "aboutText": "Lokale, verschlüsselte Saatgutbank für traditionelle Samen. AGPL-3.0.", @@ -147,9 +147,9 @@ "license": "Lizenz", "licenseValue": "AGPL-3.0", "website": "Webseite", - "sourceCode": "Quellcode", - "translate": "Beim Übersetzen helfen", - "translateSubtitle": "Hilf, Tane in deine Sprache zu bringen", + "sourceCode": "Quellcode", + "translate": "Beim Übersetzen helfen", + "translateSubtitle": "Hilf, Tane in deine Sprache zu bringen", "openSourceLicenses": "Open-Source-Lizenzen", "openSourceLicensesSubtitle": "Bibliotheken von Drittanbietern und ihre Lizenzen", "copyright": "© {years} Comunes Association, unter AGPLv3" @@ -518,7 +518,7 @@ "contact": "Nachricht", "mine": "Du", "configTitle": "Teilen-Einrichtung", - "setupIntro": "Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.", + "setupIntro": "Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - was du anbietest, läuft über Gemeinschaftsserver, die von Menschen und Kollektiven betrieben werden, nicht von einem Unternehmen, damit andere in deiner Nähe es finden können.", "areaLabel": "Dein Gebiet", "areaHelp": "Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.", "areaSet": "Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt", @@ -723,4 +723,4 @@ "manageEmpty": "Du hast niemanden blockiert", "unblock": "Entsperren" } -} \ No newline at end of file +} diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 5dd0816..4247fd4 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -1,809 +1,821 @@ { - "avatar": { - "title": "Your photo or avatar", - "fromPhoto": "Take or choose a photo", - "illustration": "Or pick an illustration", - "remove": "Remove" - }, - "favorites": { - "title": "Favorites", - "empty": "No favorites yet. Save offers you like from the market.", - "save": "Save to favorites", - "remove": "Remove from favorites", - "unavailable": "No longer available" - }, - "savedSearches": { - "title": "Saved searches", - "empty": "No saved searches yet. Save a search from the market and we'll tell you when something like it appears in your zone.", - "save": "Save this search", - "nameLabel": "Name this search", - "namePlaceholder": "e.g. Tomatoes near me", - "saved": "Search saved — we'll alert you about new matches nearby", - "openTooltip": "Saved searches", - "delete": "Delete", - "deleteConfirm": "Delete this saved search?", - "newMatchesBadge": "{n} new", - "alert": "New seeds near you: {label}" - }, - "seedSaving": { - "title": "Saving its seed", - "subtitle": "What it takes to keep the variety true", - "lifeCycle": "Cycle", - "cycleAnnual": "Annual", - "cycleBiennial": "Biennial — seeds in year 2", - "cyclePerennial": "Perennial", - "pollination": "Pollination", - "pollSelf": "Self-pollinating", - "pollCross": "Crosses with others", - "pollMixed": "Self-pollinates, sometimes crosses", - "byInsect": "by insects", - "byWind": "on the wind", - "isolation": "Keep apart", - "isolationRange": "{min}–{max} m from other varieties", - "isolationSingle": "{min} m from other varieties", - "plants": "Save from several plants", - "plantsValue": "From at least {n} plants", - "processing": "Cleaning the seed", - "procDry": "Dry seed (thresh)", - "procWet": "Wet seed (ferment and rinse)", - "difficulty": "Difficulty", - "diffEasy": "Easy", - "diffMedium": "Medium", - "diffHard": "Hard", - "advisory": "General guidance — adapt it to your climate and variety.", - "sourcePrefix": "Source" - }, - "calendar": { - "title": "This month", - "filterChip": "This month", - "selfNote": "What you've noted in your varieties.", - "nothing": "Nothing noted for {month}." - }, - "app": { - "title": "Tane" - }, - "bootstrap": { - "failed": "Tane couldn't start", - "retry": "Try again" - }, - "common": { - "save": "Save", - "cancel": "Cancel", - "delete": "Delete", - "edit": "Edit", - "type": "Type", - "comingSoon": "Coming soon", - "offline": "You're offline — sharing is paused" - }, - "home": { - "tagline": "Share and grow local seeds", - "openMarket": "Market", - "openMarketSubtitle": "Discover and share seeds nearby", - "yourInventory": "Your inventory", - "yourInventorySubtitle": "Manage your seeds" - }, - "photo": { - "camera": "Take a photo", - "gallery": "Choose from gallery", - "setAsCover": "Set as cover", - "isCover": "Cover photo", - "deleteConfirm": "Delete this photo?" - }, - "menu": { - "tagline": "your seed bank", - "inventory": "Inventory", - "market": "Market", - "profile": "Your profile", - "chat": "Chat", - "wishlist": "Favorites", - "following": "Following", - "plantares": "Plantares", - "sales": "Sales", - "calendar": "Calendar", - "settings": "Settings" - }, - "settings": { - "language": "Language", - "systemLanguage": "System language", - "langEs": "Español", - "langEn": "English", - "langPt": "Português", - "langAst": "Asturianu", - "langFr": "Français", - "langDe": "Deutsch", - "langJa": "日本語", - "about": "About", - "aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.", - "aboutOpen": "About Tane" - }, - "backup": { - "title": "Backup & restore", - "autoBackupTitle": "Automatic backups", - "autoBackupLast": "Last copy saved {date} · every {days} days", - "autoBackupNone": "A copy is kept automatically every {days} days", - "exportJson": "Save a backup", - "exportJsonSubtitle": "A complete copy to keep safe, restore later or move to another device", - "importJson": "Restore a backup", - "importJsonSubtitle": "Bring a saved copy back — nothing gets duplicated", - "exportCsv": "Export to a spreadsheet", - "exportCsvSubtitle": "A simple list for Excel or LibreOffice — no photos", - "importCsv": "Import a list", - "importCsvSubtitle": "Add entries from a spreadsheet", - "importConfirmTitle": "Restore a backup?", - "importConfirmBody": "Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.", - "importCsvConfirmTitle": "Import a list?", - "importCsvConfirmBody": "Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.", - "importAction": "Import", - "exportSaved": "Copy saved", - "cancelled": "Cancelled", - "importDone": "Imported: {added} new, {updated} updated", - "importCsvDone": "Added {count} entries", - "importFailed": "This file could not be read as a Tane copy", - "failed": "Something went wrong", - "recoveryTitle": "Your recovery code", - "recoverySubtitle": "Print it and keep it safe — it opens your copies on a new device", - "recoveryIntro": "This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.", - "recoveryCopy": "Copy", - "recoverySave": "Save the sheet", - "recoverySheetTitle": "Tane — your recovery sheet", - "recoveryPromptTitle": "Enter your recovery code", - "recoveryPromptBody": "This copy was saved with another code. Type the code from your recovery sheet to open it.", - "recoveryWrongCode": "That code doesn't open this copy" - }, - "about": { - "title": "About", - "kanji": "種", - "tagline": "A local-first, decentralized app for managing and sharing traditional seeds and seedlings.", - "intro": "Tane (種, \"seed\" in Japanese) helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. Its name comes from tanemaki (種まき), \"to sow / scatter seeds\". The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.", - "heritage": "The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the \"community currency for seed exchange\" (BAH-Semillero, 2009, CC-BY-SA). Tane is the digital Plantare.", - "version": "Version", - "license": "License", - "licenseValue": "AGPL-3.0", - "website": "Website", - "sourceCode": "Source code", - "translate": "Help translate", - "translateSubtitle": "Help bring Tane to your language", - "openSourceLicenses": "Open-source licenses", - "openSourceLicensesSubtitle": "Third-party libraries and their licenses", - "copyright": "© {years} Comunes Association, under AGPLv3" - }, - "intro": { - "skip": "Skip", - "next": "Next", - "start": "Get started", - "menuEntry": "How Tane works", - "slides": { - "welcome": { - "title": "The seed that brought you here", - "body": "Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing." - }, - "inventory": { - "title": "Your seed bank, in your pocket", - "body": "Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start." - }, - "privacy": { - "title": "Yours, and only yours", - "body": "No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared." - }, - "share": { - "title": "Share, the way it's always been done", - "body": "Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person." - }, - "plantare": { - "title": "To sow is to multiply", - "body": "With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons." - } + "avatar": { + "title": "Your photo or avatar", + "fromPhoto": "Take or choose a photo", + "illustration": "Or pick an illustration", + "remove": "Remove" + }, + "favorites": { + "title": "Favorites", + "empty": "No favorites yet. Save offers you like from the market.", + "save": "Save to favorites", + "remove": "Remove from favorites", + "unavailable": "No longer available" + }, + "savedSearches": { + "title": "Saved searches", + "empty": "No saved searches yet. Save a search from the market and we'll tell you when something like it appears in your zone.", + "save": "Save this search", + "nameLabel": "Name this search", + "namePlaceholder": "e.g. Tomatoes near me", + "saved": "Search saved — we'll alert you about new matches nearby", + "openTooltip": "Saved searches", + "delete": "Delete", + "deleteConfirm": "Delete this saved search?", + "newMatchesBadge": "{n} new", + "alert": "New seeds near you: {label}" + }, + "seedSaving": { + "title": "Saving its seed", + "subtitle": "What it takes to keep the variety true", + "lifeCycle": "Cycle", + "cycleAnnual": "Annual", + "cycleBiennial": "Biennial — seeds in year 2", + "cyclePerennial": "Perennial", + "pollination": "Pollination", + "pollSelf": "Self-pollinating", + "pollCross": "Crosses with others", + "pollMixed": "Self-pollinates, sometimes crosses", + "byInsect": "by insects", + "byWind": "on the wind", + "isolation": "Keep apart", + "isolationRange": "{min}–{max} m from other varieties", + "isolationSingle": "{min} m from other varieties", + "plants": "Save from several plants", + "plantsValue": "From at least {n} plants", + "processing": "Cleaning the seed", + "procDry": "Dry seed (thresh)", + "procWet": "Wet seed (ferment and rinse)", + "difficulty": "Difficulty", + "diffEasy": "Easy", + "diffMedium": "Medium", + "diffHard": "Hard", + "advisory": "General guidance — adapt it to your climate and variety.", + "sourcePrefix": "Source" + }, + "calendar": { + "title": "This month", + "filterChip": "This month", + "selfNote": "What you've noted in your varieties.", + "nothing": "Nothing noted for {month}." + }, + "app": { + "title": "Tane" + }, + "bootstrap": { + "failed": "Tane couldn't start", + "retry": "Try again" + }, + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "type": "Type", + "offline": "You're offline — sharing is paused" + }, + "home": { + "tagline": "Share and grow local seeds", + "openMarket": "Market", + "openMarketSubtitle": "Discover and share seeds nearby", + "yourInventory": "Your inventory", + "yourInventorySubtitle": "Manage your seeds" + }, + "photo": { + "camera": "Take a photo", + "gallery": "Choose from gallery", + "setAsCover": "Set as cover", + "isCover": "Cover photo", + "deleteConfirm": "Delete this photo?" + }, + "menu": { + "tagline": "your seed bank", + "inventory": "Inventory", + "market": "Market", + "profile": "Your profile", + "chat": "Chat", + "wishlist": "Favorites", + "following": "Following", + "plantares": "Plantares", + "sales": "Sales", + "calendar": "Calendar", + "settings": "Settings" + }, + "settings": { + "language": "Language", + "systemLanguage": "System language", + "langEs": "Español", + "langEn": "English", + "langPt": "Português", + "langPtBr": "Português (Brasil)", + "langAst": "Asturianu", + "langFr": "Français", + "langDe": "Deutsch", + "langJa": "日本語", + "about": "About", + "aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.", + "aboutOpen": "About Tane" + }, + "backup": { + "title": "Backup & restore", + "autoBackupTitle": "Automatic backups", + "autoBackupLast": "Last copy saved {date} · every {days} days", + "autoBackupNone": "A copy is kept automatically every {days} days", + "exportJson": "Save a backup", + "exportJsonSubtitle": "A complete copy to keep safe, restore later or move to another device", + "importJson": "Restore a backup", + "importJsonSubtitle": "Bring a saved copy back — nothing gets duplicated", + "exportCsv": "Export to a spreadsheet", + "exportCsvSubtitle": "A simple list for Excel or LibreOffice — no photos", + "importCsv": "Import a list", + "importCsvSubtitle": "Add entries from a spreadsheet", + "importConfirmTitle": "Restore a backup?", + "importConfirmBody": "Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.", + "importCsvConfirmTitle": "Import a list?", + "importCsvConfirmBody": "Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.", + "importAction": "Import", + "exportSaved": "Copy saved", + "cancelled": "Cancelled", + "importDone": "Imported: {added} new, {updated} updated", + "importCsvDone": "Added {count} entries", + "importFailed": "This file could not be read as a Tane copy", + "failed": "Something went wrong", + "recoveryTitle": "Your recovery code", + "recoverySubtitle": "Print it and keep it safe — it opens your copies on a new device", + "recoveryIntro": "This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.", + "recoveryCopy": "Copy", + "recoverySave": "Save the sheet", + "recoverySheetTitle": "Tane — your recovery sheet", + "recoveryPromptTitle": "Enter your recovery code", + "recoveryPromptBody": "This copy was saved with another code. Type the code from your recovery sheet to open it.", + "recoveryWrongCode": "That code doesn't open this copy" + }, + "about": { + "title": "About", + "kanji": "種", + "tagline": "A local-first, decentralized app for managing and sharing traditional seeds and seedlings.", + "intro": "Tane (種, \"seed\" in Japanese) helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. Its name comes from tanemaki (種まき), \"to sow / scatter seeds\". The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.", + "heritage": "The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the \"community currency for seed exchange\" (BAH-Semillero, 2009, CC-BY-SA). Tane is the digital Plantare.", + "version": "Version", + "license": "License", + "licenseValue": "AGPL-3.0", + "website": "Website", + "sourceCode": "Source code", + "translate": "Help translate", + "translateSubtitle": "Help bring Tane to your language", + "openSourceLicenses": "Open-source licenses", + "openSourceLicensesSubtitle": "Third-party libraries and their licenses", + "copyright": "© {years} Comunes Association, under AGPLv3" + }, + "intro": { + "skip": "Skip", + "next": "Next", + "start": "Get started", + "menuEntry": "How Tane works", + "slides": { + "welcome": { + "title": "The seed that brought you here", + "body": "Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing." + }, + "inventory": { + "title": "Your seed bank, in your pocket", + "body": "Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start." + }, + "privacy": { + "title": "Yours, and only yours", + "body": "No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared." + }, + "share": { + "title": "Share, the way it's always been done", + "body": "Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person." + }, + "plantare": { + "title": "To sow is to multiply", + "body": "With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons." + } + } + }, + "inventory": { + "title": "Inventory", + "searchHint": "Search seeds", + "empty": "No seeds yet. Tap + to add your first.", + "noMatches": "No seeds match your filters.", + "clearFilters": "Clear filters", + "uncategorized": "Uncategorized", + "needsReproductionFilter": "To regrow", + "loadError": "Couldn't open your seed bank. It may just have been busy — try again.", + "retry": "Try again" + }, + "draft": { + "capture": "Capture photos", + "captured": "{n} captured to catalogue", + "triageTitle": "To catalogue", + "triageCount": "{n} to catalogue", + "untitled": "Unnamed", + "nameField": "Name this seed", + "nameHint": "What is it?", + "suggestFromPhoto": "Suggest name from photo", + "discard": "Discard" + }, + "quickAdd": { + "title": "Add a seed", + "labelField": "Name", + "labelRequired": "Give it a name", + "addPhoto": "Add photo", + "quantity": "How much?", + "more": "Add more…", + "save": "Save", + "saveAndAddAnother": "Save & add another", + "addedCount": "{n} added", + "cancel": "Cancel" + }, + "detail": { + "notFound": "This seed is no longer here.", + "lots": "Lots", + "noLots": "No lots yet.", + "names": "Also known as", + "addName": "Add name", + "links": "Links", + "addLink": "Add link", + "linkUrl": "URL", + "linkTitle": "Title (optional)", + "reference": "Learn more", + "refGbif": "GBIF", + "refWikipedia": "Wikipedia", + "refWikispecies": "Wikispecies", + "notes": "Notes", + "addLot": "Add lot", + "editLot": "Edit lot", + "deleteConfirm": "Delete this seed?", + "year": "Year {year}", + "noYear": "Year unknown" + }, + "germination": { + "title": "Germination", + "add": "Add test", + "sampleSize": "Sample size", + "germinated": "Germinated", + "none": "No germination tests yet.", + "result": "{percent}%" + }, + "viability": { + "expiringSoon": "Use or reproduce this season", + "expiringSoonYears": "Use or reproduce this season · keeps ~{years} yr", + "expired": "Past typical viability — reproduce", + "expiredYears": "Past typical viability (~{years} yr) — reproduce" + }, + "editVariety": { + "title": "Edit seed", + "name": "Name", + "category": "Category", + "notes": "Notes", + "species": "Species (from catalog)", + "speciesHint": "Search a species…", + "speciesSuggested": "Suggested from the name", + "organic": "Organic", + "organicHint": "Grown organically (eco)" + }, + "addLot": { + "title": "Add lot", + "year": "Harvest date", + "quantity": "How much?", + "amount": "Amount" + }, + "harvest": { + "pickTitle": "Select month / year", + "anyMonth": "Any month", + "noDate": "Set harvest date", + "monthNames": [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" + ] + }, + "lotType": { + "seed": "Seeds", + "plant": "Plant", + "seedling": "Seedling", + "tree": "Tree / shrub", + "bulb": "Bulb / tuber", + "cutting": "Cutting" + }, + "presentation": { + "title": "Packaging", + "none": "Unspecified", + "pot": "Pot", + "tray": "Tray", + "plug": "Plug", + "bareRoot": "Bare-root", + "rootBall": "Root-ball" + }, + "provenance": { + "section": "Where it's from", + "seedsFrom": "Seeds from", + "seedsFromHint": "Who grew or gave them", + "place": "Place", + "placeHint": "Where they come from (with region)", + "addSeedsFrom": "Seeds from", + "addPlace": "Place" + }, + "abundance": { + "add": "How much I have", + "title": "How much I have", + "none": "Not set", + "plentyToShare": "Plenty to share", + "enoughToShare": "Enough to share a little", + "enoughForMe": "Enough for me", + "runningLow": "Running low" + }, + "share": { + "add": "Do you share it?", + "title": "Do you share it?", + "nudge": "You have plenty — you could share some.", + "price": "Price", + "priceHint": "Leave it empty to agree it later", + "private": "Just for me", + "gift": "To give away", + "exchange": "To swap", + "sell": "For sale", + "filterChip": "I share", + "printCatalog": "Print what I share", + "catalogTitle": "What I share", + "catalogSaved": "Catalog saved", + "cancelled": "Cancelled" + }, + "printLabels": { + "action": "Print labels", + "title": "Print labels", + "selectHint": "Pick the seeds to print labels for", + "selectAll": "Select all", + "selected": "{n} selected", + "none": "Select seeds first", + "format": "Label size", + "formatStickers": "Small stickers", + "formatStickersHint": "Many small labels per page — to peel onto packets", + "formatCards": "Large cards", + "formatCardsHint": "Fewer, bigger labels — for jars and boxes", + "count": "{n} labels", + "save": "Save labels", + "saved": "Labels saved", + "cancelled": "Cancelled" + }, + "scan": { + "action": "Scan a seed label", + "title": "Scan a label", + "notALabel": "That code is not a seed label", + "addTitle": "Not in your collection", + "addBody": "Add “{label}” to your seeds?", + "add": "Add it", + "added": "Added to your collection" + }, + "cropCalendar": { + "add": "Crop calendar", + "title": "Crop calendar", + "sow": "Sow", + "transplant": "Transplant", + "flowering": "Flowering", + "fruiting": "Fruiting", + "seedHarvest": "Seed harvest", + "editorHint": "Note the months typical for this variety in your area — these are your own notes.", + "unset": "—" + }, + "needsReproduction": { + "label": "To regrow this season", + "hint": "Grow it out before the seed runs out", + "badge": "To regrow" + }, + "preservation": { + "add": "How it's kept", + "title": "How it's kept", + "none": "Unspecified", + "jarWithDesiccant": "Jar with drying agent", + "glassJar": "Glass jar", + "paperEnvelope": "Paper envelope", + "paperBag": "Paper bag", + "plasticBag": "Plastic bag" + }, + "conditionCheck": { + "advanced": "Storage & seed-bank details", + "title": "Storage checks", + "add": "Add check", + "containers": "Jars / containers", + "desiccant": "Drying agent", + "none": "No storage checks yet.", + "summary": "{count} jar(s) · {state}" + }, + "desiccant": { + "none": "None", + "add": "Add some", + "replace": "Replace it", + "dry": "Blue — dry", + "fresh": "Just renewed" + }, + "unit": { + "aFew": "a few", + "some": "some", + "plenty": "plenty", + "pinch": "a pinch", + "handful": { + "singular": "handful", + "plural": "handfuls" + }, + "teaspoon": { + "singular": "teaspoon", + "plural": "teaspoons" + }, + "spoon": { + "singular": "spoon", + "plural": "spoons" + }, + "cup": { + "singular": "cup", + "plural": "cups" + }, + "jar": { + "singular": "jar", + "plural": "jars" + }, + "sack": { + "singular": "sack", + "plural": "sacks" + }, + "packet": { + "singular": "packet", + "plural": "packets" + }, + "cob": { + "singular": "cob", + "plural": "cobs" + }, + "pod": { + "singular": "pod", + "plural": "pods" + }, + "ear": { + "singular": "ear", + "plural": "ears" + }, + "head": { + "singular": "head", + "plural": "heads" + }, + "fruit": { + "singular": "fruit", + "plural": "fruits" + }, + "bulb": { + "singular": "bulb", + "plural": "bulbs" + }, + "tuber": { + "singular": "tuber", + "plural": "tubers" + }, + "seedHead": { + "singular": "seed head", + "plural": "seed heads" + }, + "bunch": { + "singular": "bunch", + "plural": "bunches" + }, + "plant": { + "singular": "plant", + "plural": "plants" + }, + "pot": { + "singular": "pot", + "plural": "pots" + }, + "tray": { + "singular": "tray", + "plural": "trays" + }, + "seedling": { + "singular": "seedling", + "plural": "seedlings" + }, + "tree": { + "singular": "tree", + "plural": "trees" + }, + "cutting": { + "singular": "cutting", + "plural": "cuttings" + }, + "grams": { + "singular": "gram", + "plural": "grams" + }, + "count": { + "singular": "seed", + "plural": "seeds" + } + }, + "market": { + "title": "Seeds near you", + "subtitle": "What others are sharing nearby", + "notSetUp": "Sharing isn't set up yet", + "notSetUpBody": "Turn on sharing to see and give seeds to people near you. It stays rough — your zone, never your exact address.", + "setUp": "Set up sharing", + "cantReach": "Can't reach the community servers right now", + "cantReachBody": "Try again, or check the servers under Advanced setup.", + "retry": "Retry", + "setArea": "Set your area", + "setAreaBody": "Tell the market roughly where you are to see seeds nearby.", + "searching": "Looking around your area…", + "empty": "No seeds shared near you yet", + "searchHint": "Search these seeds", + "noMatches": "No shared seeds match your search", + "near": "Near you", + "contact": "Message", + "mine": "You", + "configTitle": "Sharing setup", + "setupIntro": "Sharing with people nearby is optional. Just set your rough area — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.", + "areaLabel": "Your area", + "areaHelp": "Kept rough on purpose — your zone, never an exact spot.", + "areaSet": "Your area is set — kept coarse, never your exact spot", + "areaNotSet": "Area not set yet — use your location, or add a code under Advanced", + "advanced": "Advanced", + "areaCodeLabel": "Area code", + "areaCodeHint": "A short code like sp3e9 — not a place name", + "serversLabel": "Community servers", + "serversHelp": "Choose which servers to use. Leave the defaults if unsure.", + "serversAdvanced": "Add another server", + "serverAddress": "Server address", + "serverInvalid": "Enter a valid address (wss://…)", + "save": "Save", + "saved": "Saved", + "wanted": "Wanted", + "shareMine": "Share my seeds", + "sharedCount": "Shared {n} seeds nearby", + "nothingToShare": "Mark some seeds to give, swap or sell first", + "useLocation": "Use my approximate location", + "locationFailed": "Couldn't get your location — check that location is on and the permission is granted", + "queued": "Saved — we'll share these when you're connected", + "shareFailed": "Couldn't reach the community servers — your seeds weren't shared. Try again in a moment.", + "rangeLabel": "How far to look", + "rangeNear": "Very close", + "rangeArea": "Around here", + "rangeRegion": "My region", + "sharedBy": "Shared by", + "noProfile": "This person hasn't shared a profile yet", + "copyId": "Copy code", + "idCopied": "Code copied", + "photo": "Photo", + "sharingOnLabel": "Sharing is on", + "sharingOnHelp": "Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same." + }, + "profile": { + "title": "Your profile", + "name": "Display name", + "nameHint": "How others see you", + "about": "About", + "aboutHint": "A short line — what you grow, where", + "g1": "Ğ1 address (optional)", + "g1Hint": "So people can pay you in Ğ1 — separate from your key", + "yourId": "Your identity", + "idHelp": "Share this so people can recognise you", + "copy": "Copy", + "copied": "Copied", + "save": "Save", + "saved": "Profile saved", + "identities": "Your identities", + "identitiesHelp": "Keep separate identities — each with its own messages and contacts. They all come from your one backup, so switching adds nothing to remember.", + "identityLabel": "Identity {n}", + "current": "In use", + "newIdentity": "New identity", + "switchTitle": "Switch identity?", + "switchBody": "Messages and contacts are kept separate for each identity. You can switch back anytime.", + "switchAction": "Switch" + }, + "chatList": { + "title": "Messages", + "empty": "No conversations yet. Message someone from the market." + }, + "chat": { + "title": "Chat", + "hint": "Write a message…", + "send": "Send", + "empty": "No messages yet — say hello", + "offline": "Set up sharing to send messages", + "payG1": "Pay in Ğ1", + "g1Copied": "Ğ1 address copied — paste it in your wallet", + "today": "Today", + "yesterday": "Yesterday", + "sendError": "Couldn't send — check your connection", + "noLinks": "Links aren't allowed in messages" + }, + "trust": { + "none": "No one vouches for them yet", + "count": "Vouched for by {n}", + "vouch": "I know this person", + "vouched": "You vouch for them", + "circle": "In your circle" + }, + "yourPeople": { + "title": "Your people", + "help": "People you've met and vouch for, and people who vouch for you.", + "youVouchFor": "You vouch for", + "vouchesForYou": "They vouch for you", + "youVouchForEmpty": "You don't vouch for anyone yet. When you meet someone, open your chat with them and tap \"I know this person\".", + "vouchesForYouEmpty": "No one vouches for you yet", + "revoke": "Stop vouching", + "revokeConfirm": "Stop vouching for this person?", + "offline": "You're offline — try again when you're connected" + }, + "ratings": { + "rate": "Rate this person", + "edit": "Edit your rating", + "commentHint": "How did it go? (optional)", + "fromYourCircle": "{n} from people you know", + "retract": "Remove your rating", + "saved": "Rating saved" + }, + "notifications": { + "newMessageFrom": "New message from {name}" + }, + "plantare": { + "title": "Plantares", + "help": "A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It's not a sale.", + "add": "Add a commitment", + "empty": "No commitments yet. When you share or receive seed with a promise to grow it out and return some, note it here.", + "iReturn": "I'll grow it out & return some", + "owedToMe": "They'll return some to me", + "direction": "Who reproduces & returns", + "counterparty": "With whom?", + "counterpartyHint": "A person or a collective (optional)", + "owed": "What comes back?", + "owedHint": "e.g. a handful next season (optional)", + "note": "Note (optional)", + "save": "Save", + "markReturned": "Mark returned", + "markForgiven": "Let it go", + "reopen": "Reopen", + "delete": "Remove", + "statusReturned": "Returned", + "statusForgiven": "Settled", + "openSection": "Open", + "settledSection": "Done", + "removeConfirm": "Remove this commitment?", + "returnBy": "Return by {date}", + "overdue": "overdue", + "dueByLabel": "Return by (optional)", + "dueByHint": "A gentle reminder, never enforced", + "pickDate": "Pick a date", + "clearDate": "Clear date", + "sectionTitle": "Commitments", + "propose": "Propose a signed Plantaré", + "proposeHelp": "Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.", + "proposeTo": "With {name}", + "sent": "Proposal sent — waiting for them to sign", + "seedLabel": "Which seed?", + "seedHint": "The variety this promise is about", + "returnKindLabel": "What comes back?", + "returnSimilar": "A similar amount of seed", + "returnSimilarNote": "open-pollinated · non-GMO · organically grown", + "returnWork": "Some hours of work", + "returnOther": "Something else", + "workHoursLabel": "How many hours?", + "proposalsSection": "Waiting for your reply", + "incomingFrom": "{name} proposes a Plantaré", + "accept": "Accept & sign", + "declineAction": "Decline", + "declineReasonHint": "Reason (optional)", + "acceptedToast": "Signed — you both hold it now", + "declinedToast": "Declined", + "badgeAwaiting": "Awaiting signature", + "badgeSigned": "Signed by both", + "badgeDeclined": "Declined", + "offline": "You're offline — it'll be sent when you reconnect" + }, + "handover": { + "title": "Seeds changed hands", + "help": "A gift, a swap or a sale — note it down, with a return promise or without.", + "iGave": "I gave seeds", + "iReceived": "I received seeds", + "whichLot": "Which batch?", + "howMuch": "How much?", + "allOfIt": "All of it", + "partOfIt": "A part", + "paymentChip": "Money changed hands", + "promiseGave": "They'll return me seed", + "promiseReceived": "I'll return seed" + }, + "history": { + "title": "Story of this batch", + "tooltip": "Story", + "sowToday": "Sown today", + "harvestToday": "Harvested today", + "sownRecorded": "Sowing noted", + "harvestRecorded": "Harvest noted", + "movementReceived": "Received", + "movementGiven": "Given away", + "movementSown": "Sown", + "movementHarvested": "Harvested", + "movementGerminationTest": "Germination test", + "movementSplit": "Split into batches", + "movementDiscarded": "Discarded", + "created": "Added to your collection", + "from": "From {origin}", + "germinationResult": "Germination test — {percent}%", + "linkedEarlier": "Comes from an earlier batch", + "outcomeQuestion": "How did it do?", + "outcomeGood": "Well", + "outcomeMixed": "So-so", + "outcomePoor": "Poorly", + "outcomeNoteHint": "A note for your future self (optional)", + "outcomeSaved": "Noted", + "ratedGood": "It did well", + "ratedMixed": "It did so-so", + "ratedPoor": "It did poorly", + "outcomeTitle": "Season note", + "outcomeYear": "Season {year}", + "isolationHint": "This species crosses with its neighbours — grow it about {meters} m away from others to keep it true" + }, + "sale": { + "title": "Sales", + "help": "Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.", + "add": "Record a sale", + "empty": "No sales yet. Note here what you sell or buy.", + "iSold": "I sold", + "iBought": "I bought", + "direction": "Sold or bought?", + "counterparty": "With whom?", + "counterpartyHint": "A person or a collective (optional)", + "amount": "Amount", + "currency": "Currency", + "currencyHint": "€, Ğ1, hours… (optional)", + "hours": "hours", + "note": "Note (optional)", + "save": "Save", + "delete": "Remove", + "removeConfirm": "Remove this sale?" + }, + "legal": { + "title": "Privacy & rules", + "subtitle": "Your privacy, the market rules, and sharing seeds legally", + "privacyTitle": "Your privacy", + "privacyBody": "Tane works without an account, and everything you record stays on your device, encrypted. There are no ads, no trackers, and no Tane server.\n\nNothing is shared unless you choose to: when you publish an offer or your profile, or send a message, it travels through community-run servers. Offers only ever carry a rough zone — never your address.\n\nWhat you publish is public, and copies may remain even after you remove it — so share with care.", + "rulesTitle": "Rules of the road", + "rulesBody": "Tane is a tool, not a shop: exchanges are agreed directly between people, and nobody takes a cut. That also means you are responsible for what you offer and send.\n\nIn the market: be honest about your seeds, only offer what you may share, treat people well, and don't spam. You can block anyone, and report offers or people that break the rules — reports are acted on in the community servers.", + "seedsTitle": "About sharing seeds and seedlings", + "seedsBody": "Gifting and swapping seeds between amateurs is broadly recognized in most countries. Selling can be different: in many places, selling seed of varieties that aren't officially registered is restricted — check your local rules before asking a price.\n\nSending seeds to another country is often restricted too, and commercially protected varieties must not be propagated without permission. When in doubt, keep it local and keep it a gift.\n\nThe same spirit applies to seedlings and young plants, but live plants can carry stricter transport and plant-health rules than seeds — moving them across regions or borders may need extra checks.", + "readFull": "Read the full documents online" + }, + "marketGate": { + "title": "Before you join the market", + "intro": "The market is a shared space between neighbours. By continuing, you agree to a few simple rules:", + "ruleHonest": "Be honest about the seeds you offer", + "ruleLegal": "Only share what you're allowed to share where you live", + "ruleRespect": "Treat people well — no spam, no abuse", + "publicNote": "What you publish here is public, and copies may remain even if you remove it later.", + "viewLegal": "Privacy & rules", + "accept": "I agree", + "decline": "Not now", + "networkNote": "To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn't connect to anything." + }, + "report": { + "offer": "Report this offer", + "person": "Report this person", + "title": "Report", + "prompt": "What's wrong?", + "reasonSpam": "Spam or a scam", + "reasonAbuse": "Abusive or disrespectful", + "reasonIllegal": "Seeds that shouldn't be offered", + "reasonOther": "Something else", + "detailsHint": "Add details (optional)", + "send": "Send report", + "sentHidden": "Report sent — you won't see this anymore", + "failed": "Couldn't send the report — check your connection", + "alsoBlock": "Also block this person" + }, + "block": { + "action": "Block this person", + "confirmTitle": "Block this person?", + "confirmBody": "You won't see their offers or messages anymore. You can unblock them later under Blocked people in the sharing setup.", + "confirm": "Block", + "blockedToast": "Blocked — their offers and messages are hidden", + "manageTitle": "Blocked people", + "manageEmpty": "You haven't blocked anyone", + "unblock": "Unblock" + }, + "sharingInvite": { + "title": "This wakes up when you start sharing", + "perkChat": "Write to whoever has seeds near you", + "perkFavorites": "Keep the offers you like", + "perkPeople": "Your circle of people you trust", + "networkNote": "For that, Tane needs to go online. Until you say yes, it doesn't talk to anyone.", + "start": "Start sharing", + "notNow": "Not now" } - }, - "inventory": { - "title": "Inventory", - "searchHint": "Search seeds", - "empty": "No seeds yet. Tap + to add your first.", - "noMatches": "No seeds match your filters.", - "clearFilters": "Clear filters", - "uncategorized": "Uncategorized", - "needsReproductionFilter": "To regrow", - "loadError": "Couldn't open your seed bank. It may just have been busy — try again.", - "retry": "Try again" - }, - "draft": { - "capture": "Capture photos", - "captured": "{n} captured to catalogue", - "triageTitle": "To catalogue", - "triageCount": "{n} to catalogue", - "untitled": "Unnamed", - "nameField": "Name this seed", - "nameHint": "What is it?", - "suggestFromPhoto": "Suggest name from photo", - "discard": "Discard" - }, - "quickAdd": { - "title": "Add a seed", - "labelField": "Name", - "labelRequired": "Give it a name", - "addPhoto": "Add photo", - "quantity": "How much?", - "more": "Add more…", - "save": "Save", - "saveAndAddAnother": "Save & add another", - "addedCount": "{n} added", - "cancel": "Cancel" - }, - "detail": { - "notFound": "This seed is no longer here.", - "lots": "Lots", - "noLots": "No lots yet.", - "names": "Also known as", - "addName": "Add name", - "links": "Links", - "addLink": "Add link", - "linkUrl": "URL", - "linkTitle": "Title (optional)", - "reference": "Learn more", - "refGbif": "GBIF", - "refWikipedia": "Wikipedia", - "refWikispecies": "Wikispecies", - "notes": "Notes", - "addLot": "Add lot", - "editLot": "Edit lot", - "deleteConfirm": "Delete this seed?", - "year": "Year {year}", - "noYear": "Year unknown" - }, - "germination": { - "title": "Germination", - "add": "Add test", - "sampleSize": "Sample size", - "germinated": "Germinated", - "none": "No germination tests yet.", - "result": "{percent}%" - }, - "viability": { - "expiringSoon": "Use or reproduce this season", - "expiringSoonYears": "Use or reproduce this season · keeps ~{years} yr", - "expired": "Past typical viability — reproduce", - "expiredYears": "Past typical viability (~{years} yr) — reproduce" - }, - "editVariety": { - "title": "Edit seed", - "name": "Name", - "category": "Category", - "notes": "Notes", - "species": "Species (from catalog)", - "speciesHint": "Search a species…", - "speciesSuggested": "Suggested from the name", - "organic": "Organic", - "organicHint": "Grown organically (eco)" - }, - "addLot": { - "title": "Add lot", - "year": "Harvest date", - "quantity": "How much?", - "amount": "Amount" - }, - "harvest": { - "pickTitle": "Select month / year", - "anyMonth": "Any month", - "noDate": "Set harvest date", - "monthNames": [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December" - ] - }, - "lotType": { - "seed": "Seeds", - "plant": "Plant", - "seedling": "Seedling", - "tree": "Tree / shrub", - "bulb": "Bulb / tuber", - "cutting": "Cutting" - }, - "presentation": { - "title": "Packaging", - "none": "Unspecified", - "pot": "Pot", - "tray": "Tray", - "plug": "Plug", - "bareRoot": "Bare-root", - "rootBall": "Root-ball" - }, - "provenance": { - "section": "Where it's from", - "seedsFrom": "Seeds from", - "seedsFromHint": "Who grew or gave them", - "place": "Place", - "placeHint": "Where they come from (with region)", - "addSeedsFrom": "Seeds from", - "addPlace": "Place" - }, - "abundance": { - "add": "How much I have", - "title": "How much I have", - "none": "Not set", - "plentyToShare": "Plenty to share", - "enoughToShare": "Enough to share a little", - "enoughForMe": "Enough for me", - "runningLow": "Running low" - }, - "share": { - "add": "Do you share it?", - "title": "Do you share it?", - "nudge": "You have plenty — you could share some.", - "price": "Price", - "priceHint": "Leave it empty to agree it later", - "private": "Just for me", - "gift": "To give away", - "exchange": "To swap", - "sell": "For sale", - "filterChip": "I share", - "printCatalog": "Print what I share", - "catalogTitle": "What I share", - "catalogSaved": "Catalog saved", - "cancelled": "Cancelled" - }, - "printLabels": { - "action": "Print labels", - "title": "Print labels", - "selectHint": "Pick the seeds to print labels for", - "selectAll": "Select all", - "selected": "{n} selected", - "none": "Select seeds first", - "format": "Label size", - "formatStickers": "Small stickers", - "formatStickersHint": "Many small labels per page — to peel onto packets", - "formatCards": "Large cards", - "formatCardsHint": "Fewer, bigger labels — for jars and boxes", - "count": "{n} labels", - "save": "Save labels", - "saved": "Labels saved", - "cancelled": "Cancelled" - }, - "scan": { - "action": "Scan a seed label", - "title": "Scan a label", - "notALabel": "That code is not a seed label", - "addTitle": "Not in your collection", - "addBody": "Add “{label}” to your seeds?", - "add": "Add it", - "added": "Added to your collection" - }, - "cropCalendar": { - "add": "Crop calendar", - "title": "Crop calendar", - "sow": "Sow", - "transplant": "Transplant", - "flowering": "Flowering", - "fruiting": "Fruiting", - "seedHarvest": "Seed harvest", - "editorHint": "Note the months typical for this variety in your area — these are your own notes.", - "unset": "—" - }, - "needsReproduction": { - "label": "To regrow this season", - "hint": "Grow it out before the seed runs out", - "badge": "To regrow" - }, - "preservation": { - "add": "How it's kept", - "title": "How it's kept", - "none": "Unspecified", - "jarWithDesiccant": "Jar with drying agent", - "glassJar": "Glass jar", - "paperEnvelope": "Paper envelope", - "paperBag": "Paper bag", - "plasticBag": "Plastic bag" - }, - "conditionCheck": { - "advanced": "Storage & seed-bank details", - "title": "Storage checks", - "add": "Add check", - "containers": "Jars / containers", - "desiccant": "Drying agent", - "none": "No storage checks yet.", - "summary": "{count} jar(s) · {state}" - }, - "desiccant": { - "none": "None", - "add": "Add some", - "replace": "Replace it", - "dry": "Blue — dry", - "fresh": "Just renewed" - }, - "unit": { - "aFew": "a few", - "some": "some", - "plenty": "plenty", - "pinch": "a pinch", - "handful": { - "singular": "handful", - "plural": "handfuls" - }, - "teaspoon": { - "singular": "teaspoon", - "plural": "teaspoons" - }, - "spoon": { - "singular": "spoon", - "plural": "spoons" - }, - "cup": { - "singular": "cup", - "plural": "cups" - }, - "jar": { - "singular": "jar", - "plural": "jars" - }, - "sack": { - "singular": "sack", - "plural": "sacks" - }, - "packet": { - "singular": "packet", - "plural": "packets" - }, - "cob": { - "singular": "cob", - "plural": "cobs" - }, - "pod": { - "singular": "pod", - "plural": "pods" - }, - "ear": { - "singular": "ear", - "plural": "ears" - }, - "head": { - "singular": "head", - "plural": "heads" - }, - "fruit": { - "singular": "fruit", - "plural": "fruits" - }, - "bulb": { - "singular": "bulb", - "plural": "bulbs" - }, - "tuber": { - "singular": "tuber", - "plural": "tubers" - }, - "seedHead": { - "singular": "seed head", - "plural": "seed heads" - }, - "bunch": { - "singular": "bunch", - "plural": "bunches" - }, - "plant": { - "singular": "plant", - "plural": "plants" - }, - "pot": { - "singular": "pot", - "plural": "pots" - }, - "tray": { - "singular": "tray", - "plural": "trays" - }, - "seedling": { - "singular": "seedling", - "plural": "seedlings" - }, - "tree": { - "singular": "tree", - "plural": "trees" - }, - "cutting": { - "singular": "cutting", - "plural": "cuttings" - }, - "grams": { - "singular": "gram", - "plural": "grams" - }, - "count": { - "singular": "seed", - "plural": "seeds" - } - }, - "market": { - "title": "Seeds near you", - "subtitle": "What others are sharing nearby", - "notSetUp": "Sharing isn't set up yet", - "notSetUpBody": "Turn on sharing to see and give seeds to people near you. It stays rough — your zone, never your exact address.", - "setUp": "Set up sharing", - "cantReach": "Can't reach the community servers right now", - "cantReachBody": "Try again, or check the servers under Advanced setup.", - "retry": "Retry", - "setArea": "Set your area", - "setAreaBody": "Tell the market roughly where you are to see seeds nearby.", - "searching": "Looking around your area…", - "empty": "No seeds shared near you yet", - "searchHint": "Search these seeds", - "noMatches": "No shared seeds match your search", - "near": "Near you", - "contact": "Message", - "mine": "You", - "configTitle": "Sharing setup", - "setupIntro": "Sharing with people nearby is optional. Just set your rough area — you're already connected to shared community servers so people can find what you offer, with no company in the middle.", - "areaLabel": "Your area", - "areaHelp": "Kept rough on purpose — your zone, never an exact spot.", - "areaSet": "Your area is set — kept coarse, never your exact spot", - "areaNotSet": "Area not set yet — use your location, or add a code under Advanced", - "advanced": "Advanced", - "areaCodeLabel": "Area code", - "areaCodeHint": "A short code like sp3e9 — not a place name", - "serversLabel": "Community servers", - "serversHelp": "Choose which servers to use. Leave the defaults if unsure.", - "serversAdvanced": "Add another server", - "serverAddress": "Server address", - "serverInvalid": "Enter a valid address (wss://…)", - "save": "Save", - "saved": "Saved", - "wanted": "Wanted", - "shareMine": "Share my seeds", - "sharedCount": "Shared {n} seeds nearby", - "nothingToShare": "Mark some seeds to give, swap or sell first", - "useLocation": "Use my approximate location", - "locationFailed": "Couldn't get your location — check that location is on and the permission is granted", - "queued": "Saved — we'll share these when you're connected", - "shareFailed": "Couldn't reach the community servers — your seeds weren't shared. Try again in a moment.", - "rangeLabel": "How far to look", - "rangeNear": "Very close", - "rangeArea": "Around here", - "rangeRegion": "My region", - "sharedBy": "Shared by", - "noProfile": "This person hasn't shared a profile yet", - "copyId": "Copy code", - "idCopied": "Code copied", - "photo": "Photo" - }, - "profile": { - "title": "Your profile", - "name": "Display name", - "nameHint": "How others see you", - "about": "About", - "aboutHint": "A short line — what you grow, where", - "g1": "Ğ1 address (optional)", - "g1Hint": "So people can pay you in Ğ1 — separate from your key", - "yourId": "Your identity", - "idHelp": "Share this so people can recognise you", - "copy": "Copy", - "copied": "Copied", - "save": "Save", - "saved": "Profile saved", - "identities": "Your identities", - "identitiesHelp": "Keep separate identities — each with its own messages and contacts. They all come from your one backup, so switching adds nothing to remember.", - "identityLabel": "Identity {n}", - "current": "In use", - "newIdentity": "New identity", - "switchTitle": "Switch identity?", - "switchBody": "Messages and contacts are kept separate for each identity. You can switch back anytime.", - "switchAction": "Switch" - }, - "chatList": { - "title": "Messages", - "empty": "No conversations yet. Message someone from the market." - }, - "chat": { - "title": "Chat", - "hint": "Write a message…", - "send": "Send", - "empty": "No messages yet — say hello", - "offline": "Set up sharing to send messages", - "payG1": "Pay in Ğ1", - "g1Copied": "Ğ1 address copied — paste it in your wallet", - "today": "Today", - "yesterday": "Yesterday", - "sendError": "Couldn't send — check your connection", - "noLinks": "Links aren't allowed in messages" - }, - "trust": { - "none": "No one vouches for them yet", - "count": "Vouched for by {n}", - "vouch": "I know this person", - "vouched": "You vouch for them", - "circle": "In your circle" - }, - "yourPeople": { - "title": "Your people", - "help": "People you've met and vouch for, and people who vouch for you.", - "youVouchFor": "You vouch for", - "vouchesForYou": "They vouch for you", - "youVouchForEmpty": "You don't vouch for anyone yet. When you meet someone, open your chat with them and tap \"I know this person\".", - "vouchesForYouEmpty": "No one vouches for you yet", - "revoke": "Stop vouching", - "revokeConfirm": "Stop vouching for this person?", - "offline": "You're offline — try again when you're connected" - }, - "ratings": { - "rate": "Rate this person", - "edit": "Edit your rating", - "commentHint": "How did it go? (optional)", - "fromYourCircle": "{n} from people you know", - "retract": "Remove your rating", - "saved": "Rating saved" - }, - "notifications": { - "newMessageFrom": "New message from {name}" - }, - "plantare": { - "title": "Plantares", - "help": "A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It's not a sale.", - "add": "Add a commitment", - "empty": "No commitments yet. When you share or receive seed with a promise to grow it out and return some, note it here.", - "iReturn": "I'll grow it out & return some", - "owedToMe": "They'll return some to me", - "direction": "Who reproduces & returns", - "counterparty": "With whom?", - "counterpartyHint": "A person or a collective (optional)", - "owed": "What comes back?", - "owedHint": "e.g. a handful next season (optional)", - "note": "Note (optional)", - "save": "Save", - "markReturned": "Mark returned", - "markForgiven": "Let it go", - "reopen": "Reopen", - "delete": "Remove", - "statusReturned": "Returned", - "statusForgiven": "Settled", - "openSection": "Open", - "settledSection": "Done", - "removeConfirm": "Remove this commitment?", - "returnBy": "Return by {date}", - "overdue": "overdue", - "dueByLabel": "Return by (optional)", - "dueByHint": "A gentle reminder, never enforced", - "pickDate": "Pick a date", - "clearDate": "Clear date", - "sectionTitle": "Commitments", - "propose": "Propose a signed Plantaré", - "proposeHelp": "Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.", - "proposeTo": "With {name}", - "sent": "Proposal sent — waiting for them to sign", - "seedLabel": "Which seed?", - "seedHint": "The variety this promise is about", - "returnKindLabel": "What comes back?", - "returnSimilar": "A similar amount of seed", - "returnSimilarNote": "open-pollinated · non-GMO · organically grown", - "returnWork": "Some hours of work", - "returnOther": "Something else", - "workHoursLabel": "How many hours?", - "proposalsSection": "Waiting for your reply", - "incomingFrom": "{name} proposes a Plantaré", - "accept": "Accept & sign", - "declineAction": "Decline", - "declineReasonHint": "Reason (optional)", - "acceptedToast": "Signed — you both hold it now", - "declinedToast": "Declined", - "badgeAwaiting": "Awaiting signature", - "badgeSigned": "Signed by both", - "badgeDeclined": "Declined", - "offline": "You're offline — it'll be sent when you reconnect" - }, - "handover": { - "title": "Seeds changed hands", - "help": "A gift, a swap or a sale — note it down, with a return promise or without.", - "iGave": "I gave seeds", - "iReceived": "I received seeds", - "whichLot": "Which batch?", - "howMuch": "How much?", - "allOfIt": "All of it", - "partOfIt": "A part", - "paymentChip": "Money changed hands", - "promiseGave": "They'll return me seed", - "promiseReceived": "I'll return seed" - }, - "history": { - "title": "Story of this batch", - "tooltip": "Story", - "sowToday": "Sown today", - "harvestToday": "Harvested today", - "sownRecorded": "Sowing noted", - "harvestRecorded": "Harvest noted", - "movementReceived": "Received", - "movementGiven": "Given away", - "movementSown": "Sown", - "movementHarvested": "Harvested", - "movementGerminationTest": "Germination test", - "movementSplit": "Split into batches", - "movementDiscarded": "Discarded", - "created": "Added to your collection", - "from": "From {origin}", - "germinationResult": "Germination test — {percent}%", - "linkedEarlier": "Comes from an earlier batch", - "outcomeQuestion": "How did it do?", - "outcomeGood": "Well", - "outcomeMixed": "So-so", - "outcomePoor": "Poorly", - "outcomeNoteHint": "A note for your future self (optional)", - "outcomeSaved": "Noted", - "ratedGood": "It did well", - "ratedMixed": "It did so-so", - "ratedPoor": "It did poorly", - "outcomeTitle": "Season note", - "outcomeYear": "Season {year}", - "isolationHint": "This species crosses with its neighbours — grow it about {meters} m away from others to keep it true" - }, - "sale": { - "title": "Sales", - "help": "Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.", - "add": "Record a sale", - "empty": "No sales yet. Note here what you sell or buy.", - "iSold": "I sold", - "iBought": "I bought", - "direction": "Sold or bought?", - "counterparty": "With whom?", - "counterpartyHint": "A person or a collective (optional)", - "amount": "Amount", - "currency": "Currency", - "currencyHint": "€, Ğ1, hours… (optional)", - "hours": "hours", - "note": "Note (optional)", - "save": "Save", - "delete": "Remove", - "removeConfirm": "Remove this sale?" - }, - "legal": { - "title": "Privacy & rules", - "subtitle": "Your privacy, the market rules, and sharing seeds legally", - "privacyTitle": "Your privacy", - "privacyBody": "Tane works without an account, and everything you record stays on your device, encrypted. There are no ads, no trackers, and no Tane server.\n\nNothing is shared unless you choose to: when you publish an offer or your profile, or send a message, it travels through community-run servers. Offers only ever carry a rough zone — never your address.\n\nWhat you publish is public, and copies may remain even after you remove it — so share with care.", - "rulesTitle": "Rules of the road", - "rulesBody": "Tane is a tool, not a shop: exchanges are agreed directly between people, and nobody takes a cut. That also means you are responsible for what you offer and send.\n\nIn the market: be honest about your seeds, only offer what you may share, treat people well, and don't spam. You can block anyone, and report offers or people that break the rules — reports are acted on in the community servers.", - "seedsTitle": "About sharing seeds and seedlings", - "seedsBody": "Gifting and swapping seeds between amateurs is broadly recognized in most countries. Selling can be different: in many places, selling seed of varieties that aren't officially registered is restricted — check your local rules before asking a price.\n\nSending seeds to another country is often restricted too, and commercially protected varieties must not be propagated without permission. When in doubt, keep it local and keep it a gift.\n\nThe same spirit applies to seedlings and young plants, but live plants can carry stricter transport and plant-health rules than seeds — moving them across regions or borders may need extra checks.", - "readFull": "Read the full documents online" - }, - "marketGate": { - "title": "Before you join the market", - "intro": "The market is a shared space between neighbours. By continuing, you agree to a few simple rules:", - "ruleHonest": "Be honest about the seeds you offer", - "ruleLegal": "Only share what you're allowed to share where you live", - "ruleRespect": "Treat people well — no spam, no abuse", - "publicNote": "What you publish here is public, and copies may remain even if you remove it later.", - "viewLegal": "Privacy & rules", - "accept": "I agree", - "decline": "Not now" - }, - "report": { - "offer": "Report this offer", - "person": "Report this person", - "title": "Report", - "prompt": "What's wrong?", - "reasonSpam": "Spam or a scam", - "reasonAbuse": "Abusive or disrespectful", - "reasonIllegal": "Seeds that shouldn't be offered", - "reasonOther": "Something else", - "detailsHint": "Add details (optional)", - "send": "Send report", - "sentHidden": "Report sent — you won't see this anymore", - "failed": "Couldn't send the report — check your connection", - "alsoBlock": "Also block this person" - }, - "block": { - "action": "Block this person", - "confirmTitle": "Block this person?", - "confirmBody": "You won't see their offers or messages anymore. You can unblock them later under Blocked people in the sharing setup.", - "confirm": "Block", - "blockedToast": "Blocked — their offers and messages are hidden", - "manageTitle": "Blocked people", - "manageEmpty": "You haven't blocked anyone", - "unblock": "Unblock" - } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 9516b8b..a3a9214 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -1,808 +1,820 @@ { - "avatar": { - "title": "Tu foto o avatar", - "fromPhoto": "Hacer o elegir una foto", - "illustration": "O elige un dibujo", - "remove": "Quitar" - }, - "favorites": { - "title": "Favoritos", - "empty": "Aún no tienes favoritos. Guarda ofertas que te gusten del mercado.", - "save": "Guardar en favoritos", - "remove": "Quitar de favoritos", - "unavailable": "Ya no está disponible" - }, - "savedSearches": { - "title": "Búsquedas guardadas", - "empty": "Aún no tienes búsquedas guardadas. Guarda una búsqueda desde el mercado y te avisaremos cuando aparezca algo así en tu zona.", - "save": "Guardar esta búsqueda", - "nameLabel": "Nombra esta búsqueda", - "namePlaceholder": "p. ej. Tomates cerca", - "saved": "Búsqueda guardada — te avisaremos de lo nuevo que encaje cerca", - "openTooltip": "Búsquedas guardadas", - "delete": "Eliminar", - "deleteConfirm": "¿Eliminar esta búsqueda guardada?", - "newMatchesBadge": "{n} nuevas", - "alert": "Semillas nuevas cerca: {label}" - }, - "seedSaving": { - "title": "Conservar su semilla", - "subtitle": "Lo que hace falta para mantener la variedad fiel", - "lifeCycle": "Ciclo", - "cycleAnnual": "Anual", - "cycleBiennial": "Bienal — da semilla el 2.º año", - "cyclePerennial": "Perenne", - "pollination": "Polinización", - "pollSelf": "Se autopoliniza", - "pollCross": "Se cruza con otras", - "pollMixed": "Se autopoliniza, pero se cruza a veces", - "byInsect": "por insectos", - "byWind": "por el viento", - "isolation": "Sepárala", - "isolationRange": "{min}–{max} m de otras variedades", - "isolationSingle": "{min} m de otras variedades", - "plants": "Guarda de varias plantas", - "plantsValue": "De al menos {n} plantas", - "processing": "Cómo limpiarla", - "procDry": "Semilla seca (trillar)", - "procWet": "Semilla húmeda (fermentar y lavar)", - "difficulty": "Dificultad", - "diffEasy": "Fácil", - "diffMedium": "Media", - "diffHard": "Difícil", - "advisory": "Orientativo — adáptalo a tu clima y variedad.", - "sourcePrefix": "Fuente" - }, - "calendar": { - "title": "Este mes", - "filterChip": "Este mes", - "selfNote": "Lo que has anotado en tus variedades.", - "nothing": "Nada anotado para {month}." - }, - "app": { - "title": "Tane" - }, - "bootstrap": { - "failed": "Tane no pudo arrancar", - "retry": "Reintentar" - }, - "common": { - "save": "Guardar", - "cancel": "Cancelar", - "delete": "Eliminar", - "edit": "Editar", - "type": "Tipo", - "comingSoon": "Pronto", - "offline": "Sin conexión — el compartir está en pausa" - }, - "home": { - "tagline": "Comparte y cultiva semillas locales", - "openMarket": "Mercado", - "openMarketSubtitle": "Descubre y comparte semillas cerca", - "yourInventory": "Tu inventario", - "yourInventorySubtitle": "Gestiona tus semillas" - }, - "photo": { - "camera": "Hacer una foto", - "gallery": "Elegir de la galería", - "setAsCover": "Poner de portada", - "isCover": "Foto de portada", - "deleteConfirm": "¿Borrar esta foto?" - }, - "menu": { - "tagline": "tu banco de semillas", - "inventory": "Inventario", - "market": "Mercado", - "profile": "Tu perfil", - "chat": "Chat", - "wishlist": "Favoritos", - "following": "Siguiendo", - "plantares": "Plantares", - "sales": "Ventas", - "calendar": "Calendario", - "settings": "Ajustes" - }, - "settings": { - "language": "Idioma", - "systemLanguage": "Idioma del sistema", - "langEs": "Español", - "langEn": "English", - "langPt": "Português", - "langFr": "Français", - "langDe": "Deutsch", - "langJa": "日本語", - "about": "Acerca de", - "aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.", - "aboutOpen": "Acerca de Tane" - }, - "backup": { - "title": "Copia de seguridad", - "autoBackupTitle": "Copias automáticas", - "autoBackupLast": "Última copia el {date} · cada {days} días", - "autoBackupNone": "Se guarda una copia automáticamente cada {days} días", - "exportJson": "Guardar una copia de seguridad", - "exportJsonSubtitle": "Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo", - "importJson": "Restaurar una copia", - "importJsonSubtitle": "Recupera una copia guardada — no se duplica nada", - "exportCsv": "Exportar a una hoja de cálculo", - "exportCsvSubtitle": "Una lista sencilla para Excel o LibreOffice — sin fotos", - "importCsv": "Importar una lista", - "importCsvSubtitle": "Añade entradas desde una hoja de cálculo", - "importConfirmTitle": "¿Restaurar una copia?", - "importConfirmBody": "Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.", - "importCsvConfirmTitle": "¿Importar una lista?", - "importCsvConfirmBody": "Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.", - "importAction": "Importar", - "exportSaved": "Copia guardada", - "cancelled": "Cancelado", - "importDone": "Importado: {added} nuevas, {updated} actualizadas", - "importCsvDone": "Añadidas {count} entradas", - "importFailed": "Este fichero no se pudo leer como una copia de Tane", - "failed": "Algo ha salido mal", - "recoveryTitle": "Tu código de recuperación", - "recoverySubtitle": "Imprímelo y guárdalo bien: abre tus copias en otro dispositivo", - "recoveryIntro": "Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.", - "recoveryCopy": "Copiar", - "recoverySave": "Guardar la hoja", - "recoverySheetTitle": "Tane — tu hoja de recuperación", - "recoveryPromptTitle": "Escribe tu código de recuperación", - "recoveryPromptBody": "Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.", - "recoveryWrongCode": "Ese código no abre esta copia" - }, - "about": { - "title": "Acerca de", - "kanji": "種", - "tagline": "Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.", - "intro": "Tane (種, «semilla» en japonés) ayuda a personas y colectivos a llevar un inventario amable de su banco de semillas, decidir qué ofrecen y compartirlo localmente — sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. Su nombre viene de tanemaki (種まき), «sembrar / esparcir semillas». El objetivo es a la vez práctico y político: apoyar las variedades tradicionales de semillas y plantar cara al monopolio semillero.", - "heritage": "El nombre honra las viejas tradiciones japonesas de ayuda mutua en torno al arroz — yui (trabajo comunitario compartido) y tanomoshi (fondos de reciprocidad) — que inspiraron el papel Plantare, la «moneda comunitaria para el intercambio de semillas» (BAH-Semillero, 2009, CC-BY-SA). Tane es el Plantare digital.", - "version": "Versión", - "license": "Licencia", - "licenseValue": "AGPL-3.0", - "website": "Sitio web", - "sourceCode": "Código fuente", - "translate": "Ayuda a traducir", - "translateSubtitle": "Ayuda a traer Tane a tu idioma", - "openSourceLicenses": "Licencias de código abierto", - "openSourceLicensesSubtitle": "Bibliotecas de terceros y sus licencias", - "copyright": "© {years} Asociación Comunes, bajo AGPLv3" - }, - "intro": { - "skip": "Saltar", - "next": "Siguiente", - "start": "Empezar", - "menuEntry": "Cómo funciona Tane", - "slides": { - "welcome": { - "title": "La semilla que te trajo hasta aquí", - "body": "Cada semilla tradicional es una carta escrita por miles de generaciones, que pasó de mano en mano. Somos lo que somos gracias a ese intercambio." - }, - "inventory": { - "title": "Tu banco de semillas, en el bolsillo", - "body": "Apunta qué tienes, de qué año, cuánto y de dónde vino, con el nombre que tú usas. Una foto y un nombre bastan para empezar." - }, - "privacy": { - "title": "Tuyo y solo tuyo", - "body": "Sin cuenta, sin internet, sin rastreadores. Tus datos viven cifrados en tu dispositivo y solo se comparte lo que tú decidas." - }, - "share": { - "title": "Compartir, como siempre se hizo", - "body": "Ofrece lo que te sobra —regalo, trueque o venta— y que alguien cerca lo encuentre. Solo se muestra la distancia aproximada, nunca tu dirección; el trato lo cierras en persona." - }, - "plantare": { - "title": "Sembrar es multiplicar", - "body": "Con el Plantare, quien recibe promete devolver semilla más adelante. Y como para devolverla hay que cultivarla, cada préstamo multiplica el común." - } + "avatar": { + "title": "Tu foto o avatar", + "fromPhoto": "Hacer o elegir una foto", + "illustration": "O elige un dibujo", + "remove": "Quitar" + }, + "favorites": { + "title": "Favoritos", + "empty": "Aún no tienes favoritos. Guarda ofertas que te gusten del mercado.", + "save": "Guardar en favoritos", + "remove": "Quitar de favoritos", + "unavailable": "Ya no está disponible" + }, + "savedSearches": { + "title": "Búsquedas guardadas", + "empty": "Aún no tienes búsquedas guardadas. Guarda una búsqueda desde el mercado y te avisaremos cuando aparezca algo así en tu zona.", + "save": "Guardar esta búsqueda", + "nameLabel": "Nombra esta búsqueda", + "namePlaceholder": "p. ej. Tomates cerca", + "saved": "Búsqueda guardada — te avisaremos de lo nuevo que encaje cerca", + "openTooltip": "Búsquedas guardadas", + "delete": "Eliminar", + "deleteConfirm": "¿Eliminar esta búsqueda guardada?", + "newMatchesBadge": "{n} nuevas", + "alert": "Semillas nuevas cerca: {label}" + }, + "seedSaving": { + "title": "Conservar su semilla", + "subtitle": "Lo que hace falta para mantener la variedad fiel", + "lifeCycle": "Ciclo", + "cycleAnnual": "Anual", + "cycleBiennial": "Bienal — da semilla el 2.º año", + "cyclePerennial": "Perenne", + "pollination": "Polinización", + "pollSelf": "Se autopoliniza", + "pollCross": "Se cruza con otras", + "pollMixed": "Se autopoliniza, pero se cruza a veces", + "byInsect": "por insectos", + "byWind": "por el viento", + "isolation": "Sepárala", + "isolationRange": "{min}–{max} m de otras variedades", + "isolationSingle": "{min} m de otras variedades", + "plants": "Guarda de varias plantas", + "plantsValue": "De al menos {n} plantas", + "processing": "Cómo limpiarla", + "procDry": "Semilla seca (trillar)", + "procWet": "Semilla húmeda (fermentar y lavar)", + "difficulty": "Dificultad", + "diffEasy": "Fácil", + "diffMedium": "Media", + "diffHard": "Difícil", + "advisory": "Orientativo — adáptalo a tu clima y variedad.", + "sourcePrefix": "Fuente" + }, + "calendar": { + "title": "Este mes", + "filterChip": "Este mes", + "selfNote": "Lo que has anotado en tus variedades.", + "nothing": "Nada anotado para {month}." + }, + "app": { + "title": "Tane" + }, + "bootstrap": { + "failed": "Tane no pudo arrancar", + "retry": "Reintentar" + }, + "common": { + "save": "Guardar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar", + "type": "Tipo", + "offline": "Sin conexión — el compartir está en pausa" + }, + "home": { + "tagline": "Comparte y cultiva semillas locales", + "openMarket": "Mercado", + "openMarketSubtitle": "Descubre y comparte semillas cerca", + "yourInventory": "Tu inventario", + "yourInventorySubtitle": "Gestiona tus semillas" + }, + "photo": { + "camera": "Hacer una foto", + "gallery": "Elegir de la galería", + "setAsCover": "Poner de portada", + "isCover": "Foto de portada", + "deleteConfirm": "¿Borrar esta foto?" + }, + "menu": { + "tagline": "tu banco de semillas", + "inventory": "Inventario", + "market": "Mercado", + "profile": "Tu perfil", + "chat": "Chat", + "wishlist": "Favoritos", + "following": "Siguiendo", + "plantares": "Plantares", + "sales": "Ventas", + "calendar": "Calendario", + "settings": "Ajustes" + }, + "settings": { + "language": "Idioma", + "systemLanguage": "Idioma del sistema", + "langEs": "Español", + "langEn": "English", + "langPt": "Português", + "langPtBr": "Português (Brasil)", + "langFr": "Français", + "langDe": "Deutsch", + "langJa": "日本語", + "about": "Acerca de", + "aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.", + "aboutOpen": "Acerca de Tane" + }, + "backup": { + "title": "Copia de seguridad", + "autoBackupTitle": "Copias automáticas", + "autoBackupLast": "Última copia el {date} · cada {days} días", + "autoBackupNone": "Se guarda una copia automáticamente cada {days} días", + "exportJson": "Guardar una copia de seguridad", + "exportJsonSubtitle": "Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo", + "importJson": "Restaurar una copia", + "importJsonSubtitle": "Recupera una copia guardada — no se duplica nada", + "exportCsv": "Exportar a una hoja de cálculo", + "exportCsvSubtitle": "Una lista sencilla para Excel o LibreOffice — sin fotos", + "importCsv": "Importar una lista", + "importCsvSubtitle": "Añade entradas desde una hoja de cálculo", + "importConfirmTitle": "¿Restaurar una copia?", + "importConfirmBody": "Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.", + "importCsvConfirmTitle": "¿Importar una lista?", + "importCsvConfirmBody": "Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.", + "importAction": "Importar", + "exportSaved": "Copia guardada", + "cancelled": "Cancelado", + "importDone": "Importado: {added} nuevas, {updated} actualizadas", + "importCsvDone": "Añadidas {count} entradas", + "importFailed": "Este fichero no se pudo leer como una copia de Tane", + "failed": "Algo ha salido mal", + "recoveryTitle": "Tu código de recuperación", + "recoverySubtitle": "Imprímelo y guárdalo bien: abre tus copias en otro dispositivo", + "recoveryIntro": "Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.", + "recoveryCopy": "Copiar", + "recoverySave": "Guardar la hoja", + "recoverySheetTitle": "Tane — tu hoja de recuperación", + "recoveryPromptTitle": "Escribe tu código de recuperación", + "recoveryPromptBody": "Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.", + "recoveryWrongCode": "Ese código no abre esta copia" + }, + "about": { + "title": "Acerca de", + "kanji": "種", + "tagline": "Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.", + "intro": "Tane (種, «semilla» en japonés) ayuda a personas y colectivos a llevar un inventario amable de su banco de semillas, decidir qué ofrecen y compartirlo localmente — sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. Su nombre viene de tanemaki (種まき), «sembrar / esparcir semillas». El objetivo es a la vez práctico y político: apoyar las variedades tradicionales de semillas y plantar cara al monopolio semillero.", + "heritage": "El nombre honra las viejas tradiciones japonesas de ayuda mutua en torno al arroz — yui (trabajo comunitario compartido) y tanomoshi (fondos de reciprocidad) — que inspiraron el papel Plantare, la «moneda comunitaria para el intercambio de semillas» (BAH-Semillero, 2009, CC-BY-SA). Tane es el Plantare digital.", + "version": "Versión", + "license": "Licencia", + "licenseValue": "AGPL-3.0", + "website": "Sitio web", + "sourceCode": "Código fuente", + "translate": "Ayuda a traducir", + "translateSubtitle": "Ayuda a traer Tane a tu idioma", + "openSourceLicenses": "Licencias de código abierto", + "openSourceLicensesSubtitle": "Bibliotecas de terceros y sus licencias", + "copyright": "© {years} Asociación Comunes, bajo AGPLv3" + }, + "intro": { + "skip": "Saltar", + "next": "Siguiente", + "start": "Empezar", + "menuEntry": "Cómo funciona Tane", + "slides": { + "welcome": { + "title": "La semilla que te trajo hasta aquí", + "body": "Cada semilla tradicional es una carta escrita por miles de generaciones, que pasó de mano en mano. Somos lo que somos gracias a ese intercambio." + }, + "inventory": { + "title": "Tu banco de semillas, en el bolsillo", + "body": "Apunta qué tienes, de qué año, cuánto y de dónde vino, con el nombre que tú usas. Una foto y un nombre bastan para empezar." + }, + "privacy": { + "title": "Tuyo y solo tuyo", + "body": "Sin cuenta, sin internet, sin rastreadores. Tus datos viven cifrados en tu dispositivo y solo se comparte lo que tú decidas." + }, + "share": { + "title": "Compartir, como siempre se hizo", + "body": "Ofrece lo que te sobra —regalo, trueque o venta— y que alguien cerca lo encuentre. Solo se muestra la distancia aproximada, nunca tu dirección; el trato lo cierras en persona." + }, + "plantare": { + "title": "Sembrar es multiplicar", + "body": "Con el Plantare, quien recibe promete devolver semilla más adelante. Y como para devolverla hay que cultivarla, cada préstamo multiplica el común." + } + } + }, + "inventory": { + "title": "Inventario", + "searchHint": "Buscar semillas", + "empty": "Aún no hay semillas. Toca + para añadir la primera.", + "noMatches": "Ninguna semilla coincide con los filtros.", + "clearFilters": "Quitar filtros", + "uncategorized": "Sin categoría", + "needsReproductionFilter": "Por reproducir", + "loadError": "No se pudo abrir tu banco de semillas. Quizá estaba ocupado: inténtalo de nuevo.", + "retry": "Reintentar" + }, + "draft": { + "capture": "Capturar fotos", + "captured": "{n} capturadas por catalogar", + "triageTitle": "Por catalogar", + "triageCount": "{n} por catalogar", + "untitled": "Sin nombre", + "nameField": "Nombra esta semilla", + "nameHint": "¿Qué es?", + "suggestFromPhoto": "Sugerir nombre de la foto", + "discard": "Descartar" + }, + "quickAdd": { + "title": "Añadir una semilla", + "labelField": "Nombre", + "labelRequired": "Ponle un nombre", + "addPhoto": "Añadir foto", + "quantity": "¿Cuánta?", + "more": "Añadir más…", + "save": "Guardar", + "saveAndAddAnother": "Guardar y añadir otra", + "addedCount": "{n} añadidas", + "cancel": "Cancelar" + }, + "detail": { + "notFound": "Esta semilla ya no está aquí.", + "lots": "Lotes", + "noLots": "Aún no hay lotes.", + "names": "También conocida como", + "addName": "Añadir nombre", + "links": "Enlaces", + "addLink": "Añadir enlace", + "linkUrl": "URL", + "linkTitle": "Título (opcional)", + "reference": "Saber más", + "refGbif": "GBIF", + "refWikipedia": "Wikipedia", + "refWikispecies": "Wikispecies", + "notes": "Notas", + "addLot": "Añadir lote", + "editLot": "Editar lote", + "deleteConfirm": "¿Eliminar esta semilla?", + "year": "Año {year}", + "noYear": "Año desconocido" + }, + "germination": { + "title": "Germinación", + "add": "Añadir prueba", + "sampleSize": "Muestra", + "germinated": "Germinadas", + "none": "Aún no hay pruebas de germinación.", + "result": "{percent}%" + }, + "viability": { + "expiringSoon": "Úsala o multiplícala esta temporada", + "expiringSoonYears": "Úsala o multiplícala esta temporada · dura ~{years} años", + "expired": "Supera su viabilidad típica — a multiplicar", + "expiredYears": "Supera su viabilidad típica (~{years} años) — a multiplicar" + }, + "editVariety": { + "title": "Editar semilla", + "name": "Nombre", + "category": "Categoría", + "notes": "Notas", + "species": "Especie (del catálogo)", + "speciesHint": "Buscar una especie…", + "speciesSuggested": "Sugerida por el nombre", + "organic": "Ecológica", + "organicHint": "Cultivada de forma ecológica (eco)" + }, + "addLot": { + "title": "Añadir lote", + "year": "Fecha de cosecha", + "quantity": "¿Cuánta?", + "amount": "Cantidad" + }, + "harvest": { + "pickTitle": "Seleccionar mes / año", + "anyMonth": "Cualquier mes", + "noDate": "Indicar fecha de cosecha", + "monthNames": [ + "enero", + "febrero", + "marzo", + "abril", + "mayo", + "junio", + "julio", + "agosto", + "septiembre", + "octubre", + "noviembre", + "diciembre" + ] + }, + "lotType": { + "seed": "Semillas", + "plant": "Planta", + "seedling": "Plantón", + "tree": "Árbol / arbusto", + "bulb": "Bulbo / tubérculo", + "cutting": "Esqueje" + }, + "presentation": { + "title": "Envase", + "none": "Sin especificar", + "pot": "Maceta", + "tray": "Bandeja", + "plug": "Alvéolo", + "bareRoot": "Raíz desnuda", + "rootBall": "Cepellón" + }, + "provenance": { + "section": "De dónde viene", + "seedsFrom": "Semillas de", + "seedsFromHint": "Quién las cultivó o las dio", + "place": "Lugar", + "placeHint": "De dónde vienen (con provincia)", + "addSeedsFrom": "Semillas de", + "addPlace": "Lugar" + }, + "abundance": { + "add": "Cuánta tengo", + "title": "Cuánta tengo", + "none": "Sin indicar", + "plentyToShare": "De sobra para compartir", + "enoughToShare": "Bastante, para compartir con moderación", + "enoughForMe": "Suficiente para mí", + "runningLow": "Queda poca" + }, + "share": { + "add": "¿La compartes?", + "title": "¿La compartes?", + "nudge": "Tienes de sobra: podrías compartir un poco.", + "price": "Precio", + "priceHint": "Déjalo vacío para acordarlo luego", + "private": "Solo para mí", + "gift": "Para regalar", + "exchange": "Para intercambiar", + "sell": "En venta", + "filterChip": "Comparto", + "printCatalog": "Imprimir lo que comparto", + "catalogTitle": "Lo que comparto", + "catalogSaved": "Catálogo guardado", + "cancelled": "Cancelado" + }, + "printLabels": { + "action": "Imprimir etiquetas", + "title": "Imprimir etiquetas", + "selectHint": "Elige las semillas para imprimir sus etiquetas", + "selectAll": "Seleccionar todo", + "selected": "{n} seleccionadas", + "none": "Selecciona semillas primero", + "format": "Tamaño de etiqueta", + "formatStickers": "Pegatinas pequeñas", + "formatStickersHint": "Muchas etiquetas pequeñas por hoja — para pegar en sobres", + "formatCards": "Tarjetas grandes", + "formatCardsHint": "Menos etiquetas, más grandes — para botes y cajas", + "count": "{n} etiquetas", + "save": "Guardar etiquetas", + "saved": "Etiquetas guardadas", + "cancelled": "Cancelado" + }, + "scan": { + "action": "Escanear una etiqueta", + "title": "Escanear etiqueta", + "notALabel": "Ese código no es una etiqueta de semillas", + "addTitle": "No está en tu colección", + "addBody": "¿Añadir «{label}» a tus semillas?", + "add": "Añadirla", + "added": "Añadida a tu colección" + }, + "cropCalendar": { + "add": "Calendario de cultivo", + "title": "Calendario de cultivo", + "sow": "Siembra", + "transplant": "Trasplante", + "flowering": "Floración", + "fruiting": "Fructificación", + "seedHarvest": "Cosecha de semilla", + "editorHint": "Anota tú los meses típicos de esta variedad en tu zona — son tus propias notas.", + "unset": "—" + }, + "needsReproduction": { + "label": "Reproducir esta temporada", + "hint": "Cultívala antes de que se acabe la semilla", + "badge": "Por reproducir" + }, + "preservation": { + "add": "Cómo se conserva", + "title": "Cómo se conserva", + "none": "Sin especificar", + "jarWithDesiccant": "Bote con desecante", + "glassJar": "Bote de cristal", + "paperEnvelope": "Sobre de papel", + "paperBag": "Bolsa de papel", + "plasticBag": "Bolsa de plástico" + }, + "conditionCheck": { + "advanced": "Conservación y detalles del banco", + "title": "Revisiones de conservación", + "add": "Añadir revisión", + "containers": "Botes / recipientes", + "desiccant": "Desecante", + "none": "Aún no hay revisiones.", + "summary": "{count} bote(s) · {state}" + }, + "desiccant": { + "none": "No tiene", + "add": "Se pone", + "replace": "Se cambia", + "dry": "Azul — seco", + "fresh": "Recién puesto" + }, + "unit": { + "aFew": "unas pocas", + "some": "algunas", + "plenty": "muchas", + "pinch": "una pizca", + "handful": { + "singular": "puñado", + "plural": "puñados" + }, + "teaspoon": { + "singular": "cucharadita", + "plural": "cucharaditas" + }, + "spoon": { + "singular": "cuchara", + "plural": "cucharas" + }, + "cup": { + "singular": "taza", + "plural": "tazas" + }, + "jar": { + "singular": "bote", + "plural": "botes" + }, + "sack": { + "singular": "saco", + "plural": "sacos" + }, + "packet": { + "singular": "sobre", + "plural": "sobres" + }, + "cob": { + "singular": "mazorca", + "plural": "mazorcas" + }, + "pod": { + "singular": "vaina", + "plural": "vainas" + }, + "ear": { + "singular": "espiga", + "plural": "espigas" + }, + "head": { + "singular": "cabezuela", + "plural": "cabezuelas" + }, + "fruit": { + "singular": "fruto", + "plural": "frutos" + }, + "bulb": { + "singular": "bulbo", + "plural": "bulbos" + }, + "tuber": { + "singular": "tubérculo", + "plural": "tubérculos" + }, + "seedHead": { + "singular": "cabeza de semillas", + "plural": "cabezas de semillas" + }, + "bunch": { + "singular": "manojo", + "plural": "manojos" + }, + "plant": { + "singular": "planta", + "plural": "plantas" + }, + "pot": { + "singular": "maceta", + "plural": "macetas" + }, + "tray": { + "singular": "bandeja", + "plural": "bandejas" + }, + "seedling": { + "singular": "plántula", + "plural": "plántulas" + }, + "tree": { + "singular": "árbol", + "plural": "árboles" + }, + "cutting": { + "singular": "esqueje", + "plural": "esquejes" + }, + "grams": { + "singular": "gramo", + "plural": "gramos" + }, + "count": { + "singular": "semilla", + "plural": "semillas" + } + }, + "market": { + "title": "Semillas cerca de ti", + "subtitle": "Lo que otras personas comparten cerca", + "notSetUp": "Aún no has configurado el compartir", + "notSetUpBody": "Activa el compartir para ver y dar semillas a gente cercana. Se mantiene aproximado — tu zona, nunca tu dirección exacta.", + "setUp": "Configurar el compartir", + "cantReach": "No se puede conectar con los servidores ahora mismo", + "cantReachBody": "Inténtalo de nuevo, o revisa los servidores en la configuración avanzada.", + "retry": "Reintentar", + "setArea": "Indica tu zona", + "setAreaBody": "Dile al mercado tu zona aproximada para ver semillas cerca.", + "searching": "Buscando por tu zona…", + "empty": "Aún no hay semillas compartidas cerca de ti", + "searchHint": "Buscar entre estas semillas", + "noMatches": "Ninguna semilla compartida coincide con tu búsqueda", + "near": "Cerca de ti", + "contact": "Mensaje", + "mine": "Tú", + "configTitle": "Configuración de compartir", + "setupIntro": "Compartir con gente cercana es opcional. Solo indica tu zona aproximada — lo que ofreces viaja por servidores comunitarios, mantenidos por personas y colectivos, no por una empresa, para que otras personas cercanas puedan encontrarlo.", + "areaLabel": "Tu zona", + "areaHelp": "Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.", + "areaSet": "Tu zona está puesta — aproximada, nunca tu punto exacto", + "areaNotSet": "Zona sin definir — usa tu ubicación, o añade un código en Avanzado", + "advanced": "Avanzado", + "areaCodeLabel": "Código de zona", + "areaCodeHint": "Un código corto como sp3e9 — no un nombre de lugar", + "serversLabel": "Servidores de la comunidad", + "serversHelp": "Elige qué servidores usar. Déjalo así si no sabes.", + "serversAdvanced": "Añadir otro servidor", + "serverAddress": "Dirección del servidor", + "serverInvalid": "Introduce una dirección válida (wss://…)", + "save": "Guardar", + "saved": "Guardado", + "wanted": "Busco", + "shareMine": "Compartir mis semillas", + "sharedCount": "Compartidas {n} semillas", + "nothingToShare": "Marca antes algunas semillas para regalar, cambiar o vender", + "useLocation": "Usar mi ubicación aproximada", + "locationFailed": "No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido", + "queued": "Guardado — las compartiremos cuando tengas conexión", + "shareFailed": "No se pudo conectar con los servidores — tus semillas no se compartieron. Inténtalo de nuevo en un momento.", + "rangeLabel": "Hasta dónde buscar", + "rangeNear": "Muy cerca", + "rangeArea": "Por mi zona", + "rangeRegion": "Mi región", + "sharedBy": "Compartido por", + "noProfile": "Esta persona aún no ha compartido su perfil", + "copyId": "Copiar código", + "idCopied": "Código copiado", + "photo": "Foto", + "sharingOnLabel": "Compartir está activado", + "sharingOnHelp": "Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual." + }, + "profile": { + "title": "Tu perfil", + "name": "Nombre", + "nameHint": "Cómo te ven los demás", + "about": "Sobre ti", + "aboutHint": "Una línea — qué cultivas, dónde", + "g1": "Dirección Ğ1 (opcional)", + "g1Hint": "Para que te paguen en Ğ1 — aparte de tu clave", + "yourId": "Tu identidad", + "idHelp": "Compártela para que te reconozcan", + "copy": "Copiar", + "copied": "Copiado", + "save": "Guardar", + "saved": "Perfil guardado", + "identities": "Tus identidades", + "identitiesHelp": "Ten identidades separadas — cada una con sus mensajes y contactos. Todas salen de tu única copia de seguridad, así que cambiar no añade nada que recordar.", + "identityLabel": "Identidad {n}", + "current": "En uso", + "newIdentity": "Nueva identidad", + "switchTitle": "¿Cambiar de identidad?", + "switchBody": "Los mensajes y contactos se guardan por separado para cada identidad. Puedes volver cuando quieras.", + "switchAction": "Cambiar" + }, + "chatList": { + "title": "Mensajes", + "empty": "Aún no hay conversaciones. Escribe a alguien desde el mercado." + }, + "chat": { + "title": "Chat", + "hint": "Escribe un mensaje…", + "send": "Enviar", + "empty": "Aún no hay mensajes — saluda", + "offline": "Configura el compartir para enviar mensajes", + "payG1": "Pagar en Ğ1", + "g1Copied": "Dirección Ğ1 copiada — pégala en tu cartera", + "today": "Hoy", + "yesterday": "Ayer", + "sendError": "No se pudo enviar — revisa tu conexión", + "noLinks": "No se permiten enlaces en los mensajes" + }, + "trust": { + "none": "Nadie los avala aún", + "count": "Avalada por {n}", + "vouch": "Conozco a esta persona", + "vouched": "Avalas a esta persona", + "circle": "En tu círculo" + }, + "yourPeople": { + "title": "Tu gente", + "help": "Personas que conoces y avalas, y personas que te avalan.", + "youVouchFor": "Tú avalas a", + "vouchesForYou": "Te avalan", + "youVouchForEmpty": "Aún no avalas a nadie. Cuando conozcas a alguien, abre vuestro chat y toca \"Conozco a esta persona\".", + "vouchesForYouEmpty": "Nadie te avala todavía", + "revoke": "Dejar de avalar", + "revokeConfirm": "¿Dejar de avalar a esta persona?", + "offline": "Sin conexión — inténtalo cuando estés en línea" + }, + "ratings": { + "rate": "Valorar a esta persona", + "edit": "Editar tu valoración", + "commentHint": "¿Qué tal fue? (opcional)", + "fromYourCircle": "{n} de gente que conoces", + "retract": "Quitar tu valoración", + "saved": "Valoración guardada" + }, + "notifications": { + "newMessageFrom": "Nuevo mensaje de {name}" + }, + "plantare": { + "title": "Plantares", + "help": "Un Plantare es el compromiso de reproducir una semilla y devolver una parte — así una variedad sigue viajando de mano en mano. No es una venta.", + "add": "Añadir compromiso", + "empty": "Aún no hay compromisos. Cuando compartas o recibas semilla con el compromiso de reproducirla y devolver algo, anótalo aquí.", + "iReturn": "La reproduzco y devuelvo yo", + "owedToMe": "Me la devuelven a mí", + "direction": "Quién reproduce y devuelve", + "counterparty": "¿Con quién?", + "counterpartyHint": "Una persona o un colectivo (opcional)", + "owed": "¿Qué se devuelve?", + "owedHint": "p. ej. un puñado la próxima temporada (opcional)", + "note": "Nota (opcional)", + "save": "Guardar", + "markReturned": "Marcar devuelto", + "markForgiven": "Dar por saldado", + "reopen": "Reabrir", + "delete": "Quitar", + "statusReturned": "Devuelto", + "statusForgiven": "Saldado", + "openSection": "Pendientes", + "settledSection": "Hechos", + "removeConfirm": "¿Quitar este compromiso?", + "returnBy": "Devolver antes del {date}", + "overdue": "vencido", + "dueByLabel": "Devolver antes de (opcional)", + "dueByHint": "Un recordatorio suave, nunca obligatorio", + "pickDate": "Elegir fecha", + "clearDate": "Quitar fecha", + "sectionTitle": "Compromisos", + "propose": "Proponer un Plantaré firmado", + "proposeHelp": "Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.", + "proposeTo": "Con {name}", + "sent": "Propuesta enviada — esperando su firma", + "seedLabel": "¿Qué semilla?", + "seedHint": "La variedad a la que se refiere la promesa", + "returnKindLabel": "¿Qué se devuelve?", + "returnSimilar": "Una cantidad similar de semilla", + "returnSimilarNote": "polinización abierta · no transgénica · cultivada en ecológico", + "returnWork": "Unas horas de trabajo", + "returnOther": "Otra cosa", + "workHoursLabel": "¿Cuántas horas?", + "proposalsSection": "Esperando tu respuesta", + "incomingFrom": "{name} te propone un Plantaré", + "accept": "Aceptar y firmar", + "declineAction": "Rechazar", + "declineReasonHint": "Motivo (opcional)", + "acceptedToast": "Firmado — ahora lo guardáis los dos", + "declinedToast": "Rechazado", + "badgeAwaiting": "Falta firma", + "badgeSigned": "Firmado por ambos", + "badgeDeclined": "Rechazado", + "offline": "Estás sin conexión — se enviará al reconectar" + }, + "handover": { + "title": "Di o recibí semillas", + "help": "Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.", + "iGave": "Di semillas", + "iReceived": "Recibí semillas", + "whichLot": "¿De qué lote?", + "howMuch": "¿Cuánto?", + "allOfIt": "Todo", + "partOfIt": "Una parte", + "paymentChip": "Hubo dinero por medio", + "promiseGave": "Me devolverán semilla", + "promiseReceived": "Devolveré semilla" + }, + "history": { + "title": "Historia de este lote", + "tooltip": "Historia", + "sowToday": "Sembrado hoy", + "harvestToday": "Cosechado hoy", + "sownRecorded": "Siembra anotada", + "harvestRecorded": "Cosecha anotada", + "movementReceived": "Recibido", + "movementGiven": "Regalado", + "movementSown": "Sembrado", + "movementHarvested": "Cosechado", + "movementGerminationTest": "Prueba de germinación", + "movementSplit": "Repartido en lotes", + "movementDiscarded": "Descartado", + "created": "Entró en tu colección", + "from": "De {origin}", + "germinationResult": "Prueba de germinación — {percent}%", + "linkedEarlier": "Viene de un lote anterior", + "outcomeQuestion": "¿Qué tal se dio?", + "outcomeGood": "Bien", + "outcomeMixed": "Regular", + "outcomePoor": "Mal", + "outcomeNoteHint": "Una nota para tu yo del futuro (opcional)", + "outcomeSaved": "Anotado", + "ratedGood": "Se dio bien", + "ratedMixed": "Se dio regular", + "ratedPoor": "Se dio mal", + "outcomeTitle": "Nota de temporada", + "outcomeYear": "Temporada {year}", + "isolationHint": "Esta especie se cruza con sus vecinas — cultívala a unos {meters} m de otras para que se mantenga fiel" + }, + "sale": { + "title": "Ventas", + "help": "Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.", + "add": "Registrar venta", + "empty": "Aún no hay ventas. Anota aquí lo que vendes o compras.", + "iSold": "Vendí", + "iBought": "Compré", + "direction": "¿Vendes o compras?", + "counterparty": "¿Con quién?", + "counterpartyHint": "Una persona o un colectivo (opcional)", + "amount": "Importe", + "currency": "Moneda", + "currencyHint": "€, Ğ1, horas… (opcional)", + "hours": "horas", + "note": "Nota (opcional)", + "save": "Guardar", + "delete": "Quitar", + "removeConfirm": "¿Quitar esta venta?" + }, + "legal": { + "title": "Privacidad y normas", + "subtitle": "Tu privacidad, las normas del mercado y la legalidad de las semillas", + "privacyTitle": "Tu privacidad", + "privacyBody": "Tane funciona sin cuenta, y todo lo que registras se queda en tu dispositivo, cifrado. No hay publicidad, ni rastreadores, ni servidor de Tane.\n\nNo se comparte nada salvo que tú quieras: cuando publicas una oferta o tu perfil, o envías un mensaje, viaja por servidores comunitarios. Las ofertas solo llevan una zona aproximada — nunca tu dirección.\n\nLo que publicas es público, y pueden quedar copias incluso después de retirarlo — así que comparte con cuidado.", + "rulesTitle": "Las reglas del juego", + "rulesBody": "Tane es una herramienta, no una tienda: los intercambios se acuerdan directamente entre personas y nadie se lleva comisión. Eso también significa que tú respondes de lo que ofreces y envías.\n\nEn el mercado: sé honesto con tus semillas, ofrece solo lo que puedas compartir, trata bien a la gente y no hagas spam. Puedes bloquear a cualquiera y denunciar ofertas o personas que incumplan las normas — las denuncias se atienden en los servidores comunitarios.", + "seedsTitle": "Sobre compartir semillas y plantones", + "seedsBody": "Regalar e intercambiar semillas entre aficionados está ampliamente reconocido en la mayoría de países. Vender puede ser distinto: en muchos sitios, vender semilla de variedades no registradas oficialmente está restringido — consulta tu normativa antes de pedir un precio.\n\nEnviar semillas a otro país también suele estar restringido, y las variedades protegidas comercialmente no pueden propagarse sin permiso. Ante la duda, mejor local y mejor regalo.\n\nLo mismo vale para plantones y plantas jóvenes, pero la planta viva puede tener reglas de transporte y fitosanitarias más estrictas que la semilla: moverla entre regiones o países puede requerir controles adicionales.", + "readFull": "Leer los documentos completos en la web" + }, + "marketGate": { + "title": "Antes de entrar al mercado", + "intro": "El mercado es un espacio compartido entre vecinas y vecinos. Al continuar, aceptas unas pocas normas sencillas:", + "ruleHonest": "Sé honesto con las semillas que ofreces", + "ruleLegal": "Comparte solo lo que puedas compartir donde vives", + "ruleRespect": "Trata bien a la gente — sin spam ni abusos", + "publicNote": "Lo que publiques aquí es público, y pueden quedar copias aunque lo retires después.", + "viewLegal": "Privacidad y normas", + "accept": "Acepto", + "decline": "Ahora no", + "networkNote": "Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada." + }, + "report": { + "offer": "Denunciar esta oferta", + "person": "Denunciar a esta persona", + "title": "Denunciar", + "prompt": "¿Qué ocurre?", + "reasonSpam": "Spam o un engaño", + "reasonAbuse": "Abusivo o irrespetuoso", + "reasonIllegal": "Semillas que no deberían ofrecerse", + "reasonOther": "Otra cosa", + "detailsHint": "Añade detalles (opcional)", + "send": "Enviar denuncia", + "sentHidden": "Denuncia enviada — ya no verás esto", + "failed": "No se pudo enviar la denuncia — revisa tu conexión", + "alsoBlock": "Bloquear también a esta persona" + }, + "block": { + "action": "Bloquear a esta persona", + "confirmTitle": "¿Bloquear a esta persona?", + "confirmBody": "Dejarás de ver sus ofertas y mensajes. Puedes desbloquearla más adelante en Personas bloqueadas, en la configuración de compartir.", + "confirm": "Bloquear", + "blockedToast": "Persona bloqueada — sus ofertas y mensajes quedan ocultos", + "manageTitle": "Personas bloqueadas", + "manageEmpty": "No has bloqueado a nadie", + "unblock": "Desbloquear" + }, + "sharingInvite": { + "title": "Esto se enciende cuando empiezas a compartir", + "perkChat": "Escribirte con quien tiene semillas cerca", + "perkFavorites": "Guardar las ofertas que te gustan", + "perkPeople": "Tu círculo de gente de confianza", + "networkNote": "Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.", + "start": "Empezar a compartir", + "notNow": "Ahora no" } - }, - "inventory": { - "title": "Inventario", - "searchHint": "Buscar semillas", - "empty": "Aún no hay semillas. Toca + para añadir la primera.", - "noMatches": "Ninguna semilla coincide con los filtros.", - "clearFilters": "Quitar filtros", - "uncategorized": "Sin categoría", - "needsReproductionFilter": "Por reproducir", - "loadError": "No se pudo abrir tu banco de semillas. Quizá estaba ocupado: inténtalo de nuevo.", - "retry": "Reintentar" - }, - "draft": { - "capture": "Capturar fotos", - "captured": "{n} capturadas por catalogar", - "triageTitle": "Por catalogar", - "triageCount": "{n} por catalogar", - "untitled": "Sin nombre", - "nameField": "Nombra esta semilla", - "nameHint": "¿Qué es?", - "suggestFromPhoto": "Sugerir nombre de la foto", - "discard": "Descartar" - }, - "quickAdd": { - "title": "Añadir una semilla", - "labelField": "Nombre", - "labelRequired": "Ponle un nombre", - "addPhoto": "Añadir foto", - "quantity": "¿Cuánta?", - "more": "Añadir más…", - "save": "Guardar", - "saveAndAddAnother": "Guardar y añadir otra", - "addedCount": "{n} añadidas", - "cancel": "Cancelar" - }, - "detail": { - "notFound": "Esta semilla ya no está aquí.", - "lots": "Lotes", - "noLots": "Aún no hay lotes.", - "names": "También conocida como", - "addName": "Añadir nombre", - "links": "Enlaces", - "addLink": "Añadir enlace", - "linkUrl": "URL", - "linkTitle": "Título (opcional)", - "reference": "Saber más", - "refGbif": "GBIF", - "refWikipedia": "Wikipedia", - "refWikispecies": "Wikispecies", - "notes": "Notas", - "addLot": "Añadir lote", - "editLot": "Editar lote", - "deleteConfirm": "¿Eliminar esta semilla?", - "year": "Año {year}", - "noYear": "Año desconocido" - }, - "germination": { - "title": "Germinación", - "add": "Añadir prueba", - "sampleSize": "Muestra", - "germinated": "Germinadas", - "none": "Aún no hay pruebas de germinación.", - "result": "{percent}%" - }, - "viability": { - "expiringSoon": "Úsala o multiplícala esta temporada", - "expiringSoonYears": "Úsala o multiplícala esta temporada · dura ~{years} años", - "expired": "Supera su viabilidad típica — a multiplicar", - "expiredYears": "Supera su viabilidad típica (~{years} años) — a multiplicar" - }, - "editVariety": { - "title": "Editar semilla", - "name": "Nombre", - "category": "Categoría", - "notes": "Notas", - "species": "Especie (del catálogo)", - "speciesHint": "Buscar una especie…", - "speciesSuggested": "Sugerida por el nombre", - "organic": "Ecológica", - "organicHint": "Cultivada de forma ecológica (eco)" - }, - "addLot": { - "title": "Añadir lote", - "year": "Fecha de cosecha", - "quantity": "¿Cuánta?", - "amount": "Cantidad" - }, - "harvest": { - "pickTitle": "Seleccionar mes / año", - "anyMonth": "Cualquier mes", - "noDate": "Indicar fecha de cosecha", - "monthNames": [ - "enero", - "febrero", - "marzo", - "abril", - "mayo", - "junio", - "julio", - "agosto", - "septiembre", - "octubre", - "noviembre", - "diciembre" - ] - }, - "lotType": { - "seed": "Semillas", - "plant": "Planta", - "seedling": "Plantón", - "tree": "Árbol / arbusto", - "bulb": "Bulbo / tubérculo", - "cutting": "Esqueje" - }, - "presentation": { - "title": "Envase", - "none": "Sin especificar", - "pot": "Maceta", - "tray": "Bandeja", - "plug": "Alvéolo", - "bareRoot": "Raíz desnuda", - "rootBall": "Cepellón" - }, - "provenance": { - "section": "De dónde viene", - "seedsFrom": "Semillas de", - "seedsFromHint": "Quién las cultivó o las dio", - "place": "Lugar", - "placeHint": "De dónde vienen (con provincia)", - "addSeedsFrom": "Semillas de", - "addPlace": "Lugar" - }, - "abundance": { - "add": "Cuánta tengo", - "title": "Cuánta tengo", - "none": "Sin indicar", - "plentyToShare": "De sobra para compartir", - "enoughToShare": "Bastante, para compartir con moderación", - "enoughForMe": "Suficiente para mí", - "runningLow": "Queda poca" - }, - "share": { - "add": "¿La compartes?", - "title": "¿La compartes?", - "nudge": "Tienes de sobra: podrías compartir un poco.", - "price": "Precio", - "priceHint": "Déjalo vacío para acordarlo luego", - "private": "Solo para mí", - "gift": "Para regalar", - "exchange": "Para intercambiar", - "sell": "En venta", - "filterChip": "Comparto", - "printCatalog": "Imprimir lo que comparto", - "catalogTitle": "Lo que comparto", - "catalogSaved": "Catálogo guardado", - "cancelled": "Cancelado" - }, - "printLabels": { - "action": "Imprimir etiquetas", - "title": "Imprimir etiquetas", - "selectHint": "Elige las semillas para imprimir sus etiquetas", - "selectAll": "Seleccionar todo", - "selected": "{n} seleccionadas", - "none": "Selecciona semillas primero", - "format": "Tamaño de etiqueta", - "formatStickers": "Pegatinas pequeñas", - "formatStickersHint": "Muchas etiquetas pequeñas por hoja — para pegar en sobres", - "formatCards": "Tarjetas grandes", - "formatCardsHint": "Menos etiquetas, más grandes — para botes y cajas", - "count": "{n} etiquetas", - "save": "Guardar etiquetas", - "saved": "Etiquetas guardadas", - "cancelled": "Cancelado" - }, - "scan": { - "action": "Escanear una etiqueta", - "title": "Escanear etiqueta", - "notALabel": "Ese código no es una etiqueta de semillas", - "addTitle": "No está en tu colección", - "addBody": "¿Añadir «{label}» a tus semillas?", - "add": "Añadirla", - "added": "Añadida a tu colección" - }, - "cropCalendar": { - "add": "Calendario de cultivo", - "title": "Calendario de cultivo", - "sow": "Siembra", - "transplant": "Trasplante", - "flowering": "Floración", - "fruiting": "Fructificación", - "seedHarvest": "Cosecha de semilla", - "editorHint": "Anota tú los meses típicos de esta variedad en tu zona — son tus propias notas.", - "unset": "—" - }, - "needsReproduction": { - "label": "Reproducir esta temporada", - "hint": "Cultívala antes de que se acabe la semilla", - "badge": "Por reproducir" - }, - "preservation": { - "add": "Cómo se conserva", - "title": "Cómo se conserva", - "none": "Sin especificar", - "jarWithDesiccant": "Bote con desecante", - "glassJar": "Bote de cristal", - "paperEnvelope": "Sobre de papel", - "paperBag": "Bolsa de papel", - "plasticBag": "Bolsa de plástico" - }, - "conditionCheck": { - "advanced": "Conservación y detalles del banco", - "title": "Revisiones de conservación", - "add": "Añadir revisión", - "containers": "Botes / recipientes", - "desiccant": "Desecante", - "none": "Aún no hay revisiones.", - "summary": "{count} bote(s) · {state}" - }, - "desiccant": { - "none": "No tiene", - "add": "Se pone", - "replace": "Se cambia", - "dry": "Azul — seco", - "fresh": "Recién puesto" - }, - "unit": { - "aFew": "unas pocas", - "some": "algunas", - "plenty": "muchas", - "pinch": "una pizca", - "handful": { - "singular": "puñado", - "plural": "puñados" - }, - "teaspoon": { - "singular": "cucharadita", - "plural": "cucharaditas" - }, - "spoon": { - "singular": "cuchara", - "plural": "cucharas" - }, - "cup": { - "singular": "taza", - "plural": "tazas" - }, - "jar": { - "singular": "bote", - "plural": "botes" - }, - "sack": { - "singular": "saco", - "plural": "sacos" - }, - "packet": { - "singular": "sobre", - "plural": "sobres" - }, - "cob": { - "singular": "mazorca", - "plural": "mazorcas" - }, - "pod": { - "singular": "vaina", - "plural": "vainas" - }, - "ear": { - "singular": "espiga", - "plural": "espigas" - }, - "head": { - "singular": "cabezuela", - "plural": "cabezuelas" - }, - "fruit": { - "singular": "fruto", - "plural": "frutos" - }, - "bulb": { - "singular": "bulbo", - "plural": "bulbos" - }, - "tuber": { - "singular": "tubérculo", - "plural": "tubérculos" - }, - "seedHead": { - "singular": "cabeza de semillas", - "plural": "cabezas de semillas" - }, - "bunch": { - "singular": "manojo", - "plural": "manojos" - }, - "plant": { - "singular": "planta", - "plural": "plantas" - }, - "pot": { - "singular": "maceta", - "plural": "macetas" - }, - "tray": { - "singular": "bandeja", - "plural": "bandejas" - }, - "seedling": { - "singular": "plántula", - "plural": "plántulas" - }, - "tree": { - "singular": "árbol", - "plural": "árboles" - }, - "cutting": { - "singular": "esqueje", - "plural": "esquejes" - }, - "grams": { - "singular": "gramo", - "plural": "gramos" - }, - "count": { - "singular": "semilla", - "plural": "semillas" - } - }, - "market": { - "title": "Semillas cerca de ti", - "subtitle": "Lo que otras personas comparten cerca", - "notSetUp": "Aún no has configurado el compartir", - "notSetUpBody": "Activa el compartir para ver y dar semillas a gente cercana. Se mantiene aproximado — tu zona, nunca tu dirección exacta.", - "setUp": "Configurar el compartir", - "cantReach": "No se puede conectar con los servidores ahora mismo", - "cantReachBody": "Inténtalo de nuevo, o revisa los servidores en la configuración avanzada.", - "retry": "Reintentar", - "setArea": "Indica tu zona", - "setAreaBody": "Dile al mercado tu zona aproximada para ver semillas cerca.", - "searching": "Buscando por tu zona…", - "empty": "Aún no hay semillas compartidas cerca de ti", - "searchHint": "Buscar entre estas semillas", - "noMatches": "Ninguna semilla compartida coincide con tu búsqueda", - "near": "Cerca de ti", - "contact": "Mensaje", - "mine": "Tú", - "configTitle": "Configuración de compartir", - "setupIntro": "Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.", - "areaLabel": "Tu zona", - "areaHelp": "Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.", - "areaSet": "Tu zona está puesta — aproximada, nunca tu punto exacto", - "areaNotSet": "Zona sin definir — usa tu ubicación, o añade un código en Avanzado", - "advanced": "Avanzado", - "areaCodeLabel": "Código de zona", - "areaCodeHint": "Un código corto como sp3e9 — no un nombre de lugar", - "serversLabel": "Servidores de la comunidad", - "serversHelp": "Elige qué servidores usar. Déjalo así si no sabes.", - "serversAdvanced": "Añadir otro servidor", - "serverAddress": "Dirección del servidor", - "serverInvalid": "Introduce una dirección válida (wss://…)", - "save": "Guardar", - "saved": "Guardado", - "wanted": "Busco", - "shareMine": "Compartir mis semillas", - "sharedCount": "Compartidas {n} semillas", - "nothingToShare": "Marca antes algunas semillas para regalar, cambiar o vender", - "useLocation": "Usar mi ubicación aproximada", - "locationFailed": "No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido", - "queued": "Guardado — las compartiremos cuando tengas conexión", - "shareFailed": "No se pudo conectar con los servidores — tus semillas no se compartieron. Inténtalo de nuevo en un momento.", - "rangeLabel": "Hasta dónde buscar", - "rangeNear": "Muy cerca", - "rangeArea": "Por mi zona", - "rangeRegion": "Mi región", - "sharedBy": "Compartido por", - "noProfile": "Esta persona aún no ha compartido su perfil", - "copyId": "Copiar código", - "idCopied": "Código copiado", - "photo": "Foto" - }, - "profile": { - "title": "Tu perfil", - "name": "Nombre", - "nameHint": "Cómo te ven los demás", - "about": "Sobre ti", - "aboutHint": "Una línea — qué cultivas, dónde", - "g1": "Dirección Ğ1 (opcional)", - "g1Hint": "Para que te paguen en Ğ1 — aparte de tu clave", - "yourId": "Tu identidad", - "idHelp": "Compártela para que te reconozcan", - "copy": "Copiar", - "copied": "Copiado", - "save": "Guardar", - "saved": "Perfil guardado", - "identities": "Tus identidades", - "identitiesHelp": "Ten identidades separadas — cada una con sus mensajes y contactos. Todas salen de tu única copia de seguridad, así que cambiar no añade nada que recordar.", - "identityLabel": "Identidad {n}", - "current": "En uso", - "newIdentity": "Nueva identidad", - "switchTitle": "¿Cambiar de identidad?", - "switchBody": "Los mensajes y contactos se guardan por separado para cada identidad. Puedes volver cuando quieras.", - "switchAction": "Cambiar" - }, - "chatList": { - "title": "Mensajes", - "empty": "Aún no hay conversaciones. Escribe a alguien desde el mercado." - }, - "chat": { - "title": "Chat", - "hint": "Escribe un mensaje…", - "send": "Enviar", - "empty": "Aún no hay mensajes — saluda", - "offline": "Configura el compartir para enviar mensajes", - "payG1": "Pagar en Ğ1", - "g1Copied": "Dirección Ğ1 copiada — pégala en tu cartera", - "today": "Hoy", - "yesterday": "Ayer", - "sendError": "No se pudo enviar — revisa tu conexión", - "noLinks": "No se permiten enlaces en los mensajes" - }, - "trust": { - "none": "Nadie los avala aún", - "count": "Avalada por {n}", - "vouch": "Conozco a esta persona", - "vouched": "Avalas a esta persona", - "circle": "En tu círculo" - }, - "yourPeople": { - "title": "Tu gente", - "help": "Personas que conoces y avalas, y personas que te avalan.", - "youVouchFor": "Tú avalas a", - "vouchesForYou": "Te avalan", - "youVouchForEmpty": "Aún no avalas a nadie. Cuando conozcas a alguien, abre vuestro chat y toca \"Conozco a esta persona\".", - "vouchesForYouEmpty": "Nadie te avala todavía", - "revoke": "Dejar de avalar", - "revokeConfirm": "¿Dejar de avalar a esta persona?", - "offline": "Sin conexión — inténtalo cuando estés en línea" - }, - "ratings": { - "rate": "Valorar a esta persona", - "edit": "Editar tu valoración", - "commentHint": "¿Qué tal fue? (opcional)", - "fromYourCircle": "{n} de gente que conoces", - "retract": "Quitar tu valoración", - "saved": "Valoración guardada" - }, - "notifications": { - "newMessageFrom": "Nuevo mensaje de {name}" - }, - "plantare": { - "title": "Plantares", - "help": "Un Plantare es el compromiso de reproducir una semilla y devolver una parte — así una variedad sigue viajando de mano en mano. No es una venta.", - "add": "Añadir compromiso", - "empty": "Aún no hay compromisos. Cuando compartas o recibas semilla con el compromiso de reproducirla y devolver algo, anótalo aquí.", - "iReturn": "La reproduzco y devuelvo yo", - "owedToMe": "Me la devuelven a mí", - "direction": "Quién reproduce y devuelve", - "counterparty": "¿Con quién?", - "counterpartyHint": "Una persona o un colectivo (opcional)", - "owed": "¿Qué se devuelve?", - "owedHint": "p. ej. un puñado la próxima temporada (opcional)", - "note": "Nota (opcional)", - "save": "Guardar", - "markReturned": "Marcar devuelto", - "markForgiven": "Dar por saldado", - "reopen": "Reabrir", - "delete": "Quitar", - "statusReturned": "Devuelto", - "statusForgiven": "Saldado", - "openSection": "Pendientes", - "settledSection": "Hechos", - "removeConfirm": "¿Quitar este compromiso?", - "returnBy": "Devolver antes del {date}", - "overdue": "vencido", - "dueByLabel": "Devolver antes de (opcional)", - "dueByHint": "Un recordatorio suave, nunca obligatorio", - "pickDate": "Elegir fecha", - "clearDate": "Quitar fecha", - "sectionTitle": "Compromisos", - "propose": "Proponer un Plantaré firmado", - "proposeHelp": "Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.", - "proposeTo": "Con {name}", - "sent": "Propuesta enviada — esperando su firma", - "seedLabel": "¿Qué semilla?", - "seedHint": "La variedad a la que se refiere la promesa", - "returnKindLabel": "¿Qué se devuelve?", - "returnSimilar": "Una cantidad similar de semilla", - "returnSimilarNote": "polinización abierta · no transgénica · cultivada en ecológico", - "returnWork": "Unas horas de trabajo", - "returnOther": "Otra cosa", - "workHoursLabel": "¿Cuántas horas?", - "proposalsSection": "Esperando tu respuesta", - "incomingFrom": "{name} te propone un Plantaré", - "accept": "Aceptar y firmar", - "declineAction": "Rechazar", - "declineReasonHint": "Motivo (opcional)", - "acceptedToast": "Firmado — ahora lo guardáis los dos", - "declinedToast": "Rechazado", - "badgeAwaiting": "Falta firma", - "badgeSigned": "Firmado por ambos", - "badgeDeclined": "Rechazado", - "offline": "Estás sin conexión — se enviará al reconectar" - }, - "handover": { - "title": "Di o recibí semillas", - "help": "Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.", - "iGave": "Di semillas", - "iReceived": "Recibí semillas", - "whichLot": "¿De qué lote?", - "howMuch": "¿Cuánto?", - "allOfIt": "Todo", - "partOfIt": "Una parte", - "paymentChip": "Hubo dinero por medio", - "promiseGave": "Me devolverán semilla", - "promiseReceived": "Devolveré semilla" - }, - "history": { - "title": "Historia de este lote", - "tooltip": "Historia", - "sowToday": "Sembrado hoy", - "harvestToday": "Cosechado hoy", - "sownRecorded": "Siembra anotada", - "harvestRecorded": "Cosecha anotada", - "movementReceived": "Recibido", - "movementGiven": "Regalado", - "movementSown": "Sembrado", - "movementHarvested": "Cosechado", - "movementGerminationTest": "Prueba de germinación", - "movementSplit": "Repartido en lotes", - "movementDiscarded": "Descartado", - "created": "Entró en tu colección", - "from": "De {origin}", - "germinationResult": "Prueba de germinación — {percent}%", - "linkedEarlier": "Viene de un lote anterior", - "outcomeQuestion": "¿Qué tal se dio?", - "outcomeGood": "Bien", - "outcomeMixed": "Regular", - "outcomePoor": "Mal", - "outcomeNoteHint": "Una nota para tu yo del futuro (opcional)", - "outcomeSaved": "Anotado", - "ratedGood": "Se dio bien", - "ratedMixed": "Se dio regular", - "ratedPoor": "Se dio mal", - "outcomeTitle": "Nota de temporada", - "outcomeYear": "Temporada {year}", - "isolationHint": "Esta especie se cruza con sus vecinas — cultívala a unos {meters} m de otras para que se mantenga fiel" - }, - "sale": { - "title": "Ventas", - "help": "Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.", - "add": "Registrar venta", - "empty": "Aún no hay ventas. Anota aquí lo que vendes o compras.", - "iSold": "Vendí", - "iBought": "Compré", - "direction": "¿Vendes o compras?", - "counterparty": "¿Con quién?", - "counterpartyHint": "Una persona o un colectivo (opcional)", - "amount": "Importe", - "currency": "Moneda", - "currencyHint": "€, Ğ1, horas… (opcional)", - "hours": "horas", - "note": "Nota (opcional)", - "save": "Guardar", - "delete": "Quitar", - "removeConfirm": "¿Quitar esta venta?" - }, - "legal": { - "title": "Privacidad y normas", - "subtitle": "Tu privacidad, las normas del mercado y la legalidad de las semillas", - "privacyTitle": "Tu privacidad", - "privacyBody": "Tane funciona sin cuenta, y todo lo que registras se queda en tu dispositivo, cifrado. No hay publicidad, ni rastreadores, ni servidor de Tane.\n\nNo se comparte nada salvo que tú quieras: cuando publicas una oferta o tu perfil, o envías un mensaje, viaja por servidores comunitarios. Las ofertas solo llevan una zona aproximada — nunca tu dirección.\n\nLo que publicas es público, y pueden quedar copias incluso después de retirarlo — así que comparte con cuidado.", - "rulesTitle": "Las reglas del juego", - "rulesBody": "Tane es una herramienta, no una tienda: los intercambios se acuerdan directamente entre personas y nadie se lleva comisión. Eso también significa que tú respondes de lo que ofreces y envías.\n\nEn el mercado: sé honesto con tus semillas, ofrece solo lo que puedas compartir, trata bien a la gente y no hagas spam. Puedes bloquear a cualquiera y denunciar ofertas o personas que incumplan las normas — las denuncias se atienden en los servidores comunitarios.", - "seedsTitle": "Sobre compartir semillas y plantones", - "seedsBody": "Regalar e intercambiar semillas entre aficionados está ampliamente reconocido en la mayoría de países. Vender puede ser distinto: en muchos sitios, vender semilla de variedades no registradas oficialmente está restringido — consulta tu normativa antes de pedir un precio.\n\nEnviar semillas a otro país también suele estar restringido, y las variedades protegidas comercialmente no pueden propagarse sin permiso. Ante la duda, mejor local y mejor regalo.\n\nLo mismo vale para plantones y plantas jóvenes, pero la planta viva puede tener reglas de transporte y fitosanitarias más estrictas que la semilla: moverla entre regiones o países puede requerir controles adicionales.", - "readFull": "Leer los documentos completos en la web" - }, - "marketGate": { - "title": "Antes de entrar al mercado", - "intro": "El mercado es un espacio compartido entre vecinas y vecinos. Al continuar, aceptas unas pocas normas sencillas:", - "ruleHonest": "Sé honesto con las semillas que ofreces", - "ruleLegal": "Comparte solo lo que puedas compartir donde vives", - "ruleRespect": "Trata bien a la gente — sin spam ni abusos", - "publicNote": "Lo que publiques aquí es público, y pueden quedar copias aunque lo retires después.", - "viewLegal": "Privacidad y normas", - "accept": "Acepto", - "decline": "Ahora no" - }, - "report": { - "offer": "Denunciar esta oferta", - "person": "Denunciar a esta persona", - "title": "Denunciar", - "prompt": "¿Qué ocurre?", - "reasonSpam": "Spam o un engaño", - "reasonAbuse": "Abusivo o irrespetuoso", - "reasonIllegal": "Semillas que no deberían ofrecerse", - "reasonOther": "Otra cosa", - "detailsHint": "Añade detalles (opcional)", - "send": "Enviar denuncia", - "sentHidden": "Denuncia enviada — ya no verás esto", - "failed": "No se pudo enviar la denuncia — revisa tu conexión", - "alsoBlock": "Bloquear también a esta persona" - }, - "block": { - "action": "Bloquear a esta persona", - "confirmTitle": "¿Bloquear a esta persona?", - "confirmBody": "Dejarás de ver sus ofertas y mensajes. Puedes desbloquearla más adelante en Personas bloqueadas, en la configuración de compartir.", - "confirm": "Bloquear", - "blockedToast": "Persona bloqueada — sus ofertas y mensajes quedan ocultos", - "manageTitle": "Personas bloqueadas", - "manageEmpty": "No has bloqueado a nadie", - "unblock": "Desbloquear" - } } diff --git a/apps/app_seeds/lib/i18n/fr.i18n.json b/apps/app_seeds/lib/i18n/fr.i18n.json index a90758a..c285006 100644 --- a/apps/app_seeds/lib/i18n/fr.i18n.json +++ b/apps/app_seeds/lib/i18n/fr.i18n.json @@ -59,7 +59,6 @@ "delete": "Supprimer", "edit": "Modifier", "type": "Type", - "comingSoon": "À venir", "offline": "Vous êtes hors ligne — le partage est en pause" }, "home": { @@ -95,6 +94,7 @@ "langEs": "Español", "langEn": "English", "langPt": "Português", + "langPtBr": "Português (Brasil)", "langAst": "Asturianu", "langFr": "Français", "langDe": "Deutsch", @@ -147,9 +147,9 @@ "license": "Licence", "licenseValue": "AGPL-3.0", "website": "Site web", - "sourceCode": "Code source", - "translate": "Aider à traduire", - "translateSubtitle": "Aidez à traduire Tane dans votre langue", + "sourceCode": "Code source", + "translate": "Aider à traduire", + "translateSubtitle": "Aidez à traduire Tane dans votre langue", "openSourceLicenses": "Licences open source", "openSourceLicensesSubtitle": "Bibliothèques tiers et leurs licences", "copyright": "© {years} Association Comunes, sous AGPLv3" @@ -518,7 +518,7 @@ "contact": "Message", "mine": "Vous", "configTitle": "Configuration du partage", - "setupIntro": "Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — vous êtes déjà connectés aux serveurs communautaires partagés pour que les gens trouvent ce que vous offrez, sans aucune entreprise au milieu.", + "setupIntro": "Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — ce que vous offrez passe par des serveurs communautaires, tenus par des personnes et des collectifs et non par une entreprise, pour que d'autres près de chez vous puissent le trouver.", "areaLabel": "Votre zone", "areaHelp": "Gardée approximative volontairement — votre zone, jamais un point exact.", "areaSet": "Votre zone est définie — approximative, jamais votre point exact", diff --git a/apps/app_seeds/lib/i18n/ja.i18n.json b/apps/app_seeds/lib/i18n/ja.i18n.json index b84bc5f..42fbff1 100644 --- a/apps/app_seeds/lib/i18n/ja.i18n.json +++ b/apps/app_seeds/lib/i18n/ja.i18n.json @@ -1,46 +1,46 @@ { - "app": { - "title": "Tane" - }, - "common": { - "save": "保存", - "cancel": "キャンセル", - "delete": "削除", - "edit": "編集", - "type": "種類", - "comingSoon": "近日公開", - "offline": "オフラインです — 共有を一時停止しています" - }, - "menu": { - "tagline": "あなたの種子バンク", - "inventory": "在庫", - "market": "マーケット", - "profile": "プロフィール", - "chat": "チャット", - "wishlist": "お気に入り", - "following": "フォロー中", - "calendar": "カレンダー", - "settings": "設定" - }, - "settings": { - "language": "言語", - "systemLanguage": "システムの言語", - "langEs": "Español", - "langEn": "English", - "langPt": "Português", - "langAst": "Asturianu", - "langFr": "Français", - "langDe": "Deutsch", - "langJa": "日本語", - "about": "このアプリについて", - "aboutText": "在来種のためのローカルファースト・暗号化在庫管理。AGPL-3.0。", - "aboutOpen": "Tane について" - }, - "home": { - "tagline": "地域の種を分かち合い、育てよう", - "openMarket": "マーケット", - "openMarketSubtitle": "近くの種を見つけて分かち合う", - "yourInventory": "あなたの在庫", - "yourInventorySubtitle": "種を管理する" - } + "app": { + "title": "Tane" + }, + "common": { + "save": "保存", + "cancel": "キャンセル", + "delete": "削除", + "edit": "編集", + "type": "種類", + "offline": "オフラインです — 共有を一時停止しています" + }, + "menu": { + "tagline": "あなたの種子バンク", + "inventory": "在庫", + "market": "マーケット", + "profile": "プロフィール", + "chat": "チャット", + "wishlist": "お気に入り", + "following": "フォロー中", + "calendar": "カレンダー", + "settings": "設定" + }, + "settings": { + "language": "言語", + "systemLanguage": "システムの言語", + "langEs": "Español", + "langEn": "English", + "langPt": "Português", + "langPtBr": "Português (Brasil)", + "langAst": "Asturianu", + "langFr": "Français", + "langDe": "Deutsch", + "langJa": "日本語", + "about": "このアプリについて", + "aboutText": "在来種のためのローカルファースト・暗号化在庫管理。AGPL-3.0。", + "aboutOpen": "Tane について" + }, + "home": { + "tagline": "地域の種を分かち合い、育てよう", + "openMarket": "マーケット", + "openMarketSubtitle": "近くの種を見つけて分かち合う", + "yourInventory": "あなたの在庫", + "yourInventorySubtitle": "種を管理する" + } } diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 5d248ee..5c93403 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -12,6 +12,19 @@ "remove": "Remover dos favoritos", "unavailable": "Já não está disponível" }, + "savedSearches": { + "title": "Pesquisas guardadas", + "empty": "Ainda não há pesquisas guardadas. Guarda uma pesquisa no mercado e avisamos-te quando aparecer algo parecido na tua zona.", + "save": "Guardar esta pesquisa", + "nameLabel": "Dá um nome a esta pesquisa", + "namePlaceholder": "ex.: Tomates perto de mim", + "saved": "Pesquisa guardada — vamos avisar-te sobre novas correspondências perto de ti", + "openTooltip": "Pesquisas guardadas", + "delete": "Eliminar", + "deleteConfirm": "Eliminar esta pesquisa guardada?", + "newMatchesBadge": "{n} novas", + "alert": "Sementes perto de ti: {label}" + }, "seedSaving": { "title": "Guardar a sua semente", "subtitle": "O que é preciso para manter a variedade fiel", @@ -59,7 +72,6 @@ "delete": "Eliminar", "edit": "Editar", "type": "Tipo", - "comingSoon": "Em breve", "offline": "Sem ligação — a partilha está em pausa" }, "home": { @@ -95,6 +107,7 @@ "langEs": "Español", "langEn": "English", "langPt": "Português", + "langPtBr": "Português (Brasil)", "langAst": "Asturianu", "langFr": "Français", "langDe": "Deutsch", @@ -147,9 +160,9 @@ "license": "Licença", "licenseValue": "AGPL-3.0", "website": "Sítio web", - "sourceCode": "Código-fonte", - "translate": "Ajuda a traduzir", - "translateSubtitle": "Ajuda a trazer o Tane para o teu idioma", + "sourceCode": "Código-fonte", + "translate": "Ajuda a traduzir", + "translateSubtitle": "Ajuda a trazer o Tane para o teu idioma", "openSourceLicenses": "Licenças de código aberto", "openSourceLicensesSubtitle": "Bibliotecas de terceiros e as suas licenças", "copyright": "© {years} Associação Comunes, sob AGPLv3" @@ -226,6 +239,10 @@ "addLink": "Adicionar ligação", "linkUrl": "URL", "linkTitle": "Título (opcional)", + "reference": "Saber mais", + "refGbif": "GBIF", + "refWikipedia": "Wikipédia", + "refWikispecies": "Wikispecies", "notes": "Notas", "addLot": "Adicionar lote", "editLot": "Editar lote", @@ -351,6 +368,15 @@ "saved": "Etiquetas guardadas", "cancelled": "Cancelado" }, + "scan": { + "action": "Digitalizar um rótulo de semente", + "title": "Digitalizar um rótulo", + "notALabel": "Esse código não é um rótulo de semente", + "addTitle": "Não está na tua coleção", + "addBody": "Adicionar “{label}” às tuas sementes?", + "add": "Adicionar", + "added": "Adicionado à tua coleção" + }, "cropCalendar": { "add": "Calendário de cultivo", "title": "Calendário de cultivo", @@ -514,7 +540,7 @@ "contact": "Mensagem", "mine": "Tu", "configTitle": "Configuração de partilha", - "setupIntro": "Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.", + "setupIntro": "Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — o que ofereces viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de ti o possam encontrar.", "areaLabel": "A tua zona", "areaHelp": "Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.", "areaSet": "A tua zona está definida — aproximada, nunca o teu ponto exato", @@ -645,7 +671,30 @@ "dueByHint": "Um lembrete gentil, nunca imposto", "pickDate": "Escolher data", "clearDate": "Limpar data", - "sectionTitle": "Compromissos" + "sectionTitle": "Compromissos", + "propose": "Propor um Plantaré assinado", + "proposeHelp": "Ambos mantêm a mesma promessa, assinada pelos dois — prova de que esta semente mudou de mãos e será cultivada e devolvida.", + "proposeTo": "Com {name}", + "sent": "Proposta enviada — a aguardar que assinem", + "seedLabel": "Que semente?", + "seedHint": "A variedade a que se refere esta promessa", + "returnKindLabel": "O que volta?", + "returnSimilar": "Uma quantidade semelhante de semente", + "returnSimilarNote": "polinização aberta · não transgénico · cultivado organicamente", + "returnWork": "Algumas horas de trabalho", + "returnOther": "Outra coisa", + "workHoursLabel": "Quantas horas?", + "proposalsSection": "À espera da tua resposta", + "incomingFrom": "{name} propõe um Plantaré", + "accept": "Aceitar e assinar", + "declineAction": "Recusar", + "declineReasonHint": "Motivo (opcional)", + "acceptedToast": "Assinado — agora ambos o mantêm", + "declinedToast": "Recusado", + "badgeAwaiting": "A aguardar assinatura", + "badgeSigned": "Assinado por ambos", + "badgeDeclined": "Recusado", + "offline": "Estás offline — será enviado quando reconectares" }, "handover": { "title": "Dei ou recebi sementes", @@ -660,6 +709,37 @@ "promiseGave": "Vão devolver-me semente", "promiseReceived": "Vou devolver semente" }, + "history": { + "title": "História deste lote", + "tooltip": "História", + "sowToday": "Semeado hoje", + "harvestToday": "Colhido hoje", + "sownRecorded": "Sementeira registada", + "harvestRecorded": "Colheita registada", + "movementReceived": "Recebido", + "movementGiven": "Oferecido", + "movementSown": "Semeado", + "movementHarvested": "Colhido", + "movementGerminationTest": "Teste de germinação", + "movementSplit": "Dividido em lotes", + "movementDiscarded": "Descartado", + "created": "Adicionado à tua coleção", + "from": "De {origin}", + "germinationResult": "Teste de germinação — {percent}%", + "linkedEarlier": "Vem de um lote anterior", + "outcomeQuestion": "Como correu?", + "outcomeGood": "Bem", + "outcomeMixed": "Mais ou menos", + "outcomePoor": "Mal", + "outcomeNoteHint": "Uma nota para o teu eu futuro (opcional)", + "outcomeSaved": "Registado", + "ratedGood": "Correu bem", + "ratedMixed": "Correu mais ou menos", + "ratedPoor": "Correu mal", + "outcomeTitle": "Nota da estação", + "outcomeYear": "Estação {year}", + "isolationHint": "Esta espécie cruza-se com vizinhas próximas — cultiva-a a cerca de {meters} m de distância de outras para a manter pura" + }, "sale": { "title": "Vendas", "help": "Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.", diff --git a/apps/app_seeds/lib/i18n/pt_BR.i18n.json b/apps/app_seeds/lib/i18n/pt_BR.i18n.json new file mode 100644 index 0000000..94443df --- /dev/null +++ b/apps/app_seeds/lib/i18n/pt_BR.i18n.json @@ -0,0 +1,809 @@ +{ + "avatar": { + "title": "A sua foto ou avatar", + "fromPhoto": "Tirar ou escolher uma foto", + "illustration": "Ou escolhe um desenho", + "remove": "Remover" + }, + "favorites": { + "title": "Favoritos", + "empty": "Ainda não tem favoritos. Salve ofertas que goste do mercado.", + "save": "Salvar nos favoritos", + "remove": "Remover dos favoritos", + "unavailable": "Já não está disponível" + }, + "savedSearches": { + "title": "Pesquisas salvas", + "empty": "Ainda não há pesquisas salvas. Salve uma pesquisa no mercado e te avisamos quando aparecer algo parecido na sua zona.", + "save": "Salvar esta pesquisa", + "nameLabel": "Dá um nome a esta pesquisa", + "namePlaceholder": "ex.: Tomates perto de mim", + "saved": "Pesquisa salva — vamos te avisar sobre novas correspondências perto de você", + "openTooltip": "Pesquisas salvas", + "delete": "Eliminar", + "deleteConfirm": "Eliminar esta pesquisa salva?", + "newMatchesBadge": "{n} novas", + "alert": "Sementes perto de você: {label}" + }, + "seedSaving": { + "title": "Guardar a sua semente", + "subtitle": "O que é preciso para manter a variedade fiel", + "lifeCycle": "Ciclo", + "cycleAnnual": "Anual", + "cycleBiennial": "Bienal — dá semente no 2.º ano", + "cyclePerennial": "Perene", + "pollination": "Polinização", + "pollSelf": "Autopoliniza-se", + "pollCross": "Cruza-se com outras", + "pollMixed": "Autopoliniza-se, mas às vezes cruza", + "byInsect": "por insetos", + "byWind": "pelo vento", + "isolation": "Separe-a", + "isolationRange": "{min}–{max} m de outras variedades", + "isolationSingle": "{min} m de outras variedades", + "plants": "Guarde de várias plantas", + "plantsValue": "De pelo menos {n} plantas", + "processing": "Como limpá-la", + "procDry": "Semente seca (debulhar)", + "procWet": "Semente húmida (fermentar e lavar)", + "difficulty": "Dificuldade", + "diffEasy": "Fácil", + "diffMedium": "Média", + "diffHard": "Difícil", + "advisory": "Orientativo — adapte-o ao seu clima e variedade.", + "sourcePrefix": "Fonte" + }, + "calendar": { + "title": "Este mês", + "filterChip": "Este mês", + "selfNote": "O que você anotou nas suas variedades.", + "nothing": "Nada anotado para {month}." + }, + "app": { + "title": "Tane" + }, + "bootstrap": { + "failed": "O Tane não conseguiu iniciar", + "retry": "Tentar de novo" + }, + "common": { + "save": "Salvar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar", + "type": "Tipo", + "offline": "Sem ligação — a compartilha está em pausa" + }, + "home": { + "tagline": "Compartilha e cultiva sementes locais", + "openMarket": "Mercado", + "openMarketSubtitle": "Descobre e compartilha sementes por perto", + "yourInventory": "O seu inventário", + "yourInventorySubtitle": "Gere as suas sementes" + }, + "photo": { + "camera": "Tirar uma foto", + "gallery": "Escolher da galeria", + "setAsCover": "Usar como capa", + "isCover": "Foto de capa", + "deleteConfirm": "Eliminar esta foto?" + }, + "menu": { + "tagline": "o seu banco de sementes", + "inventory": "Inventário", + "market": "Mercado", + "profile": "O seu perfil", + "chat": "Conversas", + "wishlist": "Favoritos", + "following": "A seguir", + "plantares": "Plantares", + "sales": "Vendas", + "calendar": "Calendário", + "settings": "Definições" + }, + "settings": { + "language": "Idioma", + "systemLanguage": "Idioma do sistema", + "langEs": "Español", + "langEn": "English", + "langPt": "Português", + "langPtBr": "Português (Brasil)", + "langAst": "Asturianu", + "langFr": "Français", + "langDe": "Deutsch", + "langJa": "日本語", + "about": "Acerca de", + "aboutText": "Inventário local e cifrado para sementes tradicionais. AGPL-3.0.", + "aboutOpen": "Acerca do Tane" + }, + "backup": { + "title": "Cópia de segurança", + "autoBackupTitle": "Cópias automáticas", + "autoBackupLast": "Última cópia em {date} · a cada {days} dias", + "autoBackupNone": "Uma cópia é salva automaticamente a cada {days} dias", + "exportJson": "Salvar uma cópia de segurança", + "exportJsonSubtitle": "Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho", + "importJson": "Restaurar uma cópia", + "importJsonSubtitle": "Recupera uma cópia salva — nada fica duplicado", + "exportCsv": "Exportar para uma folha de cálculo", + "exportCsvSubtitle": "Uma lista simples para Excel ou LibreOffice — sem fotos", + "importCsv": "Importar uma lista", + "importCsvSubtitle": "Acrescenta entradas a partir de uma folha de cálculo", + "importConfirmTitle": "Restaurar uma cópia?", + "importConfirmBody": "As entradas fundem-se com o seu inventário; quando a mesma entrada existe dos dois lados, vence a versão mais recente. Nada fica duplicado.", + "importCsvConfirmTitle": "Importar uma lista?", + "importCsvConfirmBody": "Cada linha é acrescentada como uma entrada nova. Não funde nem substitui, por isso importar o mesmo arquivo duas vezes acrescenta-o duas vezes.", + "importAction": "Importar", + "exportSaved": "Cópia salva", + "cancelled": "Cancelado", + "importDone": "Importado: {added} novas, {updated} atualizadas", + "importCsvDone": "{count} entradas acrescentadas", + "importFailed": "Este arquivo não pôde ser lido como uma cópia do Tane", + "failed": "Algo correu mal", + "recoveryTitle": "O seu código de recuperação", + "recoverySubtitle": "Imprime-o e guarda-o bem: abre as suas cópias noutro aparelho", + "recoveryIntro": "Este código abre as suas cópias salvas e recupera o seu banco em qualquer aparelho. Guarde duas cópias em papel em lugares seguros, como a sua melhor semente. Quem o tiver pode ler as suas cópias, por isso não o compartilhe com ninguém.", + "recoveryCopy": "Copiar", + "recoverySave": "Salvar a folha", + "recoverySheetTitle": "Tane — a sua folha de recuperação", + "recoveryPromptTitle": "Escreve o seu código de recuperação", + "recoveryPromptBody": "Esta cópia foi salva com outro código. Escreve o código da sua folha de recuperação para a abrir.", + "recoveryWrongCode": "Esse código não abre esta cópia" + }, + "about": { + "title": "Acerca de", + "kanji": "種", + "tagline": "Uma aplicação local e descentralizada para gerir e compartilhar sementes e plântulas tradicionais.", + "intro": "O Tane (種, \"semente\" em japonês) ajuda pessoas e coletivos a manter um inventário amigável do seu banco de sementes, decidir o que oferecem e compartilhá-lo localmente — sem um intermediário central que possa controlar, censurar ou ser multado por isso. O seu nome vem de tanemaki (種まき), \"semear / espalhar sementes\". O objetivo é prático e político ao mesmo tempo: apoiar as variedades tradicionais e fazer frente ao monopólio das sementes.", + "heritage": "O nome honra as antigas tradições japonesas de ajuda mútua à volta do arroz — yui (trabalho comunitário compartilhado) e tanomoshi (fundos de reciprocidade) — que inspiraram o Plantare em papel, a \"moeda comunitária de troca de sementes\" (BAH-Semillero, 2009, CC-BY-SA). O Tane é o Plantare digital.", + "version": "Versão", + "license": "Licença", + "licenseValue": "AGPL-3.0", + "website": "Sítio web", + "sourceCode": "Código-fonte", + "translate": "Ajuda a traduzir", + "translateSubtitle": "Ajuda a trazer o Tane para o seu idioma", + "openSourceLicenses": "Licenças de código aberto", + "openSourceLicensesSubtitle": "Bibliotecas de terceiros e as suas licenças", + "copyright": "© {years} Associação Comunes, sob AGPLv3" + }, + "intro": { + "skip": "Saltar", + "next": "Seguinte", + "start": "Começar", + "menuEntry": "Como funciona o Tane", + "slides": { + "welcome": { + "title": "A semente que te trouxe até aqui", + "body": "Cada semente tradicional é uma carta escrita por milhares de gerações, passada de mão em mão. Somos o que somos graças a essa compartilha." + }, + "inventory": { + "title": "O seu banco de sementes, no bolso", + "body": "Anote o que tem, de que ano, quanto e de onde veio — com o nome que você usa. Uma foto e um nome chegam para começar." + }, + "privacy": { + "title": "Seu, e só seu", + "body": "Sem conta, sem internet, sem rastreadores. Os seus dados vivem cifrados no seu aparelho, e só o que você escolher é compartilhado." + }, + "share": { + "title": "Compartilhar, como sempre se fez", + "body": "Ofereça o que sobra — dar, trocar ou vender — e deixe que alguém perto de você o encontre. Só se mostra uma distância aproximada, nunca a sua morada; o acordo fecha-se em pessoa." + }, + "plantare": { + "title": "Semear é multiplicar", + "body": "Com um Plantare, quem recebe semente promete devolver uma parte mais tarde. E como devolvê-la implica cultivá-la, cada empréstimo multiplica o comum." + } + } + }, + "inventory": { + "title": "Inventário", + "searchHint": "Procurar sementes", + "empty": "Ainda não há sementes. Toca em + para adicionar a primeira.", + "noMatches": "Nenhuma semente corresponde aos seus filtros.", + "clearFilters": "Limpar filtros", + "uncategorized": "Sem categoria", + "needsReproductionFilter": "Para reproduzir", + "loadError": "Não foi possível abrir o seu banco de sementes. Talvez estivesse ocupado — tenta de novo.", + "retry": "Tentar de novo" + }, + "draft": { + "capture": "Capturar fotos", + "captured": "{n} capturadas para catalogar", + "triageTitle": "Para catalogar", + "triageCount": "{n} para catalogar", + "untitled": "Sem nome", + "nameField": "Dá um nome a esta semente", + "nameHint": "O que é?", + "suggestFromPhoto": "Sugerir nome a partir da foto", + "discard": "Descartar" + }, + "quickAdd": { + "title": "Adicionar uma semente", + "labelField": "Nome", + "labelRequired": "Dá-lhe um nome", + "addPhoto": "Adicionar foto", + "quantity": "Quanto?", + "more": "Adicionar mais…", + "save": "Salvar", + "saveAndAddAnother": "Salvar e adicionar outra", + "addedCount": "{n} adicionadas", + "cancel": "Cancelar" + }, + "detail": { + "notFound": "Esta semente já não está aqui.", + "lots": "Lotes", + "noLots": "Ainda não há lotes.", + "names": "Também conhecida como", + "addName": "Adicionar nome", + "links": "Ligações", + "addLink": "Adicionar ligação", + "linkUrl": "URL", + "linkTitle": "Título (opcional)", + "reference": "Saber mais", + "refGbif": "GBIF", + "refWikipedia": "Wikipédia", + "refWikispecies": "Wikispecies", + "notes": "Notas", + "addLot": "Adicionar lote", + "editLot": "Editar lote", + "deleteConfirm": "Eliminar esta semente?", + "year": "Ano {year}", + "noYear": "Ano desconhecido" + }, + "germination": { + "title": "Germinação", + "add": "Adicionar teste", + "sampleSize": "Tamanho da amostra", + "germinated": "Germinadas", + "none": "Ainda não há testes de germinação.", + "result": "{percent}%" + }, + "viability": { + "expiringSoon": "Usa ou reproduz nesta época", + "expiringSoonYears": "Usa ou reproduz nesta época · dura ~{years} anos", + "expired": "Passou a viabilidade típica — reproduzir", + "expiredYears": "Passou a viabilidade típica (~{years} anos) — reproduzir" + }, + "editVariety": { + "title": "Editar semente", + "name": "Nome", + "category": "Categoria", + "notes": "Notas", + "species": "Espécie (do catálogo)", + "speciesHint": "Procura uma espécie…", + "speciesSuggested": "Sugerida pelo nome", + "organic": "Biológica", + "organicHint": "Cultivada em modo biológico" + }, + "addLot": { + "title": "Adicionar lote", + "year": "Data de colheita", + "quantity": "Quanto?", + "amount": "Quantidade" + }, + "harvest": { + "pickTitle": "Escolhe mês / ano", + "anyMonth": "Qualquer mês", + "noDate": "Definir data de colheita", + "monthNames": [ + "janeiro", + "fevereiro", + "março", + "abril", + "maio", + "junho", + "julho", + "agosto", + "setembro", + "outubro", + "novembro", + "dezembro" + ] + }, + "lotType": { + "seed": "Sementes", + "plant": "Planta", + "seedling": "Plântula", + "tree": "Árvore / arbusto", + "bulb": "Bolbo / tubérculo", + "cutting": "Estaca" + }, + "presentation": { + "title": "Apresentação", + "none": "Sem indicar", + "pot": "Vaso", + "tray": "Tabuleiro", + "plug": "Alvéolo", + "bareRoot": "Raiz nua", + "rootBall": "Torrão" + }, + "provenance": { + "section": "De onde vem", + "seedsFrom": "Sementes de", + "seedsFromHint": "Quem as cultivou ou ofereceu", + "place": "Lugar", + "placeHint": "De onde vêm (com a região)", + "addSeedsFrom": "Sementes de", + "addPlace": "Lugar" + }, + "abundance": { + "add": "Quanta tenho", + "title": "Quanta tenho", + "none": "Sem indicar", + "plentyToShare": "De sobra para compartilhar", + "enoughToShare": "Bastante, para compartilhar com moderação", + "enoughForMe": "Suficiente para mim", + "runningLow": "Resta pouca" + }, + "share": { + "add": "Compartilhas esta?", + "title": "Compartilhas esta?", + "nudge": "Tem de sobra: podia compartilhar um pouco.", + "price": "Preço", + "priceHint": "Deixe vazio para combinar depois", + "private": "Só para mim", + "gift": "Para dar", + "exchange": "Para trocar", + "sell": "À venda", + "filterChip": "Compartilho", + "printCatalog": "Imprimir o que compartilho", + "catalogTitle": "O que compartilho", + "catalogSaved": "Catálogo salvo", + "cancelled": "Cancelado" + }, + "printLabels": { + "action": "Imprimir etiquetas", + "title": "Imprimir etiquetas", + "selectHint": "Escolhe as sementes para imprimir as etiquetas", + "selectAll": "Selecionar tudo", + "selected": "{n} selecionadas", + "none": "Seleciona sementes primeiro", + "format": "Tamanho da etiqueta", + "formatStickers": "Autocolantes pequenos", + "formatStickersHint": "Muitas etiquetas pequenas por folha — para colar em envelopes", + "formatCards": "Cartões grandes", + "formatCardsHint": "Menos etiquetas, maiores — para frascos e caixas", + "count": "{n} etiquetas", + "save": "Salvar etiquetas", + "saved": "Etiquetas salvas", + "cancelled": "Cancelado" + }, + "scan": { + "action": "Digitalizar um rótulo de semente", + "title": "Digitalizar um rótulo", + "notALabel": "Esse código não é um rótulo de semente", + "addTitle": "Não está na sua coleção", + "addBody": "Adicionar “{label}” às suas sementes?", + "add": "Adicionar", + "added": "Adicionado à sua coleção" + }, + "cropCalendar": { + "add": "Calendário de cultivo", + "title": "Calendário de cultivo", + "sow": "Sementeira", + "transplant": "Transplante", + "flowering": "Floração", + "fruiting": "Frutificação", + "seedHarvest": "Colheita de semente", + "editorHint": "Anote os meses típicos desta variedade na sua zona — são suas notas.", + "unset": "—" + }, + "needsReproduction": { + "label": "Reproduzir nesta época", + "hint": "Cultiva-a antes que a semente acabe", + "badge": "Por reproduzir" + }, + "preservation": { + "add": "Como está guardada", + "title": "Como está guardada", + "none": "Sem indicar", + "jarWithDesiccant": "Frasco com agente secante", + "glassJar": "Frasco de vidro", + "paperEnvelope": "Envelope de papel", + "paperBag": "Saco de papel", + "plasticBag": "Saco de plástico" + }, + "conditionCheck": { + "advanced": "Armazenamento e detalhes de banco de sementes", + "title": "Verificações de armazenamento", + "add": "Adicionar verificação", + "containers": "Frascos / recipientes", + "desiccant": "Agente secante", + "none": "Ainda não há verificações de armazenamento.", + "summary": "{count} frasco(s) · {state}" + }, + "desiccant": { + "none": "Nenhum", + "add": "Adicionar", + "replace": "Substituir", + "dry": "Azul — seco", + "fresh": "Acabado de renovar" + }, + "unit": { + "aFew": "algumas", + "some": "bastantes", + "plenty": "muitas", + "pinch": "uma pitada", + "handful": { + "singular": "mão-cheia", + "plural": "mãos-cheias" + }, + "teaspoon": { + "singular": "colher de chá", + "plural": "colheres de chá" + }, + "spoon": { + "singular": "colher", + "plural": "colheres" + }, + "cup": { + "singular": "chávena", + "plural": "chávenas" + }, + "jar": { + "singular": "frasco", + "plural": "frascos" + }, + "sack": { + "singular": "saco", + "plural": "sacos" + }, + "packet": { + "singular": "pacote", + "plural": "pacotes" + }, + "cob": { + "singular": "maçaroca", + "plural": "maçarocas" + }, + "pod": { + "singular": "vagem", + "plural": "vagens" + }, + "ear": { + "singular": "espiga", + "plural": "espigas" + }, + "head": { + "singular": "cabeça", + "plural": "cabeças" + }, + "fruit": { + "singular": "fruto", + "plural": "frutos" + }, + "bulb": { + "singular": "bolbo", + "plural": "bolbos" + }, + "tuber": { + "singular": "tubérculo", + "plural": "tubérculos" + }, + "seedHead": { + "singular": "capítulo", + "plural": "capítulos" + }, + "bunch": { + "singular": "molho", + "plural": "molhos" + }, + "plant": { + "singular": "planta", + "plural": "plantas" + }, + "pot": { + "singular": "vaso", + "plural": "vasos" + }, + "tray": { + "singular": "tabuleiro", + "plural": "tabuleiros" + }, + "seedling": { + "singular": "plântula", + "plural": "plântulas" + }, + "tree": { + "singular": "árvore", + "plural": "árvores" + }, + "cutting": { + "singular": "estaca", + "plural": "estacas" + }, + "grams": { + "singular": "grama", + "plural": "gramas" + }, + "count": { + "singular": "semente", + "plural": "sementes" + } + }, + "market": { + "title": "Sementes perto de você", + "subtitle": "O que outras pessoas compartilham por perto", + "notSetUp": "Ainda não configurou a compartilha", + "notSetUpBody": "Ativa a compartilha para ver e dar sementes a pessoas por perto. Mantém-se aproximado — a sua zona, nunca a sua morada exata.", + "setUp": "Configurar a compartilha", + "cantReach": "Não é possível ligar aos servidores neste momento", + "cantReachBody": "Tenta de novo, ou verifica os servidores na configuração avançada.", + "retry": "Tentar de novo", + "setArea": "Indica a sua zona", + "setAreaBody": "Diz ao mercado a sua zona aproximada para ver sementes por perto.", + "searching": "A procurar pela sua zona…", + "empty": "Ainda não há sementes compartilhadas perto de você", + "searchHint": "Procurar nestas sementes", + "noMatches": "Nenhuma semente compartilhada corresponde à procura", + "near": "Perto de você", + "contact": "Mensagem", + "mine": "Você", + "configTitle": "Configuração de compartilha", + "setupIntro": "Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — o que você oferece viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de você possam encontrar.", + "areaLabel": "A sua zona", + "areaHelp": "Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.", + "areaSet": "A sua zona está definida — aproximada, nunca o seu ponto exato", + "areaNotSet": "Zona por definir — usa a sua localização, ou adiciona um código em Avançado", + "advanced": "Avançado", + "areaCodeLabel": "Código de zona", + "areaCodeHint": "Um código curto como sp3e9 — não um nome de lugar", + "serversLabel": "Servidores da comunidade", + "serversHelp": "Escolha que servidores usar. Deixe assim se não souber.", + "serversAdvanced": "Adicionar outro servidor", + "serverAddress": "Endereço do servidor", + "serverInvalid": "Introduz um endereço válido (wss://…)", + "save": "Salvar", + "saved": "Salvo", + "wanted": "Procuro", + "shareMine": "Compartilhar as minhas sementes", + "sharedCount": "Compartilhadas {n} sementes", + "nothingToShare": "Marca primeiro algumas sementes para dar, trocar ou vender", + "useLocation": "Usar a minha localização aproximada", + "locationFailed": "Não foi possível obter a sua localização — verifica se a localização está ativa e a permissão concedida", + "queued": "Salvo — vamos compartilhá-las quando você tiver conexão", + "shareFailed": "Não foi possível contactar os servidores — as suas sementes não foram compartilhadas. Tenta de novo daqui a pouco.", + "rangeLabel": "Até onde procurar", + "rangeNear": "Muito perto", + "rangeArea": "Pela minha zona", + "rangeRegion": "A minha região", + "sharedBy": "Compartilhado por", + "noProfile": "Esta pessoa ainda não compartilhou o seu perfil", + "copyId": "Copiar código", + "idCopied": "Código copiado", + "photo": "Foto" + }, + "profile": { + "title": "O seu perfil", + "name": "Nome", + "nameHint": "Como os outros te veem", + "about": "Sobre você", + "aboutHint": "Uma linha — o que cultivas, onde", + "g1": "Endereço Ğ1 (opcional)", + "g1Hint": "Para que te paguem em Ğ1 — separado da sua chave", + "yourId": "A sua identidade", + "idHelp": "Compartilha-a para que te reconheçam", + "copy": "Copiar", + "copied": "Copiado", + "save": "Salvar", + "saved": "Perfil salvo", + "identities": "As suas identidades", + "identitiesHelp": "Mantém identidades separadas — cada uma com as suas mensagens e contactos. Todas vêm da sua única cópia de segurança, por isso mudar não acrescenta nada para lembrar.", + "identityLabel": "Identidade {n}", + "current": "Em uso", + "newIdentity": "Nova identidade", + "switchTitle": "Mudar de identidade?", + "switchBody": "As mensagens e contatos são guardados separadamente para cada identidade. Pode voltar quando quiser.", + "switchAction": "Mudar" + }, + "chatList": { + "title": "Mensagens", + "empty": "Ainda não há conversas. Escreve a alguém a partir do mercado." + }, + "chat": { + "title": "Conversa", + "hint": "Escreve uma mensagem…", + "send": "Enviar", + "empty": "Ainda não há mensagens — diz olá", + "offline": "Configura a compartilha para enviar mensagens", + "payG1": "Pagar em Ğ1", + "g1Copied": "Endereço Ğ1 copiado — cola-o na sua carteira", + "today": "Hoje", + "yesterday": "Ontem", + "sendError": "Não foi possível enviar — verifica a sua ligação", + "noLinks": "Não são permitidos links nas mensagens" + }, + "trust": { + "none": "Ainda ninguém os avaliza", + "count": "Avalizada por {n}", + "vouch": "Conheço esta pessoa", + "vouched": "Avalizas esta pessoa", + "circle": "No seu círculo" + }, + "yourPeople": { + "title": "A sua gente", + "help": "Pessoas que conheces e avalizas, e pessoas que te avalizam.", + "youVouchFor": "Você avaliza", + "vouchesForYou": "Avalizam-te", + "youVouchForEmpty": "Ainda não avaliza ninguém. Quando conhecer alguém, abra a conversa com essa pessoa e toque em \"Conheço esta pessoa\".", + "vouchesForYouEmpty": "Ainda ninguém te avaliza", + "revoke": "Deixar de avalizar", + "revokeConfirm": "Deixar de avalizar esta pessoa?", + "offline": "Sem conexão — tente novamente quando estiver online" + }, + "ratings": { + "rate": "Avaliar esta pessoa", + "edit": "Editar a sua avaliação", + "commentHint": "Como correu? (opcional)", + "fromYourCircle": "{n} de pessoas que conheces", + "retract": "Remover a sua avaliação", + "saved": "Avaliação salva" + }, + "notifications": { + "newMessageFrom": "Nova mensagem de {name}" + }, + "plantare": { + "title": "Plantares", + "help": "Um Plantare é o compromisso de reproduzir uma semente e devolver uma parte — assim uma variedade continua a viajar de mão em mão. Não é uma venda.", + "add": "Adicionar compromisso", + "empty": "Ainda não há compromissos. Quando compartilhar ou receber semente com o compromisso de reproduzi-la e devolver algo, anote aqui.", + "iReturn": "Reproduzo e devolvo eu", + "owedToMe": "Devolvem-me a mim", + "direction": "Quem reproduz e devolve", + "counterparty": "Com quem?", + "counterpartyHint": "Uma pessoa ou um coletivo (opcional)", + "owed": "O que é devolvido?", + "owedHint": "p. ex. um punhado na próxima temporada (opcional)", + "note": "Nota (opcional)", + "save": "Salvar", + "markReturned": "Marcar devolvido", + "markForgiven": "Dar por saldado", + "reopen": "Reabrir", + "delete": "Remover", + "statusReturned": "Devolvido", + "statusForgiven": "Saldado", + "openSection": "Pendentes", + "settledSection": "Feitos", + "removeConfirm": "Remover este compromisso?", + "returnBy": "Devolver até {date}", + "overdue": "vencido", + "dueByLabel": "Devolver até (opcional)", + "dueByHint": "Um lembrete gentil, nunca imposto", + "pickDate": "Escolher data", + "clearDate": "Limpar data", + "sectionTitle": "Compromissos", + "propose": "Propor um Plantaré assinado", + "proposeHelp": "Ambos mantêm a mesma promessa, assinada pelos dois — prova de que esta semente mudou de mãos e será cultivada e devolvida.", + "proposeTo": "Com {name}", + "sent": "Proposta enviada — a aguardar que assinem", + "seedLabel": "Que semente?", + "seedHint": "A variedade a que se refere esta promessa", + "returnKindLabel": "O que volta?", + "returnSimilar": "Uma quantidade semelhante de semente", + "returnSimilarNote": "polinização aberta · não transgénico · cultivado organicamente", + "returnWork": "Algumas horas de trabalho", + "returnOther": "Outra coisa", + "workHoursLabel": "Quantas horas?", + "proposalsSection": "À espera da sua resposta", + "incomingFrom": "{name} propõe um Plantaré", + "accept": "Aceitar e assinar", + "declineAction": "Recusar", + "declineReasonHint": "Motivo (opcional)", + "acceptedToast": "Assinado — agora ambos o mantêm", + "declinedToast": "Recusado", + "badgeAwaiting": "A aguardar assinatura", + "badgeSigned": "Assinado por ambos", + "badgeDeclined": "Recusado", + "offline": "Você está offline — será enviado quando reconectar" + }, + "handover": { + "title": "Dei ou recebi sementes", + "help": "Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.", + "iGave": "Dei sementes", + "iReceived": "Recebi sementes", + "whichLot": "De que lote?", + "howMuch": "Quanto?", + "allOfIt": "Tudo", + "partOfIt": "Uma parte", + "paymentChip": "Houve dinheiro pelo meio", + "promiseGave": "Vão devolver-me semente", + "promiseReceived": "Vou devolver semente" + }, + "history": { + "title": "História deste lote", + "tooltip": "História", + "sowToday": "Semeado hoje", + "harvestToday": "Colhido hoje", + "sownRecorded": "Sementeira registada", + "harvestRecorded": "Colheita registada", + "movementReceived": "Recebido", + "movementGiven": "Oferecido", + "movementSown": "Semeado", + "movementHarvested": "Colhido", + "movementGerminationTest": "Teste de germinação", + "movementSplit": "Dividido em lotes", + "movementDiscarded": "Descartado", + "created": "Adicionado à sua coleção", + "from": "De {origin}", + "germinationResult": "Teste de germinação — {percent}%", + "linkedEarlier": "Vem de um lote anterior", + "outcomeQuestion": "Como correu?", + "outcomeGood": "Bem", + "outcomeMixed": "Mais ou menos", + "outcomePoor": "Mal", + "outcomeNoteHint": "Uma nota para o seu eu futuro (opcional)", + "outcomeSaved": "Registado", + "ratedGood": "Correu bem", + "ratedMixed": "Correu mais ou menos", + "ratedPoor": "Correu mal", + "outcomeTitle": "Nota da estação", + "outcomeYear": "Estação {year}", + "isolationHint": "Esta espécie cruza-se com vizinhas próximas — cultiva-a a cerca de {meters} m de distância de outras para a manter pura" + }, + "sale": { + "title": "Vendas", + "help": "Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.", + "add": "Registar venda", + "empty": "Ainda não há vendas. Anota aqui o que vendes ou compras.", + "iSold": "Vendi", + "iBought": "Comprei", + "direction": "Vendes ou compras?", + "counterparty": "Com quem?", + "counterpartyHint": "Uma pessoa ou um coletivo (opcional)", + "amount": "Montante", + "currency": "Moeda", + "currencyHint": "€, Ğ1, horas… (opcional)", + "hours": "horas", + "note": "Nota (opcional)", + "save": "Salvar", + "delete": "Remover", + "removeConfirm": "Remover esta venda?" + }, + "legal": { + "title": "Privacidade e regras", + "subtitle": "A sua privacidade, as regras do mercado e a legalidade das sementes", + "privacyTitle": "A sua privacidade", + "privacyBody": "O Tane funciona sem conta, e tudo o que você registra fica no seu dispositivo, cifrado. Não há publicidade, nem rastreadores, nem servidor do Tane.\n\nNada é compartilhado a não ser que você queira: quando publica uma oferta ou o seu perfil, ou envia uma mensagem, viaja por servidores comunitários. As ofertas levam apenas uma zona aproximada — nunca a sua morada.\n\nO que publica é público, e podem ficar cópias mesmo depois de você retirar — por isso compartilhe com cuidado.", + "rulesTitle": "As regras do jogo", + "rulesBody": "O Tane é uma ferramenta, não uma loja: as trocas são acordadas diretamente entre pessoas e ninguém fica com comissão. Isso também significa que é responsável pelo que oferece e envia.\n\nNo mercado: seja honesto com suas sementes, ofereça apenas o que pode compartilhar, trate bem as pessoas e não faça spam. Pode bloquear qualquer pessoa e denunciar ofertas ou pessoas que quebrem as regras — as denúncias são tratadas nos servidores comunitários.", + "seedsTitle": "Sobre compartilhar sementes e mudas", + "seedsBody": "Oferecer e trocar sementes entre amadores é amplamente reconhecido na maioria dos países. Vender pode ser diferente: em muitos lugares, vender sementes de variedades não registadas oficialmente é restrito — consulta as regras locais antes de pedir um preço.\n\nEnviar sementes para outro país também costuma ser restrito, e as variedades protegidas comercialmente não podem ser propagadas sem autorização. Na dúvida, melhor local e melhor oferta.\n\nO mesmo se aplica a mudas e plantas jovens, mas a planta viva pode ter regras de transporte e fitossanitárias mais rígidas do que a semente: movê-la entre regiões ou países pode exigir controlos adicionais.", + "readFull": "Ler os documentos completos na web" + }, + "marketGate": { + "title": "Antes de entrar no mercado", + "intro": "O mercado é um espaço compartilhado entre vizinhas e vizinhos. Ao continuar, aceitas umas poucas regras simples:", + "ruleHonest": "Seja honesto com as sementes que oferece", + "ruleLegal": "Compartilhe apenas o que pode compartilhar onde vive", + "ruleRespect": "Trata bem as pessoas — sem spam nem abusos", + "publicNote": "O que você publicar aqui é público, e podem ficar cópias mesmo que você retire depois.", + "viewLegal": "Privacidade e regras", + "accept": "Aceito", + "decline": "Agora não" + }, + "report": { + "offer": "Denunciar esta oferta", + "person": "Denunciar esta pessoa", + "title": "Denunciar", + "prompt": "O que se passa?", + "reasonSpam": "Spam ou uma burla", + "reasonAbuse": "Abusivo ou desrespeitoso", + "reasonIllegal": "Sementes que não deviam ser oferecidas", + "reasonOther": "Outra coisa", + "detailsHint": "Acrescenta detalhes (opcional)", + "send": "Enviar denúncia", + "sentHidden": "Denúncia enviada — já não voltas a ver isto", + "failed": "Não foi possível enviar a denúncia — verifica a sua ligação", + "alsoBlock": "Bloquear também esta pessoa" + }, + "block": { + "action": "Bloquear esta pessoa", + "confirmTitle": "Bloquear esta pessoa?", + "confirmBody": "Deixa de ver as suas ofertas e mensagens. Pode desbloqueá-la mais tarde em Pessoas bloqueadas, na configuração de compartilhamento.", + "confirm": "Bloquear", + "blockedToast": "Pessoa bloqueada — as suas ofertas e mensagens ficam ocultas", + "manageTitle": "Pessoas bloqueadas", + "manageEmpty": "Você não bloqueou ninguém", + "unblock": "Desbloquear" + } +} diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 23374a5..b28d25d 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -3,10 +3,10 @@ /// Source: lib/i18n /// To regenerate, run: `dart run slang` /// -/// Locales: 7 -/// Strings: 3566 (509 per locale) +/// Locales: 8 +/// Strings: 4299 (537 per locale) /// -/// Built on 2026-07-18 at 10:28 UTC +/// Built on 2026-07-25 at 14:38 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import @@ -24,6 +24,7 @@ import 'strings_es.g.dart' as l_es; import 'strings_fr.g.dart' as l_fr; import 'strings_ja.g.dart' as l_ja; import 'strings_pt.g.dart' as l_pt; +import 'strings_pt_BR.g.dart' as l_pt_BR; part 'strings_en.g.dart'; /// Supported locales. @@ -39,7 +40,8 @@ enum AppLocale with BaseAppLocale { es(languageCode: 'es'), fr(languageCode: 'fr'), ja(languageCode: 'ja'), - pt(languageCode: 'pt'); + pt(languageCode: 'pt'), + ptBr(languageCode: 'pt', countryCode: 'BR'); const AppLocale({ required this.languageCode, @@ -113,6 +115,12 @@ enum AppLocale with BaseAppLocale { cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, ); + case AppLocale.ptBr: + return l_pt_BR.TranslationsPtBr( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); } } diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index e53dba8..c2f5033 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -199,7 +199,6 @@ class _Translations$common$ast extends Translations$common$en { @override String get delete => 'Desaniciar'; @override String get edit => 'Editar'; @override String get type => 'Triba'; - @override String get comingSoon => 'Aína'; } // Path: home @@ -262,6 +261,7 @@ class _Translations$settings$ast extends Translations$settings$en { @override String get langEs => 'Español'; @override String get langEn => 'English'; @override String get langPt => 'Português'; + @override String get langPtBr => 'Português (Brasil)'; @override String get langAst => 'Asturianu'; @override String get langFr => 'Français'; @override String get langDe => 'Deutsch'; @@ -765,7 +765,7 @@ class _Translations$market$ast extends Translations$market$en { @override String get contact => 'Mensaxe'; @override String get mine => 'Tu'; @override String get configTitle => 'Configuración de compartir'; - @override String get setupIntro => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu\'otres persones atopen lo qu\'ufiertes, ensin nenguna empresa en mediu.'; + @override String get setupIntro => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — lo qu\'ufiertes viaxa per servidores comunitarios, calteníos por persones y coleutivos, non por una empresa, pa qu\'otres persones cercanes puedan atopalo.'; @override String get areaLabel => 'La to zona'; @override String get areaHelp => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.'; @override String get areaSet => 'La to zona ta puesta — averada, enxamás el to puntu esactu'; @@ -1464,7 +1464,6 @@ extension on TranslationsAst { 'common.delete' => 'Desaniciar', 'common.edit' => 'Editar', 'common.type' => 'Triba', - 'common.comingSoon' => 'Aína', 'home.tagline' => 'Comparte y cultiva simiente llocal', 'home.openMarket' => 'Mercáu', 'home.openMarketSubtitle' => 'Descubri y comparte simiente cerca', @@ -1491,6 +1490,7 @@ extension on TranslationsAst { 'settings.langEs' => 'Español', 'settings.langEn' => 'English', 'settings.langPt' => 'Português', + 'settings.langPtBr' => 'Português (Brasil)', 'settings.langAst' => 'Asturianu', 'settings.langFr' => 'Français', 'settings.langDe' => 'Deutsch', @@ -1800,7 +1800,7 @@ extension on TranslationsAst { 'market.contact' => 'Mensaxe', 'market.mine' => 'Tu', 'market.configTitle' => 'Configuración de compartir', - 'market.setupIntro' => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu\'otres persones atopen lo qu\'ufiertes, ensin nenguna empresa en mediu.', + 'market.setupIntro' => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — lo qu\'ufiertes viaxa per servidores comunitarios, calteníos por persones y coleutivos, non por una empresa, pa qu\'otres persones cercanes puedan atopalo.', 'market.areaLabel' => 'La to zona', 'market.areaHelp' => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.', 'market.areaSet' => 'La to zona ta puesta — averada, enxamás el to puntu esactu', diff --git a/apps/app_seeds/lib/i18n/strings_de.g.dart b/apps/app_seeds/lib/i18n/strings_de.g.dart index 460891b..22e497e 100644 --- a/apps/app_seeds/lib/i18n/strings_de.g.dart +++ b/apps/app_seeds/lib/i18n/strings_de.g.dart @@ -199,7 +199,6 @@ class _Translations$common$de extends Translations$common$en { @override String get delete => 'Löschen'; @override String get edit => 'Bearbeiten'; @override String get type => 'Typ'; - @override String get comingSoon => 'Bald'; @override String get offline => 'Offline - Teilen ist unterbrochen'; } @@ -263,6 +262,7 @@ class _Translations$settings$de extends Translations$settings$en { @override String get langEs => 'Español'; @override String get langEn => 'English'; @override String get langPt => 'Português'; + @override String get langPtBr => 'Português (Brasil)'; @override String get langAst => 'Asturianu'; @override String get about => 'Über'; @override String get aboutText => 'Lokale, verschlüsselte Saatgutbank für traditionelle Samen. AGPL-3.0.'; @@ -768,7 +768,7 @@ class _Translations$market$de extends Translations$market$en { @override String get contact => 'Nachricht'; @override String get mine => 'Du'; @override String get configTitle => 'Teilen-Einrichtung'; - @override String get setupIntro => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.'; + @override String get setupIntro => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - was du anbietest, läuft über Gemeinschaftsserver, die von Menschen und Kollektiven betrieben werden, nicht von einem Unternehmen, damit andere in deiner Nähe es finden können.'; @override String get areaLabel => 'Dein Gebiet'; @override String get areaHelp => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.'; @override String get areaSet => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt'; @@ -1460,7 +1460,6 @@ extension on TranslationsDe { 'common.delete' => 'Löschen', 'common.edit' => 'Bearbeiten', 'common.type' => 'Typ', - 'common.comingSoon' => 'Bald', 'common.offline' => 'Offline - Teilen ist unterbrochen', 'home.tagline' => 'Teile und baue lokale Samen an', 'home.openMarket' => 'Markt', @@ -1488,6 +1487,7 @@ extension on TranslationsDe { 'settings.langEs' => 'Español', 'settings.langEn' => 'English', 'settings.langPt' => 'Português', + 'settings.langPtBr' => 'Português (Brasil)', 'settings.langAst' => 'Asturianu', 'settings.about' => 'Über', 'settings.aboutText' => 'Lokale, verschlüsselte Saatgutbank für traditionelle Samen. AGPL-3.0.', @@ -1799,7 +1799,7 @@ extension on TranslationsDe { 'market.contact' => 'Nachricht', 'market.mine' => 'Du', 'market.configTitle' => 'Teilen-Einrichtung', - 'market.setupIntro' => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.', + 'market.setupIntro' => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - was du anbietest, läuft über Gemeinschaftsserver, die von Menschen und Kollektiven betrieben werden, nicht von einem Unternehmen, damit andere in deiner Nähe es finden können.', 'market.areaLabel' => 'Dein Gebiet', 'market.areaHelp' => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.', 'market.areaSet' => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 3f90a13..f934138 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -93,6 +93,7 @@ class Translations with BaseTranslations { late final Translations$marketGate$en marketGate = Translations$marketGate$en.internal(_root); late final Translations$report$en report = Translations$report$en.internal(_root); late final Translations$block$en block = Translations$block$en.internal(_root); + late final Translations$sharingInvite$en sharingInvite = Translations$sharingInvite$en.internal(_root); } // Path: avatar @@ -340,9 +341,6 @@ class Translations$common$en { /// en: 'Type' String get type => 'Type'; - /// en: 'Coming soon' - String get comingSoon => 'Coming soon'; - /// en: 'You're offline — sharing is paused' String get offline => 'You\'re offline — sharing is paused'; } @@ -460,6 +458,9 @@ class Translations$settings$en { /// en: 'Português' String get langPt => 'Português'; + /// en: 'Português (Brasil)' + String get langPtBr => 'Português (Brasil)'; + /// en: 'Asturianu' String get langAst => 'Asturianu'; @@ -1480,8 +1481,8 @@ class Translations$market$en { /// en: 'Sharing setup' String get configTitle => 'Sharing setup'; - /// en: 'Sharing with people nearby is optional. Just set your rough area — you're already connected to shared community servers so people can find what you offer, with no company in the middle.' - String get setupIntro => 'Sharing with people nearby is optional. Just set your rough area — you\'re already connected to shared community servers so people can find what you offer, with no company in the middle.'; + /// en: 'Sharing with people nearby is optional. Just set your rough area — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.' + String get setupIntro => 'Sharing with people nearby is optional. Just set your rough area — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.'; /// en: 'Your area' String get areaLabel => 'Your area'; @@ -1575,6 +1576,12 @@ class Translations$market$en { /// en: 'Photo' String get photo => 'Photo'; + + /// en: 'Sharing is on' + String get sharingOnLabel => 'Sharing is on'; + + /// en: 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.' + String get sharingOnHelp => 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.'; } // Path: profile @@ -2238,6 +2245,9 @@ class Translations$marketGate$en { /// en: 'Not now' String get decline => 'Not now'; + + /// en: 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn't connect to anything.' + String get networkNote => 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn\'t connect to anything.'; } // Path: report @@ -2321,6 +2331,36 @@ class Translations$block$en { String get unblock => 'Unblock'; } +// Path: sharingInvite +class Translations$sharingInvite$en { + Translations$sharingInvite$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'This wakes up when you start sharing' + String get title => 'This wakes up when you start sharing'; + + /// en: 'Write to whoever has seeds near you' + String get perkChat => 'Write to whoever has seeds near you'; + + /// en: 'Keep the offers you like' + String get perkFavorites => 'Keep the offers you like'; + + /// en: 'Your circle of people you trust' + String get perkPeople => 'Your circle of people you trust'; + + /// en: 'For that, Tane needs to go online. Until you say yes, it doesn't talk to anyone.' + String get networkNote => 'For that, Tane needs to go online. Until you say yes, it doesn\'t talk to anyone.'; + + /// en: 'Start sharing' + String get start => 'Start sharing'; + + /// en: 'Not now' + String get notNow => 'Not now'; +} + // Path: intro.slides class Translations$intro$slides$en { Translations$intro$slides$en.internal(this._root); @@ -2836,7 +2876,6 @@ extension on Translations { 'common.delete' => 'Delete', 'common.edit' => 'Edit', 'common.type' => 'Type', - 'common.comingSoon' => 'Coming soon', 'common.offline' => 'You\'re offline — sharing is paused', 'home.tagline' => 'Share and grow local seeds', 'home.openMarket' => 'Market', @@ -2864,6 +2903,7 @@ extension on Translations { 'settings.langEs' => 'Español', 'settings.langEn' => 'English', 'settings.langPt' => 'Português', + 'settings.langPtBr' => 'Português (Brasil)', 'settings.langAst' => 'Asturianu', 'settings.langFr' => 'Français', 'settings.langDe' => 'Deutsch', @@ -3182,7 +3222,7 @@ extension on Translations { 'market.contact' => 'Message', 'market.mine' => 'You', 'market.configTitle' => 'Sharing setup', - 'market.setupIntro' => 'Sharing with people nearby is optional. Just set your rough area — you\'re already connected to shared community servers so people can find what you offer, with no company in the middle.', + 'market.setupIntro' => 'Sharing with people nearby is optional. Just set your rough area — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.', 'market.areaLabel' => 'Your area', 'market.areaHelp' => 'Kept rough on purpose — your zone, never an exact spot.', 'market.areaSet' => 'Your area is set — kept coarse, never your exact spot', @@ -3214,6 +3254,8 @@ extension on Translations { 'market.copyId' => 'Copy code', 'market.idCopied' => 'Code copied', 'market.photo' => 'Photo', + 'market.sharingOnLabel' => 'Sharing is on', + 'market.sharingOnHelp' => 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.', 'profile.title' => 'Your profile', 'profile.name' => 'Display name', 'profile.nameHint' => 'How others see you', @@ -3288,10 +3330,10 @@ extension on Translations { 'plantare.delete' => 'Remove', 'plantare.statusReturned' => 'Returned', 'plantare.statusForgiven' => 'Settled', - 'plantare.openSection' => 'Open', - 'plantare.settledSection' => 'Done', _ => null, } ?? switch (path) { + 'plantare.openSection' => 'Open', + 'plantare.settledSection' => 'Done', 'plantare.removeConfirm' => 'Remove this commitment?', 'plantare.returnBy' => ({required Object date}) => 'Return by ${date}', 'plantare.overdue' => 'overdue', @@ -3398,6 +3440,7 @@ extension on Translations { 'marketGate.viewLegal' => 'Privacy & rules', 'marketGate.accept' => 'I agree', 'marketGate.decline' => 'Not now', + 'marketGate.networkNote' => 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn\'t connect to anything.', 'report.offer' => 'Report this offer', 'report.person' => 'Report this person', 'report.title' => 'Report', @@ -3419,6 +3462,13 @@ extension on Translations { 'block.manageTitle' => 'Blocked people', 'block.manageEmpty' => 'You haven\'t blocked anyone', 'block.unblock' => 'Unblock', + 'sharingInvite.title' => 'This wakes up when you start sharing', + 'sharingInvite.perkChat' => 'Write to whoever has seeds near you', + 'sharingInvite.perkFavorites' => 'Keep the offers you like', + 'sharingInvite.perkPeople' => 'Your circle of people you trust', + 'sharingInvite.networkNote' => 'For that, Tane needs to go online. Until you say yes, it doesn\'t talk to anyone.', + 'sharingInvite.start' => 'Start sharing', + 'sharingInvite.notNow' => 'Not now', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 752c4f5..1d6a9f5 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -92,6 +92,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Eliminar'; @override String get edit => 'Editar'; @override String get type => 'Tipo'; - @override String get comingSoon => 'Pronto'; @override String get offline => 'Sin conexión — el compartir está en pausa'; } @@ -286,6 +286,7 @@ class _Translations$settings$es extends Translations$settings$en { @override String get langEs => 'Español'; @override String get langEn => 'English'; @override String get langPt => 'Português'; + @override String get langPtBr => 'Português (Brasil)'; @override String get langFr => 'Français'; @override String get langDe => 'Deutsch'; @override String get langJa => '日本語'; @@ -806,7 +807,7 @@ class _Translations$market$es extends Translations$market$en { @override String get contact => 'Mensaje'; @override String get mine => 'Tú'; @override String get configTitle => 'Configuración de compartir'; - @override String get setupIntro => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.'; + @override String get setupIntro => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — lo que ofreces viaja por servidores comunitarios, mantenidos por personas y colectivos, no por una empresa, para que otras personas cercanas puedan encontrarlo.'; @override String get areaLabel => 'Tu zona'; @override String get areaHelp => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.'; @override String get areaSet => 'Tu zona está puesta — aproximada, nunca tu punto exacto'; @@ -838,6 +839,8 @@ class _Translations$market$es extends Translations$market$en { @override String get copyId => 'Copiar código'; @override String get idCopied => 'Código copiado'; @override String get photo => 'Foto'; + @override String get sharingOnLabel => 'Compartir está activado'; + @override String get sharingOnHelp => 'Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual.'; } // Path: profile @@ -1137,6 +1140,7 @@ class _Translations$marketGate$es extends Translations$marketGate$en { @override String get viewLegal => 'Privacidad y normas'; @override String get accept => 'Acepto'; @override String get decline => 'Ahora no'; + @override String get networkNote => 'Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada.'; } // Path: report @@ -1178,6 +1182,22 @@ class _Translations$block$es extends Translations$block$en { @override String get unblock => 'Desbloquear'; } +// Path: sharingInvite +class _Translations$sharingInvite$es extends Translations$sharingInvite$en { + _Translations$sharingInvite$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Esto se enciende cuando empiezas a compartir'; + @override String get perkChat => 'Escribirte con quien tiene semillas cerca'; + @override String get perkFavorites => 'Guardar las ofertas que te gustan'; + @override String get perkPeople => 'Tu círculo de gente de confianza'; + @override String get networkNote => 'Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.'; + @override String get start => 'Empezar a compartir'; + @override String get notNow => 'Ahora no'; +} + // Path: intro.slides class _Translations$intro$slides$es extends Translations$intro$slides$en { _Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1577,7 +1597,6 @@ extension on TranslationsEs { 'common.delete' => 'Eliminar', 'common.edit' => 'Editar', 'common.type' => 'Tipo', - 'common.comingSoon' => 'Pronto', 'common.offline' => 'Sin conexión — el compartir está en pausa', 'home.tagline' => 'Comparte y cultiva semillas locales', 'home.openMarket' => 'Mercado', @@ -1605,6 +1624,7 @@ extension on TranslationsEs { 'settings.langEs' => 'Español', 'settings.langEn' => 'English', 'settings.langPt' => 'Português', + 'settings.langPtBr' => 'Português (Brasil)', 'settings.langFr' => 'Français', 'settings.langDe' => 'Deutsch', 'settings.langJa' => '日本語', @@ -1922,7 +1942,7 @@ extension on TranslationsEs { 'market.contact' => 'Mensaje', 'market.mine' => 'Tú', 'market.configTitle' => 'Configuración de compartir', - 'market.setupIntro' => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.', + 'market.setupIntro' => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — lo que ofreces viaja por servidores comunitarios, mantenidos por personas y colectivos, no por una empresa, para que otras personas cercanas puedan encontrarlo.', 'market.areaLabel' => 'Tu zona', 'market.areaHelp' => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.', 'market.areaSet' => 'Tu zona está puesta — aproximada, nunca tu punto exacto', @@ -1954,6 +1974,8 @@ extension on TranslationsEs { 'market.copyId' => 'Copiar código', 'market.idCopied' => 'Código copiado', 'market.photo' => 'Foto', + 'market.sharingOnLabel' => 'Compartir está activado', + 'market.sharingOnHelp' => 'Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual.', 'profile.title' => 'Tu perfil', 'profile.name' => 'Nombre', 'profile.nameHint' => 'Cómo te ven los demás', @@ -2029,10 +2051,10 @@ extension on TranslationsEs { 'plantare.statusReturned' => 'Devuelto', 'plantare.statusForgiven' => 'Saldado', 'plantare.openSection' => 'Pendientes', - 'plantare.settledSection' => 'Hechos', - 'plantare.removeConfirm' => '¿Quitar este compromiso?', _ => null, } ?? switch (path) { + 'plantare.settledSection' => 'Hechos', + 'plantare.removeConfirm' => '¿Quitar este compromiso?', 'plantare.returnBy' => ({required Object date}) => 'Devolver antes del ${date}', 'plantare.overdue' => 'vencido', 'plantare.dueByLabel' => 'Devolver antes de (opcional)', @@ -2138,6 +2160,7 @@ extension on TranslationsEs { 'marketGate.viewLegal' => 'Privacidad y normas', 'marketGate.accept' => 'Acepto', 'marketGate.decline' => 'Ahora no', + 'marketGate.networkNote' => 'Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada.', 'report.offer' => 'Denunciar esta oferta', 'report.person' => 'Denunciar a esta persona', 'report.title' => 'Denunciar', @@ -2159,6 +2182,13 @@ extension on TranslationsEs { 'block.manageTitle' => 'Personas bloqueadas', 'block.manageEmpty' => 'No has bloqueado a nadie', 'block.unblock' => 'Desbloquear', + 'sharingInvite.title' => 'Esto se enciende cuando empiezas a compartir', + 'sharingInvite.perkChat' => 'Escribirte con quien tiene semillas cerca', + 'sharingInvite.perkFavorites' => 'Guardar las ofertas que te gustan', + 'sharingInvite.perkPeople' => 'Tu círculo de gente de confianza', + 'sharingInvite.networkNote' => 'Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.', + 'sharingInvite.start' => 'Empezar a compartir', + 'sharingInvite.notNow' => 'Ahora no', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_fr.g.dart b/apps/app_seeds/lib/i18n/strings_fr.g.dart index f28d9cc..528e2d4 100644 --- a/apps/app_seeds/lib/i18n/strings_fr.g.dart +++ b/apps/app_seeds/lib/i18n/strings_fr.g.dart @@ -199,7 +199,6 @@ class _Translations$common$fr extends Translations$common$en { @override String get delete => 'Supprimer'; @override String get edit => 'Modifier'; @override String get type => 'Type'; - @override String get comingSoon => 'À venir'; @override String get offline => 'Vous êtes hors ligne — le partage est en pause'; } @@ -263,6 +262,7 @@ class _Translations$settings$fr extends Translations$settings$en { @override String get langEs => 'Español'; @override String get langEn => 'English'; @override String get langPt => 'Português'; + @override String get langPtBr => 'Português (Brasil)'; @override String get langAst => 'Asturianu'; @override String get langFr => 'Français'; @override String get langDe => 'Deutsch'; @@ -768,7 +768,7 @@ class _Translations$market$fr extends Translations$market$en { @override String get contact => 'Message'; @override String get mine => 'Vous'; @override String get configTitle => 'Configuration du partage'; - @override String get setupIntro => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — vous êtes déjà connectés aux serveurs communautaires partagés pour que les gens trouvent ce que vous offrez, sans aucune entreprise au milieu.'; + @override String get setupIntro => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — ce que vous offrez passe par des serveurs communautaires, tenus par des personnes et des collectifs et non par une entreprise, pour que d\'autres près de chez vous puissent le trouver.'; @override String get areaLabel => 'Votre zone'; @override String get areaHelp => 'Gardée approximative volontairement — votre zone, jamais un point exact.'; @override String get areaSet => 'Votre zone est définie — approximative, jamais votre point exact'; @@ -1460,7 +1460,6 @@ extension on TranslationsFr { 'common.delete' => 'Supprimer', 'common.edit' => 'Modifier', 'common.type' => 'Type', - 'common.comingSoon' => 'À venir', 'common.offline' => 'Vous êtes hors ligne — le partage est en pause', 'home.tagline' => 'Partagez et cultivez des graines locales', 'home.openMarket' => 'Marché', @@ -1488,6 +1487,7 @@ extension on TranslationsFr { 'settings.langEs' => 'Español', 'settings.langEn' => 'English', 'settings.langPt' => 'Português', + 'settings.langPtBr' => 'Português (Brasil)', 'settings.langAst' => 'Asturianu', 'settings.langFr' => 'Français', 'settings.langDe' => 'Deutsch', @@ -1799,7 +1799,7 @@ extension on TranslationsFr { 'market.contact' => 'Message', 'market.mine' => 'Vous', 'market.configTitle' => 'Configuration du partage', - 'market.setupIntro' => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — vous êtes déjà connectés aux serveurs communautaires partagés pour que les gens trouvent ce que vous offrez, sans aucune entreprise au milieu.', + 'market.setupIntro' => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — ce que vous offrez passe par des serveurs communautaires, tenus par des personnes et des collectifs et non par une entreprise, pour que d\'autres près de chez vous puissent le trouver.', 'market.areaLabel' => 'Votre zone', 'market.areaHelp' => 'Gardée approximative volontairement — votre zone, jamais un point exact.', 'market.areaSet' => 'Votre zone est définie — approximative, jamais votre point exact', diff --git a/apps/app_seeds/lib/i18n/strings_ja.g.dart b/apps/app_seeds/lib/i18n/strings_ja.g.dart index 01cfd2e..548c863 100644 --- a/apps/app_seeds/lib/i18n/strings_ja.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ja.g.dart @@ -68,7 +68,6 @@ class _Translations$common$ja extends Translations$common$en { @override String get delete => '削除'; @override String get edit => '編集'; @override String get type => '種類'; - @override String get comingSoon => '近日公開'; @override String get offline => 'オフラインです — 共有を一時停止しています'; } @@ -102,6 +101,7 @@ class _Translations$settings$ja extends Translations$settings$en { @override String get langEs => 'Español'; @override String get langEn => 'English'; @override String get langPt => 'Português'; + @override String get langPtBr => 'Português (Brasil)'; @override String get langAst => 'Asturianu'; @override String get langFr => 'Français'; @override String get langDe => 'Deutsch'; @@ -139,7 +139,6 @@ extension on TranslationsJa { 'common.delete' => '削除', 'common.edit' => '編集', 'common.type' => '種類', - 'common.comingSoon' => '近日公開', 'common.offline' => 'オフラインです — 共有を一時停止しています', 'menu.tagline' => 'あなたの種子バンク', 'menu.inventory' => '在庫', @@ -155,6 +154,7 @@ extension on TranslationsJa { 'settings.langEs' => 'Español', 'settings.langEn' => 'English', 'settings.langPt' => 'Português', + 'settings.langPtBr' => 'Português (Brasil)', 'settings.langAst' => 'Asturianu', 'settings.langFr' => 'Français', 'settings.langDe' => 'Deutsch', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index dad41df..3973e13 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -39,61 +39,64 @@ class TranslationsPt extends Translations with BaseTranslations? meta}) => TranslationsPt(meta: meta ?? this.$meta); // Translations - @override late final _Translations$avatar$pt avatar = _Translations$avatar$pt._(_root); - @override late final _Translations$favorites$pt favorites = _Translations$favorites$pt._(_root); - @override late final _Translations$seedSaving$pt seedSaving = _Translations$seedSaving$pt._(_root); - @override late final _Translations$calendar$pt calendar = _Translations$calendar$pt._(_root); - @override late final _Translations$app$pt app = _Translations$app$pt._(_root); - @override late final _Translations$bootstrap$pt bootstrap = _Translations$bootstrap$pt._(_root); - @override late final _Translations$common$pt common = _Translations$common$pt._(_root); - @override late final _Translations$home$pt home = _Translations$home$pt._(_root); - @override late final _Translations$photo$pt photo = _Translations$photo$pt._(_root); - @override late final _Translations$menu$pt menu = _Translations$menu$pt._(_root); - @override late final _Translations$settings$pt settings = _Translations$settings$pt._(_root); - @override late final _Translations$backup$pt backup = _Translations$backup$pt._(_root); - @override late final _Translations$about$pt about = _Translations$about$pt._(_root); - @override late final _Translations$intro$pt intro = _Translations$intro$pt._(_root); - @override late final _Translations$inventory$pt inventory = _Translations$inventory$pt._(_root); - @override late final _Translations$draft$pt draft = _Translations$draft$pt._(_root); - @override late final _Translations$quickAdd$pt quickAdd = _Translations$quickAdd$pt._(_root); - @override late final _Translations$detail$pt detail = _Translations$detail$pt._(_root); - @override late final _Translations$germination$pt germination = _Translations$germination$pt._(_root); - @override late final _Translations$viability$pt viability = _Translations$viability$pt._(_root); - @override late final _Translations$editVariety$pt editVariety = _Translations$editVariety$pt._(_root); - @override late final _Translations$addLot$pt addLot = _Translations$addLot$pt._(_root); - @override late final _Translations$harvest$pt harvest = _Translations$harvest$pt._(_root); - @override late final _Translations$lotType$pt lotType = _Translations$lotType$pt._(_root); - @override late final _Translations$presentation$pt presentation = _Translations$presentation$pt._(_root); - @override late final _Translations$provenance$pt provenance = _Translations$provenance$pt._(_root); - @override late final _Translations$abundance$pt abundance = _Translations$abundance$pt._(_root); - @override late final _Translations$share$pt share = _Translations$share$pt._(_root); - @override late final _Translations$printLabels$pt printLabels = _Translations$printLabels$pt._(_root); - @override late final _Translations$cropCalendar$pt cropCalendar = _Translations$cropCalendar$pt._(_root); - @override late final _Translations$needsReproduction$pt needsReproduction = _Translations$needsReproduction$pt._(_root); - @override late final _Translations$preservation$pt preservation = _Translations$preservation$pt._(_root); - @override late final _Translations$conditionCheck$pt conditionCheck = _Translations$conditionCheck$pt._(_root); - @override late final _Translations$desiccant$pt desiccant = _Translations$desiccant$pt._(_root); - @override late final _Translations$unit$pt unit = _Translations$unit$pt._(_root); - @override late final _Translations$market$pt market = _Translations$market$pt._(_root); - @override late final _Translations$profile$pt profile = _Translations$profile$pt._(_root); - @override late final _Translations$chatList$pt chatList = _Translations$chatList$pt._(_root); - @override late final _Translations$chat$pt chat = _Translations$chat$pt._(_root); - @override late final _Translations$trust$pt trust = _Translations$trust$pt._(_root); - @override late final _Translations$yourPeople$pt yourPeople = _Translations$yourPeople$pt._(_root); - @override late final _Translations$ratings$pt ratings = _Translations$ratings$pt._(_root); - @override late final _Translations$notifications$pt notifications = _Translations$notifications$pt._(_root); - @override late final _Translations$plantare$pt plantare = _Translations$plantare$pt._(_root); - @override late final _Translations$handover$pt handover = _Translations$handover$pt._(_root); - @override late final _Translations$sale$pt sale = _Translations$sale$pt._(_root); - @override late final _Translations$legal$pt legal = _Translations$legal$pt._(_root); - @override late final _Translations$marketGate$pt marketGate = _Translations$marketGate$pt._(_root); - @override late final _Translations$report$pt report = _Translations$report$pt._(_root); - @override late final _Translations$block$pt block = _Translations$block$pt._(_root); + @override late final Translations$avatar$pt avatar = Translations$avatar$pt.internal(_root); + @override late final Translations$favorites$pt favorites = Translations$favorites$pt.internal(_root); + @override late final Translations$savedSearches$pt savedSearches = Translations$savedSearches$pt.internal(_root); + @override late final Translations$seedSaving$pt seedSaving = Translations$seedSaving$pt.internal(_root); + @override late final Translations$calendar$pt calendar = Translations$calendar$pt.internal(_root); + @override late final Translations$app$pt app = Translations$app$pt.internal(_root); + @override late final Translations$bootstrap$pt bootstrap = Translations$bootstrap$pt.internal(_root); + @override late final Translations$common$pt common = Translations$common$pt.internal(_root); + @override late final Translations$home$pt home = Translations$home$pt.internal(_root); + @override late final Translations$photo$pt photo = Translations$photo$pt.internal(_root); + @override late final Translations$menu$pt menu = Translations$menu$pt.internal(_root); + @override late final Translations$settings$pt settings = Translations$settings$pt.internal(_root); + @override late final Translations$backup$pt backup = Translations$backup$pt.internal(_root); + @override late final Translations$about$pt about = Translations$about$pt.internal(_root); + @override late final Translations$intro$pt intro = Translations$intro$pt.internal(_root); + @override late final Translations$inventory$pt inventory = Translations$inventory$pt.internal(_root); + @override late final Translations$draft$pt draft = Translations$draft$pt.internal(_root); + @override late final Translations$quickAdd$pt quickAdd = Translations$quickAdd$pt.internal(_root); + @override late final Translations$detail$pt detail = Translations$detail$pt.internal(_root); + @override late final Translations$germination$pt germination = Translations$germination$pt.internal(_root); + @override late final Translations$viability$pt viability = Translations$viability$pt.internal(_root); + @override late final Translations$editVariety$pt editVariety = Translations$editVariety$pt.internal(_root); + @override late final Translations$addLot$pt addLot = Translations$addLot$pt.internal(_root); + @override late final Translations$harvest$pt harvest = Translations$harvest$pt.internal(_root); + @override late final Translations$lotType$pt lotType = Translations$lotType$pt.internal(_root); + @override late final Translations$presentation$pt presentation = Translations$presentation$pt.internal(_root); + @override late final Translations$provenance$pt provenance = Translations$provenance$pt.internal(_root); + @override late final Translations$abundance$pt abundance = Translations$abundance$pt.internal(_root); + @override late final Translations$share$pt share = Translations$share$pt.internal(_root); + @override late final Translations$printLabels$pt printLabels = Translations$printLabels$pt.internal(_root); + @override late final Translations$scan$pt scan = Translations$scan$pt.internal(_root); + @override late final Translations$cropCalendar$pt cropCalendar = Translations$cropCalendar$pt.internal(_root); + @override late final Translations$needsReproduction$pt needsReproduction = Translations$needsReproduction$pt.internal(_root); + @override late final Translations$preservation$pt preservation = Translations$preservation$pt.internal(_root); + @override late final Translations$conditionCheck$pt conditionCheck = Translations$conditionCheck$pt.internal(_root); + @override late final Translations$desiccant$pt desiccant = Translations$desiccant$pt.internal(_root); + @override late final Translations$unit$pt unit = Translations$unit$pt.internal(_root); + @override late final Translations$market$pt market = Translations$market$pt.internal(_root); + @override late final Translations$profile$pt profile = Translations$profile$pt.internal(_root); + @override late final Translations$chatList$pt chatList = Translations$chatList$pt.internal(_root); + @override late final Translations$chat$pt chat = Translations$chat$pt.internal(_root); + @override late final Translations$trust$pt trust = Translations$trust$pt.internal(_root); + @override late final Translations$yourPeople$pt yourPeople = Translations$yourPeople$pt.internal(_root); + @override late final Translations$ratings$pt ratings = Translations$ratings$pt.internal(_root); + @override late final Translations$notifications$pt notifications = Translations$notifications$pt.internal(_root); + @override late final Translations$plantare$pt plantare = Translations$plantare$pt.internal(_root); + @override late final Translations$handover$pt handover = Translations$handover$pt.internal(_root); + @override late final Translations$history$pt history = Translations$history$pt.internal(_root); + @override late final Translations$sale$pt sale = Translations$sale$pt.internal(_root); + @override late final Translations$legal$pt legal = Translations$legal$pt.internal(_root); + @override late final Translations$marketGate$pt marketGate = Translations$marketGate$pt.internal(_root); + @override late final Translations$report$pt report = Translations$report$pt.internal(_root); + @override late final Translations$block$pt block = Translations$block$pt.internal(_root); } // Path: avatar -class _Translations$avatar$pt extends Translations$avatar$en { - _Translations$avatar$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$avatar$pt extends Translations$avatar$en { + Translations$avatar$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -105,8 +108,8 @@ class _Translations$avatar$pt extends Translations$avatar$en { } // Path: favorites -class _Translations$favorites$pt extends Translations$favorites$en { - _Translations$favorites$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$favorites$pt extends Translations$favorites$en { + Translations$favorites$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -118,9 +121,29 @@ class _Translations$favorites$pt extends Translations$favorites$en { @override String get unavailable => 'Já não está disponível'; } +// Path: savedSearches +class Translations$savedSearches$pt extends Translations$savedSearches$en { + Translations$savedSearches$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Pesquisas guardadas'; + @override String get empty => 'Ainda não há pesquisas guardadas. Guarda uma pesquisa no mercado e avisamos-te quando aparecer algo parecido na tua zona.'; + @override String get save => 'Guardar esta pesquisa'; + @override String get nameLabel => 'Dá um nome a esta pesquisa'; + @override String get namePlaceholder => 'ex.: Tomates perto de mim'; + @override String get saved => 'Pesquisa guardada — vamos avisar-te sobre novas correspondências perto de ti'; + @override String get openTooltip => 'Pesquisas guardadas'; + @override String get delete => 'Eliminar'; + @override String get deleteConfirm => 'Eliminar esta pesquisa guardada?'; + @override String newMatchesBadge({required Object n}) => '${n} novas'; + @override String alert({required Object label}) => 'Sementes perto de ti: ${label}'; +} + // Path: seedSaving -class _Translations$seedSaving$pt extends Translations$seedSaving$en { - _Translations$seedSaving$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$seedSaving$pt extends Translations$seedSaving$en { + Translations$seedSaving$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -154,8 +177,8 @@ class _Translations$seedSaving$pt extends Translations$seedSaving$en { } // Path: calendar -class _Translations$calendar$pt extends Translations$calendar$en { - _Translations$calendar$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$calendar$pt extends Translations$calendar$en { + Translations$calendar$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -167,8 +190,8 @@ class _Translations$calendar$pt extends Translations$calendar$en { } // Path: app -class _Translations$app$pt extends Translations$app$en { - _Translations$app$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$app$pt extends Translations$app$en { + Translations$app$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -177,8 +200,8 @@ class _Translations$app$pt extends Translations$app$en { } // Path: bootstrap -class _Translations$bootstrap$pt extends Translations$bootstrap$en { - _Translations$bootstrap$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$bootstrap$pt extends Translations$bootstrap$en { + Translations$bootstrap$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -188,8 +211,8 @@ class _Translations$bootstrap$pt extends Translations$bootstrap$en { } // Path: common -class _Translations$common$pt extends Translations$common$en { - _Translations$common$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$common$pt extends Translations$common$en { + Translations$common$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -199,13 +222,12 @@ class _Translations$common$pt extends Translations$common$en { @override String get delete => 'Eliminar'; @override String get edit => 'Editar'; @override String get type => 'Tipo'; - @override String get comingSoon => 'Em breve'; @override String get offline => 'Sem ligação — a partilha está em pausa'; } // Path: home -class _Translations$home$pt extends Translations$home$en { - _Translations$home$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$home$pt extends Translations$home$en { + Translations$home$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -218,8 +240,8 @@ class _Translations$home$pt extends Translations$home$en { } // Path: photo -class _Translations$photo$pt extends Translations$photo$en { - _Translations$photo$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$photo$pt extends Translations$photo$en { + Translations$photo$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -232,8 +254,8 @@ class _Translations$photo$pt extends Translations$photo$en { } // Path: menu -class _Translations$menu$pt extends Translations$menu$en { - _Translations$menu$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$menu$pt extends Translations$menu$en { + Translations$menu$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -252,8 +274,8 @@ class _Translations$menu$pt extends Translations$menu$en { } // Path: settings -class _Translations$settings$pt extends Translations$settings$en { - _Translations$settings$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$settings$pt extends Translations$settings$en { + Translations$settings$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -263,6 +285,7 @@ class _Translations$settings$pt extends Translations$settings$en { @override String get langEs => 'Español'; @override String get langEn => 'English'; @override String get langPt => 'Português'; + @override String get langPtBr => 'Português (Brasil)'; @override String get langAst => 'Asturianu'; @override String get langFr => 'Français'; @override String get langDe => 'Deutsch'; @@ -273,8 +296,8 @@ class _Translations$settings$pt extends Translations$settings$en { } // Path: backup -class _Translations$backup$pt extends Translations$backup$en { - _Translations$backup$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$backup$pt extends Translations$backup$en { + Translations$backup$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -314,8 +337,8 @@ class _Translations$backup$pt extends Translations$backup$en { } // Path: about -class _Translations$about$pt extends Translations$about$en { - _Translations$about$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$about$pt extends Translations$about$en { + Translations$about$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -338,8 +361,8 @@ class _Translations$about$pt extends Translations$about$en { } // Path: intro -class _Translations$intro$pt extends Translations$intro$en { - _Translations$intro$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$intro$pt extends Translations$intro$en { + Translations$intro$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -348,12 +371,12 @@ class _Translations$intro$pt extends Translations$intro$en { @override String get next => 'Seguinte'; @override String get start => 'Começar'; @override String get menuEntry => 'Como funciona o Tane'; - @override late final _Translations$intro$slides$pt slides = _Translations$intro$slides$pt._(_root); + @override late final Translations$intro$slides$pt slides = Translations$intro$slides$pt.internal(_root); } // Path: inventory -class _Translations$inventory$pt extends Translations$inventory$en { - _Translations$inventory$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$inventory$pt extends Translations$inventory$en { + Translations$inventory$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -370,8 +393,8 @@ class _Translations$inventory$pt extends Translations$inventory$en { } // Path: draft -class _Translations$draft$pt extends Translations$draft$en { - _Translations$draft$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$draft$pt extends Translations$draft$en { + Translations$draft$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -388,8 +411,8 @@ class _Translations$draft$pt extends Translations$draft$en { } // Path: quickAdd -class _Translations$quickAdd$pt extends Translations$quickAdd$en { - _Translations$quickAdd$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$quickAdd$pt extends Translations$quickAdd$en { + Translations$quickAdd$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -407,8 +430,8 @@ class _Translations$quickAdd$pt extends Translations$quickAdd$en { } // Path: detail -class _Translations$detail$pt extends Translations$detail$en { - _Translations$detail$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$detail$pt extends Translations$detail$en { + Translations$detail$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -422,6 +445,10 @@ class _Translations$detail$pt extends Translations$detail$en { @override String get addLink => 'Adicionar ligação'; @override String get linkUrl => 'URL'; @override String get linkTitle => 'Título (opcional)'; + @override String get reference => 'Saber mais'; + @override String get refGbif => 'GBIF'; + @override String get refWikipedia => 'Wikipédia'; + @override String get refWikispecies => 'Wikispecies'; @override String get notes => 'Notas'; @override String get addLot => 'Adicionar lote'; @override String get editLot => 'Editar lote'; @@ -431,8 +458,8 @@ class _Translations$detail$pt extends Translations$detail$en { } // Path: germination -class _Translations$germination$pt extends Translations$germination$en { - _Translations$germination$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$germination$pt extends Translations$germination$en { + Translations$germination$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -446,8 +473,8 @@ class _Translations$germination$pt extends Translations$germination$en { } // Path: viability -class _Translations$viability$pt extends Translations$viability$en { - _Translations$viability$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$viability$pt extends Translations$viability$en { + Translations$viability$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -459,8 +486,8 @@ class _Translations$viability$pt extends Translations$viability$en { } // Path: editVariety -class _Translations$editVariety$pt extends Translations$editVariety$en { - _Translations$editVariety$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$editVariety$pt extends Translations$editVariety$en { + Translations$editVariety$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -477,8 +504,8 @@ class _Translations$editVariety$pt extends Translations$editVariety$en { } // Path: addLot -class _Translations$addLot$pt extends Translations$addLot$en { - _Translations$addLot$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$addLot$pt extends Translations$addLot$en { + Translations$addLot$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -490,8 +517,8 @@ class _Translations$addLot$pt extends Translations$addLot$en { } // Path: harvest -class _Translations$harvest$pt extends Translations$harvest$en { - _Translations$harvest$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$harvest$pt extends Translations$harvest$en { + Translations$harvest$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -516,8 +543,8 @@ class _Translations$harvest$pt extends Translations$harvest$en { } // Path: lotType -class _Translations$lotType$pt extends Translations$lotType$en { - _Translations$lotType$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$lotType$pt extends Translations$lotType$en { + Translations$lotType$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -531,8 +558,8 @@ class _Translations$lotType$pt extends Translations$lotType$en { } // Path: presentation -class _Translations$presentation$pt extends Translations$presentation$en { - _Translations$presentation$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$presentation$pt extends Translations$presentation$en { + Translations$presentation$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -547,8 +574,8 @@ class _Translations$presentation$pt extends Translations$presentation$en { } // Path: provenance -class _Translations$provenance$pt extends Translations$provenance$en { - _Translations$provenance$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$provenance$pt extends Translations$provenance$en { + Translations$provenance$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -563,8 +590,8 @@ class _Translations$provenance$pt extends Translations$provenance$en { } // Path: abundance -class _Translations$abundance$pt extends Translations$abundance$en { - _Translations$abundance$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$abundance$pt extends Translations$abundance$en { + Translations$abundance$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -579,8 +606,8 @@ class _Translations$abundance$pt extends Translations$abundance$en { } // Path: share -class _Translations$share$pt extends Translations$share$en { - _Translations$share$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$share$pt extends Translations$share$en { + Translations$share$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -602,8 +629,8 @@ class _Translations$share$pt extends Translations$share$en { } // Path: printLabels -class _Translations$printLabels$pt extends Translations$printLabels$en { - _Translations$printLabels$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$printLabels$pt extends Translations$printLabels$en { + Translations$printLabels$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -625,9 +652,25 @@ class _Translations$printLabels$pt extends Translations$printLabels$en { @override String get cancelled => 'Cancelado'; } +// Path: scan +class Translations$scan$pt extends Translations$scan$en { + Translations$scan$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get action => 'Digitalizar um rótulo de semente'; + @override String get title => 'Digitalizar um rótulo'; + @override String get notALabel => 'Esse código não é um rótulo de semente'; + @override String get addTitle => 'Não está na tua coleção'; + @override String addBody({required Object label}) => 'Adicionar “${label}” às tuas sementes?'; + @override String get add => 'Adicionar'; + @override String get added => 'Adicionado à tua coleção'; +} + // Path: cropCalendar -class _Translations$cropCalendar$pt extends Translations$cropCalendar$en { - _Translations$cropCalendar$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$cropCalendar$pt extends Translations$cropCalendar$en { + Translations$cropCalendar$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -644,8 +687,8 @@ class _Translations$cropCalendar$pt extends Translations$cropCalendar$en { } // Path: needsReproduction -class _Translations$needsReproduction$pt extends Translations$needsReproduction$en { - _Translations$needsReproduction$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$needsReproduction$pt extends Translations$needsReproduction$en { + Translations$needsReproduction$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -656,8 +699,8 @@ class _Translations$needsReproduction$pt extends Translations$needsReproduction$ } // Path: preservation -class _Translations$preservation$pt extends Translations$preservation$en { - _Translations$preservation$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$preservation$pt extends Translations$preservation$en { + Translations$preservation$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -673,8 +716,8 @@ class _Translations$preservation$pt extends Translations$preservation$en { } // Path: conditionCheck -class _Translations$conditionCheck$pt extends Translations$conditionCheck$en { - _Translations$conditionCheck$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$conditionCheck$pt extends Translations$conditionCheck$en { + Translations$conditionCheck$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -689,8 +732,8 @@ class _Translations$conditionCheck$pt extends Translations$conditionCheck$en { } // Path: desiccant -class _Translations$desiccant$pt extends Translations$desiccant$en { - _Translations$desiccant$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$desiccant$pt extends Translations$desiccant$en { + Translations$desiccant$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -703,8 +746,8 @@ class _Translations$desiccant$pt extends Translations$desiccant$en { } // Path: unit -class _Translations$unit$pt extends Translations$unit$en { - _Translations$unit$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$pt extends Translations$unit$en { + Translations$unit$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -713,35 +756,35 @@ class _Translations$unit$pt extends Translations$unit$en { @override String get some => 'bastantes'; @override String get plenty => 'muitas'; @override String get pinch => 'uma pitada'; - @override late final _Translations$unit$handful$pt handful = _Translations$unit$handful$pt._(_root); - @override late final _Translations$unit$teaspoon$pt teaspoon = _Translations$unit$teaspoon$pt._(_root); - @override late final _Translations$unit$spoon$pt spoon = _Translations$unit$spoon$pt._(_root); - @override late final _Translations$unit$cup$pt cup = _Translations$unit$cup$pt._(_root); - @override late final _Translations$unit$jar$pt jar = _Translations$unit$jar$pt._(_root); - @override late final _Translations$unit$sack$pt sack = _Translations$unit$sack$pt._(_root); - @override late final _Translations$unit$packet$pt packet = _Translations$unit$packet$pt._(_root); - @override late final _Translations$unit$cob$pt cob = _Translations$unit$cob$pt._(_root); - @override late final _Translations$unit$pod$pt pod = _Translations$unit$pod$pt._(_root); - @override late final _Translations$unit$ear$pt ear = _Translations$unit$ear$pt._(_root); - @override late final _Translations$unit$head$pt head = _Translations$unit$head$pt._(_root); - @override late final _Translations$unit$fruit$pt fruit = _Translations$unit$fruit$pt._(_root); - @override late final _Translations$unit$bulb$pt bulb = _Translations$unit$bulb$pt._(_root); - @override late final _Translations$unit$tuber$pt tuber = _Translations$unit$tuber$pt._(_root); - @override late final _Translations$unit$seedHead$pt seedHead = _Translations$unit$seedHead$pt._(_root); - @override late final _Translations$unit$bunch$pt bunch = _Translations$unit$bunch$pt._(_root); - @override late final _Translations$unit$plant$pt plant = _Translations$unit$plant$pt._(_root); - @override late final _Translations$unit$pot$pt pot = _Translations$unit$pot$pt._(_root); - @override late final _Translations$unit$tray$pt tray = _Translations$unit$tray$pt._(_root); - @override late final _Translations$unit$seedling$pt seedling = _Translations$unit$seedling$pt._(_root); - @override late final _Translations$unit$tree$pt tree = _Translations$unit$tree$pt._(_root); - @override late final _Translations$unit$cutting$pt cutting = _Translations$unit$cutting$pt._(_root); - @override late final _Translations$unit$grams$pt grams = _Translations$unit$grams$pt._(_root); - @override late final _Translations$unit$count$pt count = _Translations$unit$count$pt._(_root); + @override late final Translations$unit$handful$pt handful = Translations$unit$handful$pt.internal(_root); + @override late final Translations$unit$teaspoon$pt teaspoon = Translations$unit$teaspoon$pt.internal(_root); + @override late final Translations$unit$spoon$pt spoon = Translations$unit$spoon$pt.internal(_root); + @override late final Translations$unit$cup$pt cup = Translations$unit$cup$pt.internal(_root); + @override late final Translations$unit$jar$pt jar = Translations$unit$jar$pt.internal(_root); + @override late final Translations$unit$sack$pt sack = Translations$unit$sack$pt.internal(_root); + @override late final Translations$unit$packet$pt packet = Translations$unit$packet$pt.internal(_root); + @override late final Translations$unit$cob$pt cob = Translations$unit$cob$pt.internal(_root); + @override late final Translations$unit$pod$pt pod = Translations$unit$pod$pt.internal(_root); + @override late final Translations$unit$ear$pt ear = Translations$unit$ear$pt.internal(_root); + @override late final Translations$unit$head$pt head = Translations$unit$head$pt.internal(_root); + @override late final Translations$unit$fruit$pt fruit = Translations$unit$fruit$pt.internal(_root); + @override late final Translations$unit$bulb$pt bulb = Translations$unit$bulb$pt.internal(_root); + @override late final Translations$unit$tuber$pt tuber = Translations$unit$tuber$pt.internal(_root); + @override late final Translations$unit$seedHead$pt seedHead = Translations$unit$seedHead$pt.internal(_root); + @override late final Translations$unit$bunch$pt bunch = Translations$unit$bunch$pt.internal(_root); + @override late final Translations$unit$plant$pt plant = Translations$unit$plant$pt.internal(_root); + @override late final Translations$unit$pot$pt pot = Translations$unit$pot$pt.internal(_root); + @override late final Translations$unit$tray$pt tray = Translations$unit$tray$pt.internal(_root); + @override late final Translations$unit$seedling$pt seedling = Translations$unit$seedling$pt.internal(_root); + @override late final Translations$unit$tree$pt tree = Translations$unit$tree$pt.internal(_root); + @override late final Translations$unit$cutting$pt cutting = Translations$unit$cutting$pt.internal(_root); + @override late final Translations$unit$grams$pt grams = Translations$unit$grams$pt.internal(_root); + @override late final Translations$unit$count$pt count = Translations$unit$count$pt.internal(_root); } // Path: market -class _Translations$market$pt extends Translations$market$en { - _Translations$market$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$market$pt extends Translations$market$en { + Translations$market$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -764,7 +807,7 @@ class _Translations$market$pt extends Translations$market$en { @override String get contact => 'Mensagem'; @override String get mine => 'Tu'; @override String get configTitle => 'Configuração de partilha'; - @override String get setupIntro => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.'; + @override String get setupIntro => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — o que ofereces viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de ti o possam encontrar.'; @override String get areaLabel => 'A tua zona'; @override String get areaHelp => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.'; @override String get areaSet => 'A tua zona está definida — aproximada, nunca o teu ponto exato'; @@ -799,8 +842,8 @@ class _Translations$market$pt extends Translations$market$en { } // Path: profile -class _Translations$profile$pt extends Translations$profile$en { - _Translations$profile$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$profile$pt extends Translations$profile$en { + Translations$profile$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -829,8 +872,8 @@ class _Translations$profile$pt extends Translations$profile$en { } // Path: chatList -class _Translations$chatList$pt extends Translations$chatList$en { - _Translations$chatList$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$chatList$pt extends Translations$chatList$en { + Translations$chatList$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -840,8 +883,8 @@ class _Translations$chatList$pt extends Translations$chatList$en { } // Path: chat -class _Translations$chat$pt extends Translations$chat$en { - _Translations$chat$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$chat$pt extends Translations$chat$en { + Translations$chat$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -860,8 +903,8 @@ class _Translations$chat$pt extends Translations$chat$en { } // Path: trust -class _Translations$trust$pt extends Translations$trust$en { - _Translations$trust$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$trust$pt extends Translations$trust$en { + Translations$trust$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -874,8 +917,8 @@ class _Translations$trust$pt extends Translations$trust$en { } // Path: yourPeople -class _Translations$yourPeople$pt extends Translations$yourPeople$en { - _Translations$yourPeople$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$yourPeople$pt extends Translations$yourPeople$en { + Translations$yourPeople$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -892,8 +935,8 @@ class _Translations$yourPeople$pt extends Translations$yourPeople$en { } // Path: ratings -class _Translations$ratings$pt extends Translations$ratings$en { - _Translations$ratings$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$ratings$pt extends Translations$ratings$en { + Translations$ratings$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -907,8 +950,8 @@ class _Translations$ratings$pt extends Translations$ratings$en { } // Path: notifications -class _Translations$notifications$pt extends Translations$notifications$en { - _Translations$notifications$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$notifications$pt extends Translations$notifications$en { + Translations$notifications$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -917,8 +960,8 @@ class _Translations$notifications$pt extends Translations$notifications$en { } // Path: plantare -class _Translations$plantare$pt extends Translations$plantare$en { - _Translations$plantare$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$plantare$pt extends Translations$plantare$en { + Translations$plantare$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -952,11 +995,34 @@ class _Translations$plantare$pt extends Translations$plantare$en { @override String get pickDate => 'Escolher data'; @override String get clearDate => 'Limpar data'; @override String get sectionTitle => 'Compromissos'; + @override String get propose => 'Propor um Plantaré assinado'; + @override String get proposeHelp => 'Ambos mantêm a mesma promessa, assinada pelos dois — prova de que esta semente mudou de mãos e será cultivada e devolvida.'; + @override String proposeTo({required Object name}) => 'Com ${name}'; + @override String get sent => 'Proposta enviada — a aguardar que assinem'; + @override String get seedLabel => 'Que semente?'; + @override String get seedHint => 'A variedade a que se refere esta promessa'; + @override String get returnKindLabel => 'O que volta?'; + @override String get returnSimilar => 'Uma quantidade semelhante de semente'; + @override String get returnSimilarNote => 'polinização aberta · não transgénico · cultivado organicamente'; + @override String get returnWork => 'Algumas horas de trabalho'; + @override String get returnOther => 'Outra coisa'; + @override String get workHoursLabel => 'Quantas horas?'; + @override String get proposalsSection => 'À espera da tua resposta'; + @override String incomingFrom({required Object name}) => '${name} propõe um Plantaré'; + @override String get accept => 'Aceitar e assinar'; + @override String get declineAction => 'Recusar'; + @override String get declineReasonHint => 'Motivo (opcional)'; + @override String get acceptedToast => 'Assinado — agora ambos o mantêm'; + @override String get declinedToast => 'Recusado'; + @override String get badgeAwaiting => 'A aguardar assinatura'; + @override String get badgeSigned => 'Assinado por ambos'; + @override String get badgeDeclined => 'Recusado'; + @override String get offline => 'Estás offline — será enviado quando reconectares'; } // Path: handover -class _Translations$handover$pt extends Translations$handover$en { - _Translations$handover$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$handover$pt extends Translations$handover$en { + Translations$handover$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -974,9 +1040,47 @@ class _Translations$handover$pt extends Translations$handover$en { @override String get promiseReceived => 'Vou devolver semente'; } +// Path: history +class Translations$history$pt extends Translations$history$en { + Translations$history$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'História deste lote'; + @override String get tooltip => 'História'; + @override String get sowToday => 'Semeado hoje'; + @override String get harvestToday => 'Colhido hoje'; + @override String get sownRecorded => 'Sementeira registada'; + @override String get harvestRecorded => 'Colheita registada'; + @override String get movementReceived => 'Recebido'; + @override String get movementGiven => 'Oferecido'; + @override String get movementSown => 'Semeado'; + @override String get movementHarvested => 'Colhido'; + @override String get movementGerminationTest => 'Teste de germinação'; + @override String get movementSplit => 'Dividido em lotes'; + @override String get movementDiscarded => 'Descartado'; + @override String get created => 'Adicionado à tua coleção'; + @override String from({required Object origin}) => 'De ${origin}'; + @override String germinationResult({required Object percent}) => 'Teste de germinação — ${percent}%'; + @override String get linkedEarlier => 'Vem de um lote anterior'; + @override String get outcomeQuestion => 'Como correu?'; + @override String get outcomeGood => 'Bem'; + @override String get outcomeMixed => 'Mais ou menos'; + @override String get outcomePoor => 'Mal'; + @override String get outcomeNoteHint => 'Uma nota para o teu eu futuro (opcional)'; + @override String get outcomeSaved => 'Registado'; + @override String get ratedGood => 'Correu bem'; + @override String get ratedMixed => 'Correu mais ou menos'; + @override String get ratedPoor => 'Correu mal'; + @override String get outcomeTitle => 'Nota da estação'; + @override String outcomeYear({required Object year}) => 'Estação ${year}'; + @override String isolationHint({required Object meters}) => 'Esta espécie cruza-se com vizinhas próximas — cultiva-a a cerca de ${meters} m de distância de outras para a manter pura'; +} + // Path: sale -class _Translations$sale$pt extends Translations$sale$en { - _Translations$sale$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$sale$pt extends Translations$sale$en { + Translations$sale$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1001,8 +1105,8 @@ class _Translations$sale$pt extends Translations$sale$en { } // Path: legal -class _Translations$legal$pt extends Translations$legal$en { - _Translations$legal$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$legal$pt extends Translations$legal$en { + Translations$legal$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1019,8 +1123,8 @@ class _Translations$legal$pt extends Translations$legal$en { } // Path: marketGate -class _Translations$marketGate$pt extends Translations$marketGate$en { - _Translations$marketGate$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$marketGate$pt extends Translations$marketGate$en { + Translations$marketGate$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1037,8 +1141,8 @@ class _Translations$marketGate$pt extends Translations$marketGate$en { } // Path: report -class _Translations$report$pt extends Translations$report$en { - _Translations$report$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$report$pt extends Translations$report$en { + Translations$report$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1059,8 +1163,8 @@ class _Translations$report$pt extends Translations$report$en { } // Path: block -class _Translations$block$pt extends Translations$block$en { - _Translations$block$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$block$pt extends Translations$block$en { + Translations$block$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1076,22 +1180,22 @@ class _Translations$block$pt extends Translations$block$en { } // Path: intro.slides -class _Translations$intro$slides$pt extends Translations$intro$slides$en { - _Translations$intro$slides$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$intro$slides$pt extends Translations$intro$slides$en { + Translations$intro$slides$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field // Translations - @override late final _Translations$intro$slides$welcome$pt welcome = _Translations$intro$slides$welcome$pt._(_root); - @override late final _Translations$intro$slides$inventory$pt inventory = _Translations$intro$slides$inventory$pt._(_root); - @override late final _Translations$intro$slides$privacy$pt privacy = _Translations$intro$slides$privacy$pt._(_root); - @override late final _Translations$intro$slides$share$pt share = _Translations$intro$slides$share$pt._(_root); - @override late final _Translations$intro$slides$plantare$pt plantare = _Translations$intro$slides$plantare$pt._(_root); + @override late final Translations$intro$slides$welcome$pt welcome = Translations$intro$slides$welcome$pt.internal(_root); + @override late final Translations$intro$slides$inventory$pt inventory = Translations$intro$slides$inventory$pt.internal(_root); + @override late final Translations$intro$slides$privacy$pt privacy = Translations$intro$slides$privacy$pt.internal(_root); + @override late final Translations$intro$slides$share$pt share = Translations$intro$slides$share$pt.internal(_root); + @override late final Translations$intro$slides$plantare$pt plantare = Translations$intro$slides$plantare$pt.internal(_root); } // Path: unit.handful -class _Translations$unit$handful$pt extends Translations$unit$handful$en { - _Translations$unit$handful$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$handful$pt extends Translations$unit$handful$en { + Translations$unit$handful$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1101,8 +1205,8 @@ class _Translations$unit$handful$pt extends Translations$unit$handful$en { } // Path: unit.teaspoon -class _Translations$unit$teaspoon$pt extends Translations$unit$teaspoon$en { - _Translations$unit$teaspoon$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$teaspoon$pt extends Translations$unit$teaspoon$en { + Translations$unit$teaspoon$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1112,8 +1216,8 @@ class _Translations$unit$teaspoon$pt extends Translations$unit$teaspoon$en { } // Path: unit.spoon -class _Translations$unit$spoon$pt extends Translations$unit$spoon$en { - _Translations$unit$spoon$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$spoon$pt extends Translations$unit$spoon$en { + Translations$unit$spoon$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1123,8 +1227,8 @@ class _Translations$unit$spoon$pt extends Translations$unit$spoon$en { } // Path: unit.cup -class _Translations$unit$cup$pt extends Translations$unit$cup$en { - _Translations$unit$cup$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$cup$pt extends Translations$unit$cup$en { + Translations$unit$cup$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1134,8 +1238,8 @@ class _Translations$unit$cup$pt extends Translations$unit$cup$en { } // Path: unit.jar -class _Translations$unit$jar$pt extends Translations$unit$jar$en { - _Translations$unit$jar$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$jar$pt extends Translations$unit$jar$en { + Translations$unit$jar$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1145,8 +1249,8 @@ class _Translations$unit$jar$pt extends Translations$unit$jar$en { } // Path: unit.sack -class _Translations$unit$sack$pt extends Translations$unit$sack$en { - _Translations$unit$sack$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$sack$pt extends Translations$unit$sack$en { + Translations$unit$sack$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1156,8 +1260,8 @@ class _Translations$unit$sack$pt extends Translations$unit$sack$en { } // Path: unit.packet -class _Translations$unit$packet$pt extends Translations$unit$packet$en { - _Translations$unit$packet$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$packet$pt extends Translations$unit$packet$en { + Translations$unit$packet$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1167,8 +1271,8 @@ class _Translations$unit$packet$pt extends Translations$unit$packet$en { } // Path: unit.cob -class _Translations$unit$cob$pt extends Translations$unit$cob$en { - _Translations$unit$cob$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$cob$pt extends Translations$unit$cob$en { + Translations$unit$cob$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1178,8 +1282,8 @@ class _Translations$unit$cob$pt extends Translations$unit$cob$en { } // Path: unit.pod -class _Translations$unit$pod$pt extends Translations$unit$pod$en { - _Translations$unit$pod$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$pod$pt extends Translations$unit$pod$en { + Translations$unit$pod$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1189,8 +1293,8 @@ class _Translations$unit$pod$pt extends Translations$unit$pod$en { } // Path: unit.ear -class _Translations$unit$ear$pt extends Translations$unit$ear$en { - _Translations$unit$ear$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$ear$pt extends Translations$unit$ear$en { + Translations$unit$ear$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1200,8 +1304,8 @@ class _Translations$unit$ear$pt extends Translations$unit$ear$en { } // Path: unit.head -class _Translations$unit$head$pt extends Translations$unit$head$en { - _Translations$unit$head$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$head$pt extends Translations$unit$head$en { + Translations$unit$head$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1211,8 +1315,8 @@ class _Translations$unit$head$pt extends Translations$unit$head$en { } // Path: unit.fruit -class _Translations$unit$fruit$pt extends Translations$unit$fruit$en { - _Translations$unit$fruit$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$fruit$pt extends Translations$unit$fruit$en { + Translations$unit$fruit$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1222,8 +1326,8 @@ class _Translations$unit$fruit$pt extends Translations$unit$fruit$en { } // Path: unit.bulb -class _Translations$unit$bulb$pt extends Translations$unit$bulb$en { - _Translations$unit$bulb$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$bulb$pt extends Translations$unit$bulb$en { + Translations$unit$bulb$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1233,8 +1337,8 @@ class _Translations$unit$bulb$pt extends Translations$unit$bulb$en { } // Path: unit.tuber -class _Translations$unit$tuber$pt extends Translations$unit$tuber$en { - _Translations$unit$tuber$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$tuber$pt extends Translations$unit$tuber$en { + Translations$unit$tuber$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1244,8 +1348,8 @@ class _Translations$unit$tuber$pt extends Translations$unit$tuber$en { } // Path: unit.seedHead -class _Translations$unit$seedHead$pt extends Translations$unit$seedHead$en { - _Translations$unit$seedHead$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$seedHead$pt extends Translations$unit$seedHead$en { + Translations$unit$seedHead$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1255,8 +1359,8 @@ class _Translations$unit$seedHead$pt extends Translations$unit$seedHead$en { } // Path: unit.bunch -class _Translations$unit$bunch$pt extends Translations$unit$bunch$en { - _Translations$unit$bunch$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$bunch$pt extends Translations$unit$bunch$en { + Translations$unit$bunch$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1266,8 +1370,8 @@ class _Translations$unit$bunch$pt extends Translations$unit$bunch$en { } // Path: unit.plant -class _Translations$unit$plant$pt extends Translations$unit$plant$en { - _Translations$unit$plant$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$plant$pt extends Translations$unit$plant$en { + Translations$unit$plant$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1277,8 +1381,8 @@ class _Translations$unit$plant$pt extends Translations$unit$plant$en { } // Path: unit.pot -class _Translations$unit$pot$pt extends Translations$unit$pot$en { - _Translations$unit$pot$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$pot$pt extends Translations$unit$pot$en { + Translations$unit$pot$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1288,8 +1392,8 @@ class _Translations$unit$pot$pt extends Translations$unit$pot$en { } // Path: unit.tray -class _Translations$unit$tray$pt extends Translations$unit$tray$en { - _Translations$unit$tray$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$tray$pt extends Translations$unit$tray$en { + Translations$unit$tray$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1299,8 +1403,8 @@ class _Translations$unit$tray$pt extends Translations$unit$tray$en { } // Path: unit.seedling -class _Translations$unit$seedling$pt extends Translations$unit$seedling$en { - _Translations$unit$seedling$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$seedling$pt extends Translations$unit$seedling$en { + Translations$unit$seedling$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1310,8 +1414,8 @@ class _Translations$unit$seedling$pt extends Translations$unit$seedling$en { } // Path: unit.tree -class _Translations$unit$tree$pt extends Translations$unit$tree$en { - _Translations$unit$tree$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$tree$pt extends Translations$unit$tree$en { + Translations$unit$tree$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1321,8 +1425,8 @@ class _Translations$unit$tree$pt extends Translations$unit$tree$en { } // Path: unit.cutting -class _Translations$unit$cutting$pt extends Translations$unit$cutting$en { - _Translations$unit$cutting$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$cutting$pt extends Translations$unit$cutting$en { + Translations$unit$cutting$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1332,8 +1436,8 @@ class _Translations$unit$cutting$pt extends Translations$unit$cutting$en { } // Path: unit.grams -class _Translations$unit$grams$pt extends Translations$unit$grams$en { - _Translations$unit$grams$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$grams$pt extends Translations$unit$grams$en { + Translations$unit$grams$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1343,8 +1447,8 @@ class _Translations$unit$grams$pt extends Translations$unit$grams$en { } // Path: unit.count -class _Translations$unit$count$pt extends Translations$unit$count$en { - _Translations$unit$count$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$unit$count$pt extends Translations$unit$count$en { + Translations$unit$count$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1354,8 +1458,8 @@ class _Translations$unit$count$pt extends Translations$unit$count$en { } // Path: intro.slides.welcome -class _Translations$intro$slides$welcome$pt extends Translations$intro$slides$welcome$en { - _Translations$intro$slides$welcome$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$intro$slides$welcome$pt extends Translations$intro$slides$welcome$en { + Translations$intro$slides$welcome$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1365,8 +1469,8 @@ class _Translations$intro$slides$welcome$pt extends Translations$intro$slides$we } // Path: intro.slides.inventory -class _Translations$intro$slides$inventory$pt extends Translations$intro$slides$inventory$en { - _Translations$intro$slides$inventory$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$intro$slides$inventory$pt extends Translations$intro$slides$inventory$en { + Translations$intro$slides$inventory$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1376,8 +1480,8 @@ class _Translations$intro$slides$inventory$pt extends Translations$intro$slides$ } // Path: intro.slides.privacy -class _Translations$intro$slides$privacy$pt extends Translations$intro$slides$privacy$en { - _Translations$intro$slides$privacy$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$intro$slides$privacy$pt extends Translations$intro$slides$privacy$en { + Translations$intro$slides$privacy$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1387,8 +1491,8 @@ class _Translations$intro$slides$privacy$pt extends Translations$intro$slides$pr } // Path: intro.slides.share -class _Translations$intro$slides$share$pt extends Translations$intro$slides$share$en { - _Translations$intro$slides$share$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$intro$slides$share$pt extends Translations$intro$slides$share$en { + Translations$intro$slides$share$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1398,8 +1502,8 @@ class _Translations$intro$slides$share$pt extends Translations$intro$slides$shar } // Path: intro.slides.plantare -class _Translations$intro$slides$plantare$pt extends Translations$intro$slides$plantare$en { - _Translations$intro$slides$plantare$pt._(TranslationsPt root) : this._root = root, super.internal(root); +class Translations$intro$slides$plantare$pt extends Translations$intro$slides$plantare$en { + Translations$intro$slides$plantare$pt.internal(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1425,6 +1529,17 @@ extension on TranslationsPt { 'favorites.save' => 'Guardar nos favoritos', 'favorites.remove' => 'Remover dos favoritos', 'favorites.unavailable' => 'Já não está disponível', + 'savedSearches.title' => 'Pesquisas guardadas', + 'savedSearches.empty' => 'Ainda não há pesquisas guardadas. Guarda uma pesquisa no mercado e avisamos-te quando aparecer algo parecido na tua zona.', + 'savedSearches.save' => 'Guardar esta pesquisa', + 'savedSearches.nameLabel' => 'Dá um nome a esta pesquisa', + 'savedSearches.namePlaceholder' => 'ex.: Tomates perto de mim', + 'savedSearches.saved' => 'Pesquisa guardada — vamos avisar-te sobre novas correspondências perto de ti', + 'savedSearches.openTooltip' => 'Pesquisas guardadas', + 'savedSearches.delete' => 'Eliminar', + 'savedSearches.deleteConfirm' => 'Eliminar esta pesquisa guardada?', + 'savedSearches.newMatchesBadge' => ({required Object n}) => '${n} novas', + 'savedSearches.alert' => ({required Object label}) => 'Sementes perto de ti: ${label}', 'seedSaving.title' => 'Guardar a sua semente', 'seedSaving.subtitle' => 'O que é preciso para manter a variedade fiel', 'seedSaving.lifeCycle' => 'Ciclo', @@ -1463,7 +1578,6 @@ extension on TranslationsPt { 'common.delete' => 'Eliminar', 'common.edit' => 'Editar', 'common.type' => 'Tipo', - 'common.comingSoon' => 'Em breve', 'common.offline' => 'Sem ligação — a partilha está em pausa', 'home.tagline' => 'Partilha e cultiva sementes locais', 'home.openMarket' => 'Mercado', @@ -1491,6 +1605,7 @@ extension on TranslationsPt { 'settings.langEs' => 'Español', 'settings.langEn' => 'English', 'settings.langPt' => 'Português', + 'settings.langPtBr' => 'Português (Brasil)', 'settings.langAst' => 'Asturianu', 'settings.langFr' => 'Français', 'settings.langDe' => 'Deutsch', @@ -1596,6 +1711,10 @@ extension on TranslationsPt { 'detail.addLink' => 'Adicionar ligação', 'detail.linkUrl' => 'URL', 'detail.linkTitle' => 'Título (opcional)', + 'detail.reference' => 'Saber mais', + 'detail.refGbif' => 'GBIF', + 'detail.refWikipedia' => 'Wikipédia', + 'detail.refWikispecies' => 'Wikispecies', 'detail.notes' => 'Notas', 'detail.addLot' => 'Adicionar lote', 'detail.editLot' => 'Editar lote', @@ -1696,6 +1815,13 @@ extension on TranslationsPt { 'printLabels.save' => 'Guardar etiquetas', 'printLabels.saved' => 'Etiquetas guardadas', 'printLabels.cancelled' => 'Cancelado', + 'scan.action' => 'Digitalizar um rótulo de semente', + 'scan.title' => 'Digitalizar um rótulo', + 'scan.notALabel' => 'Esse código não é um rótulo de semente', + 'scan.addTitle' => 'Não está na tua coleção', + 'scan.addBody' => ({required Object label}) => 'Adicionar “${label}” às tuas sementes?', + 'scan.add' => 'Adicionar', + 'scan.added' => 'Adicionado à tua coleção', 'cropCalendar.add' => 'Calendário de cultivo', 'cropCalendar.title' => 'Calendário de cultivo', 'cropCalendar.sow' => 'Sementeira', @@ -1798,7 +1924,7 @@ extension on TranslationsPt { 'market.contact' => 'Mensagem', 'market.mine' => 'Tu', 'market.configTitle' => 'Configuração de partilha', - 'market.setupIntro' => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.', + 'market.setupIntro' => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — o que ofereces viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de ti o possam encontrar.', 'market.areaLabel' => 'A tua zona', 'market.areaHelp' => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.', 'market.areaSet' => 'A tua zona está definida — aproximada, nunca o teu ponto exato', @@ -1906,6 +2032,8 @@ extension on TranslationsPt { 'plantare.statusForgiven' => 'Saldado', 'plantare.openSection' => 'Pendentes', 'plantare.settledSection' => 'Feitos', + _ => null, + } ?? switch (path) { 'plantare.removeConfirm' => 'Remover este compromisso?', 'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}', 'plantare.overdue' => 'vencido', @@ -1914,6 +2042,29 @@ extension on TranslationsPt { 'plantare.pickDate' => 'Escolher data', 'plantare.clearDate' => 'Limpar data', 'plantare.sectionTitle' => 'Compromissos', + 'plantare.propose' => 'Propor um Plantaré assinado', + 'plantare.proposeHelp' => 'Ambos mantêm a mesma promessa, assinada pelos dois — prova de que esta semente mudou de mãos e será cultivada e devolvida.', + 'plantare.proposeTo' => ({required Object name}) => 'Com ${name}', + 'plantare.sent' => 'Proposta enviada — a aguardar que assinem', + 'plantare.seedLabel' => 'Que semente?', + 'plantare.seedHint' => 'A variedade a que se refere esta promessa', + 'plantare.returnKindLabel' => 'O que volta?', + 'plantare.returnSimilar' => 'Uma quantidade semelhante de semente', + 'plantare.returnSimilarNote' => 'polinização aberta · não transgénico · cultivado organicamente', + 'plantare.returnWork' => 'Algumas horas de trabalho', + 'plantare.returnOther' => 'Outra coisa', + 'plantare.workHoursLabel' => 'Quantas horas?', + 'plantare.proposalsSection' => 'À espera da tua resposta', + 'plantare.incomingFrom' => ({required Object name}) => '${name} propõe um Plantaré', + 'plantare.accept' => 'Aceitar e assinar', + 'plantare.declineAction' => 'Recusar', + 'plantare.declineReasonHint' => 'Motivo (opcional)', + 'plantare.acceptedToast' => 'Assinado — agora ambos o mantêm', + 'plantare.declinedToast' => 'Recusado', + 'plantare.badgeAwaiting' => 'A aguardar assinatura', + 'plantare.badgeSigned' => 'Assinado por ambos', + 'plantare.badgeDeclined' => 'Recusado', + 'plantare.offline' => 'Estás offline — será enviado quando reconectares', 'handover.title' => 'Dei ou recebi sementes', 'handover.help' => 'Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.', 'handover.iGave' => 'Dei sementes', @@ -1925,11 +2076,38 @@ extension on TranslationsPt { 'handover.paymentChip' => 'Houve dinheiro pelo meio', 'handover.promiseGave' => 'Vão devolver-me semente', 'handover.promiseReceived' => 'Vou devolver semente', + 'history.title' => 'História deste lote', + 'history.tooltip' => 'História', + 'history.sowToday' => 'Semeado hoje', + 'history.harvestToday' => 'Colhido hoje', + 'history.sownRecorded' => 'Sementeira registada', + 'history.harvestRecorded' => 'Colheita registada', + 'history.movementReceived' => 'Recebido', + 'history.movementGiven' => 'Oferecido', + 'history.movementSown' => 'Semeado', + 'history.movementHarvested' => 'Colhido', + 'history.movementGerminationTest' => 'Teste de germinação', + 'history.movementSplit' => 'Dividido em lotes', + 'history.movementDiscarded' => 'Descartado', + 'history.created' => 'Adicionado à tua coleção', + 'history.from' => ({required Object origin}) => 'De ${origin}', + 'history.germinationResult' => ({required Object percent}) => 'Teste de germinação — ${percent}%', + 'history.linkedEarlier' => 'Vem de um lote anterior', + 'history.outcomeQuestion' => 'Como correu?', + 'history.outcomeGood' => 'Bem', + 'history.outcomeMixed' => 'Mais ou menos', + 'history.outcomePoor' => 'Mal', + 'history.outcomeNoteHint' => 'Uma nota para o teu eu futuro (opcional)', + 'history.outcomeSaved' => 'Registado', + 'history.ratedGood' => 'Correu bem', + 'history.ratedMixed' => 'Correu mais ou menos', + 'history.ratedPoor' => 'Correu mal', + 'history.outcomeTitle' => 'Nota da estação', + 'history.outcomeYear' => ({required Object year}) => 'Estação ${year}', + 'history.isolationHint' => ({required Object meters}) => 'Esta espécie cruza-se com vizinhas próximas — cultiva-a a cerca de ${meters} m de distância de outras para a manter pura', 'sale.title' => 'Vendas', 'sale.help' => 'Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.', 'sale.add' => 'Registar venda', - _ => null, - } ?? switch (path) { 'sale.empty' => 'Ainda não há vendas. Anota aqui o que vendes ou compras.', 'sale.iSold' => 'Vendi', 'sale.iBought' => 'Comprei', diff --git a/apps/app_seeds/lib/i18n/strings_pt_BR.g.dart b/apps/app_seeds/lib/i18n/strings_pt_BR.g.dart new file mode 100644 index 0000000..9e965a6 --- /dev/null +++ b/apps/app_seeds/lib/i18n/strings_pt_BR.g.dart @@ -0,0 +1,2168 @@ +/// +/// Generated file. Do not edit. +/// +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import +// dart format off + +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:slang/generated.dart'; +import 'strings.g.dart'; +import 'strings_pt.g.dart'; + +// Path: +class TranslationsPtBr extends TranslationsPt with BaseTranslations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + TranslationsPtBr({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = meta ?? TranslationMetadata( + locale: AppLocale.ptBr, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); + + late final TranslationsPtBr _root = this; // ignore: unused_field + + @override + TranslationsPtBr $copyWith({TranslationMetadata? meta}) => TranslationsPtBr(meta: meta ?? this.$meta); + + // Translations + @override late final _Translations$avatar$pt_BR avatar = _Translations$avatar$pt_BR._(_root); + @override late final _Translations$favorites$pt_BR favorites = _Translations$favorites$pt_BR._(_root); + @override late final _Translations$savedSearches$pt_BR savedSearches = _Translations$savedSearches$pt_BR._(_root); + @override late final _Translations$seedSaving$pt_BR seedSaving = _Translations$seedSaving$pt_BR._(_root); + @override late final _Translations$calendar$pt_BR calendar = _Translations$calendar$pt_BR._(_root); + @override late final _Translations$app$pt_BR app = _Translations$app$pt_BR._(_root); + @override late final _Translations$bootstrap$pt_BR bootstrap = _Translations$bootstrap$pt_BR._(_root); + @override late final _Translations$common$pt_BR common = _Translations$common$pt_BR._(_root); + @override late final _Translations$home$pt_BR home = _Translations$home$pt_BR._(_root); + @override late final _Translations$photo$pt_BR photo = _Translations$photo$pt_BR._(_root); + @override late final _Translations$menu$pt_BR menu = _Translations$menu$pt_BR._(_root); + @override late final _Translations$settings$pt_BR settings = _Translations$settings$pt_BR._(_root); + @override late final _Translations$backup$pt_BR backup = _Translations$backup$pt_BR._(_root); + @override late final _Translations$about$pt_BR about = _Translations$about$pt_BR._(_root); + @override late final _Translations$intro$pt_BR intro = _Translations$intro$pt_BR._(_root); + @override late final _Translations$inventory$pt_BR inventory = _Translations$inventory$pt_BR._(_root); + @override late final _Translations$draft$pt_BR draft = _Translations$draft$pt_BR._(_root); + @override late final _Translations$quickAdd$pt_BR quickAdd = _Translations$quickAdd$pt_BR._(_root); + @override late final _Translations$detail$pt_BR detail = _Translations$detail$pt_BR._(_root); + @override late final _Translations$germination$pt_BR germination = _Translations$germination$pt_BR._(_root); + @override late final _Translations$viability$pt_BR viability = _Translations$viability$pt_BR._(_root); + @override late final _Translations$editVariety$pt_BR editVariety = _Translations$editVariety$pt_BR._(_root); + @override late final _Translations$addLot$pt_BR addLot = _Translations$addLot$pt_BR._(_root); + @override late final _Translations$harvest$pt_BR harvest = _Translations$harvest$pt_BR._(_root); + @override late final _Translations$lotType$pt_BR lotType = _Translations$lotType$pt_BR._(_root); + @override late final _Translations$presentation$pt_BR presentation = _Translations$presentation$pt_BR._(_root); + @override late final _Translations$provenance$pt_BR provenance = _Translations$provenance$pt_BR._(_root); + @override late final _Translations$abundance$pt_BR abundance = _Translations$abundance$pt_BR._(_root); + @override late final _Translations$share$pt_BR share = _Translations$share$pt_BR._(_root); + @override late final _Translations$printLabels$pt_BR printLabels = _Translations$printLabels$pt_BR._(_root); + @override late final _Translations$scan$pt_BR scan = _Translations$scan$pt_BR._(_root); + @override late final _Translations$cropCalendar$pt_BR cropCalendar = _Translations$cropCalendar$pt_BR._(_root); + @override late final _Translations$needsReproduction$pt_BR needsReproduction = _Translations$needsReproduction$pt_BR._(_root); + @override late final _Translations$preservation$pt_BR preservation = _Translations$preservation$pt_BR._(_root); + @override late final _Translations$conditionCheck$pt_BR conditionCheck = _Translations$conditionCheck$pt_BR._(_root); + @override late final _Translations$desiccant$pt_BR desiccant = _Translations$desiccant$pt_BR._(_root); + @override late final _Translations$unit$pt_BR unit = _Translations$unit$pt_BR._(_root); + @override late final _Translations$market$pt_BR market = _Translations$market$pt_BR._(_root); + @override late final _Translations$profile$pt_BR profile = _Translations$profile$pt_BR._(_root); + @override late final _Translations$chatList$pt_BR chatList = _Translations$chatList$pt_BR._(_root); + @override late final _Translations$chat$pt_BR chat = _Translations$chat$pt_BR._(_root); + @override late final _Translations$trust$pt_BR trust = _Translations$trust$pt_BR._(_root); + @override late final _Translations$yourPeople$pt_BR yourPeople = _Translations$yourPeople$pt_BR._(_root); + @override late final _Translations$ratings$pt_BR ratings = _Translations$ratings$pt_BR._(_root); + @override late final _Translations$notifications$pt_BR notifications = _Translations$notifications$pt_BR._(_root); + @override late final _Translations$plantare$pt_BR plantare = _Translations$plantare$pt_BR._(_root); + @override late final _Translations$handover$pt_BR handover = _Translations$handover$pt_BR._(_root); + @override late final _Translations$history$pt_BR history = _Translations$history$pt_BR._(_root); + @override late final _Translations$sale$pt_BR sale = _Translations$sale$pt_BR._(_root); + @override late final _Translations$legal$pt_BR legal = _Translations$legal$pt_BR._(_root); + @override late final _Translations$marketGate$pt_BR marketGate = _Translations$marketGate$pt_BR._(_root); + @override late final _Translations$report$pt_BR report = _Translations$report$pt_BR._(_root); + @override late final _Translations$block$pt_BR block = _Translations$block$pt_BR._(_root); +} + +// Path: avatar +class _Translations$avatar$pt_BR extends Translations$avatar$pt { + _Translations$avatar$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'A sua foto ou avatar'; + @override String get fromPhoto => 'Tirar ou escolher uma foto'; + @override String get illustration => 'Ou escolhe um desenho'; + @override String get remove => 'Remover'; +} + +// Path: favorites +class _Translations$favorites$pt_BR extends Translations$favorites$pt { + _Translations$favorites$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Favoritos'; + @override String get empty => 'Ainda não tem favoritos. Salve ofertas que goste do mercado.'; + @override String get save => 'Salvar nos favoritos'; + @override String get remove => 'Remover dos favoritos'; + @override String get unavailable => 'Já não está disponível'; +} + +// Path: savedSearches +class _Translations$savedSearches$pt_BR extends Translations$savedSearches$pt { + _Translations$savedSearches$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Pesquisas salvas'; + @override String get empty => 'Ainda não há pesquisas salvas. Salve uma pesquisa no mercado e te avisamos quando aparecer algo parecido na sua zona.'; + @override String get save => 'Salvar esta pesquisa'; + @override String get nameLabel => 'Dá um nome a esta pesquisa'; + @override String get namePlaceholder => 'ex.: Tomates perto de mim'; + @override String get saved => 'Pesquisa salva — vamos te avisar sobre novas correspondências perto de você'; + @override String get openTooltip => 'Pesquisas salvas'; + @override String get delete => 'Eliminar'; + @override String get deleteConfirm => 'Eliminar esta pesquisa salva?'; + @override String newMatchesBadge({required Object n}) => '${n} novas'; + @override String alert({required Object label}) => 'Sementes perto de você: ${label}'; +} + +// Path: seedSaving +class _Translations$seedSaving$pt_BR extends Translations$seedSaving$pt { + _Translations$seedSaving$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Guardar a sua semente'; + @override String get subtitle => 'O que é preciso para manter a variedade fiel'; + @override String get lifeCycle => 'Ciclo'; + @override String get cycleAnnual => 'Anual'; + @override String get cycleBiennial => 'Bienal — dá semente no 2.º ano'; + @override String get cyclePerennial => 'Perene'; + @override String get pollination => 'Polinização'; + @override String get pollSelf => 'Autopoliniza-se'; + @override String get pollCross => 'Cruza-se com outras'; + @override String get pollMixed => 'Autopoliniza-se, mas às vezes cruza'; + @override String get byInsect => 'por insetos'; + @override String get byWind => 'pelo vento'; + @override String get isolation => 'Separe-a'; + @override String isolationRange({required Object min, required Object max}) => '${min}–${max} m de outras variedades'; + @override String isolationSingle({required Object min}) => '${min} m de outras variedades'; + @override String get plants => 'Guarde de várias plantas'; + @override String plantsValue({required Object n}) => 'De pelo menos ${n} plantas'; + @override String get processing => 'Como limpá-la'; + @override String get procDry => 'Semente seca (debulhar)'; + @override String get procWet => 'Semente húmida (fermentar e lavar)'; + @override String get difficulty => 'Dificuldade'; + @override String get diffEasy => 'Fácil'; + @override String get diffMedium => 'Média'; + @override String get diffHard => 'Difícil'; + @override String get advisory => 'Orientativo — adapte-o ao seu clima e variedade.'; + @override String get sourcePrefix => 'Fonte'; +} + +// Path: calendar +class _Translations$calendar$pt_BR extends Translations$calendar$pt { + _Translations$calendar$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Este mês'; + @override String get filterChip => 'Este mês'; + @override String get selfNote => 'O que você anotou nas suas variedades.'; + @override String nothing({required Object month}) => 'Nada anotado para ${month}.'; +} + +// Path: app +class _Translations$app$pt_BR extends Translations$app$pt { + _Translations$app$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Tane'; +} + +// Path: bootstrap +class _Translations$bootstrap$pt_BR extends Translations$bootstrap$pt { + _Translations$bootstrap$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get failed => 'O Tane não conseguiu iniciar'; + @override String get retry => 'Tentar de novo'; +} + +// Path: common +class _Translations$common$pt_BR extends Translations$common$pt { + _Translations$common$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get save => 'Salvar'; + @override String get cancel => 'Cancelar'; + @override String get delete => 'Eliminar'; + @override String get edit => 'Editar'; + @override String get type => 'Tipo'; + @override String get offline => 'Sem ligação — a compartilha está em pausa'; +} + +// Path: home +class _Translations$home$pt_BR extends Translations$home$pt { + _Translations$home$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get tagline => 'Compartilha e cultiva sementes locais'; + @override String get openMarket => 'Mercado'; + @override String get openMarketSubtitle => 'Descobre e compartilha sementes por perto'; + @override String get yourInventory => 'O seu inventário'; + @override String get yourInventorySubtitle => 'Gere as suas sementes'; +} + +// Path: photo +class _Translations$photo$pt_BR extends Translations$photo$pt { + _Translations$photo$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get camera => 'Tirar uma foto'; + @override String get gallery => 'Escolher da galeria'; + @override String get setAsCover => 'Usar como capa'; + @override String get isCover => 'Foto de capa'; + @override String get deleteConfirm => 'Eliminar esta foto?'; +} + +// Path: menu +class _Translations$menu$pt_BR extends Translations$menu$pt { + _Translations$menu$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get tagline => 'o seu banco de sementes'; + @override String get inventory => 'Inventário'; + @override String get market => 'Mercado'; + @override String get profile => 'O seu perfil'; + @override String get chat => 'Conversas'; + @override String get wishlist => 'Favoritos'; + @override String get following => 'A seguir'; + @override String get plantares => 'Plantares'; + @override String get sales => 'Vendas'; + @override String get calendar => 'Calendário'; + @override String get settings => 'Definições'; +} + +// Path: settings +class _Translations$settings$pt_BR extends Translations$settings$pt { + _Translations$settings$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get language => 'Idioma'; + @override String get systemLanguage => 'Idioma do sistema'; + @override String get langEs => 'Español'; + @override String get langEn => 'English'; + @override String get langPt => 'Português'; + @override String get langPtBr => 'Português (Brasil)'; + @override String get langAst => 'Asturianu'; + @override String get langFr => 'Français'; + @override String get langDe => 'Deutsch'; + @override String get langJa => '日本語'; + @override String get about => 'Acerca de'; + @override String get aboutText => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.'; + @override String get aboutOpen => 'Acerca do Tane'; +} + +// Path: backup +class _Translations$backup$pt_BR extends Translations$backup$pt { + _Translations$backup$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Cópia de segurança'; + @override String get autoBackupTitle => 'Cópias automáticas'; + @override String autoBackupLast({required Object date, required Object days}) => 'Última cópia em ${date} · a cada ${days} dias'; + @override String autoBackupNone({required Object days}) => 'Uma cópia é salva automaticamente a cada ${days} dias'; + @override String get exportJson => 'Salvar uma cópia de segurança'; + @override String get exportJsonSubtitle => 'Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho'; + @override String get importJson => 'Restaurar uma cópia'; + @override String get importJsonSubtitle => 'Recupera uma cópia salva — nada fica duplicado'; + @override String get exportCsv => 'Exportar para uma folha de cálculo'; + @override String get exportCsvSubtitle => 'Uma lista simples para Excel ou LibreOffice — sem fotos'; + @override String get importCsv => 'Importar uma lista'; + @override String get importCsvSubtitle => 'Acrescenta entradas a partir de uma folha de cálculo'; + @override String get importConfirmTitle => 'Restaurar uma cópia?'; + @override String get importConfirmBody => 'As entradas fundem-se com o seu inventário; quando a mesma entrada existe dos dois lados, vence a versão mais recente. Nada fica duplicado.'; + @override String get importCsvConfirmTitle => 'Importar uma lista?'; + @override String get importCsvConfirmBody => 'Cada linha é acrescentada como uma entrada nova. Não funde nem substitui, por isso importar o mesmo arquivo duas vezes acrescenta-o duas vezes.'; + @override String get importAction => 'Importar'; + @override String get exportSaved => 'Cópia salva'; + @override String get cancelled => 'Cancelado'; + @override String importDone({required Object added, required Object updated}) => 'Importado: ${added} novas, ${updated} atualizadas'; + @override String importCsvDone({required Object count}) => '${count} entradas acrescentadas'; + @override String get importFailed => 'Este arquivo não pôde ser lido como uma cópia do Tane'; + @override String get failed => 'Algo correu mal'; + @override String get recoveryTitle => 'O seu código de recuperação'; + @override String get recoverySubtitle => 'Imprime-o e guarda-o bem: abre as suas cópias noutro aparelho'; + @override String get recoveryIntro => 'Este código abre as suas cópias salvas e recupera o seu banco em qualquer aparelho. Guarde duas cópias em papel em lugares seguros, como a sua melhor semente. Quem o tiver pode ler as suas cópias, por isso não o compartilhe com ninguém.'; + @override String get recoveryCopy => 'Copiar'; + @override String get recoverySave => 'Salvar a folha'; + @override String get recoverySheetTitle => 'Tane — a sua folha de recuperação'; + @override String get recoveryPromptTitle => 'Escreve o seu código de recuperação'; + @override String get recoveryPromptBody => 'Esta cópia foi salva com outro código. Escreve o código da sua folha de recuperação para a abrir.'; + @override String get recoveryWrongCode => 'Esse código não abre esta cópia'; +} + +// Path: about +class _Translations$about$pt_BR extends Translations$about$pt { + _Translations$about$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Acerca de'; + @override String get kanji => '種'; + @override String get tagline => 'Uma aplicação local e descentralizada para gerir e compartilhar sementes e plântulas tradicionais.'; + @override String get intro => 'O Tane (種, "semente" em japonês) ajuda pessoas e coletivos a manter um inventário amigável do seu banco de sementes, decidir o que oferecem e compartilhá-lo localmente — sem um intermediário central que possa controlar, censurar ou ser multado por isso. O seu nome vem de tanemaki (種まき), "semear / espalhar sementes". O objetivo é prático e político ao mesmo tempo: apoiar as variedades tradicionais e fazer frente ao monopólio das sementes.'; + @override String get heritage => 'O nome honra as antigas tradições japonesas de ajuda mútua à volta do arroz — yui (trabalho comunitário compartilhado) e tanomoshi (fundos de reciprocidade) — que inspiraram o Plantare em papel, a "moeda comunitária de troca de sementes" (BAH-Semillero, 2009, CC-BY-SA). O Tane é o Plantare digital.'; + @override String get version => 'Versão'; + @override String get license => 'Licença'; + @override String get licenseValue => 'AGPL-3.0'; + @override String get website => 'Sítio web'; + @override String get sourceCode => 'Código-fonte'; + @override String get translate => 'Ajuda a traduzir'; + @override String get translateSubtitle => 'Ajuda a trazer o Tane para o seu idioma'; + @override String get openSourceLicenses => 'Licenças de código aberto'; + @override String get openSourceLicensesSubtitle => 'Bibliotecas de terceiros e as suas licenças'; + @override String copyright({required Object years}) => '© ${years} Associação Comunes, sob AGPLv3'; +} + +// Path: intro +class _Translations$intro$pt_BR extends Translations$intro$pt { + _Translations$intro$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get skip => 'Saltar'; + @override String get next => 'Seguinte'; + @override String get start => 'Começar'; + @override String get menuEntry => 'Como funciona o Tane'; + @override late final _Translations$intro$slides$pt_BR slides = _Translations$intro$slides$pt_BR._(_root); +} + +// Path: inventory +class _Translations$inventory$pt_BR extends Translations$inventory$pt { + _Translations$inventory$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Inventário'; + @override String get searchHint => 'Procurar sementes'; + @override String get empty => 'Ainda não há sementes. Toca em + para adicionar a primeira.'; + @override String get noMatches => 'Nenhuma semente corresponde aos seus filtros.'; + @override String get clearFilters => 'Limpar filtros'; + @override String get uncategorized => 'Sem categoria'; + @override String get needsReproductionFilter => 'Para reproduzir'; + @override String get loadError => 'Não foi possível abrir o seu banco de sementes. Talvez estivesse ocupado — tenta de novo.'; + @override String get retry => 'Tentar de novo'; +} + +// Path: draft +class _Translations$draft$pt_BR extends Translations$draft$pt { + _Translations$draft$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get capture => 'Capturar fotos'; + @override String captured({required Object n}) => '${n} capturadas para catalogar'; + @override String get triageTitle => 'Para catalogar'; + @override String triageCount({required Object n}) => '${n} para catalogar'; + @override String get untitled => 'Sem nome'; + @override String get nameField => 'Dá um nome a esta semente'; + @override String get nameHint => 'O que é?'; + @override String get suggestFromPhoto => 'Sugerir nome a partir da foto'; + @override String get discard => 'Descartar'; +} + +// Path: quickAdd +class _Translations$quickAdd$pt_BR extends Translations$quickAdd$pt { + _Translations$quickAdd$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Adicionar uma semente'; + @override String get labelField => 'Nome'; + @override String get labelRequired => 'Dá-lhe um nome'; + @override String get addPhoto => 'Adicionar foto'; + @override String get quantity => 'Quanto?'; + @override String get more => 'Adicionar mais…'; + @override String get save => 'Salvar'; + @override String get saveAndAddAnother => 'Salvar e adicionar outra'; + @override String addedCount({required Object n}) => '${n} adicionadas'; + @override String get cancel => 'Cancelar'; +} + +// Path: detail +class _Translations$detail$pt_BR extends Translations$detail$pt { + _Translations$detail$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get notFound => 'Esta semente já não está aqui.'; + @override String get lots => 'Lotes'; + @override String get noLots => 'Ainda não há lotes.'; + @override String get names => 'Também conhecida como'; + @override String get addName => 'Adicionar nome'; + @override String get links => 'Ligações'; + @override String get addLink => 'Adicionar ligação'; + @override String get linkUrl => 'URL'; + @override String get linkTitle => 'Título (opcional)'; + @override String get reference => 'Saber mais'; + @override String get refGbif => 'GBIF'; + @override String get refWikipedia => 'Wikipédia'; + @override String get refWikispecies => 'Wikispecies'; + @override String get notes => 'Notas'; + @override String get addLot => 'Adicionar lote'; + @override String get editLot => 'Editar lote'; + @override String get deleteConfirm => 'Eliminar esta semente?'; + @override String year({required Object year}) => 'Ano ${year}'; + @override String get noYear => 'Ano desconhecido'; +} + +// Path: germination +class _Translations$germination$pt_BR extends Translations$germination$pt { + _Translations$germination$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Germinação'; + @override String get add => 'Adicionar teste'; + @override String get sampleSize => 'Tamanho da amostra'; + @override String get germinated => 'Germinadas'; + @override String get none => 'Ainda não há testes de germinação.'; + @override String result({required Object percent}) => '${percent}%'; +} + +// Path: viability +class _Translations$viability$pt_BR extends Translations$viability$pt { + _Translations$viability$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get expiringSoon => 'Usa ou reproduz nesta época'; + @override String expiringSoonYears({required Object years}) => 'Usa ou reproduz nesta época · dura ~${years} anos'; + @override String get expired => 'Passou a viabilidade típica — reproduzir'; + @override String expiredYears({required Object years}) => 'Passou a viabilidade típica (~${years} anos) — reproduzir'; +} + +// Path: editVariety +class _Translations$editVariety$pt_BR extends Translations$editVariety$pt { + _Translations$editVariety$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Editar semente'; + @override String get name => 'Nome'; + @override String get category => 'Categoria'; + @override String get notes => 'Notas'; + @override String get species => 'Espécie (do catálogo)'; + @override String get speciesHint => 'Procura uma espécie…'; + @override String get speciesSuggested => 'Sugerida pelo nome'; + @override String get organic => 'Biológica'; + @override String get organicHint => 'Cultivada em modo biológico'; +} + +// Path: addLot +class _Translations$addLot$pt_BR extends Translations$addLot$pt { + _Translations$addLot$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Adicionar lote'; + @override String get year => 'Data de colheita'; + @override String get quantity => 'Quanto?'; + @override String get amount => 'Quantidade'; +} + +// Path: harvest +class _Translations$harvest$pt_BR extends Translations$harvest$pt { + _Translations$harvest$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get pickTitle => 'Escolhe mês / ano'; + @override String get anyMonth => 'Qualquer mês'; + @override String get noDate => 'Definir data de colheita'; + @override List get monthNames => [ + 'janeiro', + 'fevereiro', + 'março', + 'abril', + 'maio', + 'junho', + 'julho', + 'agosto', + 'setembro', + 'outubro', + 'novembro', + 'dezembro', + ]; +} + +// Path: lotType +class _Translations$lotType$pt_BR extends Translations$lotType$pt { + _Translations$lotType$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get seed => 'Sementes'; + @override String get plant => 'Planta'; + @override String get seedling => 'Plântula'; + @override String get tree => 'Árvore / arbusto'; + @override String get bulb => 'Bolbo / tubérculo'; + @override String get cutting => 'Estaca'; +} + +// Path: presentation +class _Translations$presentation$pt_BR extends Translations$presentation$pt { + _Translations$presentation$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Apresentação'; + @override String get none => 'Sem indicar'; + @override String get pot => 'Vaso'; + @override String get tray => 'Tabuleiro'; + @override String get plug => 'Alvéolo'; + @override String get bareRoot => 'Raiz nua'; + @override String get rootBall => 'Torrão'; +} + +// Path: provenance +class _Translations$provenance$pt_BR extends Translations$provenance$pt { + _Translations$provenance$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get section => 'De onde vem'; + @override String get seedsFrom => 'Sementes de'; + @override String get seedsFromHint => 'Quem as cultivou ou ofereceu'; + @override String get place => 'Lugar'; + @override String get placeHint => 'De onde vêm (com a região)'; + @override String get addSeedsFrom => 'Sementes de'; + @override String get addPlace => 'Lugar'; +} + +// Path: abundance +class _Translations$abundance$pt_BR extends Translations$abundance$pt { + _Translations$abundance$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get add => 'Quanta tenho'; + @override String get title => 'Quanta tenho'; + @override String get none => 'Sem indicar'; + @override String get plentyToShare => 'De sobra para compartilhar'; + @override String get enoughToShare => 'Bastante, para compartilhar com moderação'; + @override String get enoughForMe => 'Suficiente para mim'; + @override String get runningLow => 'Resta pouca'; +} + +// Path: share +class _Translations$share$pt_BR extends Translations$share$pt { + _Translations$share$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get add => 'Compartilhas esta?'; + @override String get title => 'Compartilhas esta?'; + @override String get nudge => 'Tem de sobra: podia compartilhar um pouco.'; + @override String get price => 'Preço'; + @override String get priceHint => 'Deixe vazio para combinar depois'; + @override String get private => 'Só para mim'; + @override String get gift => 'Para dar'; + @override String get exchange => 'Para trocar'; + @override String get sell => 'À venda'; + @override String get filterChip => 'Compartilho'; + @override String get printCatalog => 'Imprimir o que compartilho'; + @override String get catalogTitle => 'O que compartilho'; + @override String get catalogSaved => 'Catálogo salvo'; + @override String get cancelled => 'Cancelado'; +} + +// Path: printLabels +class _Translations$printLabels$pt_BR extends Translations$printLabels$pt { + _Translations$printLabels$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get action => 'Imprimir etiquetas'; + @override String get title => 'Imprimir etiquetas'; + @override String get selectHint => 'Escolhe as sementes para imprimir as etiquetas'; + @override String get selectAll => 'Selecionar tudo'; + @override String selected({required Object n}) => '${n} selecionadas'; + @override String get none => 'Seleciona sementes primeiro'; + @override String get format => 'Tamanho da etiqueta'; + @override String get formatStickers => 'Autocolantes pequenos'; + @override String get formatStickersHint => 'Muitas etiquetas pequenas por folha — para colar em envelopes'; + @override String get formatCards => 'Cartões grandes'; + @override String get formatCardsHint => 'Menos etiquetas, maiores — para frascos e caixas'; + @override String count({required Object n}) => '${n} etiquetas'; + @override String get save => 'Salvar etiquetas'; + @override String get saved => 'Etiquetas salvas'; + @override String get cancelled => 'Cancelado'; +} + +// Path: scan +class _Translations$scan$pt_BR extends Translations$scan$pt { + _Translations$scan$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get action => 'Digitalizar um rótulo de semente'; + @override String get title => 'Digitalizar um rótulo'; + @override String get notALabel => 'Esse código não é um rótulo de semente'; + @override String get addTitle => 'Não está na sua coleção'; + @override String addBody({required Object label}) => 'Adicionar “${label}” às suas sementes?'; + @override String get add => 'Adicionar'; + @override String get added => 'Adicionado à sua coleção'; +} + +// Path: cropCalendar +class _Translations$cropCalendar$pt_BR extends Translations$cropCalendar$pt { + _Translations$cropCalendar$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get add => 'Calendário de cultivo'; + @override String get title => 'Calendário de cultivo'; + @override String get sow => 'Sementeira'; + @override String get transplant => 'Transplante'; + @override String get flowering => 'Floração'; + @override String get fruiting => 'Frutificação'; + @override String get seedHarvest => 'Colheita de semente'; + @override String get editorHint => 'Anote os meses típicos desta variedade na sua zona — são suas notas.'; + @override String get unset => '—'; +} + +// Path: needsReproduction +class _Translations$needsReproduction$pt_BR extends Translations$needsReproduction$pt { + _Translations$needsReproduction$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get label => 'Reproduzir nesta época'; + @override String get hint => 'Cultiva-a antes que a semente acabe'; + @override String get badge => 'Por reproduzir'; +} + +// Path: preservation +class _Translations$preservation$pt_BR extends Translations$preservation$pt { + _Translations$preservation$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get add => 'Como está guardada'; + @override String get title => 'Como está guardada'; + @override String get none => 'Sem indicar'; + @override String get jarWithDesiccant => 'Frasco com agente secante'; + @override String get glassJar => 'Frasco de vidro'; + @override String get paperEnvelope => 'Envelope de papel'; + @override String get paperBag => 'Saco de papel'; + @override String get plasticBag => 'Saco de plástico'; +} + +// Path: conditionCheck +class _Translations$conditionCheck$pt_BR extends Translations$conditionCheck$pt { + _Translations$conditionCheck$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get advanced => 'Armazenamento e detalhes de banco de sementes'; + @override String get title => 'Verificações de armazenamento'; + @override String get add => 'Adicionar verificação'; + @override String get containers => 'Frascos / recipientes'; + @override String get desiccant => 'Agente secante'; + @override String get none => 'Ainda não há verificações de armazenamento.'; + @override String summary({required Object count, required Object state}) => '${count} frasco(s) · ${state}'; +} + +// Path: desiccant +class _Translations$desiccant$pt_BR extends Translations$desiccant$pt { + _Translations$desiccant$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get none => 'Nenhum'; + @override String get add => 'Adicionar'; + @override String get replace => 'Substituir'; + @override String get dry => 'Azul — seco'; + @override String get fresh => 'Acabado de renovar'; +} + +// Path: unit +class _Translations$unit$pt_BR extends Translations$unit$pt { + _Translations$unit$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get aFew => 'algumas'; + @override String get some => 'bastantes'; + @override String get plenty => 'muitas'; + @override String get pinch => 'uma pitada'; + @override late final _Translations$unit$handful$pt_BR handful = _Translations$unit$handful$pt_BR._(_root); + @override late final _Translations$unit$teaspoon$pt_BR teaspoon = _Translations$unit$teaspoon$pt_BR._(_root); + @override late final _Translations$unit$spoon$pt_BR spoon = _Translations$unit$spoon$pt_BR._(_root); + @override late final _Translations$unit$cup$pt_BR cup = _Translations$unit$cup$pt_BR._(_root); + @override late final _Translations$unit$jar$pt_BR jar = _Translations$unit$jar$pt_BR._(_root); + @override late final _Translations$unit$sack$pt_BR sack = _Translations$unit$sack$pt_BR._(_root); + @override late final _Translations$unit$packet$pt_BR packet = _Translations$unit$packet$pt_BR._(_root); + @override late final _Translations$unit$cob$pt_BR cob = _Translations$unit$cob$pt_BR._(_root); + @override late final _Translations$unit$pod$pt_BR pod = _Translations$unit$pod$pt_BR._(_root); + @override late final _Translations$unit$ear$pt_BR ear = _Translations$unit$ear$pt_BR._(_root); + @override late final _Translations$unit$head$pt_BR head = _Translations$unit$head$pt_BR._(_root); + @override late final _Translations$unit$fruit$pt_BR fruit = _Translations$unit$fruit$pt_BR._(_root); + @override late final _Translations$unit$bulb$pt_BR bulb = _Translations$unit$bulb$pt_BR._(_root); + @override late final _Translations$unit$tuber$pt_BR tuber = _Translations$unit$tuber$pt_BR._(_root); + @override late final _Translations$unit$seedHead$pt_BR seedHead = _Translations$unit$seedHead$pt_BR._(_root); + @override late final _Translations$unit$bunch$pt_BR bunch = _Translations$unit$bunch$pt_BR._(_root); + @override late final _Translations$unit$plant$pt_BR plant = _Translations$unit$plant$pt_BR._(_root); + @override late final _Translations$unit$pot$pt_BR pot = _Translations$unit$pot$pt_BR._(_root); + @override late final _Translations$unit$tray$pt_BR tray = _Translations$unit$tray$pt_BR._(_root); + @override late final _Translations$unit$seedling$pt_BR seedling = _Translations$unit$seedling$pt_BR._(_root); + @override late final _Translations$unit$tree$pt_BR tree = _Translations$unit$tree$pt_BR._(_root); + @override late final _Translations$unit$cutting$pt_BR cutting = _Translations$unit$cutting$pt_BR._(_root); + @override late final _Translations$unit$grams$pt_BR grams = _Translations$unit$grams$pt_BR._(_root); + @override late final _Translations$unit$count$pt_BR count = _Translations$unit$count$pt_BR._(_root); +} + +// Path: market +class _Translations$market$pt_BR extends Translations$market$pt { + _Translations$market$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Sementes perto de você'; + @override String get subtitle => 'O que outras pessoas compartilham por perto'; + @override String get notSetUp => 'Ainda não configurou a compartilha'; + @override String get notSetUpBody => 'Ativa a compartilha para ver e dar sementes a pessoas por perto. Mantém-se aproximado — a sua zona, nunca a sua morada exata.'; + @override String get setUp => 'Configurar a compartilha'; + @override String get cantReach => 'Não é possível ligar aos servidores neste momento'; + @override String get cantReachBody => 'Tenta de novo, ou verifica os servidores na configuração avançada.'; + @override String get retry => 'Tentar de novo'; + @override String get setArea => 'Indica a sua zona'; + @override String get setAreaBody => 'Diz ao mercado a sua zona aproximada para ver sementes por perto.'; + @override String get searching => 'A procurar pela sua zona…'; + @override String get empty => 'Ainda não há sementes compartilhadas perto de você'; + @override String get searchHint => 'Procurar nestas sementes'; + @override String get noMatches => 'Nenhuma semente compartilhada corresponde à procura'; + @override String get near => 'Perto de você'; + @override String get contact => 'Mensagem'; + @override String get mine => 'Você'; + @override String get configTitle => 'Configuração de compartilha'; + @override String get setupIntro => 'Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — o que você oferece viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de você possam encontrar.'; + @override String get areaLabel => 'A sua zona'; + @override String get areaHelp => 'Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.'; + @override String get areaSet => 'A sua zona está definida — aproximada, nunca o seu ponto exato'; + @override String get areaNotSet => 'Zona por definir — usa a sua localização, ou adiciona um código em Avançado'; + @override String get advanced => 'Avançado'; + @override String get areaCodeLabel => 'Código de zona'; + @override String get areaCodeHint => 'Um código curto como sp3e9 — não um nome de lugar'; + @override String get serversLabel => 'Servidores da comunidade'; + @override String get serversHelp => 'Escolha que servidores usar. Deixe assim se não souber.'; + @override String get serversAdvanced => 'Adicionar outro servidor'; + @override String get serverAddress => 'Endereço do servidor'; + @override String get serverInvalid => 'Introduz um endereço válido (wss://…)'; + @override String get save => 'Salvar'; + @override String get saved => 'Salvo'; + @override String get wanted => 'Procuro'; + @override String get shareMine => 'Compartilhar as minhas sementes'; + @override String sharedCount({required Object n}) => 'Compartilhadas ${n} sementes'; + @override String get nothingToShare => 'Marca primeiro algumas sementes para dar, trocar ou vender'; + @override String get useLocation => 'Usar a minha localização aproximada'; + @override String get locationFailed => 'Não foi possível obter a sua localização — verifica se a localização está ativa e a permissão concedida'; + @override String get queued => 'Salvo — vamos compartilhá-las quando você tiver conexão'; + @override String get shareFailed => 'Não foi possível contactar os servidores — as suas sementes não foram compartilhadas. Tenta de novo daqui a pouco.'; + @override String get rangeLabel => 'Até onde procurar'; + @override String get rangeNear => 'Muito perto'; + @override String get rangeArea => 'Pela minha zona'; + @override String get rangeRegion => 'A minha região'; + @override String get sharedBy => 'Compartilhado por'; + @override String get noProfile => 'Esta pessoa ainda não compartilhou o seu perfil'; + @override String get copyId => 'Copiar código'; + @override String get idCopied => 'Código copiado'; + @override String get photo => 'Foto'; +} + +// Path: profile +class _Translations$profile$pt_BR extends Translations$profile$pt { + _Translations$profile$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'O seu perfil'; + @override String get name => 'Nome'; + @override String get nameHint => 'Como os outros te veem'; + @override String get about => 'Sobre você'; + @override String get aboutHint => 'Uma linha — o que cultivas, onde'; + @override String get g1 => 'Endereço Ğ1 (opcional)'; + @override String get g1Hint => 'Para que te paguem em Ğ1 — separado da sua chave'; + @override String get yourId => 'A sua identidade'; + @override String get idHelp => 'Compartilha-a para que te reconheçam'; + @override String get copy => 'Copiar'; + @override String get copied => 'Copiado'; + @override String get save => 'Salvar'; + @override String get saved => 'Perfil salvo'; + @override String get identities => 'As suas identidades'; + @override String get identitiesHelp => 'Mantém identidades separadas — cada uma com as suas mensagens e contactos. Todas vêm da sua única cópia de segurança, por isso mudar não acrescenta nada para lembrar.'; + @override String identityLabel({required Object n}) => 'Identidade ${n}'; + @override String get current => 'Em uso'; + @override String get newIdentity => 'Nova identidade'; + @override String get switchTitle => 'Mudar de identidade?'; + @override String get switchBody => 'As mensagens e contatos são guardados separadamente para cada identidade. Pode voltar quando quiser.'; + @override String get switchAction => 'Mudar'; +} + +// Path: chatList +class _Translations$chatList$pt_BR extends Translations$chatList$pt { + _Translations$chatList$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Mensagens'; + @override String get empty => 'Ainda não há conversas. Escreve a alguém a partir do mercado.'; +} + +// Path: chat +class _Translations$chat$pt_BR extends Translations$chat$pt { + _Translations$chat$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Conversa'; + @override String get hint => 'Escreve uma mensagem…'; + @override String get send => 'Enviar'; + @override String get empty => 'Ainda não há mensagens — diz olá'; + @override String get offline => 'Configura a compartilha para enviar mensagens'; + @override String get payG1 => 'Pagar em Ğ1'; + @override String get g1Copied => 'Endereço Ğ1 copiado — cola-o na sua carteira'; + @override String get today => 'Hoje'; + @override String get yesterday => 'Ontem'; + @override String get sendError => 'Não foi possível enviar — verifica a sua ligação'; + @override String get noLinks => 'Não são permitidos links nas mensagens'; +} + +// Path: trust +class _Translations$trust$pt_BR extends Translations$trust$pt { + _Translations$trust$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get none => 'Ainda ninguém os avaliza'; + @override String count({required Object n}) => 'Avalizada por ${n}'; + @override String get vouch => 'Conheço esta pessoa'; + @override String get vouched => 'Avalizas esta pessoa'; + @override String get circle => 'No seu círculo'; +} + +// Path: yourPeople +class _Translations$yourPeople$pt_BR extends Translations$yourPeople$pt { + _Translations$yourPeople$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'A sua gente'; + @override String get help => 'Pessoas que conheces e avalizas, e pessoas que te avalizam.'; + @override String get youVouchFor => 'Você avaliza'; + @override String get vouchesForYou => 'Avalizam-te'; + @override String get youVouchForEmpty => 'Ainda não avaliza ninguém. Quando conhecer alguém, abra a conversa com essa pessoa e toque em "Conheço esta pessoa".'; + @override String get vouchesForYouEmpty => 'Ainda ninguém te avaliza'; + @override String get revoke => 'Deixar de avalizar'; + @override String get revokeConfirm => 'Deixar de avalizar esta pessoa?'; + @override String get offline => 'Sem conexão — tente novamente quando estiver online'; +} + +// Path: ratings +class _Translations$ratings$pt_BR extends Translations$ratings$pt { + _Translations$ratings$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get rate => 'Avaliar esta pessoa'; + @override String get edit => 'Editar a sua avaliação'; + @override String get commentHint => 'Como correu? (opcional)'; + @override String fromYourCircle({required Object n}) => '${n} de pessoas que conheces'; + @override String get retract => 'Remover a sua avaliação'; + @override String get saved => 'Avaliação salva'; +} + +// Path: notifications +class _Translations$notifications$pt_BR extends Translations$notifications$pt { + _Translations$notifications$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String newMessageFrom({required Object name}) => 'Nova mensagem de ${name}'; +} + +// Path: plantare +class _Translations$plantare$pt_BR extends Translations$plantare$pt { + _Translations$plantare$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Plantares'; + @override String get help => 'Um Plantare é o compromisso de reproduzir uma semente e devolver uma parte — assim uma variedade continua a viajar de mão em mão. Não é uma venda.'; + @override String get add => 'Adicionar compromisso'; + @override String get empty => 'Ainda não há compromissos. Quando compartilhar ou receber semente com o compromisso de reproduzi-la e devolver algo, anote aqui.'; + @override String get iReturn => 'Reproduzo e devolvo eu'; + @override String get owedToMe => 'Devolvem-me a mim'; + @override String get direction => 'Quem reproduz e devolve'; + @override String get counterparty => 'Com quem?'; + @override String get counterpartyHint => 'Uma pessoa ou um coletivo (opcional)'; + @override String get owed => 'O que é devolvido?'; + @override String get owedHint => 'p. ex. um punhado na próxima temporada (opcional)'; + @override String get note => 'Nota (opcional)'; + @override String get save => 'Salvar'; + @override String get markReturned => 'Marcar devolvido'; + @override String get markForgiven => 'Dar por saldado'; + @override String get reopen => 'Reabrir'; + @override String get delete => 'Remover'; + @override String get statusReturned => 'Devolvido'; + @override String get statusForgiven => 'Saldado'; + @override String get openSection => 'Pendentes'; + @override String get settledSection => 'Feitos'; + @override String get removeConfirm => 'Remover este compromisso?'; + @override String returnBy({required Object date}) => 'Devolver até ${date}'; + @override String get overdue => 'vencido'; + @override String get dueByLabel => 'Devolver até (opcional)'; + @override String get dueByHint => 'Um lembrete gentil, nunca imposto'; + @override String get pickDate => 'Escolher data'; + @override String get clearDate => 'Limpar data'; + @override String get sectionTitle => 'Compromissos'; + @override String get propose => 'Propor um Plantaré assinado'; + @override String get proposeHelp => 'Ambos mantêm a mesma promessa, assinada pelos dois — prova de que esta semente mudou de mãos e será cultivada e devolvida.'; + @override String proposeTo({required Object name}) => 'Com ${name}'; + @override String get sent => 'Proposta enviada — a aguardar que assinem'; + @override String get seedLabel => 'Que semente?'; + @override String get seedHint => 'A variedade a que se refere esta promessa'; + @override String get returnKindLabel => 'O que volta?'; + @override String get returnSimilar => 'Uma quantidade semelhante de semente'; + @override String get returnSimilarNote => 'polinização aberta · não transgénico · cultivado organicamente'; + @override String get returnWork => 'Algumas horas de trabalho'; + @override String get returnOther => 'Outra coisa'; + @override String get workHoursLabel => 'Quantas horas?'; + @override String get proposalsSection => 'À espera da sua resposta'; + @override String incomingFrom({required Object name}) => '${name} propõe um Plantaré'; + @override String get accept => 'Aceitar e assinar'; + @override String get declineAction => 'Recusar'; + @override String get declineReasonHint => 'Motivo (opcional)'; + @override String get acceptedToast => 'Assinado — agora ambos o mantêm'; + @override String get declinedToast => 'Recusado'; + @override String get badgeAwaiting => 'A aguardar assinatura'; + @override String get badgeSigned => 'Assinado por ambos'; + @override String get badgeDeclined => 'Recusado'; + @override String get offline => 'Você está offline — será enviado quando reconectar'; +} + +// Path: handover +class _Translations$handover$pt_BR extends Translations$handover$pt { + _Translations$handover$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Dei ou recebi sementes'; + @override String get help => 'Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.'; + @override String get iGave => 'Dei sementes'; + @override String get iReceived => 'Recebi sementes'; + @override String get whichLot => 'De que lote?'; + @override String get howMuch => 'Quanto?'; + @override String get allOfIt => 'Tudo'; + @override String get partOfIt => 'Uma parte'; + @override String get paymentChip => 'Houve dinheiro pelo meio'; + @override String get promiseGave => 'Vão devolver-me semente'; + @override String get promiseReceived => 'Vou devolver semente'; +} + +// Path: history +class _Translations$history$pt_BR extends Translations$history$pt { + _Translations$history$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'História deste lote'; + @override String get tooltip => 'História'; + @override String get sowToday => 'Semeado hoje'; + @override String get harvestToday => 'Colhido hoje'; + @override String get sownRecorded => 'Sementeira registada'; + @override String get harvestRecorded => 'Colheita registada'; + @override String get movementReceived => 'Recebido'; + @override String get movementGiven => 'Oferecido'; + @override String get movementSown => 'Semeado'; + @override String get movementHarvested => 'Colhido'; + @override String get movementGerminationTest => 'Teste de germinação'; + @override String get movementSplit => 'Dividido em lotes'; + @override String get movementDiscarded => 'Descartado'; + @override String get created => 'Adicionado à sua coleção'; + @override String from({required Object origin}) => 'De ${origin}'; + @override String germinationResult({required Object percent}) => 'Teste de germinação — ${percent}%'; + @override String get linkedEarlier => 'Vem de um lote anterior'; + @override String get outcomeQuestion => 'Como correu?'; + @override String get outcomeGood => 'Bem'; + @override String get outcomeMixed => 'Mais ou menos'; + @override String get outcomePoor => 'Mal'; + @override String get outcomeNoteHint => 'Uma nota para o seu eu futuro (opcional)'; + @override String get outcomeSaved => 'Registado'; + @override String get ratedGood => 'Correu bem'; + @override String get ratedMixed => 'Correu mais ou menos'; + @override String get ratedPoor => 'Correu mal'; + @override String get outcomeTitle => 'Nota da estação'; + @override String outcomeYear({required Object year}) => 'Estação ${year}'; + @override String isolationHint({required Object meters}) => 'Esta espécie cruza-se com vizinhas próximas — cultiva-a a cerca de ${meters} m de distância de outras para a manter pura'; +} + +// Path: sale +class _Translations$sale$pt_BR extends Translations$sale$pt { + _Translations$sale$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Vendas'; + @override String get help => 'Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.'; + @override String get add => 'Registar venda'; + @override String get empty => 'Ainda não há vendas. Anota aqui o que vendes ou compras.'; + @override String get iSold => 'Vendi'; + @override String get iBought => 'Comprei'; + @override String get direction => 'Vendes ou compras?'; + @override String get counterparty => 'Com quem?'; + @override String get counterpartyHint => 'Uma pessoa ou um coletivo (opcional)'; + @override String get amount => 'Montante'; + @override String get currency => 'Moeda'; + @override String get currencyHint => '€, Ğ1, horas… (opcional)'; + @override String get hours => 'horas'; + @override String get note => 'Nota (opcional)'; + @override String get save => 'Salvar'; + @override String get delete => 'Remover'; + @override String get removeConfirm => 'Remover esta venda?'; +} + +// Path: legal +class _Translations$legal$pt_BR extends Translations$legal$pt { + _Translations$legal$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Privacidade e regras'; + @override String get subtitle => 'A sua privacidade, as regras do mercado e a legalidade das sementes'; + @override String get privacyTitle => 'A sua privacidade'; + @override String get privacyBody => 'O Tane funciona sem conta, e tudo o que você registra fica no seu dispositivo, cifrado. Não há publicidade, nem rastreadores, nem servidor do Tane.\n\nNada é compartilhado a não ser que você queira: quando publica uma oferta ou o seu perfil, ou envia uma mensagem, viaja por servidores comunitários. As ofertas levam apenas uma zona aproximada — nunca a sua morada.\n\nO que publica é público, e podem ficar cópias mesmo depois de você retirar — por isso compartilhe com cuidado.'; + @override String get rulesTitle => 'As regras do jogo'; + @override String get rulesBody => 'O Tane é uma ferramenta, não uma loja: as trocas são acordadas diretamente entre pessoas e ninguém fica com comissão. Isso também significa que é responsável pelo que oferece e envia.\n\nNo mercado: seja honesto com suas sementes, ofereça apenas o que pode compartilhar, trate bem as pessoas e não faça spam. Pode bloquear qualquer pessoa e denunciar ofertas ou pessoas que quebrem as regras — as denúncias são tratadas nos servidores comunitários.'; + @override String get seedsTitle => 'Sobre compartilhar sementes e mudas'; + @override String get seedsBody => 'Oferecer e trocar sementes entre amadores é amplamente reconhecido na maioria dos países. Vender pode ser diferente: em muitos lugares, vender sementes de variedades não registadas oficialmente é restrito — consulta as regras locais antes de pedir um preço.\n\nEnviar sementes para outro país também costuma ser restrito, e as variedades protegidas comercialmente não podem ser propagadas sem autorização. Na dúvida, melhor local e melhor oferta.\n\nO mesmo se aplica a mudas e plantas jovens, mas a planta viva pode ter regras de transporte e fitossanitárias mais rígidas do que a semente: movê-la entre regiões ou países pode exigir controlos adicionais.'; + @override String get readFull => 'Ler os documentos completos na web'; +} + +// Path: marketGate +class _Translations$marketGate$pt_BR extends Translations$marketGate$pt { + _Translations$marketGate$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Antes de entrar no mercado'; + @override String get intro => 'O mercado é um espaço compartilhado entre vizinhas e vizinhos. Ao continuar, aceitas umas poucas regras simples:'; + @override String get ruleHonest => 'Seja honesto com as sementes que oferece'; + @override String get ruleLegal => 'Compartilhe apenas o que pode compartilhar onde vive'; + @override String get ruleRespect => 'Trata bem as pessoas — sem spam nem abusos'; + @override String get publicNote => 'O que você publicar aqui é público, e podem ficar cópias mesmo que você retire depois.'; + @override String get viewLegal => 'Privacidade e regras'; + @override String get accept => 'Aceito'; + @override String get decline => 'Agora não'; +} + +// Path: report +class _Translations$report$pt_BR extends Translations$report$pt { + _Translations$report$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get offer => 'Denunciar esta oferta'; + @override String get person => 'Denunciar esta pessoa'; + @override String get title => 'Denunciar'; + @override String get prompt => 'O que se passa?'; + @override String get reasonSpam => 'Spam ou uma burla'; + @override String get reasonAbuse => 'Abusivo ou desrespeitoso'; + @override String get reasonIllegal => 'Sementes que não deviam ser oferecidas'; + @override String get reasonOther => 'Outra coisa'; + @override String get detailsHint => 'Acrescenta detalhes (opcional)'; + @override String get send => 'Enviar denúncia'; + @override String get sentHidden => 'Denúncia enviada — já não voltas a ver isto'; + @override String get failed => 'Não foi possível enviar a denúncia — verifica a sua ligação'; + @override String get alsoBlock => 'Bloquear também esta pessoa'; +} + +// Path: block +class _Translations$block$pt_BR extends Translations$block$pt { + _Translations$block$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get action => 'Bloquear esta pessoa'; + @override String get confirmTitle => 'Bloquear esta pessoa?'; + @override String get confirmBody => 'Deixa de ver as suas ofertas e mensagens. Pode desbloqueá-la mais tarde em Pessoas bloqueadas, na configuração de compartilhamento.'; + @override String get confirm => 'Bloquear'; + @override String get blockedToast => 'Pessoa bloqueada — as suas ofertas e mensagens ficam ocultas'; + @override String get manageTitle => 'Pessoas bloqueadas'; + @override String get manageEmpty => 'Você não bloqueou ninguém'; + @override String get unblock => 'Desbloquear'; +} + +// Path: intro.slides +class _Translations$intro$slides$pt_BR extends Translations$intro$slides$pt { + _Translations$intro$slides$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override late final _Translations$intro$slides$welcome$pt_BR welcome = _Translations$intro$slides$welcome$pt_BR._(_root); + @override late final _Translations$intro$slides$inventory$pt_BR inventory = _Translations$intro$slides$inventory$pt_BR._(_root); + @override late final _Translations$intro$slides$privacy$pt_BR privacy = _Translations$intro$slides$privacy$pt_BR._(_root); + @override late final _Translations$intro$slides$share$pt_BR share = _Translations$intro$slides$share$pt_BR._(_root); + @override late final _Translations$intro$slides$plantare$pt_BR plantare = _Translations$intro$slides$plantare$pt_BR._(_root); +} + +// Path: unit.handful +class _Translations$unit$handful$pt_BR extends Translations$unit$handful$pt { + _Translations$unit$handful$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'mão-cheia'; + @override String get plural => 'mãos-cheias'; +} + +// Path: unit.teaspoon +class _Translations$unit$teaspoon$pt_BR extends Translations$unit$teaspoon$pt { + _Translations$unit$teaspoon$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'colher de chá'; + @override String get plural => 'colheres de chá'; +} + +// Path: unit.spoon +class _Translations$unit$spoon$pt_BR extends Translations$unit$spoon$pt { + _Translations$unit$spoon$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'colher'; + @override String get plural => 'colheres'; +} + +// Path: unit.cup +class _Translations$unit$cup$pt_BR extends Translations$unit$cup$pt { + _Translations$unit$cup$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'chávena'; + @override String get plural => 'chávenas'; +} + +// Path: unit.jar +class _Translations$unit$jar$pt_BR extends Translations$unit$jar$pt { + _Translations$unit$jar$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'frasco'; + @override String get plural => 'frascos'; +} + +// Path: unit.sack +class _Translations$unit$sack$pt_BR extends Translations$unit$sack$pt { + _Translations$unit$sack$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'saco'; + @override String get plural => 'sacos'; +} + +// Path: unit.packet +class _Translations$unit$packet$pt_BR extends Translations$unit$packet$pt { + _Translations$unit$packet$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'pacote'; + @override String get plural => 'pacotes'; +} + +// Path: unit.cob +class _Translations$unit$cob$pt_BR extends Translations$unit$cob$pt { + _Translations$unit$cob$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'maçaroca'; + @override String get plural => 'maçarocas'; +} + +// Path: unit.pod +class _Translations$unit$pod$pt_BR extends Translations$unit$pod$pt { + _Translations$unit$pod$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'vagem'; + @override String get plural => 'vagens'; +} + +// Path: unit.ear +class _Translations$unit$ear$pt_BR extends Translations$unit$ear$pt { + _Translations$unit$ear$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'espiga'; + @override String get plural => 'espigas'; +} + +// Path: unit.head +class _Translations$unit$head$pt_BR extends Translations$unit$head$pt { + _Translations$unit$head$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'cabeça'; + @override String get plural => 'cabeças'; +} + +// Path: unit.fruit +class _Translations$unit$fruit$pt_BR extends Translations$unit$fruit$pt { + _Translations$unit$fruit$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'fruto'; + @override String get plural => 'frutos'; +} + +// Path: unit.bulb +class _Translations$unit$bulb$pt_BR extends Translations$unit$bulb$pt { + _Translations$unit$bulb$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'bolbo'; + @override String get plural => 'bolbos'; +} + +// Path: unit.tuber +class _Translations$unit$tuber$pt_BR extends Translations$unit$tuber$pt { + _Translations$unit$tuber$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'tubérculo'; + @override String get plural => 'tubérculos'; +} + +// Path: unit.seedHead +class _Translations$unit$seedHead$pt_BR extends Translations$unit$seedHead$pt { + _Translations$unit$seedHead$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'capítulo'; + @override String get plural => 'capítulos'; +} + +// Path: unit.bunch +class _Translations$unit$bunch$pt_BR extends Translations$unit$bunch$pt { + _Translations$unit$bunch$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'molho'; + @override String get plural => 'molhos'; +} + +// Path: unit.plant +class _Translations$unit$plant$pt_BR extends Translations$unit$plant$pt { + _Translations$unit$plant$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'planta'; + @override String get plural => 'plantas'; +} + +// Path: unit.pot +class _Translations$unit$pot$pt_BR extends Translations$unit$pot$pt { + _Translations$unit$pot$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'vaso'; + @override String get plural => 'vasos'; +} + +// Path: unit.tray +class _Translations$unit$tray$pt_BR extends Translations$unit$tray$pt { + _Translations$unit$tray$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'tabuleiro'; + @override String get plural => 'tabuleiros'; +} + +// Path: unit.seedling +class _Translations$unit$seedling$pt_BR extends Translations$unit$seedling$pt { + _Translations$unit$seedling$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'plântula'; + @override String get plural => 'plântulas'; +} + +// Path: unit.tree +class _Translations$unit$tree$pt_BR extends Translations$unit$tree$pt { + _Translations$unit$tree$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'árvore'; + @override String get plural => 'árvores'; +} + +// Path: unit.cutting +class _Translations$unit$cutting$pt_BR extends Translations$unit$cutting$pt { + _Translations$unit$cutting$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'estaca'; + @override String get plural => 'estacas'; +} + +// Path: unit.grams +class _Translations$unit$grams$pt_BR extends Translations$unit$grams$pt { + _Translations$unit$grams$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'grama'; + @override String get plural => 'gramas'; +} + +// Path: unit.count +class _Translations$unit$count$pt_BR extends Translations$unit$count$pt { + _Translations$unit$count$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get singular => 'semente'; + @override String get plural => 'sementes'; +} + +// Path: intro.slides.welcome +class _Translations$intro$slides$welcome$pt_BR extends Translations$intro$slides$welcome$pt { + _Translations$intro$slides$welcome$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'A semente que te trouxe até aqui'; + @override String get body => 'Cada semente tradicional é uma carta escrita por milhares de gerações, passada de mão em mão. Somos o que somos graças a essa compartilha.'; +} + +// Path: intro.slides.inventory +class _Translations$intro$slides$inventory$pt_BR extends Translations$intro$slides$inventory$pt { + _Translations$intro$slides$inventory$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'O seu banco de sementes, no bolso'; + @override String get body => 'Anote o que tem, de que ano, quanto e de onde veio — com o nome que você usa. Uma foto e um nome chegam para começar.'; +} + +// Path: intro.slides.privacy +class _Translations$intro$slides$privacy$pt_BR extends Translations$intro$slides$privacy$pt { + _Translations$intro$slides$privacy$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Seu, e só seu'; + @override String get body => 'Sem conta, sem internet, sem rastreadores. Os seus dados vivem cifrados no seu aparelho, e só o que você escolher é compartilhado.'; +} + +// Path: intro.slides.share +class _Translations$intro$slides$share$pt_BR extends Translations$intro$slides$share$pt { + _Translations$intro$slides$share$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Compartilhar, como sempre se fez'; + @override String get body => 'Ofereça o que sobra — dar, trocar ou vender — e deixe que alguém perto de você o encontre. Só se mostra uma distância aproximada, nunca a sua morada; o acordo fecha-se em pessoa.'; +} + +// Path: intro.slides.plantare +class _Translations$intro$slides$plantare$pt_BR extends Translations$intro$slides$plantare$pt { + _Translations$intro$slides$plantare$pt_BR._(TranslationsPtBr root) : this._root = root, super.internal(root); + + final TranslationsPtBr _root; // ignore: unused_field + + // Translations + @override String get title => 'Semear é multiplicar'; + @override String get body => 'Com um Plantare, quem recebe semente promete devolver uma parte mais tarde. E como devolvê-la implica cultivá-la, cada empréstimo multiplica o comum.'; +} + +/// The flat map containing all translations for locale . +/// Only for edge cases! For simple maps, use the map function of this library. +/// +/// The Dart AOT compiler has issues with very large switch statements, +/// so the map is split into smaller functions (512 entries each). +extension on TranslationsPtBr { + dynamic _flatMapFunction(String path) { + return switch (path) { + 'avatar.title' => 'A sua foto ou avatar', + 'avatar.fromPhoto' => 'Tirar ou escolher uma foto', + 'avatar.illustration' => 'Ou escolhe um desenho', + 'avatar.remove' => 'Remover', + 'favorites.title' => 'Favoritos', + 'favorites.empty' => 'Ainda não tem favoritos. Salve ofertas que goste do mercado.', + 'favorites.save' => 'Salvar nos favoritos', + 'favorites.remove' => 'Remover dos favoritos', + 'favorites.unavailable' => 'Já não está disponível', + 'savedSearches.title' => 'Pesquisas salvas', + 'savedSearches.empty' => 'Ainda não há pesquisas salvas. Salve uma pesquisa no mercado e te avisamos quando aparecer algo parecido na sua zona.', + 'savedSearches.save' => 'Salvar esta pesquisa', + 'savedSearches.nameLabel' => 'Dá um nome a esta pesquisa', + 'savedSearches.namePlaceholder' => 'ex.: Tomates perto de mim', + 'savedSearches.saved' => 'Pesquisa salva — vamos te avisar sobre novas correspondências perto de você', + 'savedSearches.openTooltip' => 'Pesquisas salvas', + 'savedSearches.delete' => 'Eliminar', + 'savedSearches.deleteConfirm' => 'Eliminar esta pesquisa salva?', + 'savedSearches.newMatchesBadge' => ({required Object n}) => '${n} novas', + 'savedSearches.alert' => ({required Object label}) => 'Sementes perto de você: ${label}', + 'seedSaving.title' => 'Guardar a sua semente', + 'seedSaving.subtitle' => 'O que é preciso para manter a variedade fiel', + 'seedSaving.lifeCycle' => 'Ciclo', + 'seedSaving.cycleAnnual' => 'Anual', + 'seedSaving.cycleBiennial' => 'Bienal — dá semente no 2.º ano', + 'seedSaving.cyclePerennial' => 'Perene', + 'seedSaving.pollination' => 'Polinização', + 'seedSaving.pollSelf' => 'Autopoliniza-se', + 'seedSaving.pollCross' => 'Cruza-se com outras', + 'seedSaving.pollMixed' => 'Autopoliniza-se, mas às vezes cruza', + 'seedSaving.byInsect' => 'por insetos', + 'seedSaving.byWind' => 'pelo vento', + 'seedSaving.isolation' => 'Separe-a', + 'seedSaving.isolationRange' => ({required Object min, required Object max}) => '${min}–${max} m de outras variedades', + 'seedSaving.isolationSingle' => ({required Object min}) => '${min} m de outras variedades', + 'seedSaving.plants' => 'Guarde de várias plantas', + 'seedSaving.plantsValue' => ({required Object n}) => 'De pelo menos ${n} plantas', + 'seedSaving.processing' => 'Como limpá-la', + 'seedSaving.procDry' => 'Semente seca (debulhar)', + 'seedSaving.procWet' => 'Semente húmida (fermentar e lavar)', + 'seedSaving.difficulty' => 'Dificuldade', + 'seedSaving.diffEasy' => 'Fácil', + 'seedSaving.diffMedium' => 'Média', + 'seedSaving.diffHard' => 'Difícil', + 'seedSaving.advisory' => 'Orientativo — adapte-o ao seu clima e variedade.', + 'seedSaving.sourcePrefix' => 'Fonte', + 'calendar.title' => 'Este mês', + 'calendar.filterChip' => 'Este mês', + 'calendar.selfNote' => 'O que você anotou nas suas variedades.', + 'calendar.nothing' => ({required Object month}) => 'Nada anotado para ${month}.', + 'app.title' => 'Tane', + 'bootstrap.failed' => 'O Tane não conseguiu iniciar', + 'bootstrap.retry' => 'Tentar de novo', + 'common.save' => 'Salvar', + 'common.cancel' => 'Cancelar', + 'common.delete' => 'Eliminar', + 'common.edit' => 'Editar', + 'common.type' => 'Tipo', + 'common.offline' => 'Sem ligação — a compartilha está em pausa', + 'home.tagline' => 'Compartilha e cultiva sementes locais', + 'home.openMarket' => 'Mercado', + 'home.openMarketSubtitle' => 'Descobre e compartilha sementes por perto', + 'home.yourInventory' => 'O seu inventário', + 'home.yourInventorySubtitle' => 'Gere as suas sementes', + 'photo.camera' => 'Tirar uma foto', + 'photo.gallery' => 'Escolher da galeria', + 'photo.setAsCover' => 'Usar como capa', + 'photo.isCover' => 'Foto de capa', + 'photo.deleteConfirm' => 'Eliminar esta foto?', + 'menu.tagline' => 'o seu banco de sementes', + 'menu.inventory' => 'Inventário', + 'menu.market' => 'Mercado', + 'menu.profile' => 'O seu perfil', + 'menu.chat' => 'Conversas', + 'menu.wishlist' => 'Favoritos', + 'menu.following' => 'A seguir', + 'menu.plantares' => 'Plantares', + 'menu.sales' => 'Vendas', + 'menu.calendar' => 'Calendário', + 'menu.settings' => 'Definições', + 'settings.language' => 'Idioma', + 'settings.systemLanguage' => 'Idioma do sistema', + 'settings.langEs' => 'Español', + 'settings.langEn' => 'English', + 'settings.langPt' => 'Português', + 'settings.langPtBr' => 'Português (Brasil)', + 'settings.langAst' => 'Asturianu', + 'settings.langFr' => 'Français', + 'settings.langDe' => 'Deutsch', + 'settings.langJa' => '日本語', + 'settings.about' => 'Acerca de', + 'settings.aboutText' => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.', + 'settings.aboutOpen' => 'Acerca do Tane', + 'backup.title' => 'Cópia de segurança', + 'backup.autoBackupTitle' => 'Cópias automáticas', + 'backup.autoBackupLast' => ({required Object date, required Object days}) => 'Última cópia em ${date} · a cada ${days} dias', + 'backup.autoBackupNone' => ({required Object days}) => 'Uma cópia é salva automaticamente a cada ${days} dias', + 'backup.exportJson' => 'Salvar uma cópia de segurança', + 'backup.exportJsonSubtitle' => 'Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho', + 'backup.importJson' => 'Restaurar uma cópia', + 'backup.importJsonSubtitle' => 'Recupera uma cópia salva — nada fica duplicado', + 'backup.exportCsv' => 'Exportar para uma folha de cálculo', + 'backup.exportCsvSubtitle' => 'Uma lista simples para Excel ou LibreOffice — sem fotos', + 'backup.importCsv' => 'Importar uma lista', + 'backup.importCsvSubtitle' => 'Acrescenta entradas a partir de uma folha de cálculo', + 'backup.importConfirmTitle' => 'Restaurar uma cópia?', + 'backup.importConfirmBody' => 'As entradas fundem-se com o seu inventário; quando a mesma entrada existe dos dois lados, vence a versão mais recente. Nada fica duplicado.', + 'backup.importCsvConfirmTitle' => 'Importar uma lista?', + 'backup.importCsvConfirmBody' => 'Cada linha é acrescentada como uma entrada nova. Não funde nem substitui, por isso importar o mesmo arquivo duas vezes acrescenta-o duas vezes.', + 'backup.importAction' => 'Importar', + 'backup.exportSaved' => 'Cópia salva', + 'backup.cancelled' => 'Cancelado', + 'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} novas, ${updated} atualizadas', + 'backup.importCsvDone' => ({required Object count}) => '${count} entradas acrescentadas', + 'backup.importFailed' => 'Este arquivo não pôde ser lido como uma cópia do Tane', + 'backup.failed' => 'Algo correu mal', + 'backup.recoveryTitle' => 'O seu código de recuperação', + 'backup.recoverySubtitle' => 'Imprime-o e guarda-o bem: abre as suas cópias noutro aparelho', + 'backup.recoveryIntro' => 'Este código abre as suas cópias salvas e recupera o seu banco em qualquer aparelho. Guarde duas cópias em papel em lugares seguros, como a sua melhor semente. Quem o tiver pode ler as suas cópias, por isso não o compartilhe com ninguém.', + 'backup.recoveryCopy' => 'Copiar', + 'backup.recoverySave' => 'Salvar a folha', + 'backup.recoverySheetTitle' => 'Tane — a sua folha de recuperação', + 'backup.recoveryPromptTitle' => 'Escreve o seu código de recuperação', + 'backup.recoveryPromptBody' => 'Esta cópia foi salva com outro código. Escreve o código da sua folha de recuperação para a abrir.', + 'backup.recoveryWrongCode' => 'Esse código não abre esta cópia', + 'about.title' => 'Acerca de', + 'about.kanji' => '種', + 'about.tagline' => 'Uma aplicação local e descentralizada para gerir e compartilhar sementes e plântulas tradicionais.', + 'about.intro' => 'O Tane (種, "semente" em japonês) ajuda pessoas e coletivos a manter um inventário amigável do seu banco de sementes, decidir o que oferecem e compartilhá-lo localmente — sem um intermediário central que possa controlar, censurar ou ser multado por isso. O seu nome vem de tanemaki (種まき), "semear / espalhar sementes". O objetivo é prático e político ao mesmo tempo: apoiar as variedades tradicionais e fazer frente ao monopólio das sementes.', + 'about.heritage' => 'O nome honra as antigas tradições japonesas de ajuda mútua à volta do arroz — yui (trabalho comunitário compartilhado) e tanomoshi (fundos de reciprocidade) — que inspiraram o Plantare em papel, a "moeda comunitária de troca de sementes" (BAH-Semillero, 2009, CC-BY-SA). O Tane é o Plantare digital.', + 'about.version' => 'Versão', + 'about.license' => 'Licença', + 'about.licenseValue' => 'AGPL-3.0', + 'about.website' => 'Sítio web', + 'about.sourceCode' => 'Código-fonte', + 'about.translate' => 'Ajuda a traduzir', + 'about.translateSubtitle' => 'Ajuda a trazer o Tane para o seu idioma', + 'about.openSourceLicenses' => 'Licenças de código aberto', + 'about.openSourceLicensesSubtitle' => 'Bibliotecas de terceiros e as suas licenças', + 'about.copyright' => ({required Object years}) => '© ${years} Associação Comunes, sob AGPLv3', + 'intro.skip' => 'Saltar', + 'intro.next' => 'Seguinte', + 'intro.start' => 'Começar', + 'intro.menuEntry' => 'Como funciona o Tane', + 'intro.slides.welcome.title' => 'A semente que te trouxe até aqui', + 'intro.slides.welcome.body' => 'Cada semente tradicional é uma carta escrita por milhares de gerações, passada de mão em mão. Somos o que somos graças a essa compartilha.', + 'intro.slides.inventory.title' => 'O seu banco de sementes, no bolso', + 'intro.slides.inventory.body' => 'Anote o que tem, de que ano, quanto e de onde veio — com o nome que você usa. Uma foto e um nome chegam para começar.', + 'intro.slides.privacy.title' => 'Seu, e só seu', + 'intro.slides.privacy.body' => 'Sem conta, sem internet, sem rastreadores. Os seus dados vivem cifrados no seu aparelho, e só o que você escolher é compartilhado.', + 'intro.slides.share.title' => 'Compartilhar, como sempre se fez', + 'intro.slides.share.body' => 'Ofereça o que sobra — dar, trocar ou vender — e deixe que alguém perto de você o encontre. Só se mostra uma distância aproximada, nunca a sua morada; o acordo fecha-se em pessoa.', + 'intro.slides.plantare.title' => 'Semear é multiplicar', + 'intro.slides.plantare.body' => 'Com um Plantare, quem recebe semente promete devolver uma parte mais tarde. E como devolvê-la implica cultivá-la, cada empréstimo multiplica o comum.', + 'inventory.title' => 'Inventário', + 'inventory.searchHint' => 'Procurar sementes', + 'inventory.empty' => 'Ainda não há sementes. Toca em + para adicionar a primeira.', + 'inventory.noMatches' => 'Nenhuma semente corresponde aos seus filtros.', + 'inventory.clearFilters' => 'Limpar filtros', + 'inventory.uncategorized' => 'Sem categoria', + 'inventory.needsReproductionFilter' => 'Para reproduzir', + 'inventory.loadError' => 'Não foi possível abrir o seu banco de sementes. Talvez estivesse ocupado — tenta de novo.', + 'inventory.retry' => 'Tentar de novo', + 'draft.capture' => 'Capturar fotos', + 'draft.captured' => ({required Object n}) => '${n} capturadas para catalogar', + 'draft.triageTitle' => 'Para catalogar', + 'draft.triageCount' => ({required Object n}) => '${n} para catalogar', + 'draft.untitled' => 'Sem nome', + 'draft.nameField' => 'Dá um nome a esta semente', + 'draft.nameHint' => 'O que é?', + 'draft.suggestFromPhoto' => 'Sugerir nome a partir da foto', + 'draft.discard' => 'Descartar', + 'quickAdd.title' => 'Adicionar uma semente', + 'quickAdd.labelField' => 'Nome', + 'quickAdd.labelRequired' => 'Dá-lhe um nome', + 'quickAdd.addPhoto' => 'Adicionar foto', + 'quickAdd.quantity' => 'Quanto?', + 'quickAdd.more' => 'Adicionar mais…', + 'quickAdd.save' => 'Salvar', + 'quickAdd.saveAndAddAnother' => 'Salvar e adicionar outra', + 'quickAdd.addedCount' => ({required Object n}) => '${n} adicionadas', + 'quickAdd.cancel' => 'Cancelar', + 'detail.notFound' => 'Esta semente já não está aqui.', + 'detail.lots' => 'Lotes', + 'detail.noLots' => 'Ainda não há lotes.', + 'detail.names' => 'Também conhecida como', + 'detail.addName' => 'Adicionar nome', + 'detail.links' => 'Ligações', + 'detail.addLink' => 'Adicionar ligação', + 'detail.linkUrl' => 'URL', + 'detail.linkTitle' => 'Título (opcional)', + 'detail.reference' => 'Saber mais', + 'detail.refGbif' => 'GBIF', + 'detail.refWikipedia' => 'Wikipédia', + 'detail.refWikispecies' => 'Wikispecies', + 'detail.notes' => 'Notas', + 'detail.addLot' => 'Adicionar lote', + 'detail.editLot' => 'Editar lote', + 'detail.deleteConfirm' => 'Eliminar esta semente?', + 'detail.year' => ({required Object year}) => 'Ano ${year}', + 'detail.noYear' => 'Ano desconhecido', + 'germination.title' => 'Germinação', + 'germination.add' => 'Adicionar teste', + 'germination.sampleSize' => 'Tamanho da amostra', + 'germination.germinated' => 'Germinadas', + 'germination.none' => 'Ainda não há testes de germinação.', + 'germination.result' => ({required Object percent}) => '${percent}%', + 'viability.expiringSoon' => 'Usa ou reproduz nesta época', + 'viability.expiringSoonYears' => ({required Object years}) => 'Usa ou reproduz nesta época · dura ~${years} anos', + 'viability.expired' => 'Passou a viabilidade típica — reproduzir', + 'viability.expiredYears' => ({required Object years}) => 'Passou a viabilidade típica (~${years} anos) — reproduzir', + 'editVariety.title' => 'Editar semente', + 'editVariety.name' => 'Nome', + 'editVariety.category' => 'Categoria', + 'editVariety.notes' => 'Notas', + 'editVariety.species' => 'Espécie (do catálogo)', + 'editVariety.speciesHint' => 'Procura uma espécie…', + 'editVariety.speciesSuggested' => 'Sugerida pelo nome', + 'editVariety.organic' => 'Biológica', + 'editVariety.organicHint' => 'Cultivada em modo biológico', + 'addLot.title' => 'Adicionar lote', + 'addLot.year' => 'Data de colheita', + 'addLot.quantity' => 'Quanto?', + 'addLot.amount' => 'Quantidade', + 'harvest.pickTitle' => 'Escolhe mês / ano', + 'harvest.anyMonth' => 'Qualquer mês', + 'harvest.noDate' => 'Definir data de colheita', + 'harvest.monthNames.0' => 'janeiro', + 'harvest.monthNames.1' => 'fevereiro', + 'harvest.monthNames.2' => 'março', + 'harvest.monthNames.3' => 'abril', + 'harvest.monthNames.4' => 'maio', + 'harvest.monthNames.5' => 'junho', + 'harvest.monthNames.6' => 'julho', + 'harvest.monthNames.7' => 'agosto', + 'harvest.monthNames.8' => 'setembro', + 'harvest.monthNames.9' => 'outubro', + 'harvest.monthNames.10' => 'novembro', + 'harvest.monthNames.11' => 'dezembro', + 'lotType.seed' => 'Sementes', + 'lotType.plant' => 'Planta', + 'lotType.seedling' => 'Plântula', + 'lotType.tree' => 'Árvore / arbusto', + 'lotType.bulb' => 'Bolbo / tubérculo', + 'lotType.cutting' => 'Estaca', + 'presentation.title' => 'Apresentação', + 'presentation.none' => 'Sem indicar', + 'presentation.pot' => 'Vaso', + 'presentation.tray' => 'Tabuleiro', + 'presentation.plug' => 'Alvéolo', + 'presentation.bareRoot' => 'Raiz nua', + 'presentation.rootBall' => 'Torrão', + 'provenance.section' => 'De onde vem', + 'provenance.seedsFrom' => 'Sementes de', + 'provenance.seedsFromHint' => 'Quem as cultivou ou ofereceu', + 'provenance.place' => 'Lugar', + 'provenance.placeHint' => 'De onde vêm (com a região)', + 'provenance.addSeedsFrom' => 'Sementes de', + 'provenance.addPlace' => 'Lugar', + 'abundance.add' => 'Quanta tenho', + 'abundance.title' => 'Quanta tenho', + 'abundance.none' => 'Sem indicar', + 'abundance.plentyToShare' => 'De sobra para compartilhar', + 'abundance.enoughToShare' => 'Bastante, para compartilhar com moderação', + 'abundance.enoughForMe' => 'Suficiente para mim', + 'abundance.runningLow' => 'Resta pouca', + 'share.add' => 'Compartilhas esta?', + 'share.title' => 'Compartilhas esta?', + 'share.nudge' => 'Tem de sobra: podia compartilhar um pouco.', + 'share.price' => 'Preço', + 'share.priceHint' => 'Deixe vazio para combinar depois', + 'share.private' => 'Só para mim', + 'share.gift' => 'Para dar', + 'share.exchange' => 'Para trocar', + 'share.sell' => 'À venda', + 'share.filterChip' => 'Compartilho', + 'share.printCatalog' => 'Imprimir o que compartilho', + 'share.catalogTitle' => 'O que compartilho', + 'share.catalogSaved' => 'Catálogo salvo', + 'share.cancelled' => 'Cancelado', + 'printLabels.action' => 'Imprimir etiquetas', + 'printLabels.title' => 'Imprimir etiquetas', + 'printLabels.selectHint' => 'Escolhe as sementes para imprimir as etiquetas', + 'printLabels.selectAll' => 'Selecionar tudo', + 'printLabels.selected' => ({required Object n}) => '${n} selecionadas', + 'printLabels.none' => 'Seleciona sementes primeiro', + 'printLabels.format' => 'Tamanho da etiqueta', + 'printLabels.formatStickers' => 'Autocolantes pequenos', + 'printLabels.formatStickersHint' => 'Muitas etiquetas pequenas por folha — para colar em envelopes', + 'printLabels.formatCards' => 'Cartões grandes', + 'printLabels.formatCardsHint' => 'Menos etiquetas, maiores — para frascos e caixas', + 'printLabels.count' => ({required Object n}) => '${n} etiquetas', + 'printLabels.save' => 'Salvar etiquetas', + 'printLabels.saved' => 'Etiquetas salvas', + 'printLabels.cancelled' => 'Cancelado', + 'scan.action' => 'Digitalizar um rótulo de semente', + 'scan.title' => 'Digitalizar um rótulo', + 'scan.notALabel' => 'Esse código não é um rótulo de semente', + 'scan.addTitle' => 'Não está na sua coleção', + 'scan.addBody' => ({required Object label}) => 'Adicionar “${label}” às suas sementes?', + 'scan.add' => 'Adicionar', + 'scan.added' => 'Adicionado à sua coleção', + 'cropCalendar.add' => 'Calendário de cultivo', + 'cropCalendar.title' => 'Calendário de cultivo', + 'cropCalendar.sow' => 'Sementeira', + 'cropCalendar.transplant' => 'Transplante', + 'cropCalendar.flowering' => 'Floração', + 'cropCalendar.fruiting' => 'Frutificação', + 'cropCalendar.seedHarvest' => 'Colheita de semente', + 'cropCalendar.editorHint' => 'Anote os meses típicos desta variedade na sua zona — são suas notas.', + 'cropCalendar.unset' => '—', + 'needsReproduction.label' => 'Reproduzir nesta época', + 'needsReproduction.hint' => 'Cultiva-a antes que a semente acabe', + 'needsReproduction.badge' => 'Por reproduzir', + 'preservation.add' => 'Como está guardada', + 'preservation.title' => 'Como está guardada', + 'preservation.none' => 'Sem indicar', + 'preservation.jarWithDesiccant' => 'Frasco com agente secante', + 'preservation.glassJar' => 'Frasco de vidro', + 'preservation.paperEnvelope' => 'Envelope de papel', + 'preservation.paperBag' => 'Saco de papel', + 'preservation.plasticBag' => 'Saco de plástico', + 'conditionCheck.advanced' => 'Armazenamento e detalhes de banco de sementes', + 'conditionCheck.title' => 'Verificações de armazenamento', + 'conditionCheck.add' => 'Adicionar verificação', + 'conditionCheck.containers' => 'Frascos / recipientes', + 'conditionCheck.desiccant' => 'Agente secante', + 'conditionCheck.none' => 'Ainda não há verificações de armazenamento.', + 'conditionCheck.summary' => ({required Object count, required Object state}) => '${count} frasco(s) · ${state}', + 'desiccant.none' => 'Nenhum', + 'desiccant.add' => 'Adicionar', + 'desiccant.replace' => 'Substituir', + 'desiccant.dry' => 'Azul — seco', + 'desiccant.fresh' => 'Acabado de renovar', + 'unit.aFew' => 'algumas', + 'unit.some' => 'bastantes', + 'unit.plenty' => 'muitas', + 'unit.pinch' => 'uma pitada', + 'unit.handful.singular' => 'mão-cheia', + 'unit.handful.plural' => 'mãos-cheias', + 'unit.teaspoon.singular' => 'colher de chá', + 'unit.teaspoon.plural' => 'colheres de chá', + 'unit.spoon.singular' => 'colher', + 'unit.spoon.plural' => 'colheres', + 'unit.cup.singular' => 'chávena', + 'unit.cup.plural' => 'chávenas', + 'unit.jar.singular' => 'frasco', + 'unit.jar.plural' => 'frascos', + 'unit.sack.singular' => 'saco', + 'unit.sack.plural' => 'sacos', + 'unit.packet.singular' => 'pacote', + 'unit.packet.plural' => 'pacotes', + 'unit.cob.singular' => 'maçaroca', + 'unit.cob.plural' => 'maçarocas', + 'unit.pod.singular' => 'vagem', + 'unit.pod.plural' => 'vagens', + 'unit.ear.singular' => 'espiga', + 'unit.ear.plural' => 'espigas', + 'unit.head.singular' => 'cabeça', + 'unit.head.plural' => 'cabeças', + 'unit.fruit.singular' => 'fruto', + 'unit.fruit.plural' => 'frutos', + 'unit.bulb.singular' => 'bolbo', + 'unit.bulb.plural' => 'bolbos', + 'unit.tuber.singular' => 'tubérculo', + 'unit.tuber.plural' => 'tubérculos', + 'unit.seedHead.singular' => 'capítulo', + 'unit.seedHead.plural' => 'capítulos', + 'unit.bunch.singular' => 'molho', + 'unit.bunch.plural' => 'molhos', + 'unit.plant.singular' => 'planta', + 'unit.plant.plural' => 'plantas', + 'unit.pot.singular' => 'vaso', + 'unit.pot.plural' => 'vasos', + 'unit.tray.singular' => 'tabuleiro', + 'unit.tray.plural' => 'tabuleiros', + 'unit.seedling.singular' => 'plântula', + 'unit.seedling.plural' => 'plântulas', + 'unit.tree.singular' => 'árvore', + 'unit.tree.plural' => 'árvores', + 'unit.cutting.singular' => 'estaca', + 'unit.cutting.plural' => 'estacas', + 'unit.grams.singular' => 'grama', + 'unit.grams.plural' => 'gramas', + 'unit.count.singular' => 'semente', + 'unit.count.plural' => 'sementes', + 'market.title' => 'Sementes perto de você', + 'market.subtitle' => 'O que outras pessoas compartilham por perto', + 'market.notSetUp' => 'Ainda não configurou a compartilha', + 'market.notSetUpBody' => 'Ativa a compartilha para ver e dar sementes a pessoas por perto. Mantém-se aproximado — a sua zona, nunca a sua morada exata.', + 'market.setUp' => 'Configurar a compartilha', + 'market.cantReach' => 'Não é possível ligar aos servidores neste momento', + 'market.cantReachBody' => 'Tenta de novo, ou verifica os servidores na configuração avançada.', + 'market.retry' => 'Tentar de novo', + 'market.setArea' => 'Indica a sua zona', + 'market.setAreaBody' => 'Diz ao mercado a sua zona aproximada para ver sementes por perto.', + 'market.searching' => 'A procurar pela sua zona…', + 'market.empty' => 'Ainda não há sementes compartilhadas perto de você', + 'market.searchHint' => 'Procurar nestas sementes', + 'market.noMatches' => 'Nenhuma semente compartilhada corresponde à procura', + 'market.near' => 'Perto de você', + 'market.contact' => 'Mensagem', + 'market.mine' => 'Você', + 'market.configTitle' => 'Configuração de compartilha', + 'market.setupIntro' => 'Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — o que você oferece viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de você possam encontrar.', + 'market.areaLabel' => 'A sua zona', + 'market.areaHelp' => 'Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.', + 'market.areaSet' => 'A sua zona está definida — aproximada, nunca o seu ponto exato', + 'market.areaNotSet' => 'Zona por definir — usa a sua localização, ou adiciona um código em Avançado', + 'market.advanced' => 'Avançado', + 'market.areaCodeLabel' => 'Código de zona', + 'market.areaCodeHint' => 'Um código curto como sp3e9 — não um nome de lugar', + 'market.serversLabel' => 'Servidores da comunidade', + 'market.serversHelp' => 'Escolha que servidores usar. Deixe assim se não souber.', + 'market.serversAdvanced' => 'Adicionar outro servidor', + 'market.serverAddress' => 'Endereço do servidor', + 'market.serverInvalid' => 'Introduz um endereço válido (wss://…)', + 'market.save' => 'Salvar', + 'market.saved' => 'Salvo', + 'market.wanted' => 'Procuro', + 'market.shareMine' => 'Compartilhar as minhas sementes', + 'market.sharedCount' => ({required Object n}) => 'Compartilhadas ${n} sementes', + 'market.nothingToShare' => 'Marca primeiro algumas sementes para dar, trocar ou vender', + 'market.useLocation' => 'Usar a minha localização aproximada', + 'market.locationFailed' => 'Não foi possível obter a sua localização — verifica se a localização está ativa e a permissão concedida', + 'market.queued' => 'Salvo — vamos compartilhá-las quando você tiver conexão', + 'market.shareFailed' => 'Não foi possível contactar os servidores — as suas sementes não foram compartilhadas. Tenta de novo daqui a pouco.', + 'market.rangeLabel' => 'Até onde procurar', + 'market.rangeNear' => 'Muito perto', + 'market.rangeArea' => 'Pela minha zona', + 'market.rangeRegion' => 'A minha região', + 'market.sharedBy' => 'Compartilhado por', + 'market.noProfile' => 'Esta pessoa ainda não compartilhou o seu perfil', + 'market.copyId' => 'Copiar código', + 'market.idCopied' => 'Código copiado', + 'market.photo' => 'Foto', + 'profile.title' => 'O seu perfil', + 'profile.name' => 'Nome', + 'profile.nameHint' => 'Como os outros te veem', + 'profile.about' => 'Sobre você', + 'profile.aboutHint' => 'Uma linha — o que cultivas, onde', + 'profile.g1' => 'Endereço Ğ1 (opcional)', + 'profile.g1Hint' => 'Para que te paguem em Ğ1 — separado da sua chave', + 'profile.yourId' => 'A sua identidade', + 'profile.idHelp' => 'Compartilha-a para que te reconheçam', + 'profile.copy' => 'Copiar', + 'profile.copied' => 'Copiado', + 'profile.save' => 'Salvar', + 'profile.saved' => 'Perfil salvo', + 'profile.identities' => 'As suas identidades', + 'profile.identitiesHelp' => 'Mantém identidades separadas — cada uma com as suas mensagens e contactos. Todas vêm da sua única cópia de segurança, por isso mudar não acrescenta nada para lembrar.', + 'profile.identityLabel' => ({required Object n}) => 'Identidade ${n}', + 'profile.current' => 'Em uso', + 'profile.newIdentity' => 'Nova identidade', + 'profile.switchTitle' => 'Mudar de identidade?', + 'profile.switchBody' => 'As mensagens e contatos são guardados separadamente para cada identidade. Pode voltar quando quiser.', + 'profile.switchAction' => 'Mudar', + 'chatList.title' => 'Mensagens', + 'chatList.empty' => 'Ainda não há conversas. Escreve a alguém a partir do mercado.', + 'chat.title' => 'Conversa', + 'chat.hint' => 'Escreve uma mensagem…', + 'chat.send' => 'Enviar', + 'chat.empty' => 'Ainda não há mensagens — diz olá', + 'chat.offline' => 'Configura a compartilha para enviar mensagens', + 'chat.payG1' => 'Pagar em Ğ1', + 'chat.g1Copied' => 'Endereço Ğ1 copiado — cola-o na sua carteira', + 'chat.today' => 'Hoje', + 'chat.yesterday' => 'Ontem', + 'chat.sendError' => 'Não foi possível enviar — verifica a sua ligação', + 'chat.noLinks' => 'Não são permitidos links nas mensagens', + 'trust.none' => 'Ainda ninguém os avaliza', + 'trust.count' => ({required Object n}) => 'Avalizada por ${n}', + 'trust.vouch' => 'Conheço esta pessoa', + 'trust.vouched' => 'Avalizas esta pessoa', + 'trust.circle' => 'No seu círculo', + 'yourPeople.title' => 'A sua gente', + 'yourPeople.help' => 'Pessoas que conheces e avalizas, e pessoas que te avalizam.', + 'yourPeople.youVouchFor' => 'Você avaliza', + 'yourPeople.vouchesForYou' => 'Avalizam-te', + 'yourPeople.youVouchForEmpty' => 'Ainda não avaliza ninguém. Quando conhecer alguém, abra a conversa com essa pessoa e toque em "Conheço esta pessoa".', + 'yourPeople.vouchesForYouEmpty' => 'Ainda ninguém te avaliza', + 'yourPeople.revoke' => 'Deixar de avalizar', + 'yourPeople.revokeConfirm' => 'Deixar de avalizar esta pessoa?', + 'yourPeople.offline' => 'Sem conexão — tente novamente quando estiver online', + 'ratings.rate' => 'Avaliar esta pessoa', + 'ratings.edit' => 'Editar a sua avaliação', + 'ratings.commentHint' => 'Como correu? (opcional)', + 'ratings.fromYourCircle' => ({required Object n}) => '${n} de pessoas que conheces', + 'ratings.retract' => 'Remover a sua avaliação', + 'ratings.saved' => 'Avaliação salva', + 'notifications.newMessageFrom' => ({required Object name}) => 'Nova mensagem de ${name}', + 'plantare.title' => 'Plantares', + 'plantare.help' => 'Um Plantare é o compromisso de reproduzir uma semente e devolver uma parte — assim uma variedade continua a viajar de mão em mão. Não é uma venda.', + 'plantare.add' => 'Adicionar compromisso', + 'plantare.empty' => 'Ainda não há compromissos. Quando compartilhar ou receber semente com o compromisso de reproduzi-la e devolver algo, anote aqui.', + 'plantare.iReturn' => 'Reproduzo e devolvo eu', + 'plantare.owedToMe' => 'Devolvem-me a mim', + 'plantare.direction' => 'Quem reproduz e devolve', + 'plantare.counterparty' => 'Com quem?', + 'plantare.counterpartyHint' => 'Uma pessoa ou um coletivo (opcional)', + 'plantare.owed' => 'O que é devolvido?', + 'plantare.owedHint' => 'p. ex. um punhado na próxima temporada (opcional)', + 'plantare.note' => 'Nota (opcional)', + 'plantare.save' => 'Salvar', + 'plantare.markReturned' => 'Marcar devolvido', + 'plantare.markForgiven' => 'Dar por saldado', + 'plantare.reopen' => 'Reabrir', + 'plantare.delete' => 'Remover', + 'plantare.statusReturned' => 'Devolvido', + 'plantare.statusForgiven' => 'Saldado', + 'plantare.openSection' => 'Pendentes', + 'plantare.settledSection' => 'Feitos', + _ => null, + } ?? switch (path) { + 'plantare.removeConfirm' => 'Remover este compromisso?', + 'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}', + 'plantare.overdue' => 'vencido', + 'plantare.dueByLabel' => 'Devolver até (opcional)', + 'plantare.dueByHint' => 'Um lembrete gentil, nunca imposto', + 'plantare.pickDate' => 'Escolher data', + 'plantare.clearDate' => 'Limpar data', + 'plantare.sectionTitle' => 'Compromissos', + 'plantare.propose' => 'Propor um Plantaré assinado', + 'plantare.proposeHelp' => 'Ambos mantêm a mesma promessa, assinada pelos dois — prova de que esta semente mudou de mãos e será cultivada e devolvida.', + 'plantare.proposeTo' => ({required Object name}) => 'Com ${name}', + 'plantare.sent' => 'Proposta enviada — a aguardar que assinem', + 'plantare.seedLabel' => 'Que semente?', + 'plantare.seedHint' => 'A variedade a que se refere esta promessa', + 'plantare.returnKindLabel' => 'O que volta?', + 'plantare.returnSimilar' => 'Uma quantidade semelhante de semente', + 'plantare.returnSimilarNote' => 'polinização aberta · não transgénico · cultivado organicamente', + 'plantare.returnWork' => 'Algumas horas de trabalho', + 'plantare.returnOther' => 'Outra coisa', + 'plantare.workHoursLabel' => 'Quantas horas?', + 'plantare.proposalsSection' => 'À espera da sua resposta', + 'plantare.incomingFrom' => ({required Object name}) => '${name} propõe um Plantaré', + 'plantare.accept' => 'Aceitar e assinar', + 'plantare.declineAction' => 'Recusar', + 'plantare.declineReasonHint' => 'Motivo (opcional)', + 'plantare.acceptedToast' => 'Assinado — agora ambos o mantêm', + 'plantare.declinedToast' => 'Recusado', + 'plantare.badgeAwaiting' => 'A aguardar assinatura', + 'plantare.badgeSigned' => 'Assinado por ambos', + 'plantare.badgeDeclined' => 'Recusado', + 'plantare.offline' => 'Você está offline — será enviado quando reconectar', + 'handover.title' => 'Dei ou recebi sementes', + 'handover.help' => 'Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.', + 'handover.iGave' => 'Dei sementes', + 'handover.iReceived' => 'Recebi sementes', + 'handover.whichLot' => 'De que lote?', + 'handover.howMuch' => 'Quanto?', + 'handover.allOfIt' => 'Tudo', + 'handover.partOfIt' => 'Uma parte', + 'handover.paymentChip' => 'Houve dinheiro pelo meio', + 'handover.promiseGave' => 'Vão devolver-me semente', + 'handover.promiseReceived' => 'Vou devolver semente', + 'history.title' => 'História deste lote', + 'history.tooltip' => 'História', + 'history.sowToday' => 'Semeado hoje', + 'history.harvestToday' => 'Colhido hoje', + 'history.sownRecorded' => 'Sementeira registada', + 'history.harvestRecorded' => 'Colheita registada', + 'history.movementReceived' => 'Recebido', + 'history.movementGiven' => 'Oferecido', + 'history.movementSown' => 'Semeado', + 'history.movementHarvested' => 'Colhido', + 'history.movementGerminationTest' => 'Teste de germinação', + 'history.movementSplit' => 'Dividido em lotes', + 'history.movementDiscarded' => 'Descartado', + 'history.created' => 'Adicionado à sua coleção', + 'history.from' => ({required Object origin}) => 'De ${origin}', + 'history.germinationResult' => ({required Object percent}) => 'Teste de germinação — ${percent}%', + 'history.linkedEarlier' => 'Vem de um lote anterior', + 'history.outcomeQuestion' => 'Como correu?', + 'history.outcomeGood' => 'Bem', + 'history.outcomeMixed' => 'Mais ou menos', + 'history.outcomePoor' => 'Mal', + 'history.outcomeNoteHint' => 'Uma nota para o seu eu futuro (opcional)', + 'history.outcomeSaved' => 'Registado', + 'history.ratedGood' => 'Correu bem', + 'history.ratedMixed' => 'Correu mais ou menos', + 'history.ratedPoor' => 'Correu mal', + 'history.outcomeTitle' => 'Nota da estação', + 'history.outcomeYear' => ({required Object year}) => 'Estação ${year}', + 'history.isolationHint' => ({required Object meters}) => 'Esta espécie cruza-se com vizinhas próximas — cultiva-a a cerca de ${meters} m de distância de outras para a manter pura', + 'sale.title' => 'Vendas', + 'sale.help' => 'Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.', + 'sale.add' => 'Registar venda', + 'sale.empty' => 'Ainda não há vendas. Anota aqui o que vendes ou compras.', + 'sale.iSold' => 'Vendi', + 'sale.iBought' => 'Comprei', + 'sale.direction' => 'Vendes ou compras?', + 'sale.counterparty' => 'Com quem?', + 'sale.counterpartyHint' => 'Uma pessoa ou um coletivo (opcional)', + 'sale.amount' => 'Montante', + 'sale.currency' => 'Moeda', + 'sale.currencyHint' => '€, Ğ1, horas… (opcional)', + 'sale.hours' => 'horas', + 'sale.note' => 'Nota (opcional)', + 'sale.save' => 'Salvar', + 'sale.delete' => 'Remover', + 'sale.removeConfirm' => 'Remover esta venda?', + 'legal.title' => 'Privacidade e regras', + 'legal.subtitle' => 'A sua privacidade, as regras do mercado e a legalidade das sementes', + 'legal.privacyTitle' => 'A sua privacidade', + 'legal.privacyBody' => 'O Tane funciona sem conta, e tudo o que você registra fica no seu dispositivo, cifrado. Não há publicidade, nem rastreadores, nem servidor do Tane.\n\nNada é compartilhado a não ser que você queira: quando publica uma oferta ou o seu perfil, ou envia uma mensagem, viaja por servidores comunitários. As ofertas levam apenas uma zona aproximada — nunca a sua morada.\n\nO que publica é público, e podem ficar cópias mesmo depois de você retirar — por isso compartilhe com cuidado.', + 'legal.rulesTitle' => 'As regras do jogo', + 'legal.rulesBody' => 'O Tane é uma ferramenta, não uma loja: as trocas são acordadas diretamente entre pessoas e ninguém fica com comissão. Isso também significa que é responsável pelo que oferece e envia.\n\nNo mercado: seja honesto com suas sementes, ofereça apenas o que pode compartilhar, trate bem as pessoas e não faça spam. Pode bloquear qualquer pessoa e denunciar ofertas ou pessoas que quebrem as regras — as denúncias são tratadas nos servidores comunitários.', + 'legal.seedsTitle' => 'Sobre compartilhar sementes e mudas', + 'legal.seedsBody' => 'Oferecer e trocar sementes entre amadores é amplamente reconhecido na maioria dos países. Vender pode ser diferente: em muitos lugares, vender sementes de variedades não registadas oficialmente é restrito — consulta as regras locais antes de pedir um preço.\n\nEnviar sementes para outro país também costuma ser restrito, e as variedades protegidas comercialmente não podem ser propagadas sem autorização. Na dúvida, melhor local e melhor oferta.\n\nO mesmo se aplica a mudas e plantas jovens, mas a planta viva pode ter regras de transporte e fitossanitárias mais rígidas do que a semente: movê-la entre regiões ou países pode exigir controlos adicionais.', + 'legal.readFull' => 'Ler os documentos completos na web', + 'marketGate.title' => 'Antes de entrar no mercado', + 'marketGate.intro' => 'O mercado é um espaço compartilhado entre vizinhas e vizinhos. Ao continuar, aceitas umas poucas regras simples:', + 'marketGate.ruleHonest' => 'Seja honesto com as sementes que oferece', + 'marketGate.ruleLegal' => 'Compartilhe apenas o que pode compartilhar onde vive', + 'marketGate.ruleRespect' => 'Trata bem as pessoas — sem spam nem abusos', + 'marketGate.publicNote' => 'O que você publicar aqui é público, e podem ficar cópias mesmo que você retire depois.', + 'marketGate.viewLegal' => 'Privacidade e regras', + 'marketGate.accept' => 'Aceito', + 'marketGate.decline' => 'Agora não', + 'report.offer' => 'Denunciar esta oferta', + 'report.person' => 'Denunciar esta pessoa', + 'report.title' => 'Denunciar', + 'report.prompt' => 'O que se passa?', + 'report.reasonSpam' => 'Spam ou uma burla', + 'report.reasonAbuse' => 'Abusivo ou desrespeitoso', + 'report.reasonIllegal' => 'Sementes que não deviam ser oferecidas', + 'report.reasonOther' => 'Outra coisa', + 'report.detailsHint' => 'Acrescenta detalhes (opcional)', + 'report.send' => 'Enviar denúncia', + 'report.sentHidden' => 'Denúncia enviada — já não voltas a ver isto', + 'report.failed' => 'Não foi possível enviar a denúncia — verifica a sua ligação', + 'report.alsoBlock' => 'Bloquear também esta pessoa', + 'block.action' => 'Bloquear esta pessoa', + 'block.confirmTitle' => 'Bloquear esta pessoa?', + 'block.confirmBody' => 'Deixa de ver as suas ofertas e mensagens. Pode desbloqueá-la mais tarde em Pessoas bloqueadas, na configuração de compartilhamento.', + 'block.confirm' => 'Bloquear', + 'block.blockedToast' => 'Pessoa bloqueada — as suas ofertas e mensagens ficam ocultas', + 'block.manageTitle' => 'Pessoas bloqueadas', + 'block.manageEmpty' => 'Você não bloqueou ninguém', + 'block.unblock' => 'Desbloquear', + _ => null, + }; + } +} diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index 0a4c2db..7509544 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'bootstrap.dart'; @@ -6,6 +7,16 @@ import 'ui/restart_widget.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); + // Draw behind the system bars (edge-to-edge) with transparent bars, so the app + // fills the screen on Android 15+ (where edge-to-edge is enforced) and stays + // consistent elsewhere. Scaffolds use SafeArea to keep content off the insets. + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Color(0x00000000), + systemNavigationBarColor: Color(0x00000000), + ), + ); // Start from the device language, then let an explicit in-app choice win — // it's the only reliable way to reach languages the OS picker doesn't offer // (e.g. Asturian). The saved choice is restored inside [Bootstrap], after DI. diff --git a/apps/app_seeds/lib/services/camera_availability.dart b/apps/app_seeds/lib/services/camera_availability.dart new file mode 100644 index 0000000..d9704e6 --- /dev/null +++ b/apps/app_seeds/lib/services/camera_availability.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Whether this device has any camera. Resolved once at startup (via the native +/// channel, `PackageManager.FEATURE_CAMERA_ANY`) and cached so `build()` methods +/// can read it synchronously — the QR scan button ([qrScanSupported]) and the +/// photo-source sheet hide their camera options when it is false. +/// +/// Defaults to `true` so a normal phone never loses camera UI over a transient +/// channel error; only genuinely camera-less devices (Chromebooks, Android +/// Automotive, some TVs — kept installable by the `uses-feature required=false` +/// in AndroidManifest) flip it to false. Non-Android platforms keep the default +/// (iOS devices have a camera; desktop/web gate camera UI elsewhere). +bool _deviceHasCamera = true; + +bool get deviceHasCamera => _deviceHasCamera; + +@visibleForTesting +set deviceHasCamera(bool value) => _deviceHasCamera = value; + +const _channel = MethodChannel('org.comunes.tane/coarse_location'); + +/// Queries the native camera-presence check and caches it. Called once during +/// bootstrap, before the first real frame, so synchronous readers see the right +/// value. Android-only; elsewhere the default stands. Never throws — a channel +/// error leaves the safe default in place. +Future initCameraAvailability() async { + if (defaultTargetPlatform != TargetPlatform.android) return; + try { + final has = await _channel.invokeMethod('hasCamera'); + if (has != null) _deviceHasCamera = has; + } catch (_) { + // Keep the default; a normal phone shouldn't lose camera UI over this. + } +} diff --git a/apps/app_seeds/lib/services/offer_thumbnail.dart b/apps/app_seeds/lib/services/offer_thumbnail.dart index 81e756a..27a6b04 100644 --- a/apps/app_seeds/lib/services/offer_thumbnail.dart +++ b/apps/app_seeds/lib/services/offer_thumbnail.dart @@ -34,6 +34,26 @@ String? offerThumbnailDataUri( } } +/// Builds a small JPEG thumbnail (longest edge [edge]px) for the inventory-list +/// avatar, so the list never has to decode the full-resolution photo. Returns +/// raw JPEG bytes, or null on undecodable input (the caller falls back to the +/// full photo). Kept as local bytes — regenerable, so it is excluded from sync +/// and backups. +Uint8List? inventoryThumbnailBytes( + Uint8List bytes, { + int edge = 96, + int quality = 72, +}) { + try { + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + final resized = _fitWithin(decoded, edge); + return Uint8List.fromList(img.encodeJpg(resized, quality: quality)); + } catch (_) { + return null; + } +} + /// Extracts the raw bytes from a `data:...;base64,…` URI, or null when [uri] is /// not a base64 data URI. Used by the UI to render an inline thumbnail with /// `Image.memory` instead of a network fetch. diff --git a/apps/app_seeds/lib/services/sharing_switch.dart b/apps/app_seeds/lib/services/sharing_switch.dart new file mode 100644 index 0000000..e1fed0b --- /dev/null +++ b/apps/app_seeds/lib/services/sharing_switch.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; + +import 'social_connection.dart'; +import 'social_settings.dart'; + +/// The one place that turns the sharing side of Tane on and off. +/// +/// Sharing is opt-in: until someone joins it, the app opens no connection at +/// all and the seed book is entirely offline. Joining has to move three things +/// at once — the stored choice, the live relay connection, and the flag the UI +/// listens to — and doing that from several screens is how they drift apart. +/// So every caller (the community-rules gate, the invite sheet, the sharing +/// setup) goes through here instead. +class SharingSwitch { + SharingSwitch({ + required SocialSettings settings, + SocialConnection? connection, + bool enabled = false, + }) : _settings = settings, + _connection = connection, + on = ValueNotifier(enabled); + + final SocialSettings _settings; + final SocialConnection? _connection; + + /// Whether sharing is on right now. Screens listen so the drawer's social + /// entries light up the moment someone joins, with no restart. + final ValueNotifier on; + + Future enable() async { + await _settings.setSharingEnabled(true); + // Safe to call even if it is already running: `start` guards itself. + _connection?.start(); + on.value = true; + } + + /// Turning it off must actually go offline — closing the live session, not + /// just recording the choice for the next launch. + Future disable() async { + await _settings.setSharingEnabled(false); + await _connection?.stop(); + on.value = false; + } + + void dispose() => on.dispose(); +} diff --git a/apps/app_seeds/lib/services/social_connection.dart b/apps/app_seeds/lib/services/social_connection.dart index 8e57949..80bd091 100644 --- a/apps/app_seeds/lib/services/social_connection.dart +++ b/apps/app_seeds/lib/services/social_connection.dart @@ -25,19 +25,36 @@ class SocialConnection { required SocialSettings settings, SessionOpener? open, Stream? online, + List? retrySchedule, }) : _settings = settings, _open = open ?? social.openSession, - _online = online; + _online = online, + _retrySchedule = retrySchedule ?? _defaultRetrySchedule; + + /// Backoff for self-retries after a failed attempt while started and not + /// knowingly offline (the fresh-install case: online the whole time, first + /// connect fails, so no connectivity change ever retriggers a connect). + static const _defaultRetrySchedule = [ + Duration(seconds: 5), + Duration(seconds: 15), + Duration(seconds: 45), + Duration(seconds: 90), + ]; final SocialSettings _settings; final SessionOpener _open; final Stream? _online; + final List _retrySchedule; final _sessions = StreamController.broadcast(); SocialSession? _current; Future? _pending; StreamSubscription? _onlineSub; bool _disposed = false; + bool _started = false; + bool _knownOffline = false; + Timer? _retryTimer; + int _retryIndex = 0; /// Emits the live session on each (re)connect, and null when it drops. Stream get sessions => _sessions.stream; @@ -46,12 +63,19 @@ class SocialConnection { SocialSession? get current => _current; /// Begins watching connectivity (reconnect on regain, drop when offline) and - /// attempts an initial connect. Idempotent-ish; call once at startup. + /// attempts an initial connect. Called at startup when sharing is already on, + /// and again the moment someone joins the sharing side — hence the guard, so + /// a second call never stacks a second connectivity subscription. void start() { + if (_started || _disposed) return; + _started = true; _onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) { + _knownOffline = !isOnline; if (!isOnline) { + _cancelRetry(); _drop(); } else if (_current == null) { + _retryIndex = 0; // fresh network — start the backoff over unawaited(session()); } }); @@ -75,15 +99,40 @@ class SocialConnection { return null; } _current = s; + _retryIndex = 0; + _cancelRetry(); _sessions.add(s); return s; } catch (_) { - return null; // unreachable — a later connectivity change retries + _scheduleRetry(); // unreachable — retry with backoff (see below) + return null; } finally { _pending = null; } } + /// After a failed attempt, retries by itself while started and not knowingly + /// offline — a connectivity change may never come if the device was online + /// all along. Un-started connections (one-shot [session] callers, tests) + /// never leave a timer behind. + void _scheduleRetry() { + if (!_started || _disposed || _knownOffline || _current != null) return; + _cancelRetry(); + final i = _retryIndex < _retrySchedule.length + ? _retryIndex + : _retrySchedule.length - 1; + _retryIndex++; + _retryTimer = Timer(_retrySchedule[i], () { + if (_disposed || _current != null) return; + unawaited(session()); + }); + } + + void _cancelRetry() { + _retryTimer?.cancel(); + _retryTimer = null; + } + void _drop() { final s = _current; _current = null; @@ -93,8 +142,22 @@ class SocialConnection { } } + /// Goes offline for good until [start] is called again: stops watching + /// connectivity, cancels any pending retry and tears the live session down. + /// This is what "turn sharing off" must do — before it existed, clearing the + /// server list only took effect on the next launch, because the already-open + /// session was never closed. Unlike [dispose] the object stays usable. + Future stop() async { + _started = false; + _cancelRetry(); + await _onlineSub?.cancel(); + _onlineSub = null; + _drop(); + } + Future dispose() async { _disposed = true; + _cancelRetry(); await _onlineSub?.cancel(); _onlineSub = null; _drop(); diff --git a/apps/app_seeds/lib/services/social_settings.dart b/apps/app_seeds/lib/services/social_settings.dart index 8dbe023..75917cc 100644 --- a/apps/app_seeds/lib/services/social_settings.dart +++ b/apps/app_seeds/lib/services/social_settings.dart @@ -5,9 +5,11 @@ import '../security/secret_store.dart'; /// keystore (via [SecretStore]) to honour "no plaintext at rest" — no /// shared_preferences. /// -/// Relays default to a small set of well-known public servers so the market -/// works out of the box; the exposure is minimal (offers are opt-in and carry -/// only a coarse geohash) and the user can swap them for a community server. +/// Sharing is off until the person joins it ([sharingEnabled]); until then the +/// app opens no connection at all. Once they do, relays default to a small set +/// of well-known public servers so the market works out of the box; the exposure +/// is minimal (offers are opt-in and carry only a coarse geohash) and the user +/// can swap them for a community server or turn them all off. /// The area stays unset until the user picks one (it's inherently personal). class SocialSettings { SocialSettings(this._store); @@ -16,6 +18,7 @@ class SocialSettings { static const _areaKey = 'tane.social.area_geohash'; static const _relaysKey = 'tane.social.relays'; + static const _sharingKey = 'tane.social.sharing_enabled'; static const _searchPrecisionKey = 'tane.social.search_precision'; static const _blockedKey = 'tane.social.blocked_pubkeys'; static const _hiddenOffersKey = 'tane.social.hidden_offers'; @@ -29,11 +32,11 @@ class SocialSettings { static const int maxSearchPrecision = 5; static const int defaultSearchPrecision = 4; - /// Community servers used automatically so sharing works from the first - /// launch. The relay pool skips any that are unreachable, so a dead one never - /// breaks the market; the user never has to know these exist. The Comunes - /// relay comes first as the reliable, non-commercial home; the public ones - /// are backup. + /// Community servers used automatically once the person joins the sharing + /// side, so the market works without any setup. The relay pool skips any that + /// are unreachable, so a dead one never breaks the market; the user never has + /// to know these exist. The Comunes relay comes first as the reliable, + /// non-commercial home; the public ones are backup. static const List defaultRelays = [ 'wss://relay.comunes.org', 'wss://nos.lol', @@ -62,6 +65,38 @@ class SocialSettings { urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'), ); + /// Whether the person has said yes to the sharing side of the app. Until they + /// do, Tane opens no connection at all — the seed book is entirely offline. + /// + /// Three states on purpose: `null` means "never asked", which is what lets an + /// install that predates this setting keep working exactly as before (see + /// `migrateSharingEnabled`). Once written it is a plain yes/no the person + /// controls from the sharing setup. + Future sharingEnabled() async { + final raw = await _store.read(_sharingKey); + if (raw == null) return null; + return raw == '1'; + } + + Future setSharingEnabled(bool enabled) => + _store.write(_sharingKey, enabled ? '1' : '0'); + + /// Decides, once, what an install that predates the setting should get, and + /// records it. Anyone who had already been through the intro was on a build + /// that connected at launch, so they keep sharing on and lose nothing — + /// messages, device sync and offer alerts keep arriving. A fresh install has + /// not seen the intro yet, so it starts fully offline and only goes online + /// when the person joins the sharing side. + /// + /// Returns the effective value. Safe to call on every launch: it writes only + /// when nothing has been recorded yet. + Future migrateSharingEnabled({required bool introSeen}) async { + final stored = await sharingEnabled(); + if (stored != null) return stored; + await setSharingEnabled(introSeen); + return introSeen; + } + /// How wide to search — a geohash prefix length in [minSearchPrecision, /// maxSearchPrecision]. Defaults (and falls back on any garbage) to /// [defaultSearchPrecision]. diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index d5fecc2..60e1c36 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -10,6 +10,7 @@ import '../services/offer_mapper.dart'; import '../services/offer_outbox.dart'; import '../services/offer_thumbnail.dart'; import '../services/social_connection.dart'; +import '../services/social_service.dart'; /// State of the offer discovery/publish screen. Transport-agnostic — it holds /// only what the UI shows, never a relay handle. @@ -24,12 +25,17 @@ class OffersState extends Equatable { this.blockedAuthors = const {}, this.hiddenOfferKeys = const {}, this.searching = false, + this.loadingMore = false, + this.nextPageCursor, this.publishing = false, this.hasSearched = false, + this.connectionEpoch = 0, this.error, }); - /// Offers discovered so far for [areaGeohash], newest appended as they arrive. + /// Offers discovered so far for [areaGeohash], newest first. Bounded in size + /// (see [OffersCubit.maxOffersKept]) so a busy area can't grow the list — and + /// the inline photo thumbnails it carries — without limit. final List offers; /// The coarse area currently being browsed. @@ -57,12 +63,30 @@ class OffersState extends Equatable { final Set hiddenOfferKeys; final bool searching; + + /// True while a "load more" page is in flight, so the UI shows a footer + /// spinner and doesn't fire overlapping page requests. + final bool loadingMore; + + /// Cursor (Unix seconds) for the next older page, or null when the newest + /// page was short (nothing older) or the in-memory cap was reached — in both + /// cases there is no more to load. + final int? nextPageCursor; + final bool publishing; + /// Whether more offers can be paged in (drives the infinite-scroll trigger). + bool get canLoadMore => nextPageCursor != null; + /// True once a discovery has been started, so the UI can tell "no search yet" /// from "searched, found nothing". final bool hasSearched; + /// Bumped whenever the underlying transport comes or goes, so the UI rebuilds + /// and re-reads [OffersCubit.isOnline] — states differing only here are + /// otherwise Equatable-equal and bloc would skip the emit. + final int connectionEpoch; + /// Last error, in human terms for the UI (null when fine). final String? error; @@ -115,8 +139,11 @@ class OffersState extends Equatable { Set? blockedAuthors, Set? hiddenOfferKeys, bool? searching, + bool? loadingMore, + int? Function()? nextPageCursor, bool? publishing, bool? hasSearched, + int? connectionEpoch, String? Function()? error, }) { return OffersState( @@ -129,8 +156,12 @@ class OffersState extends Equatable { blockedAuthors: blockedAuthors ?? this.blockedAuthors, hiddenOfferKeys: hiddenOfferKeys ?? this.hiddenOfferKeys, searching: searching ?? this.searching, + loadingMore: loadingMore ?? this.loadingMore, + nextPageCursor: + nextPageCursor != null ? nextPageCursor() : this.nextPageCursor, publishing: publishing ?? this.publishing, hasSearched: hasSearched ?? this.hasSearched, + connectionEpoch: connectionEpoch ?? this.connectionEpoch, error: error != null ? error() : this.error, ); } @@ -146,8 +177,11 @@ class OffersState extends Equatable { blockedAuthors, hiddenOfferKeys, searching, + loadingMore, + nextPageCursor, publishing, hasSearched, + connectionEpoch, error, ]; } @@ -159,15 +193,40 @@ class OffersState extends Equatable { class OffersCubit extends Cubit { OffersCubit( this._transport, { + SocialConnection? connection, Future Function(String varietyId)? coverPhoto, String? Function(Uint8List bytes)? thumbnail, Future Function()? onDispose, }) : _coverPhoto = coverPhoto, _thumbnail = thumbnail, _onDispose = onDispose, - super(const OffersState()); + super(const OffersState()) { + // Fresh-install fix: the transport captured at build time may be null while + // the shared connection is still coming up (or dropped). Follow the + // connection so the market recovers by itself instead of staying offline + // until a manual retry. + _connSub = connection?.sessions.listen(_onSession); + } - final OfferTransport? _transport; + OfferTransport? _transport; + StreamSubscription? _connSub; + + /// The last area prefix asked of [discover]; re-run when the connection + /// (re)appears so results show without the user tapping anything. + String? _lastPrefix; + + void _onSession(SocialSession? session) { + if (isClosed) return; + final transport = session?.offers; + if (identical(transport, _transport)) return; + _transport = transport; + final prefix = _lastPrefix; + if (transport != null && prefix != null) { + unawaited(discover(prefix)); // emits fresh states as results arrive + } else { + emit(state.copyWith(connectionEpoch: state.connectionEpoch + 1)); + } + } /// Fetches a variety's cover photo bytes; null in tests or when no inventory /// repo is wired. @@ -183,11 +242,25 @@ class OffersCubit extends Cubit { StreamSubscription? _sub; Timer? _searchTimeout; + /// Upper bound on offers held in memory. Each offer can carry an inline + /// (~40 KB) photo thumbnail, so an unbounded list in a busy area is a real + /// out-of-memory risk on mobile. Paging stops once the list reaches this, and + /// live offers beyond it evict the oldest — the newest stay visible. + static const int maxOffersKept = 400; + /// Whether a live transport is available (relay configured and reachable). bool get isOnline => _transport != null; - /// Starts (or restarts) discovery for [geohashPrefix]. Results stream in. + /// Current time in Unix seconds — the granularity Nostr filters use for + /// `since`/`until`. + int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + + /// Starts (or restarts) discovery for [geohashPrefix]. Fetches the newest page + /// up front (bounded), then keeps a live subscription for offers published + /// from now on — older results arrive via [loadNextPage], not by draining the + /// whole area into memory. Future discover(String geohashPrefix) async { + _lastPrefix = geohashPrefix; final transport = _transport; if (transport == null) { emit(state.copyWith(error: () => 'offline', hasSearched: true)); @@ -207,15 +280,38 @@ class OffersCubit extends Cubit { searching: true, hasSearched: true, )); - _sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen( - (offer) => - emit(state.copyWith(offers: _merge(state.offers, offer), searching: false)), - onError: (Object e) => - emit(state.copyWith(searching: false, error: () => '$e')), + + final query = DiscoveryQuery( + geohashPrefix: geohashPrefix, + types: state.typeFilter, ); - // The discover stream stays open for live offers and never signals "done", - // so stop the spinner after a beat: no results → show the empty state, not - // an endless "searching". + // Live subscription first, bounded to offers published from now on, so a new + // listing shows up immediately without re-dumping the whole area. + final since = _now(); + _sub = transport.discover(query, since: since).listen( + (offer) => emit(state.copyWith( + offers: _capped(_prepend(state.offers, offer)), + searching: false, + )), + onError: (Object e) => + emit(state.copyWith(searching: false, error: () => '$e')), + ); + + try { + final page = await transport.discoverPage(query); + if (!isClosed) { + emit(state.copyWith( + offers: _capped(_mergePage(state.offers, page.offers)), + nextPageCursor: () => page.nextCursor, + searching: false, + )); + } + } catch (e) { + if (!isClosed) emit(state.copyWith(searching: false, error: () => '$e')); + } + + // Safety net: if the first page hangs and no live offer arrives, still stop + // the spinner so the screen shows the empty state, not an endless search. _searchTimeout = Timer(const Duration(seconds: 6), () { if (!isClosed && state.searching) { emit(state.copyWith(searching: false)); @@ -223,18 +319,64 @@ class OffersCubit extends Cubit { }); } - /// Appends [incoming] to [current], replacing any existing offer with the same - /// author + id. Relays legitimately resend addressable events (a stored copy - /// plus a live echo after publishing), so a plain append would double the - /// listing; keeping one entry per (author, id) is the NIP-99 semantics. - static List _merge(List current, Offer incoming) => [ - for (final o in current) - if (!(o.id == incoming.id && - o.authorPubkeyHex == incoming.authorPubkeyHex)) - o, + /// Loads the next (older) page of offers for the current area. Called by the + /// list as it nears the end. No-op when offline, already loading, or there is + /// nothing older to fetch. + Future loadNextPage() async { + final transport = _transport; + final cursor = state.nextPageCursor; + if (transport == null || cursor == null || state.loadingMore) return; + emit(state.copyWith(loadingMore: true)); + try { + final page = await transport.discoverPage(DiscoveryQuery( + geohashPrefix: state.areaGeohash, + types: state.typeFilter, + until: cursor, + )); + final merged = _mergePage(state.offers, page.offers); + // Stop paging once the cap is reached — the list is already as large as we + // keep — otherwise carry the transport's cursor onward. + final reachedCap = merged.length >= maxOffersKept; + emit(state.copyWith( + offers: _capped(merged), + nextPageCursor: () => reachedCap ? null : page.nextCursor, + loadingMore: false, + )); + } catch (e) { + emit(state.copyWith(loadingMore: false, error: () => '$e')); + } + } + + /// The offer's identity for de-duplication: NIP-99 addressable events are keyed + /// by (author, `d`-tag id), so the same listing from two relays — or a stored + /// copy plus a live echo — collapses to one entry. + static String _key(Offer o) => '${o.authorPubkeyHex}:${o.id}'; + + /// Prepends a freshly-arrived (newer) [incoming] offer, dropping any existing + /// entry with the same identity so a live echo never doubles the listing. + static List _prepend(List current, Offer incoming) => [ incoming, + for (final o in current) + if (_key(o) != _key(incoming)) o, ]; + /// Appends an older [page] after [current] (older offers sort below newer), + /// skipping any already present. Keeps the newest-first ordering. + static List _mergePage(List current, List page) { + final seen = {for (final o in current) _key(o)}; + return [ + ...current, + for (final o in page) + if (seen.add(_key(o))) o, + ]; + } + + /// Caps the list to [maxOffersKept], keeping the newest (front) and dropping + /// the oldest (tail) — bounds memory in a busy area. + static List _capped(List offers) => offers.length <= maxOffersKept + ? offers + : offers.sublist(0, maxOffersKept); + /// Narrows the visible offers to those whose summary matches [query]. Purely /// local over the already-discovered list; does not re-hit the transport. void search(String query) => emit(state.copyWith(query: query)); @@ -361,6 +503,7 @@ class OffersCubit extends Cubit { @override Future close() async { _searchTimeout?.cancel(); + await _connSub?.cancel(); await _sub?.cancel(); await _onDispose?.call(); return super.close(); @@ -378,6 +521,7 @@ Future createOffersCubit( final session = await connection.session(); return OffersCubit( session?.offers, + connection: connection, // keeps following (re)connects — see _onSession coverPhoto: repository?.coverPhotoFor, thumbnail: offerThumbnailDataUri, ); diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart index 28d9264..df79f87 100644 --- a/apps/app_seeds/lib/ui/app_drawer.dart +++ b/apps/app_seeds/lib/ui/app_drawer.dart @@ -3,24 +3,66 @@ import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; +import '../services/onboarding_store.dart'; +import '../services/sharing_switch.dart'; import 'seed_glyph.dart'; +import 'sharing_invite_sheet.dart'; import 'theme.dart'; import 'unread_badge.dart'; /// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is -/// the live destination (green seed glyph), the social items (market, profile, -/// chat…) belong to Block 2 and are greyed with a "soon" tag, and Settings sits -/// pinned at the bottom. +/// the live destination (green seed glyph), the social items sit below a divider, +/// and Settings is pinned at the bottom. +/// +/// While sharing is off the social items stay visible but quiet, with a small +/// padlock: tapping one explains what it does and offers to join. The Market is +/// the exception — it is always live, because it is the door people go through +/// to join in the first place. class AppDrawer extends StatelessWidget { - const AppDrawer({this.marketEnabled = false, super.key}); + const AppDrawer({this.sharing, this.onboarding, super.key}); - /// When the Block 2 social layer is wired, the Market becomes a live drawer - /// destination; other social items stay "soon" until they're built. - final bool marketEnabled; + /// The sharing on/off switch. Null when the social layer isn't there at all + /// (identity derivation failed) — then the social items are not drawn, since + /// there is nothing the person could do to turn them on. + final SharingSwitch? sharing; + + /// Needed to run the community-rules step when someone accepts the invite. + final OnboardingStore? onboarding; @override Widget build(BuildContext context) { + final sharing = this.sharing; + if (sharing == null) return _build(context, social: false, sharingOn: false); + return ValueListenableBuilder( + valueListenable: sharing.on, + builder: (context, on, _) => + _build(context, social: true, sharingOn: on), + ); + } + + Widget _build( + BuildContext context, { + required bool social, + required bool sharingOn, + }) { final t = context.t; + // While sharing is off, tapping a social entry invites the person in rather + // than doing nothing. + Future invite() async { + final sharing = this.sharing; + final onboarding = this.onboarding; + if (sharing == null || onboarding == null) return; + // Close the drawer first, then run the sheet off the navigator's own + // context — this one dies with the drawer. + final navigator = Navigator.of(context); + navigator.pop(); + await showSharingInvite( + navigator.context, + onboarding: onboarding, + sharing: sharing, + ); + } + return Drawer( child: SafeArea( child: Column( @@ -69,59 +111,68 @@ class AppDrawer extends StatelessWidget { context.push('/calendar'); }, ), - _DrawerItem( - icon: const Icon(Symbols.storefront), - label: t.menu.market, - divider: true, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/market'); - } - : null, - ), - _DrawerItem( - icon: const Icon(Icons.person), - label: t.menu.profile, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/profile'); - } - : null, - ), - _DrawerItem( - icon: const UnreadBadge(child: Icon(Icons.chat_bubble)), - label: t.menu.chat, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/messages'); - } - : null, - ), - _DrawerItem( - icon: const Icon(Icons.favorite), - label: t.menu.wishlist, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/favorites'); - } - : null, - ), - // The ego-centric web of trust ("your people") — live once the - // social layer is on. - _DrawerItem( - icon: const Icon(Icons.group), - label: t.menu.following, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/your-people'); - } - : null, - ), + // The market is always live when the social layer exists: it + // is where people join sharing, so locking it would lock the + // only door. + if (social) + _DrawerItem( + icon: const Icon(Symbols.storefront), + label: t.menu.market, + divider: true, + onTap: () { + Navigator.of(context).pop(); + context.push('/market'); + }, + ), + if (social) + _DrawerItem( + icon: const Icon(Icons.person), + label: t.menu.profile, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/profile'); + } + : invite, + ), + if (social) + _DrawerItem( + icon: const UnreadBadge(child: Icon(Icons.chat_bubble)), + label: t.menu.chat, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/messages'); + } + : invite, + ), + if (social) + _DrawerItem( + icon: const Icon(Icons.favorite), + label: t.menu.wishlist, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/favorites'); + } + : invite, + ), + // The ego-centric web of trust ("your people"). + if (social) + _DrawerItem( + icon: const Icon(Icons.group), + label: t.menu.following, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/your-people'); + } + : invite, + ), ], ), ), @@ -203,6 +254,7 @@ class _DrawerItem extends StatelessWidget { required this.label, this.onTap, this.divider = false, + this.locked = false, }); final Widget icon; @@ -210,9 +262,13 @@ class _DrawerItem extends StatelessWidget { final VoidCallback? onTap; final bool divider; + /// Drawn quiet, with a padlock, but still tappable — it leads to the + /// invitation to join sharing rather than to the destination itself. + final bool locked; + @override Widget build(BuildContext context) { - final enabled = onTap != null; + final enabled = onTap != null && !locked; final fg = enabled ? seedOnSurface : const Color(0xFF9AA88F); final iconColor = enabled ? seedGreen : const Color(0xFF9AA88F); final row = InkWell( @@ -247,15 +303,11 @@ class _DrawerItem extends StatelessWidget { ), ), ), - if (!enabled) - Text( - context.t.common.comingSoon.toUpperCase(), - style: const TextStyle( - color: Color(0xFFB3BDA8), - fontSize: 11, - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), + if (locked) + const Icon( + Icons.lock_outline, + size: 16, + color: Color(0xFFB3BDA8), ), ], ), diff --git a/apps/app_seeds/lib/ui/calendar_screen.dart b/apps/app_seeds/lib/ui/calendar_screen.dart index d01df9e..717fb5b 100644 --- a/apps/app_seeds/lib/ui/calendar_screen.dart +++ b/apps/app_seeds/lib/ui/calendar_screen.dart @@ -235,8 +235,14 @@ class _CalendarRow extends StatelessWidget { leading: CircleAvatar( radius: 20, backgroundColor: swatch.fill, - foregroundImage: - entry.photo == null ? null : MemoryImage(entry.photo!), + // Decode to the 40px avatar size (× dpr), not the full photo. + foregroundImage: entry.photo == null + ? null + : ResizeImage( + MemoryImage(entry.photo!), + width: (40 * MediaQuery.devicePixelRatioOf(context)).round(), + height: (40 * MediaQuery.devicePixelRatioOf(context)).round(), + ), child: entry.photo == null ? Text( entry.label.isEmpty diff --git a/apps/app_seeds/lib/ui/draft_triage.dart b/apps/app_seeds/lib/ui/draft_triage.dart index 9f677b1..1605e52 100644 --- a/apps/app_seeds/lib/ui/draft_triage.dart +++ b/apps/app_seeds/lib/ui/draft_triage.dart @@ -108,6 +108,10 @@ class _DraftTile extends StatelessWidget { photo, width: 48, height: 48, + cacheWidth: + (48 * MediaQuery.devicePixelRatioOf(context)).round(), + cacheHeight: + (48 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), @@ -192,6 +196,8 @@ class _NameDraftDialogState extends State { child: Image.memory( widget.photo!, height: 140, + cacheHeight: + (140 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), diff --git a/apps/app_seeds/lib/ui/home_screen.dart b/apps/app_seeds/lib/ui/home_screen.dart index adf4aba..c13c7dc 100644 --- a/apps/app_seeds/lib/ui/home_screen.dart +++ b/apps/app_seeds/lib/ui/home_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; +import '../services/onboarding_store.dart'; +import '../services/sharing_switch.dart'; import 'app_drawer.dart'; import 'seed_glyph.dart'; import 'theme.dart'; @@ -9,14 +11,19 @@ import 'unread_badge.dart'; /// The main menu (redesign screen 00): a sprout logo in a soft green disc over a /// faint seed-glyph watermark, with "Your inventory" as the primary green call -/// to action and "Open market" (Block 2) as a disabled outlined card. The -/// hamburger opens [AppDrawer]. +/// to action and "Open market" as an outlined card below it. The hamburger opens +/// [AppDrawer]. +/// +/// The market card is always live when the social layer exists — it is the door +/// people go through to join sharing — and simply isn't drawn when it doesn't. class HomeScreen extends StatelessWidget { - const HomeScreen({this.marketEnabled = false, super.key}); + const HomeScreen({this.sharing, this.onboarding, super.key}); - /// When the Block 2 social layer is wired, the market becomes a live - /// destination; otherwise it stays a disabled "coming soon" card. - final bool marketEnabled; + /// The sharing on/off switch, or null when there is no social layer at all. + final SharingSwitch? sharing; + + /// Needed to run the community-rules step from the drawer's invitation. + final OnboardingStore? onboarding; @override Widget build(BuildContext context) { @@ -34,7 +41,7 @@ class HomeScreen extends StatelessWidget { ), ), ), - drawer: AppDrawer(marketEnabled: marketEnabled), + drawer: AppDrawer(sharing: sharing, onboarding: onboarding), body: Stack( children: [ const Positioned.fill(child: _SeedWatermark()), @@ -99,17 +106,16 @@ class HomeScreen extends StatelessWidget { subtitle: t.home.yourInventorySubtitle, onTap: () => context.push('/inventory'), ), - const SizedBox(height: 16), - _OutlinedMenuCard( - key: const Key('home.market'), - icon: Icons.storefront_outlined, - label: t.home.openMarket, - subtitle: t.home.openMarketSubtitle, - tag: marketEnabled ? null : t.common.comingSoon, - onTap: marketEnabled - ? () => context.push('/market') - : null, - ), + if (sharing != null) ...[ + const SizedBox(height: 16), + _OutlinedMenuCard( + key: const Key('home.market'), + icon: Icons.storefront_outlined, + label: t.home.openMarket, + subtitle: t.home.openMarketSubtitle, + onTap: () => context.push('/market'), + ), + ], ], ), ), @@ -175,14 +181,12 @@ class _PrimaryMenuCard extends StatelessWidget { } } -/// A disabled Block-2 destination: an outlined white card with a green-disc -/// icon and a "soon" tag. +/// A secondary destination: an outlined white card with a green-disc icon. class _OutlinedMenuCard extends StatelessWidget { const _OutlinedMenuCard({ required this.icon, required this.label, required this.subtitle, - this.tag, this.onTap, super.key, }); @@ -191,9 +195,6 @@ class _OutlinedMenuCard extends StatelessWidget { final String label; final String subtitle; - /// A small trailing tag (e.g. "coming soon"); omitted for live cards. - final String? tag; - /// When set, the card is tappable; otherwise it reads as disabled. final VoidCallback? onTap; @@ -221,17 +222,7 @@ class _OutlinedMenuCard extends StatelessWidget { subtitleColor: seedMuted, ), ), - if (tag != null) - Text( - tag!.toUpperCase(), - style: const TextStyle( - color: Color(0xFFB3BDA8), - fontSize: 11, - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), - ) - else if (onTap != null) + if (onTap != null) const Icon(Icons.chevron_right, color: seedMuted), ], ), diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index bcda588..2ec0925 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -582,27 +582,47 @@ class _InventoryBody extends StatelessWidget { } // Items arrive ordered by category then label; insert a header whenever the - // category changes. - final rows = []; + // category changes. Precompute a flat row model (header or item) so the + // ListView can build tiles lazily — with a large inventory, building every + // tile upfront (the old `ListView(children:)`) stalled the first paint. + final rows = <_InventoryRow>[]; String? currentCategory; for (final item in items) { final category = item.category ?? t.inventory.uncategorized; if (category != currentCategory) { currentCategory = category; - rows.add(_CategoryHeader(title: category)); + rows.add(_InventoryRow.header(category)); } - rows.add( - _VarietyTile( - item: item, - selectionMode: selectionMode, - selected: selectedIds.contains(item.id), - ), - ); + rows.add(_InventoryRow.variety(item)); } - return ListView(children: rows); + return ListView.builder( + itemCount: rows.length, + itemBuilder: (context, i) { + final row = rows[i]; + final header = row.header; + if (header != null) { + return _CategoryHeader(title: header); + } + return _VarietyTile( + item: row.item!, + selectionMode: selectionMode, + selected: selectedIds.contains(row.item!.id), + ); + }, + ); } } +/// A single row in the flattened inventory list: either a category [header] or +/// a variety [item] (exactly one is non-null). +class _InventoryRow { + const _InventoryRow.header(this.header) : item = null; + const _InventoryRow.variety(this.item) : header = null; + + final String? header; + final VarietyListItem? item; +} + class _CategoryHeader extends StatelessWidget { const _CategoryHeader({required this.title}); @@ -767,10 +787,21 @@ class _Avatar extends StatelessWidget { // Decorative: the tile title already announces the variety name, so keep // the thumbnail / initial out of the semantics tree. if (photo != null) { + // Decode straight to the 48px avatar size (× device pixel ratio) instead + // of holding the full-resolution photo in the image cache — decisive for + // memory with a large inventory of photographed varieties. + final cachePx = (48 * MediaQuery.devicePixelRatioOf(context)).round(); return ExcludeSemantics( child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.memory(photo, width: 48, height: 48, fit: BoxFit.cover), + child: Image.memory( + photo, + width: 48, + height: 48, + cacheWidth: cachePx, + cacheHeight: cachePx, + fit: BoxFit.cover, + ), ), ); } diff --git a/apps/app_seeds/lib/ui/market_gate.dart b/apps/app_seeds/lib/ui/market_gate.dart index b43edb1..c6eb383 100644 --- a/apps/app_seeds/lib/ui/market_gate.dart +++ b/apps/app_seeds/lib/ui/market_gate.dart @@ -3,17 +3,29 @@ import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; import '../services/onboarding_store.dart'; +import '../services/sharing_switch.dart'; import 'theme.dart'; /// Makes sure the community rules have been accepted once before the user /// joins the market or publishes anything. Returns true when the rules are /// (or become) accepted; false when the user declines. Play/App Store UGC /// policies require this acceptance before content can be created. +/// +/// This is also where Tane goes online for the first time: agreeing here is the +/// opt-in that turns [sharing] on. Keeping both in one step means there is a +/// single moment where someone says yes, and it is a moment that explains +/// itself — rather than a connection that happened at launch without asking. Future ensureMarketRulesAccepted( BuildContext context, - OnboardingStore store, -) async { - if (await store.marketRulesAccepted()) return true; + OnboardingStore store, { + SharingSwitch? sharing, +}) async { + if (await store.marketRulesAccepted()) { + // Already agreed, but sharing may still be off (they turned it off in the + // sharing setup, or agreed on a build that had no switch): honour the ask. + if (sharing != null && !sharing.on.value) await sharing.enable(); + return true; + } if (!context.mounted) return false; final accepted = await showModalBottomSheet( context: context, @@ -24,6 +36,7 @@ Future ensureMarketRulesAccepted( ); if (accepted == true) { await store.markMarketRulesAccepted(); + await sharing?.enable(); return true; } return false; @@ -77,6 +90,14 @@ class MarketGateSheet extends StatelessWidget { height: 1.4, ), ), + const SizedBox(height: 8), + Text( + t.marketGate.networkNote, + style: theme.textTheme.bodySmall?.copyWith( + color: seedMuted, + height: 1.4, + ), + ), TextButton.icon( style: TextButton.styleFrom( padding: EdgeInsets.zero, diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index b4a7eeb..d0a77a0 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -11,6 +11,7 @@ import '../services/offer_outbox.dart'; import '../services/saved_searches_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; +import '../services/sharing_switch.dart'; import '../services/social_settings.dart'; import '../services/onboarding_store.dart'; import '../state/offers_cubit.dart'; @@ -35,6 +36,7 @@ class MarketScreen extends StatefulWidget { this.onboarding, this.savedSearches, this.initialSearch, + this.sharing, super.key, }); @@ -54,6 +56,10 @@ class MarketScreen extends StatefulWidget { /// created). Null in tests → no gate. final OnboardingStore? onboarding; + /// The sharing on/off switch. Accepting the rules turns it on (that is the + /// moment Tane first goes online); the sharing setup can turn it back off. + final SharingSwitch? sharing; + /// The shared relay connection (one per identity), reused for discovery. final SocialConnection connection; @@ -84,11 +90,18 @@ class _MarketScreenState extends State { /// network; declining leaves the market. Future _start() async { final store = widget.onboarding; - if (store != null && !await store.marketRulesAccepted()) { + final sharing = widget.sharing; + // The rules step is also the moment Tane first goes online, so it runs + // whenever sharing is still off — even for someone who agreed long ago and + // later switched sharing back off. + if (store != null && + (!await store.marketRulesAccepted() || + (sharing != null && !sharing.on.value))) { // Wait for the first frame so the sheet has a surface to attach to. await WidgetsBinding.instance.endOfFrame; if (!mounted) return; - final ok = await ensureMarketRulesAccepted(context, store); + final ok = await ensureMarketRulesAccepted(context, store, + sharing: sharing); if (!ok) { if (mounted) context.pop(); return; @@ -100,11 +113,11 @@ class _MarketScreenState extends State { Future _init() async { // Only needed to flush the outbox; read here (while mounted) to avoid using // context across the awaits below. Null when there's no outbox (e.g. tests). - final repo = - widget.outbox != null ? context.read() : null; + final repo = widget.outbox != null + ? context.read() + : null; setState(() => _loading = true); - final cubit = - await createOffersCubit(widget.connection, repository: repo); + final cubit = await createOffersCubit(widget.connection, repository: repo); cubit.setBlockedAuthors(await widget.settings.blockedPubkeys()); cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys()); final area = await widget.settings.areaGeohash(); @@ -122,10 +135,10 @@ class _MarketScreenState extends State { _loading = false; }); await previous?.close(); - if (area.isNotEmpty && cubit.isOnline) { + if (area.isNotEmpty) { // Flush anything parked while offline, then show what's out there. final outbox = widget.outbox; - if (outbox != null && repo != null) { + if (outbox != null && repo != null && cubit.isOnline) { await flushOutbox( outbox: outbox, cubit: cubit, @@ -136,6 +149,8 @@ class _MarketScreenState extends State { } if (mounted) { final precision = await widget.settings.searchPrecision(); + // Even when offline: this records the wanted area on the cubit, which + // re-runs the discovery by itself once the connection comes up. if (mounted) await cubit.discover(searchPrefix(area, precision)); } } @@ -154,8 +169,11 @@ class _MarketScreenState extends State { final changed = await showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => - _ConfigSheet(settings: widget.settings, location: widget.location), + builder: (_) => _ConfigSheet( + settings: widget.settings, + location: widget.location, + sharing: widget.sharing, + ), ); if (changed == true) await _init(); } @@ -206,12 +224,18 @@ class _MarketScreenState extends State { await widget.outbox?.remove(ids); // published now, so unpark any duplicates // count == 0 with lots to share means every relay refused — say so plainly // rather than "shared 0", which reads like success. - messenger.showSnackBar(SnackBar( - content: Text(count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count)), - )); + messenger.showSnackBar( + SnackBar( + content: Text( + count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count), + ), + ), + ); final precision = await widget.settings.searchPrecision(); if (!mounted) return; - await cubit.discover(searchPrefix(area, precision)); // refresh incl. just-shared + await cubit.discover( + searchPrefix(area, precision), + ); // refresh incl. just-shared } @override @@ -240,13 +264,13 @@ class _MarketScreenState extends State { ), floatingActionButton: cubit != null && (cubit.isOnline || widget.outbox != null) - ? FloatingActionButton.extended( - key: const Key('market.shareMine'), - onPressed: _shareMine, - icon: const Icon(Icons.campaign_outlined), - label: Text(t.market.shareMine), - ) - : null, + ? FloatingActionButton.extended( + key: const Key('market.shareMine'), + onPressed: _shareMine, + icon: const Icon(Icons.campaign_outlined), + label: Text(t.market.shareMine), + ) + : null, body: _loading || cubit == null ? const Center(child: CircularProgressIndicator()) : BlocProvider.value( @@ -298,6 +322,17 @@ class MarketBody extends StatelessWidget { final t = context.t; return BlocBuilder( builder: (context, state) { + // A new user's first step is setting a zone — which works offline — so + // that prompt wins over the connection error. + if (!hasArea) { + return _EmptyState( + icon: Icons.place_outlined, + title: t.market.setArea, + body: t.market.setAreaBody, + actionLabel: t.market.setUp, + onAction: onConfigure, + ); + } if (!context.read().isOnline) { // Default community servers exist, so "offline" means unreachable — // offer a retry rather than "set up sharing". @@ -309,15 +344,6 @@ class MarketBody extends StatelessWidget { onAction: onRetry, ); } - if (!hasArea) { - return _EmptyState( - icon: Icons.place_outlined, - title: t.market.setArea, - body: t.market.setAreaBody, - actionLabel: t.market.setUp, - onAction: onConfigure, - ); - } if (state.searching && state.offers.isEmpty) { return Center( child: Column( @@ -325,8 +351,10 @@ class MarketBody extends StatelessWidget { children: [ const CircularProgressIndicator(), const SizedBox(height: 16), - Text(t.market.searching, - style: const TextStyle(color: seedMuted)), + Text( + t.market.searching, + style: const TextStyle(color: seedMuted), + ), ], ), ); @@ -370,16 +398,22 @@ class MarketBody extends StatelessWidget { prefixIcon: const Icon(Icons.search, color: seedMuted), // Offer to save the current search once it's worth alerting on // (some text typed or a chip active), never for the bare list. - suffixIcon: savedSearches != null && + suffixIcon: + savedSearches != null && (state.query.trim().isNotEmpty || state.hasActiveFilter) ? IconButton( key: const Key('market.saveSearch'), - icon: const Icon(Icons.bookmark_add_outlined, - color: seedGreen), + icon: const Icon( + Icons.bookmark_add_outlined, + color: seedGreen, + ), tooltip: t.savedSearches.save, - onPressed: () => - _saveCurrentSearch(context, savedSearches!, state), + onPressed: () => _saveCurrentSearch( + context, + savedSearches!, + state, + ), ) : null, hintStyle: const TextStyle(color: seedMuted), @@ -414,23 +448,48 @@ class MarketBody extends StatelessWidget { ), ], ) - : ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), - itemCount: visible.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (context, i) { - final o = visible[i]; - final mine = o.authorPubkeyHex == selfPubkey; - return _OfferCard( - offer: o, - mine: mine, - onTap: () async { - await context.push('/market/offer', extra: o); - await onOfferClosed?.call(); - }, - ); + : NotificationListener( + // Page in older offers as the list nears its end, so a + // large area streams in on demand rather than all at + // once. The cubit guards against overlapping loads. + onNotification: (n) { + if (state.canLoadMore && + !state.loadingMore && + n.metrics.axis == Axis.vertical && + n.metrics.extentAfter < 500) { + cubit.loadNextPage(); + } + return false; }, + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + // A trailing spinner row while the next page loads. + itemCount: + visible.length + (state.loadingMore ? 1 : 0), + separatorBuilder: (_, _) => + const SizedBox(height: 12), + itemBuilder: (context, i) { + if (i >= visible.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + final o = visible[i]; + final mine = o.authorPubkeyHex == selfPubkey; + return _OfferCard( + offer: o, + mine: mine, + onTap: () async { + await context.push('/market/offer', extra: o); + await onOfferClosed?.call(); + }, + ); + }, + ), ), ), ), @@ -553,7 +612,9 @@ class _OfferCard extends StatelessWidget { if (mine) ...[ Container( padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 3), + horizontal: 8, + vertical: 3, + ), decoration: BoxDecoration( color: seedGreen, borderRadius: BorderRadius.circular(20), @@ -577,8 +638,10 @@ class _OfferCard extends StatelessWidget { children: [ const Icon(Icons.place_outlined, size: 15, color: seedMuted), const SizedBox(width: 4), - Text(t.market.near, - style: const TextStyle(color: seedMuted, fontSize: 13)), + Text( + t.market.near, + style: const TextStyle(color: seedMuted, fontSize: 13), + ), if (offer.isOrganic) ...[ const SizedBox(width: 10), const Icon(Icons.eco, size: 15, color: seedGreen), @@ -586,7 +649,8 @@ class _OfferCard extends StatelessWidget { const Spacer(), if (offer.type == OfferType.sale && offer.priceAmount != null) Text( - '${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(), + '${offer.priceAmount} ${offer.priceCurrency ?? ''}' + .trim(), style: const TextStyle( color: seedOnSurface, fontWeight: FontWeight.w600, @@ -738,10 +802,11 @@ class _EmptyState extends StatelessWidget { /// Coarse-area + community-server setup. Kept behind progressive disclosure — a /// power-user surface — with human-worded labels. class _ConfigSheet extends StatefulWidget { - const _ConfigSheet({required this.settings, this.location}); + const _ConfigSheet({required this.settings, this.location, this.sharing}); final SocialSettings settings; final CoarseLocationProvider? location; + final SharingSwitch? sharing; @override State<_ConfigSheet> createState() => _ConfigSheetState(); @@ -885,194 +950,250 @@ class _ConfigSheetState extends State<_ConfigSheet> { Widget build(BuildContext context) { final t = context.t; final hasArea = _area.text.trim().isNotEmpty; - return Padding( - padding: EdgeInsets.only( - left: 20, - right: 20, - top: 20, - bottom: MediaQuery.of(context).viewInsets.bottom + 20, - ), - child: _loading - ? const SizedBox( - height: 120, child: Center(child: CircularProgressIndicator())) - : SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - t.market.configTitle, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: seedTitle, - ), - ), - const SizedBox(height: 8), - Text( - t.market.setupIntro, - style: const TextStyle( - color: seedMuted, fontSize: 13, height: 1.4), - ), - const SizedBox(height: 18), - // Setting your area from device location is the human path; - // typing a code is the advanced fallback. - if (widget.location != null) - FilledButton.tonalIcon( - key: const Key('market.useLocation'), - onPressed: _locationBusy ? null : _useLocation, - icon: _locationBusy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.my_location, size: 18), - label: Text(t.market.useLocation), - ), - if (_locationError != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - _locationError!, - style: const TextStyle( - color: Color(0xFFB3261E), fontSize: 12), + // SafeArea keeps the Save button above the system nav bar on edge-to-edge + // devices; the viewInsets padding still lifts it above the keyboard. + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + ), + child: _loading + ? const SizedBox( + height: 120, + child: Center(child: CircularProgressIndicator()), + ) + : SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.market.configTitle, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: seedTitle, ), ), - const SizedBox(height: 12), - Row( - children: [ - Icon( - hasArea - ? Icons.check_circle - : Icons.pending_outlined, - size: 18, - color: hasArea ? seedGreen : seedMuted, + const SizedBox(height: 8), + Text( + t.market.setupIntro, + style: const TextStyle( + color: seedMuted, + fontSize: 13, + height: 1.4, ), - const SizedBox(width: 8), - Expanded( + ), + const SizedBox(height: 18), + // Setting your area from device location is the human path; + // typing a code is the advanced fallback. + if (widget.location != null) + FilledButton.tonalIcon( + key: const Key('market.useLocation'), + onPressed: _locationBusy ? null : _useLocation, + icon: _locationBusy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.my_location, size: 18), + label: Text(t.market.useLocation), + ), + if (_locationError != null) + Padding( + padding: const EdgeInsets.only(top: 6), child: Text( - hasArea ? t.market.areaSet : t.market.areaNotSet, - style: TextStyle( - color: hasArea ? seedOnSurface : seedMuted, - fontSize: 13, + _locationError!, + style: const TextStyle( + color: Color(0xFFB3261E), + fontSize: 12, ), ), ), - ], - ), - const SizedBox(height: 16), - Align( - alignment: AlignmentDirectional.centerStart, - child: Text( - t.market.rangeLabel, - style: const TextStyle(fontSize: 13, color: seedMuted), - ), - ), - const SizedBox(height: 8), - SegmentedButton( - key: const Key('market.range'), - segments: [ - ButtonSegment(value: 5, label: Text(t.market.rangeNear)), - ButtonSegment(value: 4, label: Text(t.market.rangeArea)), - ButtonSegment(value: 3, label: Text(t.market.rangeRegion)), - ], - selected: {_precision}, - showSelectedIcon: false, - onSelectionChanged: (s) => - setState(() => _precision = s.first), - ), - const SizedBox(height: 8), - Theme( - data: Theme.of(context) - .copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - key: const Key('market.advanced'), - tilePadding: EdgeInsets.zero, - childrenPadding: const EdgeInsets.only(bottom: 8), - title: Text( - t.market.advanced, - style: const TextStyle(fontSize: 13, color: seedMuted), - ), + const SizedBox(height: 12), + Row( children: [ - TextField( - key: const Key('market.area'), - controller: _area, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: t.market.areaCodeLabel, - helperText: t.market.areaCodeHint, - helperMaxLines: 2, - ), + Icon( + hasArea ? Icons.check_circle : Icons.pending_outlined, + size: 18, + color: hasArea ? seedGreen : seedMuted, ), - const SizedBox(height: 14), - Align( - alignment: AlignmentDirectional.centerStart, + const SizedBox(width: 8), + Expanded( child: Text( - t.market.serversLabel, - style: const TextStyle( - fontSize: 13, color: seedMuted), - ), - ), - Padding( - padding: const EdgeInsetsDirectional.only( - top: 2, bottom: 4), - child: Text( - t.market.serversHelp, - style: const TextStyle( - fontSize: 12, color: seedMuted, height: 1.3), - ), - ), - for (final url in _knownRelays) - CheckboxListTile( - key: Key('market.server.$url'), - contentPadding: EdgeInsets.zero, - dense: true, - controlAffinity: ListTileControlAffinity.leading, - value: _selectedRelays.contains(url), - title: Text(_relayLabel(url)), - onChanged: (on) => setState(() { - if (on ?? false) { - _selectedRelays.add(url); - } else { - _selectedRelays.remove(url); - } - }), - ), - Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton.icon( - key: const Key('market.addServer'), - onPressed: _addServer, - icon: const Icon(Icons.add, size: 18), - label: Text(t.market.serversAdvanced), - ), - ), - ListTile( - key: const Key('market.blockedPeople'), - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.block, color: seedMuted), - title: Text(t.block.manageTitle), - trailing: const Icon(Icons.chevron_right), - onTap: () => showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => - BlockedPeopleSheet(settings: widget.settings), + hasArea ? t.market.areaSet : t.market.areaNotSet, + style: TextStyle( + color: hasArea ? seedOnSurface : seedMuted, + fontSize: 13, + ), ), ), ], ), - ), - const SizedBox(height: 20), - FilledButton( - key: const Key('market.save'), - onPressed: _save, - child: Text(t.market.save), - ), - ], + const SizedBox(height: 16), + Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + t.market.rangeLabel, + style: const TextStyle(fontSize: 13, color: seedMuted), + ), + ), + const SizedBox(height: 8), + SegmentedButton( + key: const Key('market.range'), + segments: [ + ButtonSegment( + value: 5, + label: Text(t.market.rangeNear), + ), + ButtonSegment( + value: 4, + label: Text(t.market.rangeArea), + ), + ButtonSegment( + value: 3, + label: Text(t.market.rangeRegion), + ), + ], + selected: {_precision}, + showSelectedIcon: false, + onSelectionChanged: (s) => + setState(() => _precision = s.first), + ), + const SizedBox(height: 8), + Theme( + data: Theme.of( + context, + ).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + key: const Key('market.advanced'), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 8), + title: Text( + t.market.advanced, + style: const TextStyle( + fontSize: 13, + color: seedMuted, + ), + ), + children: [ + // The master switch: turning it off takes Tane fully + // offline right away, not at the next launch. + if (widget.sharing != null) + ValueListenableBuilder( + valueListenable: widget.sharing!.on, + builder: (context, on, _) => SwitchListTile( + key: const Key('market.sharingOn'), + contentPadding: EdgeInsets.zero, + dense: true, + value: on, + title: Text(t.market.sharingOnLabel), + subtitle: Text( + t.market.sharingOnHelp, + style: const TextStyle( + fontSize: 12, + color: seedMuted, + height: 1.3, + ), + ), + onChanged: (want) => want + ? widget.sharing!.enable() + : widget.sharing!.disable(), + ), + ), + TextField( + key: const Key('market.area'), + controller: _area, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: t.market.areaCodeLabel, + helperText: t.market.areaCodeHint, + helperMaxLines: 2, + ), + ), + const SizedBox(height: 14), + Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + t.market.serversLabel, + style: const TextStyle( + fontSize: 13, + color: seedMuted, + ), + ), + ), + Padding( + padding: const EdgeInsetsDirectional.only( + top: 2, + bottom: 4, + ), + child: Text( + t.market.serversHelp, + style: const TextStyle( + fontSize: 12, + color: seedMuted, + height: 1.3, + ), + ), + ), + for (final url in _knownRelays) + CheckboxListTile( + key: Key('market.server.$url'), + contentPadding: EdgeInsets.zero, + dense: true, + controlAffinity: ListTileControlAffinity.leading, + value: _selectedRelays.contains(url), + title: Text(_relayLabel(url)), + onChanged: (on) => setState(() { + if (on ?? false) { + _selectedRelays.add(url); + } else { + _selectedRelays.remove(url); + } + }), + ), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + key: const Key('market.addServer'), + onPressed: _addServer, + icon: const Icon(Icons.add, size: 18), + label: Text(t.market.serversAdvanced), + ), + ), + ListTile( + key: const Key('market.blockedPeople'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.block, color: seedMuted), + title: Text(t.block.manageTitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => + BlockedPeopleSheet(settings: widget.settings), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + FilledButton( + key: const Key('market.save'), + onPressed: _save, + child: Text(t.market.save), + ), + ], + ), ), - ), + ), ); } } diff --git a/apps/app_seeds/lib/ui/market_widgets.dart b/apps/app_seeds/lib/ui/market_widgets.dart index 20c9f31..92da91d 100644 --- a/apps/app_seeds/lib/ui/market_widgets.dart +++ b/apps/app_seeds/lib/ui/market_widgets.dart @@ -36,6 +36,7 @@ class OfferThumbnail extends StatelessWidget { return ClipRRect( borderRadius: BorderRadius.circular(10), child: _offerImage( + context, url, semanticLabel: semanticLabel, width: size, @@ -63,7 +64,7 @@ class OfferHeroImage extends StatelessWidget { borderRadius: BorderRadius.circular(14), child: AspectRatio( aspectRatio: 4 / 3, - child: _offerImage(url, semanticLabel: semanticLabel), + child: _offerImage(context, url, semanticLabel: semanticLabel), ), ); } @@ -73,6 +74,7 @@ class OfferHeroImage extends StatelessWidget { /// remote URL (via network), always cropping to fill and falling back to a /// neutral placeholder — a broken image must never block the card. Widget _offerImage( + BuildContext context, String url, { required String semanticLabel, double? width, @@ -80,10 +82,16 @@ Widget _offerImage( }) { final inline = decodeDataUri(url); if (inline != null) { + // For a sized thumbnail, decode down to its on-screen pixels instead of the + // photo's full resolution (Play's "bitmap downscaling"). The hero image + // passes no width, so it decodes at native size as intended. + final dpr = MediaQuery.devicePixelRatioOf(context); return Image.memory( inline, width: width, height: height, + cacheWidth: width == null ? null : (width * dpr).round(), + cacheHeight: height == null ? null : (height * dpr).round(), fit: BoxFit.cover, semanticLabel: semanticLabel, errorBuilder: (context, _, _) => diff --git a/apps/app_seeds/lib/ui/peer_avatar.dart b/apps/app_seeds/lib/ui/peer_avatar.dart index d501b9d..09bd3ef 100644 --- a/apps/app_seeds/lib/ui/peer_avatar.dart +++ b/apps/app_seeds/lib/ui/peer_avatar.dart @@ -34,7 +34,19 @@ class PeerAvatar extends StatelessWidget { if (pic.startsWith('data:')) { final bytes = decodeDataUri(pic); if (bytes != null) { - return CircleAvatar(radius: radius, backgroundImage: MemoryImage(bytes)); + // Decode the photo down to the avatar's on-screen size (in physical + // pixels) instead of full resolution — the "bitmap downscaling" Play + // recommends. A tiny disc never needs a multi-megapixel decode. + final side = (radius * 2 * MediaQuery.devicePixelRatioOf(context)) + .round(); + return CircleAvatar( + radius: radius, + backgroundImage: ResizeImage( + MemoryImage(bytes), + width: side, + height: side, + ), + ); } } else if (pic.startsWith(avatarGlyphPrefix)) { final glyph = avatarGlyphChar(pic.substring(avatarGlyphPrefix.length)); diff --git a/apps/app_seeds/lib/ui/photo_pick.dart b/apps/app_seeds/lib/ui/photo_pick.dart index f01f684..a7ae779 100644 --- a/apps/app_seeds/lib/ui/photo_pick.dart +++ b/apps/app_seeds/lib/ui/photo_pick.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../i18n/strings.g.dart'; +import '../services/camera_availability.dart'; /// Asks the user to take a photo or pick one from the gallery, then returns its /// bytes (or null if cancelled/unavailable). Camera failures — e.g. on desktop, @@ -16,12 +17,15 @@ Future pickPhoto(BuildContext context) async { child: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - key: const Key('photo.source.camera'), - leading: const Icon(Icons.photo_camera_outlined), - title: Text(t.photo.camera), - onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), - ), + // Only offer the camera when the device actually has one; camera-less + // devices (Chromebooks, Automotive, some TVs) fall back to gallery. + if (deviceHasCamera) + ListTile( + key: const Key('photo.source.camera'), + leading: const Icon(Icons.photo_camera_outlined), + title: Text(t.photo.camera), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), + ), ListTile( key: const Key('photo.source.gallery'), leading: const Icon(Icons.photo_library_outlined), @@ -57,12 +61,15 @@ Future> pickPhotos(BuildContext context) async { child: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - key: const Key('photos.source.camera'), - leading: const Icon(Icons.photo_camera_outlined), - title: Text(t.photo.camera), - onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), - ), + // Only offer the camera when the device actually has one; camera-less + // devices (Chromebooks, Automotive, some TVs) fall back to gallery. + if (deviceHasCamera) + ListTile( + key: const Key('photos.source.camera'), + leading: const Icon(Icons.photo_camera_outlined), + title: Text(t.photo.camera), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), + ), ListTile( key: const Key('photos.source.gallery'), leading: const Icon(Icons.photo_library_outlined), diff --git a/apps/app_seeds/lib/ui/qr_scan.dart b/apps/app_seeds/lib/ui/qr_scan.dart index 05126ed..f765d81 100644 --- a/apps/app_seeds/lib/ui/qr_scan.dart +++ b/apps/app_seeds/lib/ui/qr_scan.dart @@ -7,12 +7,17 @@ import 'package:zxing_barcode_scanner/zxing_barcode_scanner.dart'; import '../data/species_repository.dart'; import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; +import '../services/camera_availability.dart'; import '../services/seed_label_scan.dart'; /// Whether this platform can open the camera scanner. Mobile-only for now /// (pure-ZXing platform views; no Google Play Services) — elsewhere the scan -/// button simply isn't offered, same pattern as the OCR capture. -bool get qrScanSupported => !kIsWeb && (Platform.isAndroid || Platform.isIOS); +/// button simply isn't offered, same pattern as the OCR capture. On Android it +/// also requires an actual camera, so camera-less devices (Chromebooks, +/// Automotive, some TVs) don't get a scan button that can't open — see +/// [deviceHasCamera]. +bool get qrScanSupported => + !kIsWeb && ((Platform.isAndroid && deviceHasCamera) || Platform.isIOS); /// Opens the full-screen camera scanner and returns the first decoded QR /// payload, or null if the person backed out. (Ğ1nkgo's scanner pattern on diff --git a/apps/app_seeds/lib/ui/quick_add_sheet.dart b/apps/app_seeds/lib/ui/quick_add_sheet.dart index 49574ab..349d247 100644 --- a/apps/app_seeds/lib/ui/quick_add_sheet.dart +++ b/apps/app_seeds/lib/ui/quick_add_sheet.dart @@ -199,6 +199,8 @@ class _MoreSection extends StatelessWidget { child: Image.memory( state.photoBytes!, height: 120, + cacheHeight: + (120 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), diff --git a/apps/app_seeds/lib/ui/settings_screen.dart b/apps/app_seeds/lib/ui/settings_screen.dart index ff619ef..fa49126 100644 --- a/apps/app_seeds/lib/ui/settings_screen.dart +++ b/apps/app_seeds/lib/ui/settings_screen.dart @@ -57,6 +57,7 @@ List<(AppLocale, String)> _languages(Translations t) => [ (AppLocale.ast, t.settings.langAst), (AppLocale.en, t.settings.langEn), (AppLocale.pt, t.settings.langPt), + (AppLocale.ptBr, t.settings.langPtBr), (AppLocale.fr, t.settings.langFr), (AppLocale.de, t.settings.langDe), (AppLocale.ja, t.settings.langJa), diff --git a/apps/app_seeds/lib/ui/sharing_invite_sheet.dart b/apps/app_seeds/lib/ui/sharing_invite_sheet.dart new file mode 100644 index 0000000..5cbe32b --- /dev/null +++ b/apps/app_seeds/lib/ui/sharing_invite_sheet.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; +import '../services/onboarding_store.dart'; +import '../services/sharing_switch.dart'; +import 'market_gate.dart'; +import 'theme.dart'; + +/// Shown when someone taps a social entry (chat, profile, favourites, your +/// people) while sharing is still off. +/// +/// The alternative was hiding those entries until sharing is on, but then +/// nobody would ever discover that Tane does any of it. So they stay in the +/// drawer, quiet, and tapping one explains what they are and offers to switch +/// them on — with the community rules, which is the same single consent step +/// the market uses. Returns true when sharing ended up on. +Future showSharingInvite( + BuildContext context, { + required OnboardingStore onboarding, + required SharingSwitch sharing, +}) async { + final wants = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => const SharingInviteSheet(), + ); + if (wants != true || !context.mounted) return false; + return ensureMarketRulesAccepted(context, onboarding, sharing: sharing); +} + +/// The invitation itself: what lights up when you join, and the plain fact that +/// it needs a connection. +class SharingInviteSheet extends StatelessWidget { + const SharingInviteSheet({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.t; + final theme = Theme.of(context); + return SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 24, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.sharingInvite.title, + style: theme.textTheme.titleLarge?.copyWith( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + _Perk(Icons.chat_bubble_outline, t.sharingInvite.perkChat), + _Perk(Icons.favorite_border, t.sharingInvite.perkFavorites), + _Perk(Icons.group_outlined, t.sharingInvite.perkPeople), + const SizedBox(height: 12), + Text( + t.sharingInvite.networkNote, + style: theme.textTheme.bodySmall?.copyWith( + color: seedMuted, + height: 1.4, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + key: const Key('sharingInvite.notNow'), + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.sharingInvite.notNow), + ), + const SizedBox(width: 8), + FilledButton( + key: const Key('sharingInvite.start'), + onPressed: () => Navigator.of(context).pop(true), + child: Text(t.sharingInvite.start), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _Perk extends StatelessWidget { + const _Perk(this.icon, this.text); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: seedGreen), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: seedOnSurface, + height: 1.35, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 740713b..e9d6634 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -2244,10 +2244,16 @@ class _PhotoGalleryState extends State<_PhotoGallery> { itemBuilder: (_, i) => GestureDetector( key: Key('photo.thumb.$i'), onTap: () => _openPhotoViewer(context, cubit, i), + // Decode to the 140px thumbnail size (× dpr), not full photo + // resolution — the full-res decode happens in the viewer. child: Image.memory( photos[i].bytes, width: 140, height: 140, + cacheWidth: + (140 * MediaQuery.devicePixelRatioOf(context)).round(), + cacheHeight: + (140 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index bf5103b..402f502 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -1,7 +1,7 @@ name: tane description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market." publish_to: 'none' -version: 0.1.3+5 +version: 0.1.16+18 environment: sdk: ^3.11.5 diff --git a/apps/app_seeds/test/data/inventory_scale_test.dart b/apps/app_seeds/test/data/inventory_scale_test.dart index d6928df..576e9b7 100644 --- a/apps/app_seeds/test/data/inventory_scale_test.dart +++ b/apps/app_seeds/test/data/inventory_scale_test.dart @@ -19,8 +19,8 @@ void main() { }); tearDown(() => db.close()); - test('the inventory view loads 3000 varieties quickly', () async { - const count = 3000; + test('the inventory view loads 10000 varieties quickly', () async { + const count = 10000; for (var i = 0; i < count; i++) { final id = await repo.addQuickVariety( label: 'Variety $i', @@ -43,11 +43,12 @@ void main() { sw.stop(); expect(view.items, hasLength(count)); - // Generous ceiling for CI; typical local runs are well under 500ms. The - // point is to catch an accidental N+1 regression, not to micro-benchmark. + // Generous ceiling for CI (the point is to catch an N+1 regression, not to + // micro-benchmark). Includes the ~250ms debounce on the first emit; typical + // local runs are well under 2s for 10k rows with the v14 indexes. expect( sw.elapsedMilliseconds, - lessThan(3000), + lessThan(6000), reason: 'inventory view took ${sw.elapsedMilliseconds}ms for $count rows', ); }); diff --git a/apps/app_seeds/test/db/migration_test.dart b/apps/app_seeds/test/db/migration_test.dart index 7b7f0cd..4653582 100644 --- a/apps/app_seeds/test/db/migration_test.dart +++ b/apps/app_seeds/test/db/migration_test.dart @@ -11,19 +11,19 @@ void main() { verifier = SchemaVerifier(GeneratedHelper()); }); - test('freshly created database matches the exported schema v13', () async { - final schema = await verifier.schemaAt(13); + test('freshly created database matches the exported schema v14', () async { + final schema = await verifier.schemaAt(14); final db = AppDatabase(schema.newConnection()); - await verifier.migrateAndValidate(db, 13); + await verifier.migrateAndValidate(db, 14); await db.close(); }); - // Every historical version upgrades cleanly to the current schema (v13). - for (var from = 1; from <= 12; from++) { - test('upgrades v$from → v13 and matches the fresh schema', () async { + // Every historical version upgrades cleanly to the current schema (v14). + for (var from = 1; from <= 13; from++) { + test('upgrades v$from → v14 and matches the fresh schema', () async { final connection = await verifier.startAt(from); final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 13); + await verifier.migrateAndValidate(db, 14); await db.close(); }); } diff --git a/apps/app_seeds/test/db/schema/schema.dart b/apps/app_seeds/test/db/schema/schema.dart index ba56c19..938ab12 100644 --- a/apps/app_seeds/test/db/schema/schema.dart +++ b/apps/app_seeds/test/db/schema/schema.dart @@ -17,6 +17,7 @@ import 'schema_v10.dart' as v10; import 'schema_v11.dart' as v11; import 'schema_v12.dart' as v12; import 'schema_v13.dart' as v13; +import 'schema_v14.dart' as v14; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -48,10 +49,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v12.DatabaseAtV12(db); case 13: return v13.DatabaseAtV13(db); + case 14: + return v14.DatabaseAtV14(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]; } diff --git a/apps/app_seeds/test/db/schema/schema_v14.dart b/apps/app_seeds/test/db/schema/schema_v14.dart new file mode 100644 index 0000000..ad1dee1 --- /dev/null +++ b/apps/app_seeds/test/db/schema/schema_v14.dart @@ -0,0 +1,2248 @@ +// dart format width=80 +import 'dart:typed_data' as i2; +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Varieties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Varieties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn label = GeneratedColumn( + 'label', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn cultivarName = GeneratedColumn( + 'cultivar_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isDraft = GeneratedColumn( + 'is_draft', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_draft IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isOrganic = GeneratedColumn( + 'is_organic', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_organic IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn needsReproduction = GeneratedColumn( + 'needs_reproduction', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: + 'NOT NULL DEFAULT 0 CHECK (needs_reproduction IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn sowMonths = GeneratedColumn( + 'sow_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn transplantMonths = GeneratedColumn( + 'transplant_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn floweringMonths = GeneratedColumn( + 'flowering_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn fruitingMonths = GeneratedColumn( + 'fruiting_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seedHarvestMonths = GeneratedColumn( + 'seed_harvest_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + label, + speciesId, + cultivarName, + category, + notes, + isDraft, + isOrganic, + needsReproduction, + sowMonths, + transplantMonths, + floweringMonths, + fruitingMonths, + seedHarvestMonths, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'varieties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Varieties createAlias(String alias) { + return Varieties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class VarietyVernacularNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + VarietyVernacularNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn region = GeneratedColumn( + 'region', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + name, + language, + region, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'variety_vernacular_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + VarietyVernacularNames createAlias(String alias) { + return VarietyVernacularNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Species extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Species(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn scientificName = GeneratedColumn( + 'scientific_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn wikidataQid = GeneratedColumn( + 'wikidata_qid', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn gbifKey = GeneratedColumn( + 'gbif_key', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn family = GeneratedColumn( + 'family', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isBundled = GeneratedColumn( + 'is_bundled', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_bundled IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn viabilityYears = GeneratedColumn( + 'viability_years', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + scientificName, + wikidataQid, + gbifKey, + family, + isBundled, + viabilityYears, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Species createAlias(String alias) { + return Species(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class SpeciesCommonNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SpeciesCommonNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + speciesId, + name, + language, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species_common_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + SpeciesCommonNames createAlias(String alias) { + return SpeciesCommonNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Lots extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Lots(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'seed\'', + defaultValue: const CustomExpression('\'seed\''), + ); + late final GeneratedColumn harvestYear = GeneratedColumn( + 'harvest_year', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn harvestMonth = GeneratedColumn( + 'harvest_month', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn presentation = GeneratedColumn( + 'presentation', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn storageLocation = GeneratedColumn( + 'storage_location', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn offerStatus = GeneratedColumn( + 'offer_status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'private\'', + defaultValue: const CustomExpression('\'private\''), + ); + late final GeneratedColumn seedbankId = GeneratedColumn( + 'seedbank_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originName = GeneratedColumn( + 'origin_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originPlace = GeneratedColumn( + 'origin_place', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn abundance = GeneratedColumn( + 'abundance', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn preservationFormat = + GeneratedColumn( + 'preservation_format', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn priceAmount = GeneratedColumn( + 'price_amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn priceCurrency = GeneratedColumn( + 'price_currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + type, + harvestYear, + harvestMonth, + quantityKind, + quantityPrecise, + quantityLabel, + presentation, + storageLocation, + offerStatus, + seedbankId, + originName, + originPlace, + abundance, + preservationFormat, + priceAmount, + priceCurrency, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'lots'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Lots createAlias(String alias) { + return Lots(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class GerminationTests extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GerminationTests(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn testedOn = GeneratedColumn( + 'tested_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sampleSize = GeneratedColumn( + 'sample_size', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn germinatedCount = GeneratedColumn( + 'germinated_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + testedOn, + sampleSize, + germinatedCount, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'germination_tests'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GerminationTests createAlias(String alias) { + return GerminationTests(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ConditionChecks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ConditionChecks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn checkedOn = GeneratedColumn( + 'checked_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn containerCount = GeneratedColumn( + 'container_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn desiccantState = GeneratedColumn( + 'desiccant_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + checkedOn, + containerCount, + desiccantState, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'condition_checks'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ConditionChecks createAlias(String alias) { + return ConditionChecks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class GardenOutcomes extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GardenOutcomes(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn year = GeneratedColumn( + 'year', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn rating = GeneratedColumn( + 'rating', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + year, + rating, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'garden_outcomes'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GardenOutcomes createAlias(String alias) { + return GardenOutcomes(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Movements extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Movements(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn occurredOn = GeneratedColumn( + 'occurred_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn counterpartyId = GeneratedColumn( + 'counterparty_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn parentMovementId = GeneratedColumn( + 'parent_movement_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn plantareId = GeneratedColumn( + 'plantare_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + lastAuthor, + schemaRowVersion, + lotId, + type, + occurredOn, + counterpartyId, + quantityKind, + quantityPrecise, + quantityLabel, + parentMovementId, + plantareId, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'movements'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Movements createAlias(String alias) { + return Movements(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Parties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Parties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn publicKey = GeneratedColumn( + 'public_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'person\'', + defaultValue: const CustomExpression('\'person\''), + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + displayName, + publicKey, + kind, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'parties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Parties createAlias(String alias) { + return Parties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Attachments extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Attachments(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn uri = GeneratedColumn( + 'uri', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn bytes = + GeneratedColumn( + 'bytes', + aliasedName, + true, + type: DriftSqlType.blob, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn thumbnail = + GeneratedColumn( + 'thumbnail', + aliasedName, + true, + type: DriftSqlType.blob, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn mimeType = GeneratedColumn( + 'mime_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sortOrder = GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + kind, + uri, + bytes, + thumbnail, + mimeType, + sortOrder, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'attachments'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Attachments createAlias(String alias) { + return Attachments(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ExternalLinks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ExternalLinks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + url, + title, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'external_links'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ExternalLinks createAlias(String alias) { + return ExternalLinks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Plantares extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Plantares(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn owedDescription = GeneratedColumn( + 'owed_description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn madeOn = GeneratedColumn( + 'made_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn dueBy = GeneratedColumn( + 'due_by', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'open\'', + defaultValue: const CustomExpression('\'open\''), + ); + late final GeneratedColumn settledOn = GeneratedColumn( + 'settled_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn pledgeId = GeneratedColumn( + 'pledge_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn debtorKey = GeneratedColumn( + 'debtor_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn creditorKey = GeneratedColumn( + 'creditor_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn debtorSignature = GeneratedColumn( + 'debtor_signature', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn creditorSignature = + GeneratedColumn( + 'creditor_signature', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn movementId = GeneratedColumn( + 'movement_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn remoteState = GeneratedColumn( + 'remote_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn returnKind = GeneratedColumn( + 'return_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'similar\'', + defaultValue: const CustomExpression('\'similar\''), + ); + late final GeneratedColumn workHours = GeneratedColumn( + 'work_hours', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + owedDescription, + madeOn, + dueBy, + status, + settledOn, + note, + pledgeId, + debtorKey, + creditorKey, + debtorSignature, + creditorSignature, + movementId, + remoteState, + returnKind, + workHours, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'plantares'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Plantares createAlias(String alias) { + return Plantares(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Sales extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Sales(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn amount = GeneratedColumn( + 'amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn currency = GeneratedColumn( + 'currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn soldOn = GeneratedColumn( + 'sold_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + amount, + currency, + soldOn, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sales'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Sales createAlias(String alias) { + return Sales(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class DatabaseAtV14 extends GeneratedDatabase { + DatabaseAtV14(QueryExecutor e) : super(e); + late final Varieties varieties = Varieties(this); + late final VarietyVernacularNames varietyVernacularNames = + VarietyVernacularNames(this); + late final Species species = Species(this); + late final SpeciesCommonNames speciesCommonNames = SpeciesCommonNames(this); + late final Lots lots = Lots(this); + late final GerminationTests germinationTests = GerminationTests(this); + late final ConditionChecks conditionChecks = ConditionChecks(this); + late final GardenOutcomes gardenOutcomes = GardenOutcomes(this); + late final Movements movements = Movements(this); + late final Parties parties = Parties(this); + late final Attachments attachments = Attachments(this); + late final ExternalLinks externalLinks = ExternalLinks(this); + late final Plantares plantares = Plantares(this); + late final Sales sales = Sales(this); + late final Index idxVarietiesDeletedDraft = Index( + 'idx_varieties_deleted_draft', + 'CREATE INDEX idx_varieties_deleted_draft ON varieties (is_deleted, is_draft)', + ); + late final Index idxLotsVariety = Index( + 'idx_lots_variety', + 'CREATE INDEX idx_lots_variety ON lots (variety_id)', + ); + late final Index idxAttachmentsParent = Index( + 'idx_attachments_parent', + 'CREATE INDEX idx_attachments_parent ON attachments (parent_type, parent_id, kind)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + varieties, + varietyVernacularNames, + species, + speciesCommonNames, + lots, + germinationTests, + conditionChecks, + gardenOutcomes, + movements, + parties, + attachments, + externalLinks, + plantares, + sales, + idxVarietiesDeletedDraft, + idxLotsVariety, + idxAttachmentsParent, + ]; + @override + int get schemaVersion => 14; +} diff --git a/apps/app_seeds/test/screenshots/screenshots_test.dart b/apps/app_seeds/test/screenshots/screenshots_test.dart index fac0139..edfe643 100644 --- a/apps/app_seeds/test/screenshots/screenshots_test.dart +++ b/apps/app_seeds/test/screenshots/screenshots_test.dart @@ -105,7 +105,7 @@ void main() { seeded = await seedShowcase(db, repo, locale); }); final child = switch (name) { - 'home' => const HomeScreen(marketEnabled: true), + 'home' => HomeScreen(sharing: newTestSharingSwitch()), 'inventory' => const InventoryListScreen(), 'market' => marketWidget(locale), 'calendar' => const CalendarScreen(initialMonth: 4), @@ -156,7 +156,11 @@ class _SeededOffersCubit extends OffersCubit { /// A transport that is present (so the market reads as online) but never used. class _NoopOfferTransport implements OfferTransport { @override - Stream discover(DiscoveryQuery query) => const Stream.empty(); + Stream discover(DiscoveryQuery query, {int? since}) => + const Stream.empty(); + @override + Future discoverPage(DiscoveryQuery query) async => + const OfferPage(offers: []); @override Future publish(Offer offer) => throw UnimplementedError(); @override diff --git a/apps/app_seeds/test/services/i18n_pt_br_locale_test.dart b/apps/app_seeds/test/services/i18n_pt_br_locale_test.dart new file mode 100644 index 0000000..84a4d3a --- /dev/null +++ b/apps/app_seeds/test/services/i18n_pt_br_locale_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; + +/// Brazilian Portuguese (`pt_BR`) is the first locale in the app to carry a +/// region code alongside a base language that already has its own locale +/// (`pt`, European Portuguese) — a case slang and the settings picker had +/// never exercised before. These tests pin that both variants are wired +/// distinctly and neither silently falls back to the other. +void main() { + test('pt and pt_BR are both supported, as distinct locales', () { + expect( + AppLocaleUtils.supportedLocales, + containsAll([const Locale('pt'), const Locale('pt', 'BR')]), + ); + }); + + test('core strings resolve in Brazilian Portuguese', () { + final ptBr = AppLocale.ptBr.buildSync(); + expect(ptBr.menu.inventory, 'Inventário'); + expect(ptBr.common.save, 'Salvar'); + expect(ptBr.settings.langPtBr, 'Português (Brasil)'); + }); + + test('pt_BR wording diverges from pt where Brazilian usage differs', () { + final pt = AppLocale.pt.buildSync(); + final ptBr = AppLocale.ptBr.buildSync(); + // European Portuguese uses "Guardar"; Brazilian software uses "Salvar". + expect(pt.common.save, 'Guardar'); + expect(ptBr.common.save, 'Salvar'); + }); + + test('pt_BR translates a key added after the initial pt rollout', () { + final en = AppLocale.en.buildSync(); + final ptBr = AppLocale.ptBr.buildSync(); + // savedSearches was a later addition; if pt_BR were missing it, this + // would silently surface the English fallback instead. + expect(ptBr.savedSearches.title, isNot(en.savedSearches.title)); + expect(ptBr.savedSearches.title, isNotEmpty); + }); +} diff --git a/apps/app_seeds/test/services/social_connection_test.dart b/apps/app_seeds/test/services/social_connection_test.dart index e4c7151..4618691 100644 --- a/apps/app_seeds/test/services/social_connection_test.dart +++ b/apps/app_seeds/test/services/social_connection_test.dart @@ -45,11 +45,13 @@ void main() { required List opened, Stream? online, bool Function()? fail, + List? retrySchedule, }) => SocialConnection( social: social, settings: settings, online: online, + retrySchedule: retrySchedule, open: (_) async { if (fail?.call() ?? false) throw StateError('unreachable'); final ch = FakeChannel(); @@ -106,6 +108,69 @@ void main() { await online.close(); }); + test( + 'started connection retries by itself after a failed first attempt, ' + 'without any connectivity event', () async { + // The fresh-install case: device online the whole time, but the very first + // connect fails (cold DNS/TLS). The connectivity stream never fires, so + // only the backoff retry can bring the market up without a manual Retry. + final opened = []; + var down = true; + final conn = make( + opened: opened, + online: const Stream.empty(), // connectivity never speaks + fail: () => down, + retrySchedule: const [Duration(milliseconds: 5)], + ); + final emitted = []; + conn.sessions.listen(emitted.add); + + conn.start(); + await Future.delayed(Duration.zero); + expect(conn.current, isNull); // first attempt failed + + down = false; // network path recovers + await Future.delayed(const Duration(milliseconds: 100)); + expect(conn.current, isNotNull, reason: 'backoff retry reconnected'); + expect(opened, hasLength(1)); + expect(emitted.last, isNotNull, reason: 'recovery announced on sessions'); + + await conn.dispose(); + }); + + test('a plain session() failure schedules no retry when never started', + () async { + // Widget tests build cubits against an un-started connection; a failed + // one-shot session() must not leave a pending retry timer behind. + final opened = []; + final conn = make( + opened: opened, + fail: () => true, + retrySchedule: const [Duration(milliseconds: 5)], + ); + expect(await conn.session(), isNull); + await Future.delayed(const Duration(milliseconds: 50)); + expect(opened, isEmpty); // no background retry fired + await conn.dispose(); + }); + + test('dispose cancels a pending backoff retry', () async { + final opened = []; + var down = true; + final conn = make( + opened: opened, + online: const Stream.empty(), + fail: () => down, + retrySchedule: const [Duration(milliseconds: 20)], + ); + conn.start(); + await Future.delayed(Duration.zero); // first attempt fails + await conn.dispose(); // cancels the scheduled retry + down = false; + await Future.delayed(const Duration(milliseconds: 100)); + expect(opened, isEmpty, reason: 'no reconnect after dispose'); + }); + test('returns null when the relay is unreachable, and retries later', () async { final opened = []; @@ -116,4 +181,70 @@ void main() { expect(await conn.session(), isNotNull); // succeeds on retry await conn.dispose(); }); + + test('start is idempotent: a second call adds no second connect', () async { + // Joining sharing calls start() while bootstrap may already have, so a + // repeat must not stack another connectivity subscription or dial again. + final opened = []; + final online = StreamController.broadcast(); + final conn = make(opened: opened, online: online.stream); + + conn.start(); + await conn.session(); + conn.start(); + await Future.delayed(Duration.zero); + expect(opened, hasLength(1)); + + // One subscription, so one drop — not two competing reactions. + online.add(false); + await Future.delayed(Duration.zero); + expect(conn.current, isNull); + expect(opened.first.closed, isTrue); + + await conn.dispose(); + await online.close(); + }); + + test('stop goes offline now, not at the next launch', () async { + // Turning sharing off used to leave the live session open until restart. + final opened = []; + final online = StreamController.broadcast(); + final conn = make(opened: opened, online: online.stream); + final emitted = []; + conn.sessions.listen(emitted.add); + + conn.start(); + expect(await conn.session(), isNotNull); + + await conn.stop(); + await Future.delayed(Duration.zero); // let the drop be announced + expect(conn.current, isNull); + expect(opened.single.closed, isTrue); + expect(emitted.last, isNull); + + // And it stays off: a connectivity event must not resurrect it. + online.add(true); + await Future.delayed(Duration.zero); + expect(conn.current, isNull); + expect(opened, hasLength(1)); + + await conn.dispose(); + await online.close(); + }); + + test('stop leaves the connection usable: start brings it back', () async { + final opened = []; + final online = StreamController.broadcast(); + final conn = make(opened: opened, online: online.stream); + conn.start(); + await conn.session(); + await conn.stop(); + + conn.start(); + await Future.delayed(Duration.zero); + expect(conn.current, isNotNull); + expect(opened, hasLength(2)); + await conn.dispose(); + await online.close(); + }); } diff --git a/apps/app_seeds/test/services/social_settings_test.dart b/apps/app_seeds/test/services/social_settings_test.dart index c3abe43..02577dd 100644 --- a/apps/app_seeds/test/services/social_settings_test.dart +++ b/apps/app_seeds/test/services/social_settings_test.dart @@ -101,4 +101,34 @@ void main() { {SocialSettings.offerKey('ab' * 32, 'tomate-1')}, ); }); + + group('sharing opt-in', () { + test('starts unanswered, then round-trips', () async { + expect(await settings.sharingEnabled(), isNull); + await settings.setSharingEnabled(true); + expect(await settings.sharingEnabled(), isTrue); + await settings.setSharingEnabled(false); + expect(await settings.sharingEnabled(), isFalse); + }); + + test('an install that had been through the intro keeps sharing on', + () async { + // The upgrade path that must not regress: these people were on a build + // that connected at launch, so they keep messaging, sync and alerts. + expect(await settings.migrateSharingEnabled(introSeen: true), isTrue); + expect(await settings.sharingEnabled(), isTrue); + }); + + test('a fresh install starts offline', () async { + expect(await settings.migrateSharingEnabled(introSeen: false), isFalse); + expect(await settings.sharingEnabled(), isFalse); + }); + + test('migration never overwrites an answer already given', () async { + await settings.setSharingEnabled(false); + // Later launches see the intro as seen; the recorded "no" must survive. + expect(await settings.migrateSharingEnabled(introSeen: true), isFalse); + expect(await settings.sharingEnabled(), isFalse); + }); + }); } diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index d38a96e..4c84dc6 100644 --- a/apps/app_seeds/test/state/offers_cubit_test.dart +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -7,7 +7,11 @@ import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/enums.dart'; import 'package:tane/services/discovery_area.dart'; import 'package:tane/services/offer_mapper.dart'; +import 'package:nostr/nostr.dart'; import 'package:tane/services/offer_outbox.dart'; +import 'package:tane/services/social_connection.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; import 'package:tane/state/offers_cubit.dart'; import '../support/test_support.dart'; @@ -25,7 +29,7 @@ class FakeOfferTransport implements OfferTransport { } @override - Stream discover(DiscoveryQuery query) { + Stream discover(DiscoveryQuery query, {int? since}) { final controller = StreamController(); for (final o in _offers) { if (o.approxGeohash.startsWith(query.geohashPrefix)) controller.add(o); @@ -33,6 +37,15 @@ class FakeOfferTransport implements OfferTransport { return controller.stream; // left open (live), like a real subscription } + @override + Future discoverPage(DiscoveryQuery query) async { + final matches = [ + for (final o in _offers) + if (o.approxGeohash.startsWith(query.geohashPrefix)) o, + ]; + return OfferPage(offers: matches); // short page → nothing older to load + } + @override Future retract(String offerId) async => _offers.removeWhere((o) => o.id == offerId); @@ -54,7 +67,7 @@ class DuplicatingOfferTransport implements OfferTransport { } @override - Stream discover(DiscoveryQuery query) { + Stream discover(DiscoveryQuery query, {int? since}) { final controller = StreamController(); for (final o in _offers) { if (o.approxGeohash.startsWith(query.geohashPrefix)) { @@ -65,6 +78,17 @@ class DuplicatingOfferTransport implements OfferTransport { return controller.stream; } + @override + Future discoverPage(DiscoveryQuery query) async { + // The stored copy (deduped at relay level); the live echo (the duplicate) + // still arrives via [discover], so the cubit's dedup is what's under test. + final matches = [ + for (final o in _offers) + if (o.approxGeohash.startsWith(query.geohashPrefix)) o, + ]; + return OfferPage(offers: matches); + } + @override Future retract(String offerId) async => _offers.removeWhere((o) => o.id == offerId); @@ -73,6 +97,59 @@ class DuplicatingOfferTransport implements OfferTransport { Future close() async {} } +/// A transport that serves offers in `until`-cursored pages (like a relay's +/// stored events), so the cubit's pagination and memory cap can be exercised. +/// Each seeded offer gets a descending createdAt; [discover] (live) is empty. +class PaginatingOfferTransport implements OfferTransport { + PaginatingOfferTransport(int count, {this.geohash = 'sp3e9'}) { + // Newest (highest createdAt) first: offer 0 is newest. + for (var i = 0; i < count; i++) { + _byCreatedAt.add(( + createdAt: 1000000 - i, + offer: Offer( + id: 'o$i', + authorPubkeyHex: 'ab' * 32, + summary: 'offer $i', + type: OfferType.gift, + approxGeohash: geohash, + ), + )); + } + } + + final String geohash; + final List<({int createdAt, Offer offer})> _byCreatedAt = []; + + @override + Stream discover(DiscoveryQuery query, {int? since}) => + const Stream.empty(); + + @override + Future discoverPage(DiscoveryQuery query) async { + final matched = _byCreatedAt + .where((e) => e.offer.approxGeohash.startsWith(query.geohashPrefix)) + .where((e) => query.until == null || e.createdAt <= query.until!) + .toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final page = matched.take(query.limit).toList(); + final nextCursor = page.length >= query.limit && page.isNotEmpty + ? page.last.createdAt - 1 + : null; + return OfferPage( + offers: [for (final e in page) e.offer], + nextCursor: nextCursor, + ); + } + + @override + Future publish(Offer offer) async => + PublishResult(accepted: true, transportRef: offer.id); + @override + Future retract(String offerId) async {} + @override + Future close() async {} +} + void main() { group('OfferMapper', () { test('maps local sharing intent to the network offer type', () { @@ -701,4 +778,172 @@ void main() { await cubit.close(); }); }); + + group('OffersCubit pagination', () { + test('discover loads the first page and exposes a next-page cursor', + () async { + final cubit = OffersCubit(PaginatingOfferTransport(250)); + await cubit.discover('sp3'); + await pumpEventQueue(); + // One page (default limit 100) — not the whole 250 — is kept in memory. + expect(cubit.state.offers, hasLength(100)); + expect(cubit.state.canLoadMore, isTrue); + expect(cubit.state.searching, isFalse); + await cubit.close(); + }); + + test('loadNextPage accumulates older offers until the source is exhausted', + () async { + final cubit = OffersCubit(PaginatingOfferTransport(250)); + await cubit.discover('sp3'); + await pumpEventQueue(); + + await cubit.loadNextPage(); + expect(cubit.state.offers, hasLength(200)); + expect(cubit.state.canLoadMore, isTrue); + + await cubit.loadNextPage(); + // Only 250 exist: the third page is short, so there is nothing older. + expect(cubit.state.offers, hasLength(250)); + expect(cubit.state.canLoadMore, isFalse); + + // Paging past the end is a harmless no-op. + await cubit.loadNextPage(); + expect(cubit.state.offers, hasLength(250)); + await cubit.close(); + }); + + test('the in-memory list is capped and paging stops at the cap', () async { + final cubit = OffersCubit(PaginatingOfferTransport(600)); + await cubit.discover('sp3'); + await pumpEventQueue(); + // Keep paging until the cubit says there is no more. + var guard = 0; + while (cubit.state.canLoadMore && guard++ < 20) { + await cubit.loadNextPage(); + } + expect(cubit.state.offers, hasLength(OffersCubit.maxOffersKept)); + expect(cubit.state.canLoadMore, isFalse); + await cubit.close(); + }); + + test('offers are de-duplicated across pages by (author, id)', () async { + // Two pages that overlap on the boundary id must not double it. + final cubit = OffersCubit(PaginatingOfferTransport(150)); + await cubit.discover('sp3'); + await pumpEventQueue(); + await cubit.loadNextPage(); + final ids = cubit.state.offers.map((o) => o.id).toList(); + expect(ids.toSet(), hasLength(ids.length)); // no duplicates + expect(cubit.state.offers, hasLength(150)); + await cubit.close(); + }); + }); + + group('connection recovery (fresh-install fix)', () { + const seedHex = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + + late SocialService social; + late SocialSettings settings; + + setUp(() async { + social = await SocialService.fromRootSeedHex(seedHex); + settings = SocialSettings(InMemorySecretStore()); + }); + + SocialConnection makeConnection({ + required bool Function() down, + Stream? online, + }) => + SocialConnection( + social: social, + settings: settings, + online: online ?? const Stream.empty(), + retrySchedule: const [Duration(milliseconds: 5)], + open: (_) async { + if (down()) throw StateError('unreachable'); + return SocialSession(_NoopChannel()); + }, + ); + + test('comes online by itself when the shared connection connects later', + () async { + var down = true; + final conn = makeConnection(down: () => down); + final cubit = await createOffersCubit(conn); + expect(cubit.isOnline, isFalse); // built while unreachable + + final epochBefore = cubit.state.connectionEpoch; + conn.start(); + down = false; // network path recovers; backoff retry reconnects + await Future.delayed(const Duration(milliseconds: 100)); + + expect(cubit.isOnline, isTrue, reason: 'no manual Retry needed'); + expect(cubit.state.connectionEpoch, greaterThan(epochBefore), + reason: 'a state was emitted so the UI re-reads isOnline'); + + await cubit.close(); + await conn.dispose(); + }); + + test('re-runs the last discovery when the connection recovers', () async { + var down = true; + final conn = makeConnection(down: () => down); + final cubit = await createOffersCubit(conn); + + await cubit.discover('sp3e9'); // offline → error, but the wish is kept + expect(cubit.state.error, 'offline'); + + conn.start(); + down = false; + await Future.delayed(const Duration(milliseconds: 100)); + + expect(cubit.isOnline, isTrue); + expect(cubit.state.areaGeohash, 'sp3e9', reason: 'discovery re-ran'); + expect(cubit.state.hasSearched, isTrue); + expect(cubit.state.error, isNull, reason: 'offline error cleared'); + + await cubit.close(); + await conn.dispose(); + }); + + test('goes offline (and tells the UI) when the session drops', () async { + final online = StreamController.broadcast(); + final conn = makeConnection(down: () => false, online: online.stream); + conn.start(); + await pumpEventQueue(); + final cubit = await createOffersCubit(conn); + expect(cubit.isOnline, isTrue); + + final epochBefore = cubit.state.connectionEpoch; + online.add(false); // network lost + await pumpEventQueue(); + + expect(cubit.isOnline, isFalse); + expect(cubit.state.connectionEpoch, greaterThan(epochBefore)); + + await cubit.close(); + await conn.dispose(); + await online.close(); + }); + }); +} + +/// A no-op [NostrChannel]: just enough to build a [SocialSession] whose offer +/// transport answers with empty results. +class _NoopChannel implements NostrChannel { + @override + String get privateKeyHex => '00' * 32; + @override + String get publicKeyHex => 'ab' * 32; + @override + Future<({bool accepted, String message})> publish(Event event) async => + (accepted: true, message: ''); + @override + Stream subscribe(Filter filter) => const Stream.empty(); + @override + Future> reqOnce(Filter filter) async => const []; + @override + Future close() async {} } diff --git a/apps/app_seeds/test/support/test_support.dart b/apps/app_seeds/test/support/test_support.dart index ba39a2f..eefb065 100644 --- a/apps/app_seeds/test/support/test_support.dart +++ b/apps/app_seeds/test/support/test_support.dart @@ -11,6 +11,8 @@ import 'package:tane/db/database.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/security/secret_store.dart'; import 'package:tane/services/onboarding_store.dart'; +import 'package:tane/services/sharing_switch.dart'; +import 'package:tane/services/social_settings.dart'; import 'package:tane/app.dart' show materialLocaleFor; import 'package:tane/state/inventory_cubit.dart'; import 'package:tane/state/variety_detail_cubit.dart'; @@ -59,6 +61,14 @@ OnboardingStore newTestOnboardingStore({bool introSeen = true}) { return OnboardingStore(store); } +/// A [SharingSwitch] over in-memory storage and no relay connection, for +/// screens that only care whether sharing is on. Defaults to on, which is what +/// most screen tests want (the social entries live, nothing dialled). +SharingSwitch newTestSharingSwitch({bool enabled = true}) => SharingSwitch( + settings: SocialSettings(InMemorySecretStore()), + enabled: enabled, + ); + /// Wraps [child] with the providers a screen expects (repository, inventory /// cubit) plus i18n and Material localizations, pinned to [locale]. Widget wrapScreen({ diff --git a/apps/app_seeds/test/ui/home_screen_test.dart b/apps/app_seeds/test/ui/home_screen_test.dart index 1ca72ec..76b84be 100644 --- a/apps/app_seeds/test/ui/home_screen_test.dart +++ b/apps/app_seeds/test/ui/home_screen_test.dart @@ -2,15 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/app.dart'; import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/sharing_switch.dart'; import '../support/test_support.dart'; void main() { - Widget app(db) => TranslationProvider( + /// The everyday case: the social layer exists and sharing is already on. + Widget app(db, {SharingSwitch? sharing}) => TranslationProvider( child: TaneApp( repository: newTestRepository(db), species: newTestSpeciesRepository(db), onboarding: newTestOnboardingStore(), + sharing: sharing ?? newTestSharingSwitch(), ), ); @@ -24,7 +27,8 @@ void main() { expect(find.text('Your inventory'), findsOneWidget); expect(find.text('Market'), findsOneWidget); - expect(find.text('COMING SOON'), findsOneWidget); // market is Block 2 + // "Coming soon" is gone for good: everything on this screen is built. + expect(find.textContaining('COMING SOON'), findsNothing); // Redesign copy: tagline + the two destination subtitles. expect(find.text('Share and grow local seeds'), findsOneWidget); @@ -49,8 +53,9 @@ void main() { await tester.tap(find.byIcon(Icons.menu)); // hamburger await tester.pumpAndSettle(); - // Social destinations are shown but disabled. + // With sharing on, the social destinations are live. expect(find.text('Your profile'), findsOneWidget); + expect(find.byIcon(Icons.lock_outline), findsNothing); await tester.tap(find.text('Inventory')); // active drawer item await tester.pumpAndSettle(); @@ -84,6 +89,62 @@ void main() { await disposeTree(tester); }); + testWidgets('sharing off: market stays open, the rest wears a padlock', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final db = newTestDatabase(); + addTearDown(db.close); + + await tester.pumpWidget( + app(db, sharing: newTestSharingSwitch(enabled: false)), + ); + await tester.pumpAndSettle(); + + // The market card is the door into sharing, so it must stay reachable. + expect(find.byKey(const Key('home.market')), findsOneWidget); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + + // Market live; the social entries are visible but padlocked — and tapping + // one invites the person in rather than doing nothing. + expect(find.text('Your profile'), findsOneWidget); + expect(find.byIcon(Icons.lock_outline), findsWidgets); + + await tester.tap(find.text('Your profile')); + await tester.pumpAndSettle(); + expect(find.text('This wakes up when you start sharing'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('no social layer: the market and its friends are not drawn', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final db = newTestDatabase(); + addTearDown(db.close); + + await tester.pumpWidget( + TranslationProvider( + child: TaneApp( + repository: newTestRepository(db), + species: newTestSpeciesRepository(db), + onboarding: newTestOnboardingStore(), + ), + ), + ); + await tester.pumpAndSettle(); + + // Nothing here can be switched on, so it is hidden rather than teased. + expect(find.byKey(const Key('home.market')), findsNothing); + expect(find.text('Your inventory'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + expect(find.text('Your profile'), findsNothing); + expect(find.byIcon(Icons.lock_outline), findsNothing); + await disposeTree(tester); + }); + testWidgets('drawer Settings opens the settings screen', (tester) async { diff --git a/apps/app_seeds/test/ui/inventory_list_lazy_test.dart b/apps/app_seeds/test/ui/inventory_list_lazy_test.dart new file mode 100644 index 0000000..a9475e1 --- /dev/null +++ b/apps/app_seeds/test/ui/inventory_list_lazy_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/ui/inventory_list_screen.dart'; + +import '../support/test_support.dart'; + +/// The inventory list must render lazily: with a large catalogue only the tiles +/// near the viewport are built, not one widget per row. Guards the regression +/// from `ListView(children: …)` (every tile built upfront) back in. +void main() { + testWidgets('builds only the on-screen tiles for a large inventory', + (tester) async { + final db = newTestDatabase(); + final repo = newTestRepository(db); + // Enough rows that an eager list would build hundreds of tiles at once. + for (var i = 0; i < 300; i++) { + await repo.addQuickVariety( + label: 'Variety ${i.toString().padLeft(3, '0')}', + category: i.isEven ? 'Poaceae' : 'Fabaceae', + ); + } + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + // Let the debounced inventory stream emit and the async load resolve + // (bounded pumps — the screen holds a live Drift stream, so pumpAndSettle + // would hang; see testing.md). + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 100)); + + // Every variety tile is a ListTile; a lazy list builds only a viewport-worth. + final built = find.byType(ListTile).evaluate().length; + expect(built, greaterThan(0), reason: 'the list rendered some tiles'); + expect( + built, + lessThan(50), + reason: 'lazy list should build ~a screenful, not all 300 ($built built)', + ); + + await disposeTree(tester); + await db.close(); + }); +} diff --git a/apps/app_seeds/test/ui/market_gate_test.dart b/apps/app_seeds/test/ui/market_gate_test.dart index 7cce096..6a74331 100644 --- a/apps/app_seeds/test/ui/market_gate_test.dart +++ b/apps/app_seeds/test/ui/market_gate_test.dart @@ -3,12 +3,17 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/services/onboarding_store.dart'; +import 'package:tane/services/sharing_switch.dart'; import 'package:tane/ui/market_gate.dart'; import '../support/test_support.dart'; void main() { - Widget host(OnboardingStore store, void Function(bool) onResult) => + Widget host( + OnboardingStore store, + void Function(bool) onResult, { + SharingSwitch? sharing, + }) => TranslationProvider( child: MaterialApp( localizationsDelegates: GlobalMaterialLocalizations.delegates, @@ -16,7 +21,13 @@ void main() { builder: (context) => Center( child: ElevatedButton( onPressed: () async { - onResult(await ensureMarketRulesAccepted(context, store)); + onResult( + await ensureMarketRulesAccepted( + context, + store, + sharing: sharing, + ), + ); }, child: const Text('enter'), ), @@ -87,5 +98,52 @@ void main() { expect(find.text('Treat people well — no spam, no abuse'), findsOneWidget); expect(find.textContaining('public'), findsWidgets); expect(find.text('Privacy & rules'), findsOneWidget); + // The sheet is also where Tane says it is about to go online for the first + // time — the reviewer's complaint was that this was never stated. + expect(find.textContaining('community servers'), findsOneWidget); + }); + + testWidgets('agreeing is what turns sharing on', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, (_) {}, sharing: sharing)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + expect(sharing.on.value, isFalse, reason: 'still offline while asking'); + + await tester.tap(find.text('I agree')); + await tester.pumpAndSettle(); + expect(sharing.on.value, isTrue); + }); + + testWidgets('declining leaves the app offline', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, (_) {}, sharing: sharing)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + + expect(sharing.on.value, isFalse); + }); + + testWidgets('someone who agreed long ago but switched sharing off is asked ' + 'nothing, yet comes back online', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + await store.markMarketRulesAccepted(); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, (_) {}, sharing: sharing)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + + expect(find.text('Before you join the market'), findsNothing); + expect(sharing.on.value, isTrue); }); } diff --git a/apps/app_seeds/test/ui/market_screen_test.dart b/apps/app_seeds/test/ui/market_screen_test.dart index b063201..5f99e18 100644 --- a/apps/app_seeds/test/ui/market_screen_test.dart +++ b/apps/app_seeds/test/ui/market_screen_test.dart @@ -84,8 +84,9 @@ void main() { findsOneWidget); }); - testWidgets('MarketScreen with no relays configured degrades to offline', - (tester) async { + testWidgets( + 'a fresh install (no area, unreachable) asks for the area first, ' + 'not the connection', (tester) async { final social = await SocialService.fromRootSeedHex('00' * 32); final settings = SocialSettings(InMemorySecretStore()); await settings.setRelayUrls(const []); // offline: don't hit the network @@ -94,7 +95,23 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Seeds near you'), findsOneWidget); // app bar title - expect(find.text('Retry'), findsOneWidget); // can't-reach state offers retry + // Setting a zone works offline and is the first step — the connection + // error must not bury it. + expect(find.text('Set your area'), findsOneWidget); + expect(find.text('Retry'), findsNothing); + }); + + testWidgets('with an area set, unreachable servers degrade to retry', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); // offline: don't hit the network + await settings.setAreaGeohash('ezsn9'); + + await tester.pumpWidget(_wrapMarket(social, settings)); + await tester.pumpAndSettle(); + + expect(find.text('Retry'), findsOneWidget); }); testWidgets('the range selector persists the chosen search precision', @@ -148,6 +165,36 @@ void main() { expect(tester.widget(find.byKey(firstKey)).value, isTrue); }); + testWidgets('the config sheet Save button stays above the system nav bar', + (tester) async { + // Edge-to-edge Android: a 50-logical-px gesture/nav bar at the bottom. + tester.view.padding = const FakeViewPadding(bottom: 150); // physical px + tester.view.viewPadding = const FakeViewPadding(bottom: 150); + addTearDown(tester.view.reset); + + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); // offline: don't hit the network + + await tester.pumpWidget(_wrapMarket(social, settings)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('market.config'))); + await tester.pumpAndSettle(); + + final save = find.byKey(const Key('market.save')); + await tester.ensureVisible(save); + await tester.pumpAndSettle(); + + final screenHeight = tester.view.physicalSize.height / + tester.view.devicePixelRatio; // 600 on the default test surface + const navBarLogical = 150 / 3.0; // FakeViewPadding is physical px + expect( + tester.getRect(save).bottom, + lessThanOrEqualTo(screenHeight - navBarLogical + 0.1), + reason: 'Save must not sit under the system navigation bar', + ); + }); + testWidgets('"use my location" fills the area with a coarse geohash', (tester) async { final social = await SocialService.fromRootSeedHex('00' * 32); diff --git a/apps/app_seeds/test/ui/sharing_invite_sheet_test.dart b/apps/app_seeds/test/ui/sharing_invite_sheet_test.dart new file mode 100644 index 0000000..d8b61ee --- /dev/null +++ b/apps/app_seeds/test/ui/sharing_invite_sheet_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/onboarding_store.dart'; +import 'package:tane/services/sharing_switch.dart'; +import 'package:tane/ui/sharing_invite_sheet.dart'; + +import '../support/test_support.dart'; + +void main() { + Widget host(OnboardingStore store, SharingSwitch sharing) => + TranslationProvider( + child: MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () => showSharingInvite( + context, + onboarding: store, + sharing: sharing, + ), + child: const Text('tap a locked entry'), + ), + ), + ), + ), + ); + + testWidgets('the invite says what lights up and that it needs a connection', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await tester.pumpWidget( + TranslationProvider( + child: const MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Scaffold(body: SharingInviteSheet()), + ), + ), + ); + await tester.pump(); + + expect(find.text('This wakes up when you start sharing'), findsOneWidget); + expect(find.text('Write to whoever has seeds near you'), findsOneWidget); + expect(find.text('Keep the offers you like'), findsOneWidget); + expect(find.text('Your circle of people you trust'), findsOneWidget); + // The honest part: it is not free of consequences. + expect(find.textContaining('go online'), findsOneWidget); + }); + + testWidgets('"Not now" leaves the app exactly as offline as it was', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, sharing)); + await tester.tap(find.text('tap a locked entry')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('sharingInvite.notNow'))); + await tester.pumpAndSettle(); + + expect(sharing.on.value, isFalse); + expect(await store.marketRulesAccepted(), isFalse); + }); + + testWidgets('accepting the invite goes through the community rules once', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, sharing)); + await tester.tap(find.text('tap a locked entry')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('sharingInvite.start'))); + await tester.pumpAndSettle(); + + // Same single consent surface the market uses — not a second one. + expect(find.text('Before you join the market'), findsOneWidget); + expect(sharing.on.value, isFalse, reason: 'not online until they agree'); + + await tester.tap(find.text('I agree')); + await tester.pumpAndSettle(); + + expect(sharing.on.value, isTrue); + expect(await store.marketRulesAccepted(), isTrue); + }); +} diff --git a/apps/app_seeds/test/ui/small_screen_overflow_test.dart b/apps/app_seeds/test/ui/small_screen_overflow_test.dart index d5c18c8..1255da9 100644 --- a/apps/app_seeds/test/ui/small_screen_overflow_test.dart +++ b/apps/app_seeds/test/ui/small_screen_overflow_test.dart @@ -93,7 +93,7 @@ void main() { wrapScreen( repository: newTestRepository(db), locale: locale, - child: const HomeScreen(marketEnabled: true), + child: HomeScreen(sharing: newTestSharingSwitch()), ), ); await disposeTree(tester); diff --git a/docs/design/open-decisions.md b/docs/design/open-decisions.md index 161b9b2..5e8e32e 100644 --- a/docs/design/open-decisions.md +++ b/docs/design/open-decisions.md @@ -34,6 +34,8 @@ Estas fijan `schemaVersion = 1` y el arranque técnico: - **Paquete legal + moderación mínima — DECIDIDO/IMPLEMENTADO (2026-07-13).** Se escribe la capa legal completa en [`docs/legal/`](../legal/README.md): política de privacidad, condiciones de uso, normas de la comunidad y aviso sobre legalidad de semillas (masters en inglés + espejo es), más docs internos de cumplimiento (Play Data Safety/UGC, notas Apple, memoria jurídica con calendario de revisión — el PRM europeo sigue en trílogos). Decisiones que fija: **(a)** consentimiento único **al entrar al mercado por primera vez** (hoja modal con las normas; el inventario local no pide nada — progressive disclosure), con la misma bandera guardando también publicar-oferta y primer DM; **(b)** **bloqueo local** de claves (keystore, sin migración de esquema): filtra ofertas, chats y DMs entrantes; **(c)** **denuncias estándar NIP-56** (kind 1984) publicadas a los relays + ocultación local; en `relay.comunes.org` (nuestro) se actúa sobre ellas — esa es la historia de "moderación con respuesta" para la revisión de Play; **(d)** se reafirma **cero comisiones** sobre semillas y **Ğ1 solo como etiqueta de precio** (sin flujos de pago in-app, precedente Damus/Apple); **(e)** borrado honesto: NIP-09 es best-effort y así se cuenta al usuario. Metadata de tienda en `fastlane/metadata/android/` (la reutiliza F-Droid). +- **Distribución en F-Droid — HECHO (2026-07-28).** Tane está publicada en el repo oficial de F-Droid (, v0.1.16) **sin antifeatures**: la MR de inclusión ([43144](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/43144)) se aceptó el 2026-07-25. Lo que lo desbloqueó: **(a)** build **reproducible y firmada por nosotros** (`AllowedAPKSigningKeys` + `binary:` por ABI ⇒ misma firma que Play, se puede cambiar de tienda sin reinstalar); **(b)** APK libre de Google (sin GMS) con splits por ABI; **(c)** **red opt-in** desde v0.1.16 — la app no abre ningún socket hasta que la persona activa compartir, lo que sostuvo el rechazo de `NonFreeNet`/`TetheredNet` (protocolo abierto, relays configurables). Consecuencia operativa: cada versión nueva solo requiere subir los `versionCode` por ABI en la receta de `fdroiddata`. → [release.md](../release.md) + ## C) Puede esperar (no bloquea nada ahora) - Negación plausible / bóveda señuelo; modo discreto. → security-privacy diff --git a/docs/fdroid/org.comunes.tane.yml b/docs/fdroid/org.comunes.tane.yml index e3d0c2a..230d77e 100644 --- a/docs/fdroid/org.comunes.tane.yml +++ b/docs/fdroid/org.comunes.tane.yml @@ -15,87 +15,135 @@ AutoName: Tane RepoType: git Repo: https://git.comunes.org/comunes/tane.git -# Reproducible, developer-signed builds: F-Droid rebuilds from source and, if the -# output matches our own signed APK on git.comunes.org byte-for-byte, publishes -# OUR APK. Same signature as the Google Play build, so users move between stores -# without reinstalling. The fingerprint below is the SHA-256 of the tane-upload -# signing certificate (public value); the keystore/passwords never leave CI. -AllowedAPKSigningKeys: ddfae432091b8248a8a4a1b353487fa626301f4357ef835e94ec312f69418e38 - -# One build entry per ABI: `flutter build --split-per-abi` yields smaller APKs and -# each split carries a distinct versionCode (gradle: versionCode*10 + {arm-v7a:1, -# arm64:2, x86_64:3}), so v0.1.2 (+4) -> 41/42/43. The `binary:` is the reference -# APK F-Droid verifies against, uploaded to the Forgejo release by -# .forgejo/workflows/release.yml on the `v*` tag. Builds: - - versionName: 0.1.3 - versionCode: 51 - commit: v0.1.3 + - versionName: 0.1.16 + versionCode: 181 + commit: 954dc2caae10c373c4bdfcbd5dfb3d6c3906b598 subdir: apps/app_seeds - sudo: - - apt-get update - - apt-get install -y git unzip xz-utils - - git clone --depth 1 -b 3.41.9 https://github.com/flutter/flutter.git /opt/flutter - - chown -R vagrant:vagrant /opt/flutter output: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk - binary: https://git.comunes.org/comunes/tane/releases/download/v%v/app-armeabi-v7a-release.apk + binary: + https://git.comunes.org/comunes/tane/releases/download/v%v/app-armeabi-v7a-release.apk + srclibs: + - flutter@stable + - tesseract4android@4.9.0 prebuild: - - export PATH="/opt/flutter/bin:$PATH" - - git config --global --add safe.directory /opt/flutter - - flutter config --no-analytics - - flutter pub get - - dart run slang - - dart run build_runner build --delete-conflicting-outputs + - export flutterVersion=$(sed -n -E 's,.*cirruslabs/flutter:([0-9.]+).*,\1,p' + ../../.forgejo/workflows/release.yml | head -1) + - '[[ $flutterVersion ]]' + - git -C $$flutter$$ checkout -f $flutterVersion + - cd ../.. + - export PUB_CACHE=$(pwd)/.pub-cache + - $$flutter$$/bin/flutter config --no-analytics + - $$flutter$$/bin/flutter pub get --enforce-lockfile + - sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle + - echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties + - echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties + - cd apps/app_seeds + - $$flutter$$/bin/dart run slang + - $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs + scandelete: + - .pub-cache build: - - export PATH="/opt/flutter/bin:$PATH" - - flutter build apk --release --split-per-abi --target-platform=android-arm + - export PUB_CACHE=$(cd ../.. && pwd)/.pub-cache + - pushd $$tesseract4android$$/tesseract4android + - gradle --no-daemon assembleStandardRelease + - popd + - cp + $$tesseract4android$$/tesseract4android/build/outputs/aar/tesseract4android-standard-release.aar + $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar + - $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm - - versionName: 0.1.3 - versionCode: 52 - commit: v0.1.3 + - versionName: 0.1.16 + versionCode: 182 + commit: 954dc2caae10c373c4bdfcbd5dfb3d6c3906b598 subdir: apps/app_seeds - sudo: - - apt-get update - - apt-get install -y git unzip xz-utils - - git clone --depth 1 -b 3.41.9 https://github.com/flutter/flutter.git /opt/flutter - - chown -R vagrant:vagrant /opt/flutter output: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk - binary: https://git.comunes.org/comunes/tane/releases/download/v%v/app-arm64-v8a-release.apk + binary: + https://git.comunes.org/comunes/tane/releases/download/v%v/app-arm64-v8a-release.apk + srclibs: + - flutter@stable + - tesseract4android@4.9.0 prebuild: - - export PATH="/opt/flutter/bin:$PATH" - - git config --global --add safe.directory /opt/flutter - - flutter config --no-analytics - - flutter pub get - - dart run slang - - dart run build_runner build --delete-conflicting-outputs + - export flutterVersion=$(sed -n -E 's,.*cirruslabs/flutter:([0-9.]+).*,\1,p' + ../../.forgejo/workflows/release.yml | head -1) + - '[[ $flutterVersion ]]' + - git -C $$flutter$$ checkout -f $flutterVersion + - cd ../.. + - export PUB_CACHE=$(pwd)/.pub-cache + - $$flutter$$/bin/flutter config --no-analytics + - $$flutter$$/bin/flutter pub get --enforce-lockfile + - sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle + - echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties + - echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties + - cd apps/app_seeds + - $$flutter$$/bin/dart run slang + - $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs + scandelete: + - .pub-cache build: - - export PATH="/opt/flutter/bin:$PATH" - - flutter build apk --release --split-per-abi --target-platform=android-arm64 + - export PUB_CACHE=$(cd ../.. && pwd)/.pub-cache + - pushd $$tesseract4android$$/tesseract4android + - gradle --no-daemon assembleStandardRelease + - popd + - cp + $$tesseract4android$$/tesseract4android/build/outputs/aar/tesseract4android-standard-release.aar + $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar + - $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm64 - - versionName: 0.1.3 - versionCode: 53 - commit: v0.1.3 + - versionName: 0.1.16 + versionCode: 183 + commit: 954dc2caae10c373c4bdfcbd5dfb3d6c3906b598 subdir: apps/app_seeds - sudo: - - apt-get update - - apt-get install -y git unzip xz-utils - - git clone --depth 1 -b 3.41.9 https://github.com/flutter/flutter.git /opt/flutter - - chown -R vagrant:vagrant /opt/flutter output: build/app/outputs/flutter-apk/app-x86_64-release.apk - binary: https://git.comunes.org/comunes/tane/releases/download/v%v/app-x86_64-release.apk + binary: + https://git.comunes.org/comunes/tane/releases/download/v%v/app-x86_64-release.apk + srclibs: + - flutter@stable + - tesseract4android@4.9.0 prebuild: - - export PATH="/opt/flutter/bin:$PATH" - - git config --global --add safe.directory /opt/flutter - - flutter config --no-analytics - - flutter pub get - - dart run slang - - dart run build_runner build --delete-conflicting-outputs + - export flutterVersion=$(sed -n -E 's,.*cirruslabs/flutter:([0-9.]+).*,\1,p' + ../../.forgejo/workflows/release.yml | head -1) + - '[[ $flutterVersion ]]' + - git -C $$flutter$$ checkout -f $flutterVersion + - cd ../.. + - export PUB_CACHE=$(pwd)/.pub-cache + - $$flutter$$/bin/flutter config --no-analytics + - $$flutter$$/bin/flutter pub get --enforce-lockfile + - sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle + - echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties + - echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties + - cd apps/app_seeds + - $$flutter$$/bin/dart run slang + - $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs + scandelete: + - .pub-cache build: - - export PATH="/opt/flutter/bin:$PATH" - - flutter build apk --release --split-per-abi --target-platform=android-x64 + - export PUB_CACHE=$(cd ../.. && pwd)/.pub-cache + - pushd $$tesseract4android$$/tesseract4android + - gradle --no-daemon assembleStandardRelease + - popd + - cp + $$tesseract4android$$/tesseract4android/build/outputs/aar/tesseract4android-standard-release.aar + $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar + - $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-x64 + +# Reproducible, developer-signed: F-Droid rebuilds each split and, if it matches +# our own signed APK on the git.comunes.org release byte-for-byte, publishes OUR +# APK (same signature as the Play build). Value is the SHA-256 of the tane-upload +# signing certificate (public). +# Note: `commit:` must be a full commit hash (not a tag) per F-Droid review, so +# it can only be filled in once the tag exists; it is synced here and in the +# fdroiddata fork right after tagging, before the fork's pipeline runs. +# 954dc2caae10c373c4bdfcbd5dfb3d6c3906b598 is the commit tag v0.1.16 points to. +AllowedAPKSigningKeys: ddfae432091b8248a8a4a1b353487fa626301f4357ef835e94ec312f69418e38 AutoUpdateMode: Version UpdateCheckMode: Tags v[\d.]+ -UpdateCheckData: apps/app_seeds/pubspec.yaml|version:\s*[\d.]+\+(\d+)|apps/app_seeds/pubspec.yaml|version:\s*([\d.]+)\+ -CurrentVersion: 0.1.3 -CurrentVersionCode: 53 +VercodeOperation: + - '%c * 10 + 1' + - '%c * 10 + 2' + - '%c * 10 + 3' +UpdateCheckData: + apps/app_seeds/pubspec.yaml|version:\s*[\d.]+\+(\d+)|apps/app_seeds/pubspec.yaml|version:\s*([\d.]+)\+ +CurrentVersion: 0.1.16 +CurrentVersionCode: 183 diff --git a/docs/legal/pt/aviso-sobre-sementes.md b/docs/legal/pt/aviso-sobre-sementes.md new file mode 100644 index 0000000..9667041 --- /dev/null +++ b/docs/legal/pt/aviso-sobre-sementes.md @@ -0,0 +1,69 @@ +# Tane — Aviso sobre a Legalidade de Trocar Sementes e Mudas + +**Versão 1.0 — 13 de julho de 2026** + +O Tane ajuda pessoas a guardar e partilhar sementes e mudas tradicionais. As leis sobre sementes +diferem muito entre países, e distinguem claramente entre **dar ou +trocar** e **vender**. Este aviso é informação geral, não aconselhamento jurídico; tu +és responsável por cumprir a lei onde vives e para onde vão as tuas sementes ou plantas. + +## Oferecer e trocar entre amadores + +Trocar sementes **sem fins comerciais** — ofertas, trocas, bancos de sementes comunitários, +redes de conservação — é amplamente reconhecido e legal na maioria dos lugares: + +- **União Europeia.** A legislação da UE sobre comercialização de sementes regula a *comercialização* + de sementes. A troca em espécie entre particulares, e o trabalho de redes de conservação e + bancos de germoplasma, ficam fora ou estão expressamente isentos; a reforma do material + de reprodução vegetal em curso (proposta COM(2023) 414) mantém isenções para amadores e redes + de conservação, desde que a atividade não seja comercial. +- **Espanha.** A Ley 30/2006 regula o comércio *comercial* de sementes; a troca não comercial + entre amadores e a troca orientada para a conservação são prática reconhecida (art. 24). +- **Estados Unidos.** Após a alteração de 2016 à Recommended Uniform State Seed + Law, a maioria dos estados isenta a **partilha não comercial de sementes** (bancos de sementes comunitários, trocas) dos + requisitos comerciais de rotulagem e ensaio. + +## Vender sementes + +Vender é diferente. Na UE e em muitas outras jurisdições, **comercializar semente de +variedades não registadas num catálogo oficial pode estar restringido ou proibido**, +e as quantidades, rotulagem ou espécies podem ser reguladas mesmo para pequenos vendedores. +Existem isenções para variedades de conservação e quantidades de amador, mas variam. Se +usares a opção do Tane para pedir um preço, **verifica primeiro as regras do teu país** — a app não +o pode fazer por ti. + +## Variedades protegidas + +Variedades cobertas por **direitos de obtentor (DOV) ou patentes** não podem ser +propagadas ou vendidas sem autorização do titular — isto pode aplicar-se mesmo a semente +que compraste legalmente. As variedades tradicionais e antigas geralmente não são protegidas, +mas as variedades comerciais modernas normalmente são. Em caso de dúvida, não a ofereças. + +## Enviar sementes através de fronteiras + +A maioria dos países restringe a importação de sementes: aplicam-se frequentemente **certificados +fitossanitários**, listas de espécies proibidas e regras de quarentena — mesmo a pequenos envelopes entre +particulares, e também **dentro** da UE para certas espécies (regras de passaporte fitossanitário). +Antes de enviares sementes para outro país, verifica as regras do país de destino. +As trocas mais seguras são as locais — que é para isso que o Tane foi desenhado. + +## Mudas e plantas vivas + +Os mesmos princípios aplicam-se a mudas, estacas, bolbos e outro material vegetal vivo — +mas as plantas vivas costumam ser reguladas **de forma mais estrita** do que a semente. Regras +fitossanitárias, requisitos de passaporte fitossanitário dentro da UE, e quarentena na +importação aplicam-se frequentemente a plantas vivas mesmo quando a semente equivalente está isenta. Mantém +as trocas de mudas locais, e verifica as regras fitossanitárias antes de deslocares plantas vivas +entre regiões ou através de fronteiras. + +## Espécies invasoras e protegidas + +Algumas espécies podem não poder ser trocadas de todo em certas regiões, seja porque são +**invasoras** ali, seja porque são **protegidas**. As listas são regionais; verifica a tua. + +--- + +*O Tane não cobra comissão, não intermedeia nenhuma transação, e não verifica nenhuma oferta: as trocas +são negócios privados entre as pessoas envolvidas (ver os +[Termos de uso](terms-of-use.md)). Este aviso existe para que esses negócios sejam +informados.* diff --git a/docs/legal/pt/normas-da-comunidade.md b/docs/legal/pt/normas-da-comunidade.md new file mode 100644 index 0000000..6fc3bd3 --- /dev/null +++ b/docs/legal/pt/normas-da-comunidade.md @@ -0,0 +1,42 @@ +# Tane — Regras da Comunidade + +**Versão 1.0 — 13 de julho de 2026** + +O mercado e as mensagens no Tane são espaços partilhados construídos sobre a confiança entre vizinhos. +Estas regras são o que todos aceitam antes de participar. Também definem o que conta como +conteúdo que pode ser denunciado e removido dos servidores que operamos. + +## As regras + +1. **Sê honesto sobre as tuas sementes.** Descreve o que realmente tens: a variedade tal como a + conheces, o ano de colheita, tudo o que for relevante sobre como foi cultivada. Não inventes + proveniência. +2. **Partilha apenas o que podes partilhar.** Não ofereças: + - semente de **variedades protegidas comercialmente** (direitos de obtentor, patentes) + a não ser que estejas autorizado; + - **espécies invasoras ou protegidas** onde a sua troca esteja proibida; + - qualquer outra coisa que seja ilegal partilhar ou vender onde tu ou o destinatário vivem. +3. **Sementes e mudas, nada mais.** O mercado é para sementes, mudas e outro + material de reprodução vegetal (estacas, bolbos, tubérculos) no espírito da app. + Não é um quadro de classificados geral. +4. **Respeita as pessoas.** Sem assédio, ameaças, ódio ou discriminação — em ofertas, + perfis, avaliações ou mensagens. +5. **Sem spam nem burlas.** Sem publicações repetidas, ofertas enganosas, phishing, ou + pressão para levar as pessoas a negócios duvidosos. +6. **Reputação honesta.** Avaliza apenas pessoas com quem te encontraste ou trocaste + realmente; avalia apenas experiências reais. Não manipules o sistema de confiança. + +## O que acontece se alguém as quebrar + +- Qualquer pessoa te pode **bloquear** — as tuas ofertas e mensagens desaparecem para ela. +- Qualquer pessoa pode **denunciar** uma oferta ou uma pessoa. As denúncias viajam para os servidores que transportam + o conteúdo; no relé operado pela Comunes (`relay.comunes.org`), conteúdo e chaves + que quebrem estas regras são removidos. Outros servidores aplicam as suas próprias políticas. +- Não há recursos nem suspensões de conta porque não há contas: a + rede simplesmente deixa de transportar e mostrar o que quebra as regras. + +## Uma nota sobre boa-fé + +A maior parte da partilha de sementes é entre amadores, em pequenas quantidades, pelo prazer de manter +variedades vivas. Em caso de dúvida, prefere **oferecer e trocar** em vez de vender, identifica +as coisas claramente, e pergunta. É assim que sempre se fez. diff --git a/docs/legal/pt/politica-de-privacidade.md b/docs/legal/pt/politica-de-privacidade.md new file mode 100644 index 0000000..e833d2b --- /dev/null +++ b/docs/legal/pt/politica-de-privacidade.md @@ -0,0 +1,89 @@ +# Tane — Política de Privacidade + +**Versão 1.0 — 13 de julho de 2026** + +O Tane é publicado pela **Asociación Comunes** (Espanha) — — +contacto: . O Tane é software livre (AGPL-3.0); o seu código-fonte é +público, por isso tudo o que aqui se descreve pode ser verificado. + +## A versão curta + +- O Tane funciona **sem conta**. Não sabemos quem és. +- Tudo o que registas fica **no teu aparelho, cifrado**. Não temos servidores que + o recebam, e não temos forma de o ler. +- Nada sai do teu aparelho a não ser que **tu** decidas partilhá-lo (uma oferta, uma + mensagem, o teu perfil). O que partilhas viaja por servidores geridos pela comunidade, que não controlamos. +- **Sem análises, sem publicidade, sem rastreadores.** O Tane não recolhe nada sobre ti. + +## 1. Sem contas, sem recolha por nossa parte + +O Tane não pede o teu nome, e-mail ou número de telefone. Não há registo nem +nenhum servidor central do Tane. A tua identidade na app é uma chave criptográfica criada no teu +aparelho e guardada no cofre seguro do sistema do teu aparelho. A Asociación Comunes não recolhe, +recebe nem processa os teus dados pessoais através da app. + +## 2. Dados guardados no teu aparelho + +O teu inventário de sementes (variedades, quantidades, notas, fotos), as tuas mensagens, os +avales e avaliações dos teus contactos, e as tuas chaves ficam guardados **apenas no teu aparelho**, numa +base de dados cifrada (SQLCipher). A chave de cifra vive no cofre seguro do sistema do teu +aparelho. As cópias de segurança que crias também são cifradas; escolhes onde as guardar, e +nunca passam por nós. + +## 3. Dados que saem do teu aparelho — só quando escolheres + +Nada é publicado automaticamente. Cada uma destas ações só acontece por tua ação explícita: + +- **Ofertas.** Quando ofereces sementes ou mudas, a app publica o título da oferta, a descrição, + a foto opcional, o preço opcional, e uma **zona aproximada** (uma área de cerca de 2–80 km, + como a configurares — nunca a tua morada ou localização precisa). As ofertas são públicas. +- **Perfil.** Se o preencheres, o teu nome escolhido, biografia e avatar são públicos. +- **Mensagens.** As mensagens diretas são **cifradas de ponta a ponta**: só tu e a pessoa + a quem escreves as podem ler. Os servidores que as transportam só veem envelopes cifrados. +- **Avales e avaliações.** Se avalizares ou avaliares alguém, essa declaração é pública + e assinada pela tua chave. +- **Sincronização entre os teus aparelhos.** Se ligares um segundo aparelho, os teus dados viajam entre + eles cifrados, para que só os teus aparelhos os possam ler. + +## 4. Para onde vão os dados partilhados: relés da comunidade + +As funcionalidades sociais do Tane usam servidores abertos, geridos pela comunidade ("relés"). Por predefinição a app +usa um pequeno conjunto que inclui `relay.comunes.org` (operado pela Asociación Comunes) e +relés públicos bem conhecidos; podes alterar esta lista, ou esvaziá-la para desligar a rede +completamente. Os relés diferentes de `relay.comunes.org` são **infraestrutura de terceiros**: +os seus operadores decidem as suas próprias políticas de retenção, e esta política não os cobre. + +## 5. Eliminação — uma nota honesta + +Podes eliminar qualquer coisa local instantaneamente, e podes retirar uma oferta a qualquer momento. +Quando retiras ou eliminas algo que tinhas publicado, a app pede aos relés que o +eliminem também. Relés que não operamos podem manter cópias apesar desse pedido, e publicações +públicas podem ter sido copiadas para outro lado enquanto estavam visíveis. **Trata tudo o que +publicares como potencialmente permanente.** No `relay.comunes.org` respeitamos os pedidos de eliminação. + +## 6. Permissões do aparelho + +- **Fotos / câmara** — só quando anexas uma imagem a uma variedade, oferta ou perfil. +- **Localização aproximada** (opcional) — só se usares "definir a minha zona a partir de onde estou", + para escolher a tua zona de partilha aproximada. O Tane nunca pede localização precisa. +- **Notificações** (opcional) — para te avisar sobre novas mensagens. + +## 7. Os teus direitos + +Como os teus dados vivem no teu aparelho sob o teu controlo, exerces a maioria dos direitos +tu mesmo: ler, corrigir, exportar ou eliminar tudo dentro da app. Para dados no +`relay.comunes.org`, ou qualquer questão sobre esta política, escreve para . +Se estiveres na UE, tens também o direito de apresentar reclamação junto da tua autoridade de +proteção de dados (em Espanha, a AEPD). + +## 8. Crianças + +O inventário de sementes pode ser usado por qualquer pessoa. As funcionalidades de mercado e mensagens não +são dirigidas a crianças; ao usá-las confirmas que tens idade suficiente para usar funcionalidades sociais +segundo as leis do teu país. + +## 9. Alterações + +Atualizaremos esta política se o comportamento do Tane mudar, e assinalaremos as alterações nas +notas de lançamento da app. A versão atual vive sempre em +, com o histórico no repositório público. diff --git a/docs/legal/pt/termos-de-uso.md b/docs/legal/pt/termos-de-uso.md new file mode 100644 index 0000000..8461cb9 --- /dev/null +++ b/docs/legal/pt/termos-de-uso.md @@ -0,0 +1,75 @@ +# Tane — Termos de Uso + +**Versão 1.0 — 13 de julho de 2026** + +Estes termos cobrem o uso da aplicação Tane, publicada pela **Asociación Comunes** +(Espanha) — , contacto . Ao usares as funcionalidades de partilha +do Tane (o mercado e as mensagens) aceitas estes termos e as +[Regras da comunidade](community-rules.md). + +## 1. O que o Tane é — e o que não é + +O Tane é uma **ferramenta pessoal**: um inventário de sementes que vive no teu aparelho, mais uma forma de +descobrir pessoas perto de ti que partilham sementes e mudas. O Tane **não é operador de mercado, não é +loja, e não é parte em nenhuma troca**: + +- Não hospedamos anúncios — as ofertas são publicadas por ti, sob a tua própria chave, para + relés geridos pela comunidade. +- Não cobramos **nenhuma comissão** e não processamos pagamentos. Qualquer troca, oferta ou venda é + combinada e realizada diretamente entre as pessoas envolvidas. +- Não verificamos sementes, utilizadores nem ofertas, e não podemos remover conteúdo de servidores + que não operamos. + +## 2. As tuas responsabilidades + +Tu és o único responsável por: + +- **O que ofereces e trocas.** Certifica-te de que tens permissão para partilhar ou vender as + sementes que anuncias. As regras diferem por país — ver o + [Aviso sobre legalidade das sementes](seed-legality-notice.md). Em particular, vender semente de + variedades não registadas pode estar restringido onde vives, e propagar + variedades protegidas comercialmente sem autorização é ilegal na maioria dos países. +- **O envio.** Enviar sementes através de fronteiras é frequentemente restringido (regras fitossanitárias). + Verifica antes de enviares qualquer coisa para o estrangeiro. +- **O que publicas.** As ofertas, o teu perfil, avales e avaliações são públicos e + assinados por ti. Segue as [Regras da comunidade](community-rules.md). +- **As tuas chaves e cópias de segurança.** Não há recuperação de conta: se perderes o teu aparelho e + o teu código de recuperação, não podemos restaurar nada. + +## 3. Sem garantias + +O Tane é fornecido **"tal como está"**, sem garantia de nenhum tipo, como software livre sob a +[licença AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html). Em particular, ninguém — +nem a Comunes, nem as pessoas com quem trocas através da app — garante a +**identidade, pureza, germinação, saúde ou legalidade** de nenhuma semente ou planta trocada. A +informação de cultivo mostrada na app (calendários, dicas de guardar semente, estimativas de viabilidade) é +orientação geral, não aconselhamento profissional. + +## 4. Litígios entre utilizadores + +As trocas são negócios privados entre as pessoas envolvidas. A Comunes não é mediadora +e não aceita responsabilidade por trocas falhadas, disputas de qualidade ou perdas delas +resultantes. As funcionalidades de aval e avaliação existem para que as comunidades possam construir a sua própria confiança. + +## 5. Moderação + +Não há moderação central: a tua app filtra o que vês através das pessoas em quem +confias, e podes **bloquear** qualquer pessoa e **denunciar** ofertas ou pessoas dentro da +app. As denúncias são partilhadas com os servidores envolvidos; no relé que operamos +(`relay.comunes.org`), o conteúdo que quebra as regras da comunidade é removido. Relés geridos +por outros aplicam as suas próprias políticas. + +## 6. Responsabilidade + +Na medida máxima permitida por lei, a responsabilidade total da Asociación Comunes decorrente +da app está limitada ao valor que pagaste por ela (o Tane é gratuito: zero). Nada +nestes termos limita responsabilidade que não possa ser limitada por lei, nem os teus direitos legais +como consumidor. + +## 7. Geral + +Se alguma parte destes termos for considerada inválida, o resto mantém-se em vigor. Estes termos são +regidos pela lei espanhola e da UE, sem te privar das proteções da lei do teu +país de residência. Podemos atualizar estes termos; alterações relevantes serão assinaladas nas +notas de lançamento da app, e a versão atual vive sempre em +. diff --git a/docs/notes/ecorred-encuentro-2026.md b/docs/notes/ecorred-encuentro-2026.md deleted file mode 100644 index 3df55c9..0000000 --- a/docs/notes/ecorred-encuentro-2026.md +++ /dev/null @@ -1,139 +0,0 @@ -# Ecorred: Encuentro Estatal (Piloña) y financiación RCF - -> Nota interna (sin trackear). Preparada el 18-07-2026. **El formulario cierra el lunes 21 de julio.** - -## 1. El encuentro - -**Encuentro Estatal de Comunidades Regenerativas** — [ecorred.es/encuentro-comunidades-regenerativas](https://www.ecorred.es/encuentro-comunidades-regenerativas/) - -- **Cuándo**: 24–27 de septiembre de 2026. -- **Dónde**: La Benéfica de Piloña, L'Infiestu (Asturias). -- **Qué incluye**: alojamiento compartido (3 noches), comidas de los 3 días, ayuda de viaje de hasta 100 €/persona. -- **Programa**: jueves llegada y "pasillo de la fama" (espacio expositivo de iniciativas); viernes sesión estratégica entre redes; sábado jornada pública del Día Europeo de las Comunidades Sostenibles, con diálogo institucional; domingo visitas opcionales a proyectos locales. -- **Reuniones preparatorias online**: 1 y 8 de septiembre. -- **Plazos**: formulario de interés hasta el **21 de julio**; selección comunicada **antes del 31 de julio**. Plazas limitadas; seleccionan buscando diversidad de territorios, enfoques y perfiles. -- **Formulario**: https://forms.gle/bcizx2eXHPFigZZp7 (lo gestiona Altekio S.Coop.Mad.) - -**Por qué encaja Tane**: priorizan iniciativas de alimentación y agroecología, y el formulario pregunta literalmente *"¿Qué traéis al encuentro? Un aprendizaje, una herramienta, una experiencia concreta que otras iniciativas podrían necesitar escuchar"*. Tane es exactamente eso: una herramienta libre y ya disponible. Además, el encuentro reúne al público ideal (redes de semillas, grupos agroecológicos, bancos comunitarios) para pilotos y difusión. - -## 2. Financiación en Ecorred más allá del encuentro - -Ecorred es el nodo español del **Regenerative Communities Fund (RCF)** de ECOLISE (2025–2027), financiado por la UE dentro de "Funding Fairer Futures" (programa DEAR). Nodos hermanos en Croacia, Finlandia, Francia y Portugal. Dos líneas: - -### Pathfinder / Comunidades Emergentes — la oportunidad real - -- **4.000 €** por iniciativa + formación, acompañamiento, visitas a iniciativas demostrativas, apoyo en captación de fondos y comunicación. Presupuesto total del proyecto entre 1.000 y 10.000 € (cofinanciación no obligatoria). -- La **3ª convocatoria cerró el 26-01-2026** (42 solicitudes en España; resolución hacia finales de septiembre de 2026, ~10 seleccionadas por país). -- **Habrá una 4ª convocatoria a mediados de 2026** para otras 50 comunidades. **Esta es la ventana para Tane** — y el encuentro de Piloña, el sitio donde enterarse de las fechas y conocer al equipo de Ecorredes (que forma parte del comité de selección). - -**Elegibilidad** (según las [bases de la 3ª convocatoria](https://ecolise.eu/wp-content/uploads/2025/11/CfP-Regenerative-Communities-Fund-%E2%80%93-Cycle-III-2025-Pathfinder-Communities.pdf), previsiblemente similares en la 4ª): - -- Puede solicitar una **entidad sin ánimo de lucro registrada** (CSO) **o un grupo no registrado** con entidad fiscal de acogida. La asociación serviría como vehículo legal, sin protagonismo. -- Establecida y trabajando en España ✓; no haber recibido fondos DEAR ✓. -- **Punto de fricción**: piden iniciativas *"pequeñas, arraigadas localmente"* que *"trabajen con comunidades locales de un territorio concreto"* (escala barrio/pueblo). Una app, por sí sola, encaja regular. -- **Encaje recomendado**: presentar un **proyecto local y concreto** — un piloto con una red de intercambio de semillas o banco comunitario de un territorio (talleres, inventariado colectivo del banco local, trueques con la app, formación intergeneracional) — donde Tane es la herramienta, no el fin. Ahí el encaje es bueno: soberanía alimentaria, empoderamiento comunitario, participación de mayores y jóvenes (criterio de relevancia, 30 % de la nota, prima colectivos infrarrepresentados). -- Solicitud en inglés (la propuesta narrativa puede ir en castellano + copia en inglés). Informes narrativos, sin informes financieros. -- Obligaciones si seleccionan: ejecutar el proyecto (13 meses), participar en formaciones, organizar un **diálogo local con la administración** durante el Día Europeo de las Comunidades Sostenibles, y compartir la historia en los canales de ECOLISE. -- Contacto: rcf@ecolise.eu (indicar SPAIN en el asunto). - -**Conclusión**: buscar antes del verano/otoño una **comunidad local aliada** dispuesta a co-presentar el piloto. El encuentro de Piloña es el mejor sitio para encontrarla. - -### Demonstrator / Iniciativas Demostrativas - -Para iniciativas consolidadas que sirven de modelo a otras (15 ya seleccionadas en las dos primeras convocatorias). Requisitos y cuantías no publicados en la web de Ecorred. **Preguntar en el encuentro** si habrá nuevas convocatorias y si una herramienta con comunidad de uso podría entrar más adelante. - -### Fuentes - -- [Fondo en ecorred.es](https://www.ecorred.es/fondo-de-comunidades-regenerativas/) -- [Bases 3ª convocatoria Pathfinder (PDF, ECOLISE)](https://ecolise.eu/wp-content/uploads/2025/11/CfP-Regenerative-Communities-Fund-%E2%80%93-Cycle-III-2025-Pathfinder-Communities.pdf) -- [Anuncio de la convocatoria (ECOLISE)](https://ecolise.eu/regenerative-communities-fund-apply-call-for-pathfinder-communities/) -- [Nota en economiasolidaria.org](https://www.economiasolidaria.org/noticias/convocatoria-de-comunidades-emergentes-pathfinder-del-fondo-para-comunidades-regenerativas/) · [Red de Transición](https://www.reddetransicion.org/abierta-la-convocatoria-de-comunidades-emergentes-pathfinder-del-fondo-para-comunidades-regenerativas/) - -## 3. Borrador de respuestas al formulario de interés - -Listo para copiar y pegar. Los campos `[RELLENAR]` son personales. Límites de palabras respetados con margen. - -### Datos personales - -| Campo | Respuesta | -|---|---| -| Nombre y apellidos | `[RELLENAR]` | -| Correo electrónico | `[RELLENAR]` (sugerencia: vjrj@comunes.org) | -| Teléfono de contacto | `[RELLENAR]` | -| ¿Cómo has conocido este encuentro? | `[RELLENAR]` (opciones: RCF y Ecorredes / Red o entidad en la que participo / Redes sociales / Otro) | - -### Red, organización o iniciativa - -| Campo | Respuesta | -|---|---| -| Nombre | **Tane** | -| Territorio donde opera | `[CONFIRMAR]` (p. ej. tu municipio/comunidad como base, aclarando que la herramienta se usa en todo el estado) | -| Año de inicio | `[CONFIRMAR: 2025 o 2026]` | -| Web o redes sociales | https://tane.comunes.org | -| Ámbito principal de trabajo | **alimentación y agroecología** | - -**Describe brevemente qué hacéis, cómo y para quién** *(máx. 200 palabras; el borrador tiene ~170)*: - -> Tane es una aplicación libre y gratuita para guardar, organizar e intercambiar semillas tradicionales. Cada persona lleva su banco de semillas en el bolsillo: qué variedades tiene, de dónde vienen, cuándo se sembraron, si siguen germinando bien y a quién se han dado, para que las variedades locales no se pierdan. -> -> Funciona sin internet, sin cuentas y sin servidores centrales: los datos se quedan cifrados en el dispositivo de cada persona. Cuando hay conexión, se puede publicar lo que se ofrece y conversar con otras personas para intercambiar. Nadie hace negocio con los datos ni con las semillas. -> -> Está pensada para hortelanas y hortelanos de cualquier edad, redes de intercambio y bancos comunitarios de semillas: es muy simple de entrada (basta apuntar el nombre de una variedad) y ofrece profundidad a quien la busca: pruebas de germinación, calendario de siembra, consejos para producir semilla de cada cultivo, avisos cuando una semilla envejece. -> -> Es software libre, multilingüe (cualquiera puede ayudar a traducirla) y está disponible gratis en Google Play y F-Droid. - -### Impacto y representatividad - -**¿Qué representa vuestra iniciativa en el ecosistema regenerativo?** *(máx. 150 palabras; ~130)*: - -> Tane es una herramienta común de reciente creación: infraestructura digital al servicio de las redes de semillas, los bancos comunitarios y los grupos agroecológicos, construida como bien colectivo y no como plataforma comercial. -> -> Es descentralizada por diseño, y eso se nota en algo poco habitual: no sabemos quién la usa ni cuántas personas somos, porque no existe ningún registro central ni recopilamos dato alguno. Igual que las semillas pasan de mano en mano, la aplicación funciona directamente entre las personas, sin pasar por ningún servidor nuestro. Esa renuncia deliberada a controlar es nuestra manera de entender la tecnología para el procomún. -> -> Venimos del mundo del software libre y estamos en contacto con comunidades que ya se intercambian semillas de mano en mano. Queremos hacer de puente entre quienes guardan semillas y quienes construyen tecnología comunitaria, y tejer en este encuentro las alianzas que un proyecto joven necesita. - -**¿Qué traéis al encuentro?** *(máx. 150 palabras; ~100)*: - -> Una herramienta concreta, libre y gratuita que cualquier iniciativa puede empezar a usar hoy mismo: sirve para inventariar un banco comunitario de semillas, organizar trueques y no perder la memoria de cada variedad (de dónde viene, quién la ha cuidado, cómo germina). -> -> Y una experiencia que creemos útil compartir: cómo construir tecnología al servicio de las comunidades y no al revés — sin recopilar datos, sin publicidad, funcionando incluso sin internet, traducida por su propia comunidad. Podemos enseñarla en directo, escuchar qué le falta y adaptarla a lo que las redes de semillas necesiten de verdad. - -**¿Qué buscáis?** *(máx. 150 palabras; ~90)*: - -> Alianzas con redes de intercambio de semillas, bancos comunitarios y grupos agroecológicos que quieran probar la herramienta en su día a día y decirnos qué mejorar: pilotos reales con personas reales, especialmente con quienes llevan décadas guardando semillas y con gente joven que empieza. -> -> También queremos entender cómo sostener un proyecto así sin traicionarlo (sin vender datos ni cobrar por lo básico), y conocer de cerca el Fondo de Comunidades Regenerativas y la próxima convocatoria de comunidades emergentes, idealmente de la mano de una comunidad local con la que presentar un proyecto conjunto. - -**¿Qué conversación queréis tener con las administraciones?** *(máx. 150 palabras; ~105)*: - -> Con humildad: somos un proyecto joven y no venimos a pedir nada para nosotros, sino a escuchar y a acompañar lo que las redes de semillas llevan años planteando — que conservar e intercambiar variedades tradicionales siga siendo posible para personas y colectivos, no solo para empresas, también en la revisión europea de la normativa de semillas que está en curso. -> -> Y una idea sencilla que sí es nuestra: cuando las administraciones apoyen la digitalización del mundo rural y comunitario, que cuenten con herramientas libres y respetuosas con los datos, que funcionen incluso con mala conexión, en lugar de atar a las comunidades a plataformas comerciales. - -### Diversidad e inclusión - -| Campo | Respuesta | -|---|---| -| Género | `[RELLENAR]` | -| Edad | `[RELLENAR]` | -| ¿Trabajáis prioritariamente con jóvenes, migrantes o colectivos vulnerables? | Sugerencia: **Parcialmente** (la app está pensada para cualquier edad, 10–80 años, con especial cuidado de mayores y gente sin soltura digital) — `[DECIDIR]` | -| ¿Participáis en el Consejo Estratégico de Ecorredes o en el RCF? | **No** | - -### Logística - -| Campo | Respuesta | -|---|---| -| ¿Puedes asistir a las reuniones preparatorias online? (1 y/o 8 sept) | `[RELLENAR]` (opciones: Ambas / Solo la primera / Solo la segunda / Ninguna) | -| ¿Necesitas apoyo económico para el desplazamiento? | `[RELLENAR]` (Sí / Sería de ayuda, pero podría cubrirlo / No es necesario) | -| ¿Participarías igualmente costeando viaje y alojamiento? | `[RELLENAR]` (Sí, probablemente / No me sería posible) | -| ¿Necesidad especial para no compartir habitación? | `[RELLENAR]` | -| ¿Restricción alimentaria o accesibilidad? | `[RELLENAR]` | -| ¿Comentario adicional? | Sugerencia: ofrecer una demostración en vivo de Tane en el "pasillo de la fama" del jueves. | - -## 4. Lista de tareas - -- [ ] **Antes del lunes 21-07**: rellenar y enviar el formulario (respuestas de arriba + campos personales). -- [ ] Antes del 31-07: llegará la respuesta de selección. -- [ ] Si seleccionan: apuntar reuniones preparatorias del **1 y 8 de septiembre**; preparar material para el pasillo de la fama (cartel con QR de descarga, capturas localizadas ya existentes de los golden tests, móvil/tablet con la app, sobres de semillas de muestra). -- [ ] **Vigilar la 4ª convocatoria Pathfinder** (mediados de 2026, 4.000 € + acompañamiento): suscribirse a la [lista de FFF](https://ecolise.eu/) o preguntar en el encuentro; buscar comunidad local aliada para co-presentar un piloto. -- [ ] Preguntar en el encuentro por la línea Demonstrator (requisitos, próximas convocatorias). diff --git a/docs/notes/ngi-fediversity-application.md b/docs/notes/ngi-fediversity-application.md deleted file mode 100644 index 7fc6700..0000000 --- a/docs/notes/ngi-fediversity-application.md +++ /dev/null @@ -1,292 +0,0 @@ -# Tane — NGI Fediversity (NLnet) application draft - -> **Working document, NOT tracked in git.** Ready-to-submit draft for the NLnet -> NGI Fediversity call (nlnet.nl/propose). Deadline **1 Aug 2026, 12:00 CEST** (confirmed on the site). -> Derived from `VISION.md`, `PLAN.md`, `docs/design/*` and `docs/notes/modelos-financiacion.md`. -> Written in English (the proposal must be in English; VISION.md is the Spanish source). -> **Updated 2026-07-17** to reflect the shipped state: app live on Google Play (v0.1.1), site at -> tane.comunes.org, most of the social layer already built. The ask now funds the *remaining* -> federated-infrastructure work, not a from-scratch build. - ---- - -## Part A — Strategy & framing (for us, NOT for the form) - -### The new, stronger story: we shipped, now we complete the federated infrastructure -Since the first draft, the project advanced a lot. This is now a **track-record application**, not a -promise: -- **Live on Google Play** (v0.1.1, `org.comunes.tane`), **GMS-free build** ready for F-Droid. -- **Public site** at **tane.comunes.org**, translated via Weblate (translate.comunes.org). -- **Block 1 (inventory) shipped**, and **most of Block 2 (the social layer) already shipped**: offers - (NIP-99), private metadata-hiding messaging (NIP-17), a Duniter-style egocentric web of trust - (kind 30777), ratings (kind 30778), profiles + multi-account identity, Plantaré pledges, saved-search - alerts. All transports live in the reusable `commons_core` engine, with ~131 test files and CI. - -NLnet weighs **technical feasibility (30%)** and **value for money (30%)**: a shipped, tested, -in-store app de-risks both. Lead with that. - -### Fit with Fediversity — now genuine, not a stretch -The Fediversity fund leans to the *hosting stack / self-hosting* side. The **work that remains is -exactly that side**, which is why the fit is now real: -1. **Self-hostable community relay** — `relay.comunes.org` is **already deployed and running**. The - remaining Fediversity-shaped work is turning it into a **reproducible, documented recipe any seed - network can self-host**, plus moderation tooling (NIP-56 reports, key-blocking) and operational - hardening — so the network is owned by communities, not a single instance. -2. **Multi-device sync** — the encrypted `SyncTransport` landed (NIP-78, relay sees only ciphertext); - the app-level `SyncService` that replicates your inventory across your own devices is **pending**. - This is data portability + self-custody, a Fediversity theme. -3. **Interoperability & portability** — open, versioned export/import already exists; add opt-in - **Darwin Core / GBIF** export so agrobiodiversity data can flow to open biodiversity atlases. -4. **`commons_core` as reusable federated infrastructure** — Tane is a seed app wrapped around a generic - Nostr + CRDT + web-of-trust + messaging engine; seeds are the first app, others can follow. - -Framing in one line: **bringing federation beyond social media, into the physical commons — and we -already have a working app to prove it.** - -### Plan B — don't bet everything on this call -Honest odds: the fit is now decent (the remaining work IS hosting/portability-shaped) but Fediversity -reviewers may still read "mobile app on Nostr" as adjacent to their ActivityPub/NixOS hosting focus. -Mitigation, at near-zero extra cost: -- **Submit to Fediversity anyway** (short form, strong track record, real infra milestones) — worst case - is a rejection with feedback. -- **Reuse this same dossier for NGI Zero Commons / Core when regular calls reopen after summer 2026** — - Tane as commons infrastructure is arguably a *better* fit there (`commons_core`, open protocols, - community-run relays). Keep this file as the master; only the fund-specific framing paragraph changes. -- Meanwhile, keep the **Goteo matchfunding** (Red de Semillas) track as the community-round complement. - -### NLnet requirements checklist -- [x] **FOSS licence** — code AGPL-3.0; docs/assets CC-BY-SA. -- [x] **Open standards** — Nostr NIPs (99/17/44/78/56), Duniter-style WoT, open data formats, Darwin Core. -- [x] **European dimension** — EC-LLD (23 European seed networks), Red de Semillas, EU seed-law (PRM) context. -- [x] **Milestone-based, verifiable deliverables** — see Part C. -- [x] **Sustainability after funding** — local-first, open data/code, cheap community relays; never per-transaction fees. -- [ ] **Generative-AI disclosure** — declared honestly and minimally (see §13 + the log template below). -- [ ] **Privacy statement** — tick the acknowledgement of NLnet's privacy statement on the form. - -### The AI concern (read before submitting) -NLnet has a formal **Generative AI policy** (nlnet.nl/foundation/policies/generativeAI/). It does **not** -forbid AI, but imposes real obligations, and reviewers value **substance over marketing**. Three things, -don't conflate them: -1. **Prompt provenance log (required):** if GenAI is used in the application process, you must keep a - log of prompts/outputs. → the Appendix table below. -2. **Public disclosure (required for substantive use):** any GenAI use that materially affects the - output must be disclosed so evaluators understand how the proposal was produced. → §13. -3. **Purely-AI outcomes are NOT payable, and must be FLOSS-clean:** "Outcomes purely generated by AI are - not allowed to be submitted as work eligible for payment." → this hits the **milestones (M1–M5)**, not - just the application: the delivered code/docs must be **human-authored** (AI-assisted is fine; you must - also verify outputs don't reproduce copyrighted/licence-incompatible material). **Non-compliance can - mean rejection of the proposal or termination of a running grant.** - -Plus the softer risk: an application that *reads* as AI slop scores worse. Fix for all of it = make the -text genuinely yours. - -**Action for vjrj — rewrite §3, §4, §5 in your own voice** (that's what a reviewer reads first; the rest -is technical scaffolding). Add first-person specifics only you can write: Kokopelli, the 2009 paper -Plantare you originated, why the whole seed sector refuses per-transaction fees, your Ğ1nkgo/Duniter -background, and the fact the app is already in people's hands. - -**De-"slop" pass:** cut the tells — em-dashes everywhere, rule-of-three lists, heavy bold, -"not X but Y" constructions, brochure cadence. Rougher, more technical, more personal = better here. - -### Process notes -- Submit several days early — "deadlines are hard". Draft offline (this file), paste at the end. -- Plain text is preferred; keep formatting light. Attachments ≤ 50 MB total. -- You may submit multiple independent proposals in one round; do not double-submit via FundingBox + NLnet. - ---- - -## Part B — Form answers (the actual draft, English) - -### Current status (facts to weave in — all verifiable) -- **Live:** Google Play v0.1.1 (`org.comunes.tane`); site **tane.comunes.org**; community Nostr relay - **relay.comunes.org running**; source public (AGPL-3.0). -- **F-Droid:** GMS-free build + recipe ready (awaiting registry upload). -- **Languages:** 7 (en, es, pt, fr, ast, de, ja); RTL + CJK fonts bundled; translated on Weblate. -- **Engine:** `commons_core` (pure Dart) — identity derivation, HLC, CRDT, geohash, all Nostr transports. -- **Tests/CI:** ~131 test files; Forgejo Actions runs analyze + test on both packages. - -### 1. Contact / Organisation / Country -- **Applicant organisation:** Comunes (registered non-profit association), Spain / EU. -- **Project lead:** Vicente J. Ruiz Jurado (vjrj) — technical lead. -- **Package id / namespace:** `org.comunes.tane`. -- **Contact:** vjrj@comunes.org. -- **Project page:** https://tane.comunes.org · source repository public (AGPL-3.0) · live on Google Play. - -### 2. Project name -**Tane — self-hostable, local-first infrastructure for seed-sharing communities.** (種, "seed"; from -*tanemaki*, "to sow".) - -### 3. Abstract (a few sentences) -> ✍️ **vjrj: rewrite in your own voice.** Scaffolding below — keep the meaning, drop the polished cadence. - -Tane lets communities run their own seed-sharing network end to end: a local-first mobile app, a -self-hostable relay, and identity and data the user fully owns and can take anywhere — no account, no -central server, no company in the middle. The app is already live on Google Play in seven languages; -offers, private messaging, reputation and encrypted device sync ride on open federated protocols -(Nostr NIPs) and a Duniter-style web of trust, implemented in a reusable engine (`commons_core`) where -seeds are only the first application. What remains — and what this grant funds — is the hosting and -portability side: a **reproducible relay recipe any collective can self-host** (our reference instance -already runs), **completion of encrypted multi-device sync**, **open-standards data portability** -(Darwin Core / GBIF export), and production hardening — so a seed network can operate the whole stack -without depending on us, or on anyone. - -### 4. Can you explain the whole project and its expected outcome? -> ✍️ **vjrj: rewrite in your own voice.** Add first-person specifics (Kokopelli, the 2009 paper Plantare, -> Ğ1nkgo). Scaffolding below. - -A handful of corporations control most of the world's seed supply, and traditional varieties — and the -knowledge to grow them — are being lost. Sharing seeds has even become legally suspect (e.g. Kokopelli -fined in France). Seed networks resist with notebooks and spreadsheets; there was no friendly, free, -decentralised tool for them. - -Tane is built in **layers, each useful alone**, and most are **already shipped**: -1. **Inventory (local-first) — shipped.** Encrypted seed bank on your phone (SQLite + SQLCipher), fully - offline, no account; backup + printed recovery; 7 languages. -2. **Offer status — shipped.** Per item: private / gift / exchange / sale. -3. **Local sharing (federated) — shipped.** Offers as signed Nostr events (NIP-99), discovered by - **coarse geohash** (~2.4 km, never your address); private DMs (NIP-17); ratings; Plantaré pledges; - saved-search alerts. No central operator can censor, sell data, or charge a fee. -4. **Trust — shipped.** Egocentric Duniter-style web of trust (you vouch for people you've met); spam - from unknown keys is filtered with no central moderator. - -A de-risking spike proved the whole social happy-path on vetted pure-Dart libraries, and it is now -**production code in people's hands**. **What this grant funds is the hosting and portability layer that -makes the network genuinely community-owned** (see Part C): turning our running relay into a -**reproducible, self-hostable recipe with moderation tooling**; completing **encrypted multi-device -sync** (the transport already landed; the relay only ever sees ciphertext); **open-standards data -portability** (opt-in Darwin Core / GBIF export toward public biodiversity atlases); plus trust -cold-start for real seed fairs, optional Ğ1 price tags, and messaging/offers hardening. - -**Expected outcome:** any seed collective in Europe can **self-host the entire stack from a documented -recipe** — relay, moderation, app, data — with identity and data fully portable and **zero dependency on -Comunes or any single operator**. And because the hard parts live in the generic `commons_core` engine, -the same infrastructure is reusable for other physical-commons networks (tool libraries are the next -candidate). - -### 5. Compare your own project with existing or historical efforts -> ✍️ **vjrj: rewrite in your own voice.** You know this sector first-hand — say why it refuses -> per-transaction fees, and tell the Plantare story as its originator. Scaffolding below. - -- **Seed Savers Exchange, Graines de Troc, Arche Noah, Red de Semillas** — valuable, but each is a - **centralised web platform**: single operator, an account, a server that can go dark, no local-first - offline app, not federated, not self-hostable. -- **Garden/inventory apps** — mostly **proprietary and extractive**; none do decentralised sharing or trust. -- **Marketplaces (Wallapop-style)** — a central intermediary that takes a commission and can censor. - Tellingly, **no seed-exchange platform anywhere charges per-transaction fees** — the whole sector uses - gift/exchange/membership. We follow that, by design. -- **Historical precedent — the Plantare (2009).** A paper "seed promissory note" already solved - decentralised trust and reciprocity *socially* (a bearer instrument, both parties hold a copy, the - ledger distributed across drawers). We digitise it without a central register. - -**Why it's needed / why existing solutions don't answer it:** none combines local-first, federation over -open standards, self-hostability, identity/data portability, and a spam-resistant web of trust — while -staying free and non-extractive. Tane already does, in production; the engine is reusable for other -physical-commons networks. - -### 6. Significant technical challenges (the remaining work) -- **Self-hostable relay + moderation** — `relay.comunes.org` already runs; the challenge is making it a - reproducible recipe others can run, with NIP-56 report handling and key-blocking, so moderation is - "with a response" but not centralised. -- **Multi-device sync at the app level** — wire `SyncService` onto the landed encrypted `SyncTransport`: - serialise inventory, push on mutation, idempotent LWW import on receipt, stable per-install device id, - rare-conflict UI. Relay only ever sees ciphertext. -- **Interoperability / portability** — opt-in Darwin Core / GBIF export of agrobiodiversity (blurred - location, collective-bank level, one-way) — exactly the data those atlases lack. -- **Messaging/offers hardening** — NIP-44 interop-exact test vectors, offline DM delivery/retry, offer - expiry & revocation on already-replicated relays. -- **Trust cold-start** — bootstrap the web of trust at real fairs / Ğ1 seed groups (QR, no network). -- **Portable derived identity** — one root seed, one-way HKDF secp256k1 subkey for Nostr, one printable - QR backs up everything (already implemented; polish + docs). - -### 7. Project ecosystem & stakeholder engagement -- **Seed networks:** Red de Semillas (Spain), **EC-LLD / Let's Liberate Diversity** (23 European seed - networks) for European reach; Arche Noah as a policy ally. -- **Free-money communities (Ğ1/Duniter):** first pilots — existing identity, existing web of trust, - aligned values. -- **Seed fairs and markets:** where trust bootstraps face to face. -- **Translators & contributors:** already active via Weblate; code and docs public. - -### 8. Relevant prior experience & track record -- **Shipped, in production:** Tane is **live on Google Play** (v0.1.1), GMS-free build ready for F-Droid, - site at tane.comunes.org, 7 languages via Weblate, RTL+CJK support, a full legal package, automated - store publishing (Fastlane) and CI (Forgejo Actions, ~131 test files). Block 1 + most of Block 2 done. -- **vjrj** authored **Ğ1nkgo**, a Duniter/Ğ1 wallet in Flutter/Dart; Tane reuses its cryptographic - primitives (secp256k1, HKDF, Duniter identity) — no reinvention. -- **vjrj originated the Plantare concept** (BAH-Semillero, 2009, CC-BY-SA) and wrote *"Las semillas del - conocimiento libre"* (2005) — a decade of domain authority in seed commons. -- **Comunes** — registered association, stable legal counterpart for milestone accountability. - -### 9. Requested amount -**€32,000.** - -### 10. Budget usage / task breakdown -See Part C. Milestone-based; each milestone is an independent, verifiable deliverable with a CI-gated -test suite where applicable. NLnet pays on verification of each milestone. - -### 11. Other funding sources (past and present) -- Built so far with **volunteer work**; **no prior grant funding**; no overlapping funding for this scope. -- Complementary community funding (e.g. Goteo matchfunding with Red de Semillas) is a *future* - sustainability avenue, not a duplicate of this request. - -### 12. Sustainability after the grant -- **Local-first:** useful even with no servers and no maintainer — utility does not expire. -- **Open code & data (AGPL-3.0, open export):** anyone can continue, fork, or **self-host the relay**. -- **Cheap, community-run relays** (collectives / Comunes), plus opportunistic app-as-relay and - physical-proximity exchange at fairs. -- **Non-extractive revenue only, never on the seed:** voluntary association membership (Seed Savers - model), optional managed services for institutional seed banks. No per-transaction commission — legally - prudent (EU PRM in trilogue) and coherent with the commons. - -### 13. Compliance & disclosure -- **Licence:** AGPL-3.0 (code); CC-BY-SA (docs/assets). Compatible with the reused Duniter/Ğ1nkgo stack. -- **Privacy statement:** acknowledged. -- **Generative-AI disclosure (honest & minimal — paste into the form):** - > An AI assistant was used only as a drafting aid for this application text: to translate and structure - > an initial English outline from our pre-existing Spanish design documents (VISION.md, PLAN.md, - > docs/design/*), which predate and are independent of this application. All technical claims, - > architecture, milestones and budget are the applicant's own; the project lead rewrote and verified - > the text, and no AI output was submitted unedited. A prompt provenance log is available on request. - > The funded deliverables (M1–M5) will be human-authored (AI-assisted at most), released under - > AGPL-3.0, with no purely-AI-generated outcomes submitted for payment. - - *Adjust to match what you actually did before sending. If you rewrite §3–§5 yourself (recommended), - this statement stays true and strong.* - ---- - -## Part C — Milestones & budget (€32,000) - -Milestones reflect the **real remaining work** (Block 1 + most of Block 2 are already shipped). They are -ordered by theme: **M1–M3 are the federated-hosting core** (self-hosting, sync, portability); **M4–M5 -harden and bootstrap it**. Each is an independent, verifiable deliverable; NLnet pays on verification. - -| # | Milestone | Verifiable deliverable | € | -|---|-----------|------------------------|---| -| **M1** | **Self-hostable relay recipe + moderation** | `relay.comunes.org` already runs; deliver a **reproducible, documented recipe** any seed network can self-host, plus NIP-56 report handling + key-blocking moderation tooling and an operator manual | 6,000 | -| **M2** | **Multi-device sync (app `SyncService`)** | Inventory replication across a user's own devices over the landed encrypted `SyncTransport` (NIP-78): serialise + push on mutation, idempotent LWW import, stable per-install device id, conflict UI; tests (relay sees only ciphertext) | 8,000 | -| **M3** | **Interoperability & data portability** | Opt-in Darwin Core / GBIF export of agrobiodiversity (blurred location, collective-bank level, one-way); polished open export/import + identity portability docs | 5,000 | -| **M4** | **Messaging & offers hardening** | NIP-44 interop-exact test vectors, offline DM delivery/retry queue, offer expiry + revocation on replicated relays, spam-filter polish; CI-gated | 7,000 | -| **M5** | **Trust cold-start + Ğ1 price integration + docs/release** | WoT cold-start flows for fairs / Ğ1 groups (QR, offline); Ğ1 price tag + deep-link to wallets (no in-app payment); contributor architecture guide + multilingual user guide; F-Droid release | 6,000 | -| | **Total** | | **32,000** | - ---- - -## Submission checklist (before nlnet.nl/propose) -1. Cross-check every field above against the live form + Guide for Applicants. -2. Fit test: does the abstract + §4 opening read "federation / portability / self-hosting" *before* "seeds"? -3. Budget: milestones independent & verifiable, effort realistic, sum = €32,000. -4. Compliance: FOSS licence stated, privacy statement acknowledged, **AI-disclosure + provenance log ready**. -5. Confirm the 1 Aug 2026 deadline on the site; submit with several days' margin. -6. Language pass: rewrite §3–§5 in vjrj's voice; clear, technical English throughout. - ---- - -## Appendix — AI-use log (keep, attach or provide on request) -Fill as you go so the disclosure is accurate and verifiable. - -| Date | Tool / model | Prompt (verbatim) | What the output was used for | Kept unedited? | -|------|--------------|-------------------|------------------------------|----------------| -| 2026-07-10 | Claude Code (Opus 4.8) | "Me ayudas a presentar tane a esta convocatoria? qué pedirías…" | Initial English draft/outline from repo docs | No — to be rewritten & verified by lead | -| 2026-07-17 | Claude Code (Opus 4.8) | "He avanzado mucho en el desarrollo, puedes actualizar la application?…" | Updated draft to reflect shipped status + new milestones | No — to be rewritten & verified by lead | - -> Keep the raw session transcript alongside this table. If §3–§5 are rewritten by hand, note that here too. diff --git a/docs/o-que-e-tane.md b/docs/o-que-e-tane.md new file mode 100644 index 0000000..bb886ed --- /dev/null +++ b/docs/o-que-e-tane.md @@ -0,0 +1,85 @@ +# Tane — o que é + +**Tane** (種) significa "semente" em japonês; o nome completo, *tanemaki* (種まき), significa "semear / espalhar sementes". + +## Numa frase + +Uma app simples e livre para **guardares as tuas sementes e as partilhares** com quem quiseres. Funciona +**sem internet** e sem nenhuma empresa pelo meio. + +## Porque é preciso + +Hoje, um punhado de empresas controla grande parte das sementes do mundo. Muitas sementes são feitas para +não servirem outra vez: dão uma colheita, mas as suas sementes não germinam bem, por isso é preciso +**comprá-las de novo todos os anos**. Entretanto, as variedades tradicionais — cultivadas e melhoradas +ao longo de milhares de anos — estão a perder-se, e com elas o conhecimento de como as cultivar. + +Partilhar sementes é uma das coisas mais antigas e naturais que há, e ainda assim em alguns sítios +tornou-se complicado ou até foi multado (em França uma associação foi penalizada por distribuir +variedades que não estavam na lista oficial aprovada). E quem cuida de sementes continua a fazê-lo +com cadernos e folhas de cálculo. + +## O que faz, tornado simples + +- **Guardar** — o teu inventário de sementes: o que tens, de que ano, quanto, de onde + veio, com **o nome que usas** (sem precisar de latim). Tanto faz se tens quatro + pacotes numa gaveta ou um grupo com centenas de variedades. +- **Partilhar** — dizes o que ofereces; alguém perto vê-o e escreve-te, para **dar, + trocar ou vender** se quiseres. Fechas o negócio tu mesmo, diretamente. Nenhuma empresa + entra nem cobra comissão. + +## Porque é diferente + +- **É livre e pertence a todos.** Não é propriedade de nenhuma empresa: qualquer pessoa pode usar, + copiar, traduzir ou melhorar, de forma livre e para sempre. É mais uma *receita que se partilha* + do que um produto fechado — chama-se **software livre**. E quem o melhora fica obrigado a + partilhar essas melhorias igualmente livres, para que ninguém o possa "fechar" depois (é isso o + **copyleft**). +- **Funciona sem ligação.** O essencial funciona mesmo no campo sem sinal. +- **Os teus dados são teus.** A tua informação fica guardada no **teu próprio telemóvel**, protegida com uma + chave, não nos computadores de nenhuma empresa. Não a vendemos nem a colocamos em lado nenhum. Sem publicidade. +- **Ninguém o pode desligar ou controlar.** Não há nenhum computador central por onde tudo passa: + o que partilhas viaja por **servidores da comunidade**, mantidos por pessoas e coletivos — + há vários e qualquer pessoa pode acrescentar o seu — pelo que nenhum deles é um centro + a partir do qual censurar ou cobrar para o usares, como quem passa sementes de mão em mão + (é isso que significa ser **descentralizado**). +- **Confias de boca em boca.** Lidas com pessoas que conheces e com as que são avalizadas + por pessoas em quem confias — como sempre funcionou numa aldeia ou numa feira — sem + teres de dar o teu nome verdadeiro ou o teu telefone a não ser que queiras. +- **Em muitas línguas.** Para qualquer parte do mundo, não para um único país. Voluntários + traduzem-no. + +## Para quem é + +Grupos e redes de sementes, hortas e coletivos, agricultores… e qualquer pessoa com uns pacotes +guardados. Para todas as idades, dos 10 aos 99. + +## Um nome japonês + +**Tane** significa "semente" (種); *tanemaki* (種まき) significa "semear sementes". E não é por acaso +que o nome é japonês. Séculos atrás, no Japão, tiras de papel impressas que prometiam +arroz passavam de mão em mão: eram emitidas por templos, comerciantes e armazéns de arroz — +não por um banco ou uma autoridade central — e as pessoas aceitavam-nas confiando nessa promessa. +Uma rede de trocas sem centro, sustentada pela confiança, onde um pedaço de papel bastava +para pôr o arroz em movimento. Séculos depois, essa mesma ideia inspirou redes modernas como +o *sistema WAT* do Japão (2000): uma nota que qualquer pessoa pode imprimir e passar adiante, sem banco nem centro. + +O Tane herda esse espírito. Quando partilhas sementes podes anexar um [**Plantaré**](https://plantare.ourproject.org). O nome +faz um jogo com o espanhol *pagaré* — uma promessa de pagamento, literalmente "pagarei" — trocando *pagar* por +*plantar*: não "pagarei" mas "plantarei". É uma promessa de devolver, quando puderes, uma +quantidade semelhante de semente livre. Não é uma compra nem uma obrigação — é um compromisso de +manter a semente viva e em movimento. E como devolvê-la significa cultivá-la, e cultivar +multiplica-a, cada gesto faz os comuns crescer em vez de os encolher. + +## Onde está e como ajudar + +Ainda dá os primeiros passos. Podes ajudar experimentando-o num grupo de sementes real, [traduzindo-o](https://translate.comunes.org/projects/tane/), +partilhando o que sabes sobre as variedades, ou programando. Não tem preço e nunca terá: é +sustentado por voluntários, pelas próprias comunidades, e por financiamento público para projetos +de interesse comum. + +--- + +> *A ideia por trás disto: as sementes tradicionais são um património da humanidade — de todos e de +> ninguém. Contra quem as queira privatizar, o Tane procura tornar fácil guardá-las, +> multiplicá-las e mantê-las em movimento.* diff --git a/docs/que-es-tane.md b/docs/que-es-tane.md index 61d8b53..3dc5c3e 100644 --- a/docs/que-es-tane.md +++ b/docs/que-es-tane.md @@ -41,9 +41,10 @@ con libretas y hojas de cálculo. clave, no en los ordenadores de una empresa. No la vendemos ni la subimos a ningún sitio. Sin publicidad. - **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo: - las personas se comunican **directamente entre sí**, como quien se pasa unas semillas de - mano en mano. Por eso nadie puede censurarla ni cobrarte por usarla (eso es que sea - **descentralizada**). + lo que compartes viaja por **servidores de la comunidad**, mantenidos por personas y + colectivos — hay varios y cualquiera puede añadir el suyo — así que ninguno es un centro + desde el que censurarla o cobrarte por usarla, como quien se pasa unas semillas de mano + en mano (eso es que sea **descentralizada**). - **Te fías por el boca a boca.** Te relacionas con gente que conoces y con quien te recomienda gente de confianza —como siempre se ha hecho en un pueblo o en una feria— sin tener que dar tu nombre real ni tu teléfono si no quieres. diff --git a/docs/release.md b/docs/release.md index 8b44a4f..04512ab 100644 --- a/docs/release.md +++ b/docs/release.md @@ -15,7 +15,11 @@ git tag v0.1.0 && git push origin v0.1.0 ``` Forgejo Actions ([`../.forgejo/workflows/release.yml`](../.forgejo/workflows/release.yml)) -builds the signed AAB/APK and uploads to Play's **internal** track. No passwords are typed. +builds the signed AAB/APK and uploads to Play's **production** track at **100% +rollout**. No passwords are typed. Because a tag now publishes to everyone +(subject to Google review), run the [manual smoke test](#manual-smoke-test-before-shipping) +**before** tagging. To stage on the internal track first, run +`bundle exec fastlane deploy_internal` by hand. ## Versioning @@ -123,13 +127,42 @@ listing (titles, descriptions, changelogs, screenshots) is read straight from `fastlane/metadata/android/`. Data Safety and content-rating answers: [`legal/internal/play-compliance.md`](legal/internal/play-compliance.md). +Lanes: +- `deploy_play` — upload the AAB to **production** at 100% (what CI runs on tag). +- `deploy_internal` — same AAB to the **internal** test track (manual QA safety net). +- `deploy_metadata` — push only the store listing (no binary). +- `promote_production` — promote an internal release to production without a rebuild. + +### Country/region availability (Play Console, not this repo) + +Which countries the app is offered in is **not** in the repo and `fastlane supply` +does not manage it — it's a Play Console setting. To add countries: +**Play Console → (Tane) → Production → Countries / regions → Add countries/regions** +(under "Availability"). Play asks you to pick countries when you first move a +release to production; widen the list there for new markets. + +Note: the locales under `fastlane/metadata/android/` (`en-US`, `es-ES`) +are **listing languages**, not countries — adding a locale improves the store +page but does not by itself make the app available in a new country. + ## Publish to F-Droid (official repo) +**Tane is live in F-Droid** since 2026-07-28 (v0.1.16, no antifeatures): +. The inclusion MR +([43144](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/43144)) was merged +on 2026-07-25; from now on each release only needs the recipe in `fdroiddata` +master bumped, not a new inclusion request. Note that publication lags the green +build by a day or two — check `repo/status/build.json` before assuming breakage. + F-Droid builds from source after a merge request to `fdroiddata`. The build recipe is kept in-repo at [`fdroid/org.comunes.tane.yml`](fdroid/org.comunes.tane.yml). Copy it to `metadata/org.comunes.tane.yml` in a fork of fdroiddata, validate with -`fdroid lint` / `fdroid build -l org.comunes.tane`, then open the MR -([43144](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/43144)). +`fdroid lint` / `fdroid build -l org.comunes.tane`, then open the MR. + +Every version bump **must** advance the recipe's per-ABI `versionCode`s +(`build × 10 + ABI offset`) together with the `pubspec.yaml` bump — otherwise the +`fdroid_reference` job fails, and a bad tag cannot be fixed by re-tagging (Play +rejects a reused versionCode): cut the next version instead. **Reproducible, developer-signed.** The recipe pins `AllowedAPKSigningKeys` (the SHA-256 of the tane-upload certificate) and a `binary:` URL per ABI. F-Droid diff --git a/docs/what-is-tane.md b/docs/what-is-tane.md index a378f0f..9d3ce9c 100644 --- a/docs/what-is-tane.md +++ b/docs/what-is-tane.md @@ -39,9 +39,10 @@ still do it with notebooks and spreadsheets. - **Your data is yours.** Your information is kept on **your own phone**, protected with a key, not on some company's computers. We don't sell it or upload it anywhere. No ads. - **No one can shut it down or control it.** There's no central computer that everything - passes through: people communicate **directly with each other**, like passing seeds from - hand to hand. So no one can censor it or charge you to use it (that's what being - **decentralized** means). + passes through: what you share travels over **community servers**, run by people and + collectives — there are many, and anyone can add their own — so none of them is a center + that could censor it or charge you to use it, like passing seeds from hand to hand + (that's what being **decentralized** means). - **You trust by word of mouth.** You deal with people you know and with those vouched for by people you trust — the way it's always worked in a village or at a fair — without having to give your real name or phone number unless you want to. diff --git a/fdroid-ci/config.yml b/fdroid-ci/config.yml new file mode 100644 index 0000000..0f104e1 --- /dev/null +++ b/fdroid-ci/config.yml @@ -0,0 +1,89 @@ +--- + +repo_url: "https://f-droid.org/repo" +repo_maxage: 14 +repo_web_base_url: "https://f-droid.org/packages" + +# needed until this is fixed: https://gitlab.com/fdroid/fdroidserver/-/issues/1143 +repo_name: "F-Droid" + +archive_older: 3 + +repo_keyalias: ciarang +repo_key_sha256: 43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab +repo_pubkey: 3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef + +gpgkey: 37D2C98789D8311948394E3E41E7044E1DBA2E89 +gpghome: {env: gpghome} + +keystore: {env: keystore} +keydname: "CN=FDroid, OU=FDroid, O=fdroid.org, L=ORG, S=ORG, C=UK" +keystorepass: {env: keystorepass} +keypass: {env: keypass} + +serverwebroot: {env: serverwebroot} +nonstandardwebroot: true + +deploy_process_logs: true +keep_when_not_allowed: true +make_current_version_link: false +refresh_scanner: true + +binary_transparency_remote: git@gitlab.com:fdroid/f-droid.org-transparency-log.git + +keyaliases: + com.ghostsq.commander.samba: '@com.ghostsq.commander' + com.nextcloud.talk2: '@com.nextcloud.client' + com.termux.api: '@com.termux' + com.termux.boot: '@com.termux' + com.termux.gui: '@com.termux' + com.termux.styling: '@com.termux' + com.termux.tasker: '@com.termux' + com.termux.widget: '@com.termux' + com.termux.window: '@com.termux' + org.fdroid.fdroid.privileged: 'ciarang' + org.fdroid.fdroid: 'ciarang' + +# APKs signed by publicly available private signing keys are not +# allowed to be included in this repo, even if they are reproducible. +# +# Hash calculated with `openssl x509 -in -outform der | sha256sum` +apk_signing_key_block_list: + # https://android.googlesource.com/platform/build/+/refs/heads/main/target/product/security + - a6ccc500ff0e7421200eb66a7fe174ef1b00e52ca91727070cbedf061ff76c35 # AOSP bluetooth.x509.pem + - ce7b2b47ae2b7552c8f92cc29124279883041fb623a5f194a82c9bf15d492aa0 # AOSP cts_uicc_2021.x509.pem + - 465983f7791f2abeb43ea2cbdc7f21a8260b72bc08a55c839fc1a43bc741a81e # AOSP media.x509.pem + - e1dbadce60dc080d15b58a014b0dcf9400e24de23fa00b287a5a982bfebda2ee # AOSP networkstack.x509.pem + - fae9122a8721d6e2a196d2224dffcf773c9127e2bb956cbddb40b009192ffdfd # AOSP nfc.x509.pem + - c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8 # AOSP platform.x509.pem + - abf21f9e2af1d881cc673fddcefa6ed9c269a437bd64b279cf45844cfd589126 # AOSP sdk_sandbox.x509.pem + - 28bbfe4a7b97e74681dc55c2fbb6ccb8d6c74963733f6af6ae74d8c3a6e879fd # AOSP shared.x509.pem + - a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc # AOSP testkey.x509.pem + # Leaked Platform Certificates https://bugs.chromium.org/p/apvi/issues/detail?id=100 + - 2464ddfefa071f268ea7667123df05ead2293272ff2a64d9cee021c38b46c6af + - 2bfa22964760a25d99ab9a14910e44fe2063b51d5b4ac2e4282573ce94996aa3 + - 34df0e7a9f1cf1892e45c056b4973cd81ccf148a4050d11aea4ac5a65f900a42 + - 369c38b18401ea16785f11720e37d7a2bc5a4d209e76955c0858ea469ad62fdf + - 4274243d7a954ac6482866f0cc67ca1843ca94d68a0ee53f837d6740a8134421 + - 5304915c4bb7baca28776231993996fde1baffcbbe6500fb0fc7f2d3a2888cb7 + - 9200c550f2374706eff37e3a8674bc03aeba8b25c052de638972ab94365af0a2 + - 9fc510e167d8d312e758273285414e77edac9fed944741f5682be92501f095d4 + - a7a0e10a61a5af93624376df60e9def9436358f50aa6174e5423633b856e2be1 + - b01dcea669eefdd991fc6a24678a8b6e6a6d0ad8986950328c69d0eea1dec0d5 + # publicly available in https://github.com/esabook/auzen-android + - 30d7eef6321f81f43c008665abc46f680006be6e89961481ed4e1d6981b8e5a0 + # publicly available in https://github.com/Goooler/LawnchairRelease + - 8f5b1353db08cd9287f8ecc6bad2eb9be2668d476af90415bd7bfdff257840e4 + # publicly available in https://github.com/jkas-dbt/AndroidPE + - f1881e27ceb9b4341a0fc00db27bf5f46a38e4c0c1884353f76b0fe4b0e4dccf + # publicly available in https://github.com/iebb/NekokoLPA + - 6966004ad0161165335f9204f8f5a52df49fe068c9545c0c589c23221af8d3a4 + - 4139278AACCE8338C03DC9A6B0172C1880E0A4949FF3E05292C2E781765026FA + # publicly available in https://github.com/MaYiFei1995/PackageViewer + - 24117de73612bcfeaf2a6a24bd044f2e33e52d41965f504d74177f4fe255eb26 + # publicly available in https://github.com/teambtcmap/btcmap-android + - 37cdf8e8fdff2252681c1f7d68f3850f916f05607442c6762f86f6a23a7c1ad0 + # publicly available in https://github.com/Ashinch/ReadYou/blob/0.11.1/signature/keystore.properties + - 715696914a35366d98fa45312af96811f7e6de4085b5e4709f4c1e74f8bde4d0 + # Privacy QR Scanner leaked key + - 2529f2fd61ac279806b7bc423daef5b36fdac496c87ff9fa36c8bdeead12acaa diff --git a/fdroid-ci/srclibs/flutter.yml b/fdroid-ci/srclibs/flutter.yml new file mode 100644 index 0000000..2a3d516 --- /dev/null +++ b/fdroid-ci/srclibs/flutter.yml @@ -0,0 +1,2 @@ +RepoType: git +Repo: https://github.com/flutter/flutter.git diff --git a/fdroid-ci/srclibs/tesseract4android.yml b/fdroid-ci/srclibs/tesseract4android.yml new file mode 100644 index 0000000..6f59415 --- /dev/null +++ b/fdroid-ci/srclibs/tesseract4android.yml @@ -0,0 +1,2 @@ +RepoType: git +Repo: https://github.com/adaptech-cz/Tesseract4Android diff --git a/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart b/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart index c9408b9..bd4c9d8 100644 --- a/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart +++ b/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart @@ -13,10 +13,12 @@ class NostrOfferTransport implements OfferTransport { final NostrChannel _conn; final Nip99Codec _codec = Nip99Codec(); - Filter _filter(DiscoveryQuery q) => Filter( + Filter _filter(DiscoveryQuery q, {int? since}) => Filter( kinds: const [Nip99Codec.kindActive], tagFilters: {'g': [q.geohashPrefix]}, limit: q.limit, + since: since, + until: q.until, ); @override @@ -37,11 +39,35 @@ class NostrOfferTransport implements OfferTransport { } @override - Stream discover(DiscoveryQuery query) => _conn - .subscribe(_filter(query)) + Stream discover(DiscoveryQuery query, {int? since}) => _conn + .subscribe(_filter(query, since: since)) .map(_codec.decode) .where((o) => query.types.isEmpty || query.types.contains(o.type)); + @override + Future discoverPage(DiscoveryQuery query) async { + // Newest first; the relays dedup within themselves and reqOnce dedups + // across them, but order is not guaranteed, so sort here — on a copy, as + // the channel may hand out an unmodifiable list. + final events = [...await _conn.reqOnce(_filter(query))] + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final offers = []; + for (final e in events) { + final offer = _codec.decode(e); + if (_matchesType(offer, query)) offers.add(offer); + } + // A full page means more may exist older than the oldest event we saw; a + // short page means we've reached the end. Base the cursor on ALL events + // (not just type-matched ones) so type filtering never makes us re-fetch. + final nextCursor = events.length >= query.limit && events.isNotEmpty + ? events.last.createdAt - 1 + : null; + return OfferPage(offers: offers, nextCursor: nextCursor); + } + + bool _matchesType(Offer o, DiscoveryQuery q) => + q.types.isEmpty || q.types.contains(o.type); + /// Collects matches up to EOSE (tests/one-shot browse). Future> discoverUntilEose(DiscoveryQuery query) async { final events = await _conn.reqOnce(_filter(query)); diff --git a/packages/commons_core/lib/src/social/nostr/relay_pool.dart b/packages/commons_core/lib/src/social/nostr/relay_pool.dart index eac038d..aad754d 100644 --- a/packages/commons_core/lib/src/social/nostr/relay_pool.dart +++ b/packages/commons_core/lib/src/social/nostr/relay_pool.dart @@ -26,19 +26,32 @@ class RelayPool implements NostrChannel { /// Number of relays currently connected (for diagnostics/tests). int get relayCount => _connections.length; - /// Connects to each of [relayUrls], skipping any that fail. Throws - /// [StateError] only when NONE are reachable, so the caller can treat that as - /// "offline". + /// Connects to each of [relayUrls], skipping any that fail or take longer + /// than [connectTimeout] — a relay on a silently-filtering network would + /// otherwise hang the whole pool for minutes. Throws [StateError] only when + /// NONE are reachable, so the caller can treat that as "offline". static Future connect( List relayUrls, { required NostrIdentity identity, + Duration connectTimeout = const Duration(seconds: 10), }) async { final connections = []; await Future.wait( relayUrls.map((url) async { try { + final attempt = NostrConnection.connect(url, identity: identity); connections.add( - await NostrConnection.connect(url, identity: identity), + await attempt.timeout( + connectTimeout, + onTimeout: () { + // Abandon the hung attempt; if it ever completes, close it so + // the socket doesn't leak. + unawaited( + attempt.then((c) => c.close()).catchError((_) {}), + ); + throw TimeoutException('relay connect timed out', connectTimeout); + }, + ), ); } catch (_) { // Unreachable relay — skip it, keep the rest. diff --git a/packages/commons_core/lib/src/social/offer.dart b/packages/commons_core/lib/src/social/offer.dart index 5baa1b1..97472d9 100644 --- a/packages/commons_core/lib/src/social/offer.dart +++ b/packages/commons_core/lib/src/social/offer.dart @@ -88,10 +88,28 @@ class DiscoveryQuery { required this.geohashPrefix, this.types = const {}, this.limit = 100, + this.until, }); /// Coarse geohash prefix to search near (e.g. "u09" ≈ tens of km). final String geohashPrefix; final Set types; final int limit; + + /// Pagination cursor (NIP-01 `until`): return only offers published at or + /// before this Unix time (seconds). Null asks for the newest page. Comes from + /// the previous page's [OfferPage.nextCursor]. + final int? until; +} + +/// One page of discovered offers plus the cursor to fetch the next (older) page. +class OfferPage { + const OfferPage({required this.offers, this.nextCursor}); + + /// The page's offers, newest first. + final List offers; + + /// Pass as the next query's [DiscoveryQuery.until] to page further back. Null + /// when the relays returned less than a full page — there is nothing older. + final int? nextCursor; } diff --git a/packages/commons_core/lib/src/social/offer_transport.dart b/packages/commons_core/lib/src/social/offer_transport.dart index 3dc3740..3d7928e 100644 --- a/packages/commons_core/lib/src/social/offer_transport.dart +++ b/packages/commons_core/lib/src/social/offer_transport.dart @@ -13,8 +13,16 @@ abstract interface class OfferTransport { Future publish(Offer offer); /// Streams offers matching [query]: stored matches first, then live ones, - /// until the caller cancels the subscription. - Stream discover(DiscoveryQuery query); + /// until the caller cancels the subscription. Pass [since] (Unix seconds) to + /// stream only offers published after it — used to keep a live subscription + /// for NEW offers while older ones are browsed via [discoverPage]. + Stream discover(DiscoveryQuery query, {int? since}); + + /// Fetches ONE page of stored offers matching [query] (up to EOSE), newest + /// first, with a cursor to page further back. Bounded — unlike [discover] it + /// does not hold a live subscription — so the UI can scroll a large result + /// set without accumulating every offer in memory. + Future discoverPage(DiscoveryQuery query); /// Retracts a previously published offer (relays that already replicated it /// drop it over time). diff --git a/packages/commons_core/test/social/nostr_offer_transport_page_test.dart b/packages/commons_core/test/social/nostr_offer_transport_page_test.dart new file mode 100644 index 0000000..25f9642 --- /dev/null +++ b/packages/commons_core/test/social/nostr_offer_transport_page_test.dart @@ -0,0 +1,109 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:nostr/nostr.dart'; +import 'package:test/test.dart'; + +/// A stand-in relay channel: [reqOnce] honours the filter's `until` and `limit` +/// (newest first), like a relay serving stored events, so [discoverPage]'s +/// ordering and cursor maths can be asserted deterministically. +class _FakeChannel implements NostrChannel { + _FakeChannel(this._events); + + final List _events; + + @override + Future> reqOnce(Filter filter) async { + final until = filter.until; + final matched = _events + .where((e) => until == null || e.createdAt <= until) + .toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return matched.take(filter.limit ?? matched.length).toList(); + } + + @override + String get privateKeyHex => '00' * 32; + @override + String get publicKeyHex => 'ab' * 32; + @override + Future<({bool accepted, String message})> publish(Event event) async => + (accepted: true, message: ''); + @override + Stream subscribe(Filter filter) => const Stream.empty(); + @override + Future close() async {} +} + +Event _evt(String id, int createdAt, {String type = 'gift'}) => Event( + 'evt-$id', + 'ab' * 32, + createdAt, + Nip99Codec.kindActive, + [ + ['d', id], + ['g', 'sp3e9'], + ['offer_type', type], + ['title', 'offer $id'], + ], + 'offer $id', + '00' * 64, + verify: false, + ); + +void main() { + group('NostrOfferTransport.discoverPage', () { + test('returns offers newest-first with a cursor to the older page', + () async { + final transport = NostrOfferTransport(_FakeChannel([ + _evt('a', 100), // oldest + _evt('c', 300), // newest + _evt('b', 200), + ])); + + final page1 = await transport.discoverPage( + const DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 2), + ); + // A full page (2 of the 3) sorted newest-first, with a cursor set. + expect(page1.offers.map((o) => o.id), ['c', 'b']); + expect(page1.nextCursor, 199, reason: 'oldest kept (200) minus one'); + + final page2 = await transport.discoverPage( + DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 2, until: page1.nextCursor), + ); + // Only the last one remains — a short page, so nothing older. + expect(page2.offers.map((o) => o.id), ['a']); + expect(page2.nextCursor, isNull); + }); + + test('a short first page reports no next cursor', () async { + final transport = NostrOfferTransport(_FakeChannel([ + _evt('a', 100), + _evt('b', 200), + ])); + final page = await transport.discoverPage( + const DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 100), + ); + expect(page.offers, hasLength(2)); + expect(page.nextCursor, isNull); + }); + + test('type filtering narrows the offers but not the cursor', () async { + // A full page of gifts with one sale mixed in; asking for sales only must + // still advance the cursor by the oldest EVENT seen, not the oldest match, + // so paging never skips unmatched offers. + final transport = NostrOfferTransport(_FakeChannel([ + _evt('g1', 300), + _evt('s1', 200, type: 'sale'), + _evt('g2', 100), + ])); + final page = await transport.discoverPage( + const DiscoveryQuery( + geohashPrefix: 'sp3e9', + limit: 3, + types: {OfferType.sale}, + ), + ); + expect(page.offers.map((o) => o.id), ['s1']); + expect(page.nextCursor, 99, reason: 'oldest event (100) minus one'); + }); + }); +} diff --git a/packages/commons_core/test/social/relay_pool_test.dart b/packages/commons_core/test/social/relay_pool_test.dart index 52de12f..cdb41bc 100644 --- a/packages/commons_core/test/social/relay_pool_test.dart +++ b/packages/commons_core/test/social/relay_pool_test.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:typed_data'; import 'package:commons_core/commons_core.dart'; @@ -58,6 +59,38 @@ void main() { await r1.stop(); }); + test('a hung relay cannot stall the pool past the connect timeout', () async { + // A server that accepts TCP but never answers the WebSocket handshake — + // the shape of a silently-filtered network, where connect hangs for minutes. + final hang = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final held = []; + hang.listen(held.add); + final r1 = await MiniRelay.start(); + final alice = await idFor(5); + + final sw = Stopwatch()..start(); + final pool = await RelayPool.connect( + [r1.url, 'ws://127.0.0.1:${hang.port}'], + identity: alice, + connectTimeout: const Duration(milliseconds: 500), + ); + sw.stop(); + + expect(pool.relayCount, 1, reason: 'the live relay is kept'); + expect( + sw.elapsed, + lessThan(const Duration(seconds: 5)), + reason: 'the hung relay must not stall the whole pool', + ); + + await pool.close(); + for (final s in held) { + s.destroy(); + } + await hang.close(); + await r1.stop(); + }); + test('throws when no relay is reachable (caller treats as offline)', () async { final alice = await idFor(4); expect( diff --git a/site/assets/css/main.css b/site/assets/css/main.css index 68effd2..96f2f89 100644 --- a/site/assets/css/main.css +++ b/site/assets/css/main.css @@ -71,15 +71,65 @@ a { color: var(--green); } } .site-nav a { color: #fff; text-decoration: none; opacity: .95; white-space: nowrap; } .site-nav a:hover { text-decoration: underline; } -.lang-switch { display: inline-flex; gap: .6rem; font-size: .92rem; white-space: nowrap; } -.lang-switch .current { opacity: .7; } -.lang-switch a.lang { color: #fff; } +/* A typical globe+code language menu (
/, no JS) instead of + spelling out every language name in the header — scales past 3 languages + without crowding the About/Legal links. */ +.lang-switch { + position: relative; + font-size: .9rem; +} +.lang-switch summary { + display: inline-flex; + align-items: center; + gap: .35rem; + cursor: pointer; + list-style: none; + padding: .3rem .6rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, .4); + color: #fff; +} +.lang-switch summary::-webkit-details-marker { display: none; } +.lang-switch summary::after { content: "▾"; font-size: .7em; opacity: .85; } +.lang-switch[open] summary::after { content: "▴"; } +.lang-switch summary:hover { border-color: #fff; } +.lang-code { font-weight: 600; letter-spacing: .02em; } +.lang-menu { + position: absolute; + inset-inline-end: 0; + top: calc(100% + .4rem); + margin: 0; + padding: .35rem; + list-style: none; + min-width: 9rem; + background: #fff; + color: var(--title); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, .22); + z-index: 10; +} +.lang-menu li + li { margin-top: .1rem; } +.lang-menu a.lang, +.lang-menu span.current { + display: block; + padding: .45rem .6rem; + border-radius: 6px; + text-decoration: none; + white-space: nowrap; +} +.lang-menu a.lang { color: var(--title); } +.lang-menu a.lang:hover { background: var(--container); } +.lang-menu span.current { color: var(--muted); font-weight: 600; } -/* Narrow phones: brand on its own row, nav wraps beneath it, left-aligned. */ +/* Narrow phones: shrink a bit, but only wrap the nav below the brand if it + actually stops fitting — the language menu is a compact pill now, not + three spelled-out language names, so it fits alongside "About Legal" down + to fairly small widths. flex-wrap on .site-header handles the overflow + case on its own; no need to force it. */ @media (max-width: 560px) { .site-header { gap: .5rem; } .brand { font-size: 1.15rem; } - .site-nav { width: 100%; gap: .4rem 1rem; font-size: .95rem; } + .site-nav { gap: .4rem 1rem; font-size: .95rem; } } main { max-width: var(--maxw); margin: 0 auto; padding: 0 clamp(1rem, 4vw, 2rem); } @@ -169,7 +219,14 @@ h2 { font-size: clamp(1.4rem, 3vw, 1.9rem); color: var(--green-dark); } .values li { margin: .45rem 0; } .get-it { text-align: center; } -.store-badges { margin-top: 1.2rem; } +.store-badges { + margin-top: 1.2rem; + display: flex; + flex-wrap: wrap; + gap: .6rem; + justify-content: inherit; +} +.get-it .store-badges { justify-content: center; } .coming-soon { color: var(--muted); font-style: italic; } .badge { display: inline-block; @@ -181,6 +238,14 @@ h2 { font-size: clamp(1.4rem, 3vw, 1.9rem); color: var(--green-dark); } margin: .3rem; font-weight: 600; } +/* Store links: same pill, one-colour icon so Play and F-Droid read apart. */ +.badge-store { + display: inline-flex; + align-items: center; + gap: .55rem; + margin: 0; +} +.badge-store .store-icon { flex: none; } /* Legal docs */ .doc { padding: 2rem 0 3rem; max-width: 760px; } diff --git a/site/build-legal.sh b/site/build-legal.sh index 40db16b..3216ca0 100755 --- a/site/build-legal.sh +++ b/site/build-legal.sh @@ -11,12 +11,12 @@ site_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" legal_src="$(cd "$site_dir/../docs/legal" && pwd)" out="$site_dir/content/legal" -# slug | English master | Spanish mirror | menu weight +# slug | English master | Spanish mirror | Portuguese mirror | menu weight rows=( - "privacy|privacy-policy.md|politica-de-privacidad.md|1" - "terms|terms-of-use.md|condiciones-de-uso.md|2" - "rules|community-rules.md|normas-de-la-comunidad.md|3" - "seeds|seed-legality-notice.md|aviso-sobre-semillas.md|4" + "privacy|privacy-policy.md|politica-de-privacidad.md|politica-de-privacidade.md|1" + "terms|terms-of-use.md|condiciones-de-uso.md|termos-de-uso.md|2" + "rules|community-rules.md|normas-de-la-comunidad.md|normas-da-comunidade.md|3" + "seeds|seed-legality-notice.md|aviso-sobre-semillas.md|aviso-sobre-sementes.md|4" ) emit() { # @@ -40,9 +40,10 @@ emit() { # } for row in "${rows[@]}"; do - IFS='|' read -r slug en es weight <<<"$row" + IFS='|' read -r slug en es pt weight <<<"$row" emit "$legal_src/$en" "$out/$slug.en.md" "$slug" "$weight" emit "$legal_src/es/$es" "$out/$slug.es.md" "$slug" "$weight" + emit "$legal_src/pt/$pt" "$out/$slug.pt.md" "$slug" "$weight" done echo "Generated legal pages in $out" @@ -69,3 +70,5 @@ emit_about "$docs_dir/que-es-tane.md" "$site_dir/content/about.es.md" \ "Qué es Tane" "Tane explicada para todos los públicos." emit_about "$docs_dir/what-is-tane.md" "$site_dir/content/about.en.md" \ "What is Tane" "Tane explained for everyone." +emit_about "$docs_dir/o-que-e-tane.md" "$site_dir/content/about.pt.md" \ + "O que é o Tane" "O Tane explicado para todos." diff --git a/site/config.toml b/site/config.toml index 9522ac3..f53f839 100644 --- a/site/config.toml +++ b/site/config.toml @@ -16,7 +16,7 @@ enableGitInfo = false license = "AGPL-3.0" # Links rendered only when set, so nothing broken shows before they exist: playStoreURL = "https://play.google.com/store/apps/details?id=org.comunes.tane" # Google Play - fdroidURL = "" # F-Droid, once published + fdroidURL = "https://f-droid.org/packages/org.comunes.tane/" # F-Droid, published 2026-07-28 sourceURL = "https://git.comunes.org/comunes/tane" # public source repository (AGPL) weblateURL = "https://translate.comunes.org/projects/tane/" # translation platform @@ -33,6 +33,12 @@ enableGitInfo = false weight = 2 [languages.es.params] description = "Una app local-first y descentralizada para guardar y compartir semillas tradicionales. Sin conexión, cifrada, sin cuenta." + [languages.pt] + languageName = "Português" + languageCode = "pt" + weight = 3 + [languages.pt.params] + description = "Uma app local-first e descentralizada para guardar e partilhar sementes tradicionais. Sem ligação, cifrada, sem conta." [markup.goldmark.renderer] unsafe = false diff --git a/site/content/_index.en.md b/site/content/_index.en.md index 7889ac6..7babb61 100644 --- a/site/content/_index.en.md +++ b/site/content/_index.en.md @@ -16,7 +16,7 @@ pillars: body: "Add a seed or seedling with just a photo and a name — no Latin required. Note the year, how much you have, where it came from, germination tests. Search, filter, snap now and name later." - icon: "🔒" title: "Yours, and only yours" - body: "Works fully offline. No account, no server, no trackers. Everything is encrypted on your device. Keep an encrypted backup and a printed recovery sheet to restore your whole bank on a new device." + body: "Your seed book works with no internet at all, and Tane doesn't connect to anything until you join the sharing part. No account, no sign-up, no trackers. Everything is encrypted on your device. Keep an encrypted backup and a printed recovery sheet to restore your whole bank on a new device." - icon: "🤝" title: "Share, as it's always been done" body: "Mark what you have spare to give away, swap or sell. Someone nearby finds it and contacts you — no middleman. Print a catalog of what you share to take to a seed fair." @@ -57,7 +57,7 @@ values: points: - "No center: no company can shut it down, censor it, or be fined for it." - "Anti-monopoly by design — sharing seeds multiplies the commons instead of depleting it." - - "Spreads like a seed, person to person, with no server in the middle." + - "Spreads like a seed, person to person; what you share travels through community servers anyone can run — not a company's central server." - "Free software (AGPL-3.0). No ads, no commissions, no business model." --- diff --git a/site/content/_index.es.md b/site/content/_index.es.md index 31043d7..9d767d6 100644 --- a/site/content/_index.es.md +++ b/site/content/_index.es.md @@ -16,7 +16,7 @@ pillars: body: "Añade una semilla o plantón con solo una foto y un nombre; sin latín. Anota el año, cuánta tienes, de dónde viene, pruebas de germinación. Busca, filtra, haz la foto ahora y ponle nombre después." - icon: "🔒" title: "Tuyo, y solo tuyo" - body: "Funciona totalmente sin conexión. Sin cuenta, sin servidor, sin rastreadores. Todo va cifrado en tu dispositivo. Guarda una copia cifrada y una hoja de recuperación impresa para restaurar todo tu banco en un dispositivo nuevo." + body: "Tu cuaderno de semillas funciona sin nada de internet, y Tane no se conecta a nada hasta que entras en la parte de compartir. Sin cuenta, sin registro, sin rastreadores. Todo va cifrado en tu dispositivo. Guarda una copia cifrada y una hoja de recuperación impresa para restaurar todo tu banco en un dispositivo nuevo." - icon: "🤝" title: "Compartir, como se ha hecho siempre" body: "Marca lo que te sobra para regalar, intercambiar o vender. Alguien cerca lo encuentra y te contacta, sin intermediarios. Imprime un catálogo de lo que compartes para llevar a una feria de semillas." @@ -57,7 +57,7 @@ values: points: - "Sin centro: ninguna empresa puede apagarlo, censurarlo ni ser multada por ello." - "Anti-monopolio por diseño: compartir semillas multiplica el común en lugar de agotarlo." - - "Se propaga como una semilla, de persona a persona, sin servidor en medio." + - "Se propaga como una semilla, de persona a persona; lo que compartes viaja por servidores comunitarios que cualquiera puede montar — no por el servidor central de una empresa." - "Software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo de negocio." --- diff --git a/site/content/_index.pt.md b/site/content/_index.pt.md new file mode 100644 index 0000000..1dedc44 --- /dev/null +++ b/site/content/_index.pt.md @@ -0,0 +1,67 @@ +--- +title: "O teu banco de sementes, no bolso" +description: "Uma app local-first e descentralizada para guardar e partilhar sementes e mudas tradicionais. Sem ligação, cifrada, sem conta." +hero: + title: "O teu banco de sementes, no bolso" + tagline: "Guarda e partilha sementes e mudas tradicionais — sem ligação, cifradas, sem conta." + lead: > + Cada semente tradicional é uma carta escrita por milhares de gerações, + passada de mão em mão. O Tane ajuda pessoas e coletivos guardadores de + sementes a manter um inventário simples do seu banco, decidir o que + oferecem e partilhá-lo localmente — sem um intermediário central que o + possa controlar, censurar ou multar por isso. +pillars: + - icon: "🌱" + title: "Gere o teu banco" + body: "Acrescenta uma semente ou muda com apenas uma foto e um nome — sem precisar de latim. Anota o ano, quanto tens, de onde veio, testes de germinação. Procura, filtra, tira a foto agora e dá-lhe nome depois." + - icon: "🔒" + title: "Teu, e só teu" + body: "Funciona totalmente sem ligação. Sem conta, sem servidor, sem rastreadores. Tudo é cifrado no teu aparelho. Guarda uma cópia de segurança cifrada e uma folha de recuperação impressa para restaurar todo o teu banco num aparelho novo." + - icon: "🤝" + title: "Partilha, como sempre se fez" + body: "Marca o que tens a mais para dar, trocar ou vender. Alguém perto encontra e contacta-te — sem intermediários. Imprime um catálogo do que partilhas para levar a uma feira de sementes." +features: + title: "O que podes fazer com o Tane" + intro: "Uma app discreta para toda a vida de uma semente — guardá-la, cuidar dela e passá-la adiante." + groups: + - icon: "🌱" + title: "O teu banco de sementes, no bolso" + items: + - "Acrescenta uma semente em segundos — uma foto e um nome é tudo o que precisas." + - "Regista cada lote por ano e quantidade, em unidades reais: espigas, vagens, um punhado, um frasco." + - "Guarda fotos, notas e testes de germinação de cada variedade." + - "Dá vários nomes a uma semente, em várias línguas." + - icon: "🗓️" + title: "Cuidado e calendário" + items: + - "Orientação para guardar semente de cada cultivo: polinização, isolamento, quantas plantas, secagem." + - "Um calendário do mês: o que semear, transplantar ou colher para semente." + - "Avisos simpáticos quando uma semente está a envelhecer, para a renovares a tempo." + - icon: "🔒" + title: "No papel, e fora da rede" + items: + - "Etiquetas e cartões imprimíveis para os teus pacotes e frascos, cada um com um código para digitalizar." + - "Um toque guarda uma cópia de segurança selada; um cartão de recuperação traz tudo de volta." + - "Funciona totalmente sem ligação, sem conta. Os teus dados ficam no teu telemóvel, a não ser que os envies." + - icon: "🤝" + title: "Partilha e mantém-te ligado" + items: + - "Oferece semente a mais para dar, trocar ou vender — e vê o que outros partilham." + - "Guarda uma pesquisa e recebe um aviso na app assim que aparecer algo parecido perto de ti." + - "Mensagens privadas para combinar uma troca, pessoa a pessoa." + - "Avaliza pessoas que conheces e vê em quem podes confiar." + - "Faz uma promessa de plantaré: compromete-te a devolver parte do que multiplicares." + - "Mantém tudo sincronizado entre os teus próprios aparelhos." +values: + title: "Porque é que o Tane é diferente" + points: + - "Sem centro: nenhuma empresa o pode desligar, censurar ou multar por isso." + - "Anti-monopólio por desenho — partilhar sementes multiplica os comuns em vez de os esgotar." + - "Espalha-se como uma semente, de pessoa a pessoa; o que partilhas viaja por servidores comunitários que qualquer pessoa pode montar — não pelo servidor central de uma empresa." + - "Software livre (AGPL-3.0). Sem publicidade, sem comissões, sem modelo de negócio." +--- + +O Tane (種, "semente"; de *tanemaki* 種まき, "semear sementes") é uma app +local-first e descentralizada para guardar e partilhar sementes e mudas +tradicionais. Funciona sem qualquer ligação — e liga-te a uma rede de +partilha de sementes de vizinhança quando quiseres. diff --git a/site/content/about.en.md b/site/content/about.en.md index c87f780..dd404d3 100644 --- a/site/content/about.en.md +++ b/site/content/about.en.md @@ -45,9 +45,10 @@ still do it with notebooks and spreadsheets. - **Your data is yours.** Your information is kept on **your own phone**, protected with a key, not on some company's computers. We don't sell it or upload it anywhere. No ads. - **No one can shut it down or control it.** There's no central computer that everything - passes through: people communicate **directly with each other**, like passing seeds from - hand to hand. So no one can censor it or charge you to use it (that's what being - **decentralized** means). + passes through: what you share travels over **community servers**, run by people and + collectives — there are many, and anyone can add their own — so none of them is a center + that could censor it or charge you to use it, like passing seeds from hand to hand + (that's what being **decentralized** means). - **You trust by word of mouth.** You deal with people you know and with those vouched for by people you trust — the way it's always worked in a village or at a fair — without having to give your real name or phone number unless you want to. diff --git a/site/content/about.es.md b/site/content/about.es.md index 09e06e6..9de300c 100644 --- a/site/content/about.es.md +++ b/site/content/about.es.md @@ -47,9 +47,10 @@ con libretas y hojas de cálculo. clave, no en los ordenadores de una empresa. No la vendemos ni la subimos a ningún sitio. Sin publicidad. - **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo: - las personas se comunican **directamente entre sí**, como quien se pasa unas semillas de - mano en mano. Por eso nadie puede censurarla ni cobrarte por usarla (eso es que sea - **descentralizada**). + lo que compartes viaja por **servidores de la comunidad**, mantenidos por personas y + colectivos — hay varios y cualquiera puede añadir el suyo — así que ninguno es un centro + desde el que censurarla o cobrarte por usarla, como quien se pasa unas semillas de mano + en mano (eso es que sea **descentralizada**). - **Te fías por el boca a boca.** Te relacionas con gente que conoces y con quien te recomienda gente de confianza —como siempre se ha hecho en un pueblo o en una feria— sin tener que dar tu nombre real ni tu teléfono si no quieres. diff --git a/site/content/about.pt.md b/site/content/about.pt.md new file mode 100644 index 0000000..307a876 --- /dev/null +++ b/site/content/about.pt.md @@ -0,0 +1,91 @@ +--- +title: "O que é o Tane" +slug: "about" +translationKey: "about" +description: "O Tane explicado para todos." +--- + + +**Tane** (種) significa "semente" em japonês; o nome completo, *tanemaki* (種まき), significa "semear / espalhar sementes". + +## Numa frase + +Uma app simples e livre para **guardares as tuas sementes e as partilhares** com quem quiseres. Funciona +**sem internet** e sem nenhuma empresa pelo meio. + +## Porque é preciso + +Hoje, um punhado de empresas controla grande parte das sementes do mundo. Muitas sementes são feitas para +não servirem outra vez: dão uma colheita, mas as suas sementes não germinam bem, por isso é preciso +**comprá-las de novo todos os anos**. Entretanto, as variedades tradicionais — cultivadas e melhoradas +ao longo de milhares de anos — estão a perder-se, e com elas o conhecimento de como as cultivar. + +Partilhar sementes é uma das coisas mais antigas e naturais que há, e ainda assim em alguns sítios +tornou-se complicado ou até foi multado (em França uma associação foi penalizada por distribuir +variedades que não estavam na lista oficial aprovada). E quem cuida de sementes continua a fazê-lo +com cadernos e folhas de cálculo. + +## O que faz, tornado simples + +- **Guardar** — o teu inventário de sementes: o que tens, de que ano, quanto, de onde + veio, com **o nome que usas** (sem precisar de latim). Tanto faz se tens quatro + pacotes numa gaveta ou um grupo com centenas de variedades. +- **Partilhar** — dizes o que ofereces; alguém perto vê-o e escreve-te, para **dar, + trocar ou vender** se quiseres. Fechas o negócio tu mesmo, diretamente. Nenhuma empresa + entra nem cobra comissão. + +## Porque é diferente + +- **É livre e pertence a todos.** Não é propriedade de nenhuma empresa: qualquer pessoa pode usar, + copiar, traduzir ou melhorar, de forma livre e para sempre. É mais uma *receita que se partilha* + do que um produto fechado — chama-se **software livre**. E quem o melhora fica obrigado a + partilhar essas melhorias igualmente livres, para que ninguém o possa "fechar" depois (é isso o + **copyleft**). +- **Funciona sem ligação.** O essencial funciona mesmo no campo sem sinal. +- **Os teus dados são teus.** A tua informação fica guardada no **teu próprio telemóvel**, protegida com uma + chave, não nos computadores de nenhuma empresa. Não a vendemos nem a colocamos em lado nenhum. Sem publicidade. +- **Ninguém o pode desligar ou controlar.** Não há nenhum computador central por onde tudo passa: + o que partilhas viaja por **servidores da comunidade**, mantidos por pessoas e coletivos — + há vários e qualquer pessoa pode acrescentar o seu — pelo que nenhum deles é um centro + a partir do qual censurar ou cobrar para o usares, como quem passa sementes de mão em mão + (é isso que significa ser **descentralizado**). +- **Confias de boca em boca.** Lidas com pessoas que conheces e com as que são avalizadas + por pessoas em quem confias — como sempre funcionou numa aldeia ou numa feira — sem + teres de dar o teu nome verdadeiro ou o teu telefone a não ser que queiras. +- **Em muitas línguas.** Para qualquer parte do mundo, não para um único país. Voluntários + traduzem-no. + +## Para quem é + +Grupos e redes de sementes, hortas e coletivos, agricultores… e qualquer pessoa com uns pacotes +guardados. Para todas as idades, dos 10 aos 99. + +## Um nome japonês + +**Tane** significa "semente" (種); *tanemaki* (種まき) significa "semear sementes". E não é por acaso +que o nome é japonês. Séculos atrás, no Japão, tiras de papel impressas que prometiam +arroz passavam de mão em mão: eram emitidas por templos, comerciantes e armazéns de arroz — +não por um banco ou uma autoridade central — e as pessoas aceitavam-nas confiando nessa promessa. +Uma rede de trocas sem centro, sustentada pela confiança, onde um pedaço de papel bastava +para pôr o arroz em movimento. Séculos depois, essa mesma ideia inspirou redes modernas como +o *sistema WAT* do Japão (2000): uma nota que qualquer pessoa pode imprimir e passar adiante, sem banco nem centro. + +O Tane herda esse espírito. Quando partilhas sementes podes anexar um [**Plantaré**](https://plantare.ourproject.org). O nome +faz um jogo com o espanhol *pagaré* — uma promessa de pagamento, literalmente "pagarei" — trocando *pagar* por +*plantar*: não "pagarei" mas "plantarei". É uma promessa de devolver, quando puderes, uma +quantidade semelhante de semente livre. Não é uma compra nem uma obrigação — é um compromisso de +manter a semente viva e em movimento. E como devolvê-la significa cultivá-la, e cultivar +multiplica-a, cada gesto faz os comuns crescer em vez de os encolher. + +## Onde está e como ajudar + +Ainda dá os primeiros passos. Podes ajudar experimentando-o num grupo de sementes real, [traduzindo-o](https://translate.comunes.org/projects/tane/), +partilhando o que sabes sobre as variedades, ou programando. Não tem preço e nunca terá: é +sustentado por voluntários, pelas próprias comunidades, e por financiamento público para projetos +de interesse comum. + +--- + +> *A ideia por trás disto: as sementes tradicionais são um património da humanidade — de todos e de +> ninguém. Contra quem as queira privatizar, o Tane procura tornar fácil guardá-las, +> multiplicá-las e mantê-las em movimento.* diff --git a/site/content/legal/_index.pt.md b/site/content/legal/_index.pt.md new file mode 100644 index 0000000..28ccda9 --- /dev/null +++ b/site/content/legal/_index.pt.md @@ -0,0 +1,4 @@ +--- +title: "Legal" +description: "Política de privacidade, termos de uso, regras da comunidade e aviso sobre a legalidade das sementes do Tane." +--- diff --git a/site/content/legal/privacy.pt.md b/site/content/legal/privacy.pt.md new file mode 100644 index 0000000..4494851 --- /dev/null +++ b/site/content/legal/privacy.pt.md @@ -0,0 +1,95 @@ +--- +title: "Política de Privacidade" +slug: "privacy" +weight: 1 +translationKey: "legal-privacy" +--- + + +**Versão 1.0 — 13 de julho de 2026** + +O Tane é publicado pela **Asociación Comunes** (Espanha) — — +contacto: . O Tane é software livre (AGPL-3.0); o seu código-fonte é +público, por isso tudo o que aqui se descreve pode ser verificado. + +## A versão curta + +- O Tane funciona **sem conta**. Não sabemos quem és. +- Tudo o que registas fica **no teu aparelho, cifrado**. Não temos servidores que + o recebam, e não temos forma de o ler. +- Nada sai do teu aparelho a não ser que **tu** decidas partilhá-lo (uma oferta, uma + mensagem, o teu perfil). O que partilhas viaja por servidores geridos pela comunidade, que não controlamos. +- **Sem análises, sem publicidade, sem rastreadores.** O Tane não recolhe nada sobre ti. + +## 1. Sem contas, sem recolha por nossa parte + +O Tane não pede o teu nome, e-mail ou número de telefone. Não há registo nem +nenhum servidor central do Tane. A tua identidade na app é uma chave criptográfica criada no teu +aparelho e guardada no cofre seguro do sistema do teu aparelho. A Asociación Comunes não recolhe, +recebe nem processa os teus dados pessoais através da app. + +## 2. Dados guardados no teu aparelho + +O teu inventário de sementes (variedades, quantidades, notas, fotos), as tuas mensagens, os +avales e avaliações dos teus contactos, e as tuas chaves ficam guardados **apenas no teu aparelho**, numa +base de dados cifrada (SQLCipher). A chave de cifra vive no cofre seguro do sistema do teu +aparelho. As cópias de segurança que crias também são cifradas; escolhes onde as guardar, e +nunca passam por nós. + +## 3. Dados que saem do teu aparelho — só quando escolheres + +Nada é publicado automaticamente. Cada uma destas ações só acontece por tua ação explícita: + +- **Ofertas.** Quando ofereces sementes ou mudas, a app publica o título da oferta, a descrição, + a foto opcional, o preço opcional, e uma **zona aproximada** (uma área de cerca de 2–80 km, + como a configurares — nunca a tua morada ou localização precisa). As ofertas são públicas. +- **Perfil.** Se o preencheres, o teu nome escolhido, biografia e avatar são públicos. +- **Mensagens.** As mensagens diretas são **cifradas de ponta a ponta**: só tu e a pessoa + a quem escreves as podem ler. Os servidores que as transportam só veem envelopes cifrados. +- **Avales e avaliações.** Se avalizares ou avaliares alguém, essa declaração é pública + e assinada pela tua chave. +- **Sincronização entre os teus aparelhos.** Se ligares um segundo aparelho, os teus dados viajam entre + eles cifrados, para que só os teus aparelhos os possam ler. + +## 4. Para onde vão os dados partilhados: relés da comunidade + +As funcionalidades sociais do Tane usam servidores abertos, geridos pela comunidade ("relés"). Por predefinição a app +usa um pequeno conjunto que inclui `relay.comunes.org` (operado pela Asociación Comunes) e +relés públicos bem conhecidos; podes alterar esta lista, ou esvaziá-la para desligar a rede +completamente. Os relés diferentes de `relay.comunes.org` são **infraestrutura de terceiros**: +os seus operadores decidem as suas próprias políticas de retenção, e esta política não os cobre. + +## 5. Eliminação — uma nota honesta + +Podes eliminar qualquer coisa local instantaneamente, e podes retirar uma oferta a qualquer momento. +Quando retiras ou eliminas algo que tinhas publicado, a app pede aos relés que o +eliminem também. Relés que não operamos podem manter cópias apesar desse pedido, e publicações +públicas podem ter sido copiadas para outro lado enquanto estavam visíveis. **Trata tudo o que +publicares como potencialmente permanente.** No `relay.comunes.org` respeitamos os pedidos de eliminação. + +## 6. Permissões do aparelho + +- **Fotos / câmara** — só quando anexas uma imagem a uma variedade, oferta ou perfil. +- **Localização aproximada** (opcional) — só se usares "definir a minha zona a partir de onde estou", + para escolher a tua zona de partilha aproximada. O Tane nunca pede localização precisa. +- **Notificações** (opcional) — para te avisar sobre novas mensagens. + +## 7. Os teus direitos + +Como os teus dados vivem no teu aparelho sob o teu controlo, exerces a maioria dos direitos +tu mesmo: ler, corrigir, exportar ou eliminar tudo dentro da app. Para dados no +`relay.comunes.org`, ou qualquer questão sobre esta política, escreve para . +Se estiveres na UE, tens também o direito de apresentar reclamação junto da tua autoridade de +proteção de dados (em Espanha, a AEPD). + +## 8. Crianças + +O inventário de sementes pode ser usado por qualquer pessoa. As funcionalidades de mercado e mensagens não +são dirigidas a crianças; ao usá-las confirmas que tens idade suficiente para usar funcionalidades sociais +segundo as leis do teu país. + +## 9. Alterações + +Atualizaremos esta política se o comportamento do Tane mudar, e assinalaremos as alterações nas +notas de lançamento da app. A versão atual vive sempre em +, com o histórico no repositório público. diff --git a/site/content/legal/rules.pt.md b/site/content/legal/rules.pt.md new file mode 100644 index 0000000..0fc3c43 --- /dev/null +++ b/site/content/legal/rules.pt.md @@ -0,0 +1,48 @@ +--- +title: "Regras da Comunidade" +slug: "rules" +weight: 3 +translationKey: "legal-rules" +--- + + +**Versão 1.0 — 13 de julho de 2026** + +O mercado e as mensagens no Tane são espaços partilhados construídos sobre a confiança entre vizinhos. +Estas regras são o que todos aceitam antes de participar. Também definem o que conta como +conteúdo que pode ser denunciado e removido dos servidores que operamos. + +## As regras + +1. **Sê honesto sobre as tuas sementes.** Descreve o que realmente tens: a variedade tal como a + conheces, o ano de colheita, tudo o que for relevante sobre como foi cultivada. Não inventes + proveniência. +2. **Partilha apenas o que podes partilhar.** Não ofereças: + - semente de **variedades protegidas comercialmente** (direitos de obtentor, patentes) + a não ser que estejas autorizado; + - **espécies invasoras ou protegidas** onde a sua troca esteja proibida; + - qualquer outra coisa que seja ilegal partilhar ou vender onde tu ou o destinatário vivem. +3. **Sementes e mudas, nada mais.** O mercado é para sementes, mudas e outro + material de reprodução vegetal (estacas, bolbos, tubérculos) no espírito da app. + Não é um quadro de classificados geral. +4. **Respeita as pessoas.** Sem assédio, ameaças, ódio ou discriminação — em ofertas, + perfis, avaliações ou mensagens. +5. **Sem spam nem burlas.** Sem publicações repetidas, ofertas enganosas, phishing, ou + pressão para levar as pessoas a negócios duvidosos. +6. **Reputação honesta.** Avaliza apenas pessoas com quem te encontraste ou trocaste + realmente; avalia apenas experiências reais. Não manipules o sistema de confiança. + +## O que acontece se alguém as quebrar + +- Qualquer pessoa te pode **bloquear** — as tuas ofertas e mensagens desaparecem para ela. +- Qualquer pessoa pode **denunciar** uma oferta ou uma pessoa. As denúncias viajam para os servidores que transportam + o conteúdo; no relé operado pela Comunes (`relay.comunes.org`), conteúdo e chaves + que quebrem estas regras são removidos. Outros servidores aplicam as suas próprias políticas. +- Não há recursos nem suspensões de conta porque não há contas: a + rede simplesmente deixa de transportar e mostrar o que quebra as regras. + +## Uma nota sobre boa-fé + +A maior parte da partilha de sementes é entre amadores, em pequenas quantidades, pelo prazer de manter +variedades vivas. Em caso de dúvida, prefere **oferecer e trocar** em vez de vender, identifica +as coisas claramente, e pergunta. É assim que sempre se fez. diff --git a/site/content/legal/seeds.pt.md b/site/content/legal/seeds.pt.md new file mode 100644 index 0000000..5b89058 --- /dev/null +++ b/site/content/legal/seeds.pt.md @@ -0,0 +1,75 @@ +--- +title: "Aviso sobre a Legalidade de Trocar Sementes e Mudas" +slug: "seeds" +weight: 4 +translationKey: "legal-seeds" +--- + + +**Versão 1.0 — 13 de julho de 2026** + +O Tane ajuda pessoas a guardar e partilhar sementes e mudas tradicionais. As leis sobre sementes +diferem muito entre países, e distinguem claramente entre **dar ou +trocar** e **vender**. Este aviso é informação geral, não aconselhamento jurídico; tu +és responsável por cumprir a lei onde vives e para onde vão as tuas sementes ou plantas. + +## Oferecer e trocar entre amadores + +Trocar sementes **sem fins comerciais** — ofertas, trocas, bancos de sementes comunitários, +redes de conservação — é amplamente reconhecido e legal na maioria dos lugares: + +- **União Europeia.** A legislação da UE sobre comercialização de sementes regula a *comercialização* + de sementes. A troca em espécie entre particulares, e o trabalho de redes de conservação e + bancos de germoplasma, ficam fora ou estão expressamente isentos; a reforma do material + de reprodução vegetal em curso (proposta COM(2023) 414) mantém isenções para amadores e redes + de conservação, desde que a atividade não seja comercial. +- **Espanha.** A Ley 30/2006 regula o comércio *comercial* de sementes; a troca não comercial + entre amadores e a troca orientada para a conservação são prática reconhecida (art. 24). +- **Estados Unidos.** Após a alteração de 2016 à Recommended Uniform State Seed + Law, a maioria dos estados isenta a **partilha não comercial de sementes** (bancos de sementes comunitários, trocas) dos + requisitos comerciais de rotulagem e ensaio. + +## Vender sementes + +Vender é diferente. Na UE e em muitas outras jurisdições, **comercializar semente de +variedades não registadas num catálogo oficial pode estar restringido ou proibido**, +e as quantidades, rotulagem ou espécies podem ser reguladas mesmo para pequenos vendedores. +Existem isenções para variedades de conservação e quantidades de amador, mas variam. Se +usares a opção do Tane para pedir um preço, **verifica primeiro as regras do teu país** — a app não +o pode fazer por ti. + +## Variedades protegidas + +Variedades cobertas por **direitos de obtentor (DOV) ou patentes** não podem ser +propagadas ou vendidas sem autorização do titular — isto pode aplicar-se mesmo a semente +que compraste legalmente. As variedades tradicionais e antigas geralmente não são protegidas, +mas as variedades comerciais modernas normalmente são. Em caso de dúvida, não a ofereças. + +## Enviar sementes através de fronteiras + +A maioria dos países restringe a importação de sementes: aplicam-se frequentemente **certificados +fitossanitários**, listas de espécies proibidas e regras de quarentena — mesmo a pequenos envelopes entre +particulares, e também **dentro** da UE para certas espécies (regras de passaporte fitossanitário). +Antes de enviares sementes para outro país, verifica as regras do país de destino. +As trocas mais seguras são as locais — que é para isso que o Tane foi desenhado. + +## Mudas e plantas vivas + +Os mesmos princípios aplicam-se a mudas, estacas, bolbos e outro material vegetal vivo — +mas as plantas vivas costumam ser reguladas **de forma mais estrita** do que a semente. Regras +fitossanitárias, requisitos de passaporte fitossanitário dentro da UE, e quarentena na +importação aplicam-se frequentemente a plantas vivas mesmo quando a semente equivalente está isenta. Mantém +as trocas de mudas locais, e verifica as regras fitossanitárias antes de deslocares plantas vivas +entre regiões ou através de fronteiras. + +## Espécies invasoras e protegidas + +Algumas espécies podem não poder ser trocadas de todo em certas regiões, seja porque são +**invasoras** ali, seja porque são **protegidas**. As listas são regionais; verifica a tua. + +--- + +*O Tane não cobra comissão, não intermedeia nenhuma transação, e não verifica nenhuma oferta: as trocas +são negócios privados entre as pessoas envolvidas (ver os +[Termos de uso](terms-of-use.md)). Este aviso existe para que esses negócios sejam +informados.* diff --git a/site/content/legal/terms.pt.md b/site/content/legal/terms.pt.md new file mode 100644 index 0000000..6dddc5f --- /dev/null +++ b/site/content/legal/terms.pt.md @@ -0,0 +1,81 @@ +--- +title: "Termos de Uso" +slug: "terms" +weight: 2 +translationKey: "legal-terms" +--- + + +**Versão 1.0 — 13 de julho de 2026** + +Estes termos cobrem o uso da aplicação Tane, publicada pela **Asociación Comunes** +(Espanha) — , contacto . Ao usares as funcionalidades de partilha +do Tane (o mercado e as mensagens) aceitas estes termos e as +[Regras da comunidade](community-rules.md). + +## 1. O que o Tane é — e o que não é + +O Tane é uma **ferramenta pessoal**: um inventário de sementes que vive no teu aparelho, mais uma forma de +descobrir pessoas perto de ti que partilham sementes e mudas. O Tane **não é operador de mercado, não é +loja, e não é parte em nenhuma troca**: + +- Não hospedamos anúncios — as ofertas são publicadas por ti, sob a tua própria chave, para + relés geridos pela comunidade. +- Não cobramos **nenhuma comissão** e não processamos pagamentos. Qualquer troca, oferta ou venda é + combinada e realizada diretamente entre as pessoas envolvidas. +- Não verificamos sementes, utilizadores nem ofertas, e não podemos remover conteúdo de servidores + que não operamos. + +## 2. As tuas responsabilidades + +Tu és o único responsável por: + +- **O que ofereces e trocas.** Certifica-te de que tens permissão para partilhar ou vender as + sementes que anuncias. As regras diferem por país — ver o + [Aviso sobre legalidade das sementes](seed-legality-notice.md). Em particular, vender semente de + variedades não registadas pode estar restringido onde vives, e propagar + variedades protegidas comercialmente sem autorização é ilegal na maioria dos países. +- **O envio.** Enviar sementes através de fronteiras é frequentemente restringido (regras fitossanitárias). + Verifica antes de enviares qualquer coisa para o estrangeiro. +- **O que publicas.** As ofertas, o teu perfil, avales e avaliações são públicos e + assinados por ti. Segue as [Regras da comunidade](community-rules.md). +- **As tuas chaves e cópias de segurança.** Não há recuperação de conta: se perderes o teu aparelho e + o teu código de recuperação, não podemos restaurar nada. + +## 3. Sem garantias + +O Tane é fornecido **"tal como está"**, sem garantia de nenhum tipo, como software livre sob a +[licença AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html). Em particular, ninguém — +nem a Comunes, nem as pessoas com quem trocas através da app — garante a +**identidade, pureza, germinação, saúde ou legalidade** de nenhuma semente ou planta trocada. A +informação de cultivo mostrada na app (calendários, dicas de guardar semente, estimativas de viabilidade) é +orientação geral, não aconselhamento profissional. + +## 4. Litígios entre utilizadores + +As trocas são negócios privados entre as pessoas envolvidas. A Comunes não é mediadora +e não aceita responsabilidade por trocas falhadas, disputas de qualidade ou perdas delas +resultantes. As funcionalidades de aval e avaliação existem para que as comunidades possam construir a sua própria confiança. + +## 5. Moderação + +Não há moderação central: a tua app filtra o que vês através das pessoas em quem +confias, e podes **bloquear** qualquer pessoa e **denunciar** ofertas ou pessoas dentro da +app. As denúncias são partilhadas com os servidores envolvidos; no relé que operamos +(`relay.comunes.org`), o conteúdo que quebra as regras da comunidade é removido. Relés geridos +por outros aplicam as suas próprias políticas. + +## 6. Responsabilidade + +Na medida máxima permitida por lei, a responsabilidade total da Asociación Comunes decorrente +da app está limitada ao valor que pagaste por ela (o Tane é gratuito: zero). Nada +nestes termos limita responsabilidade que não possa ser limitada por lei, nem os teus direitos legais +como consumidor. + +## 7. Geral + +Se alguma parte destes termos for considerada inválida, o resto mantém-se em vigor. Estes termos são +regidos pela lei espanhola e da UE, sem te privar das proteções da lei do teu +país de residência. Podemos atualizar estes termos; alterações relevantes serão assinaladas nas +notas de lançamento da app, e a versão atual vive sempre em +. diff --git a/site/i18n/en.json b/site/i18n/en.json index b6d4d3f..847e4f0 100644 --- a/site/i18n/en.json +++ b/site/i18n/en.json @@ -11,6 +11,9 @@ "navHome": { "other": "Home" }, + "navLanguage": { + "other": "Language" + }, "screenshotsTitle": { "other": "A look inside" }, @@ -42,7 +45,7 @@ "other": "Where Tane is right now" }, "statusBody": { - "other": "The inventory works today: keep your seeds offline, encrypted, with no account. The neighbourly sharing layer — offers, messaging and trust — is now arriving, in beta. Everything already works with zero network." + "other": "The inventory works today: keep your seeds offline, encrypted, with no account — the app doesn't connect to anything until you join the sharing part. The neighbourly sharing layer — offers, messaging and trust — is now arriving, in beta." }, "screenCapHome": { "other": "Home — your bank and the market, one tap away" @@ -111,7 +114,7 @@ "other": "Do I need an account?" }, "faqAccountA": { - "other": "No account, no sign-up, no server. Your data stays on your device." + "other": "No account and no sign-up. Your seeds stay on your device, and Tane doesn't connect to anything until you join the sharing part — then it uses community servers you can change or switch off." }, "faqOfflineQ": { "other": "Does it work offline?" diff --git a/site/i18n/es.json b/site/i18n/es.json index 7c6ec53..3b6a212 100644 --- a/site/i18n/es.json +++ b/site/i18n/es.json @@ -11,6 +11,9 @@ "navHome": { "other": "Inicio" }, + "navLanguage": { + "other": "Idioma" + }, "screenshotsTitle": { "other": "Un vistazo por dentro" }, @@ -42,7 +45,7 @@ "other": "En qué punto está Tane" }, "statusBody": { - "other": "El inventario ya funciona: guarda tus semillas sin conexión, cifradas y sin cuenta. La capa de intercambio vecinal —ofertas, mensajería y confianza— está llegando ya, en beta. Todo funciona sin ninguna red." + "other": "El inventario ya funciona: guarda tus semillas sin conexión, cifradas y sin cuenta; la app no se conecta a nada hasta que entras en la parte de compartir. La capa de intercambio vecinal —ofertas, mensajería y confianza— está llegando ya, en beta." }, "screenCapHome": { "other": "Inicio — tu banco y el mercado a un toque" @@ -111,7 +114,7 @@ "other": "¿Necesito cuenta?" }, "faqAccountA": { - "other": "Sin cuenta, sin registro, sin servidor. Tus datos se quedan en tu dispositivo." + "other": "Sin cuenta y sin registro. Tus semillas se quedan en tu dispositivo, y Tane no se conecta a nada hasta que entras en la parte de compartir; entonces usa servidores comunitarios que puedes cambiar o desactivar." }, "faqOfflineQ": { "other": "¿Funciona sin conexión?" diff --git a/site/i18n/pt.json b/site/i18n/pt.json new file mode 100644 index 0000000..67373e9 --- /dev/null +++ b/site/i18n/pt.json @@ -0,0 +1,143 @@ +{ + "skipToContent": { + "other": "Ir para o conteúdo" + }, + "navAbout": { + "other": "Sobre" + }, + "navLegal": { + "other": "Legal" + }, + "navHome": { + "other": "Início" + }, + "navLanguage": { + "other": "Idioma" + }, + "screenshotsTitle": { + "other": "Um vislumbre por dentro" + }, + "getItTitle": { + "other": "Obter o Tane" + }, + "getItComingSoon": { + "other": "Em breve nas lojas de aplicações. Entretanto, o Tane é software livre que podes compilar e executar hoje." + }, + "legalIntro": { + "other": "Os documentos em linguagem clara que regem o Tane. Versão curta: os teus dados ficam no teu aparelho, o Tane é apenas uma ferramenta, e tu és responsável pelas sementes que partilhas." + }, + "footerFreeSoftware": { + "other": "Software livre (AGPL-3.0). Sem publicidade, sem comissões, sem venda de dados." + }, + "footerPublishedBy": { + "other": "Publicado por" + }, + "footerContact": { + "other": "Contacto" + }, + "backToHome": { + "other": "Voltar ao início" + }, + "statusBadge": { + "other": "Beta" + }, + "statusTitle": { + "other": "Onde está o Tane agora" + }, + "statusBody": { + "other": "O inventário funciona hoje: guarda as tuas sementes sem ligação, cifradas, sem conta. A camada de partilha entre vizinhos — ofertas, mensagens e confiança — está agora a chegar, em beta. Tudo já funciona sem qualquer ligação." + }, + "screenCapHome": { + "other": "Início — o teu banco e o mercado, a um toque de distância" + }, + "screenCapInventory": { + "other": "Inventário — as tuas sementes, agrupadas por família" + }, + "screenCapMarket": { + "other": "Mercado — dá, troca ou vende com pessoas por perto" + }, + "screenCapCalendar": { + "other": "Calendário — o que semear este mês" + }, + "screenCapDetail": { + "other": "Variedade — anos, quantidades, origem, germinação" + }, + "rtlNoteTitle": { + "other": "Internacional por desenho" + }, + "rtlNoteBody": { + "other": "Da direita para a esquerda e escritas não latinas são funcionalidades de primeira classe. Aqui está o inventário espelhado para RTL." + }, + "collabTitle": { + "other": "Participa" + }, + "collabIntro": { + "other": "O Tane não tem modelo de negócio — vive de voluntários, comunidades e financiamento público para tecnologia. Podes ajudar:" + }, + "collabTranslateT": { + "other": "Traduzir" + }, + "collabTranslateB": { + "other": "Traz o Tane para a tua língua. Os ficheiros de tradução são simples; contribuições são bem-vindas." + }, + "collabTestT": { + "other": "Experimenta-o num grupo de sementes" + }, + "collabTestB": { + "other": "Usa-o numa rede de sementes ou feira real e diz-nos o que falha ou o que falta." + }, + "collabCodeT": { + "other": "Contribui com código" + }, + "collabCodeB": { + "other": "Flutter/Dart, software livre. Documentos de desenho e testes incluídos." + }, + "collabFundT": { + "other": "Apoia-o" + }, + "collabFundB": { + "other": "Sem publicidade, sem comissões. Ajuda a financiar o trabalho ou indica-nos financiamentos para os comuns." + }, + "collabContact": { + "other": "Escreve-nos" + }, + "faqTitle": { + "other": "Perguntas" + }, + "faqFreeQ": { + "other": "É gratuito?" + }, + "faqFreeA": { + "other": "Sim. Software livre (AGPL-3.0), sem publicidade, sem comissões, sem venda de dados." + }, + "faqAccountQ": { + "other": "Preciso de uma conta?" + }, + "faqAccountA": { + "other": "Sem conta, sem registo, sem servidor. Os teus dados ficam no teu aparelho." + }, + "faqOfflineQ": { + "other": "Funciona sem ligação?" + }, + "faqOfflineA": { + "other": "Totalmente. A internet só o enriquece; tudo o essencial funciona sem ligação." + }, + "faqLegalQ": { + "other": "É legal partilhar sementes?" + }, + "faqLegalA": { + "other": "Dar e trocar entre cultivadores é amplamente permitido; vender pode ser regulado. Tu és responsável pelo que partilhas — ver o aviso sobre a legalidade das sementes." + }, + "faqDataQ": { + "other": "E a minha privacidade?" + }, + "faqDataA": { + "other": "Os dados são cifrados no teu aparelho e só o que partilhares explicitamente sai dele. Ver a política de privacidade." + }, + "aboutCta": { + "other": "Descobre o que é o Tane" + }, + "footerSource": { + "other": "Código-fonte" + } +} diff --git a/site/layouts/partials/lang-switch.html b/site/layouts/partials/lang-switch.html index 356d294..5f61839 100644 --- a/site/layouts/partials/lang-switch.html +++ b/site/layouts/partials/lang-switch.html @@ -1,16 +1,34 @@ -{{- /* Links to this page in the other language; falls back to that language's home. */ -}} - - {{- range .Site.Languages -}} - {{- $lang := . -}} - {{- if eq $lang.Lang $.Site.Language.Lang -}} - {{ $lang.LanguageName }} - {{- else -}} - {{- $target := "" -}} - {{- range $.AllTranslations -}} - {{- if eq .Language.Lang $lang.Lang -}}{{- $target = .RelPermalink -}}{{- end -}} - {{- end -}} - {{- if not $target -}}{{- $target = ($lang.Lang | printf "/%s/" | relURL) -}}{{- end -}} - {{ $lang.LanguageName }} +{{- /* + A typical "globe + code" language menu, done with
/ so it + needs no JS (site stays static per config.toml). Links to this page in the + other language; falls back to that language's home. +*/ -}} +
+ + {{- /* Material Icons "translate" glyph (文A) — the same icon the app uses + for its language picker (Icons.translate in settings_screen.dart). + Inline so it needs no icon font or external request (self-contained + per CSP). */ -}} + + {{ .Site.Language.LanguageCode | upper }} + +
    + {{- range .Site.Languages -}} + {{- $lang := . -}} +
  • + {{- if eq $lang.Lang $.Site.Language.Lang -}} + {{ $lang.LanguageName }} + {{- else -}} + {{- $target := "" -}} + {{- range $.AllTranslations -}} + {{- if eq .Language.Lang $lang.Lang -}}{{- $target = .RelPermalink -}}{{- end -}} + {{- end -}} + {{- if not $target -}}{{- $target = ($lang.Lang | printf "/%s/" | relURL) -}}{{- end -}} + {{ $lang.LanguageName }} + {{- end -}} +
  • {{- end -}} - {{- end -}} - +
+
diff --git a/site/layouts/partials/store-badges.html b/site/layouts/partials/store-badges.html index ec6d5c7..a5b15e2 100644 --- a/site/layouts/partials/store-badges.html +++ b/site/layouts/partials/store-badges.html @@ -1,10 +1,26 @@ -{{- /* Renders store links only when configured, so nothing broken shows pre-launch. */ -}} +{{- /* Renders store links only when configured, so nothing broken shows pre-launch. + Icons are single-colour (currentColor) so the two stores are told apart by + shape, not by brand colour: a play triangle vs. the free-software robot. */ -}}
{{- with .Site.Params.playStoreURL -}} - Google Play + + + Google Play + {{- end -}} {{- with .Site.Params.fdroidURL -}} - F-Droid + + + F-Droid + {{- end -}} {{- if and (not .Site.Params.playStoreURL) (not .Site.Params.fdroidURL) -}}

{{ T "getItComingSoon" }}

diff --git a/site/nginx.conf b/site/nginx.conf index 9dce0bb..0c87910 100644 --- a/site/nginx.conf +++ b/site/nginx.conf @@ -1,9 +1,46 @@ +# Auto-detect language from the browser's Accept-Language header, no JS +# involved (site stays static/no-JS per config.toml). Only the exact "/" is +# affected — deep links (e.g. someone shares /es/legal/rules/) are untouched. +# Fires once per visitor: a "tane_visited" cookie (set on every response) is +# checked first, so a later visit to "/" — including clicking "English" from +# the switcher on a non-English browser — is never redirected again. +map $http_accept_language $tane_lang { + default en; + ~*^es es; + ~*^pt pt; +} + +map $http_cookie $tane_seen { + default 0; + "~*tane_visited=1" 1; +} + server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; + # Non-sensitive, no PII: only remembers "this browser has been here + # before" so the auto-redirect below doesn't repeat. No Secure flag since + # the value carries no sensitive data and this must also work over plain + # http in local/dev previews; TLS is terminated upstream in production. + add_header Set-Cookie "tane_visited=1; Path=/; Max-Age=31536000; SameSite=Lax" always; + + location = / { + set $tane_redirect ""; + if ($tane_seen = 0) { + set $tane_redirect $tane_lang; + } + if ($tane_redirect = en) { + set $tane_redirect ""; + } + if ($tane_redirect != "") { + return 302 /$tane_redirect/; + } + try_files /index.html =404; + } + # Security headers. The page is fully static and self-contained: inline CSS # (needs style-src 'unsafe-inline'), same-origin images (+ data: URIs), no # third-party scripts. HSTS is host-scoped (no includeSubDomains) so it can't diff --git a/site/static/og-pt.png b/site/static/og-pt.png new file mode 100644 index 0000000..b3df23d Binary files /dev/null and b/site/static/og-pt.png differ