diff --git a/.githooks/post-checkout b/.githooks/post-checkout index 83f917854..0e232b034 100755 --- a/.githooks/post-checkout +++ b/.githooks/post-checkout @@ -1,3 +1,3 @@ #!/usr/bin/env bash -# Refresh lib/core/build_info.g.dart after HEAD moves. See tool/setup.sh. -exec "$(git rev-parse --show-toplevel)/tool/gen_build_info.sh" +# Refresh lib/core/build_info.g.dart after HEAD moves. See tool/dev/setup.sh. +exec "$(git rev-parse --show-toplevel)/tool/release/build_info.sh" diff --git a/.githooks/post-commit b/.githooks/post-commit index 83f917854..0e232b034 100755 --- a/.githooks/post-commit +++ b/.githooks/post-commit @@ -1,3 +1,3 @@ #!/usr/bin/env bash -# Refresh lib/core/build_info.g.dart after HEAD moves. See tool/setup.sh. -exec "$(git rev-parse --show-toplevel)/tool/gen_build_info.sh" +# Refresh lib/core/build_info.g.dart after HEAD moves. See tool/dev/setup.sh. +exec "$(git rev-parse --show-toplevel)/tool/release/build_info.sh" diff --git a/.githooks/post-merge b/.githooks/post-merge index 83f917854..0e232b034 100755 --- a/.githooks/post-merge +++ b/.githooks/post-merge @@ -1,3 +1,3 @@ #!/usr/bin/env bash -# Refresh lib/core/build_info.g.dart after HEAD moves. See tool/setup.sh. -exec "$(git rev-parse --show-toplevel)/tool/gen_build_info.sh" +# Refresh lib/core/build_info.g.dart after HEAD moves. See tool/dev/setup.sh. +exec "$(git rev-parse --show-toplevel)/tool/release/build_info.sh" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..8c6f5e055 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Refuses a commit that tool/commit.sh calls a blocker. +# +# It used to only print them. A check nothing enforces is a check, then a habit, +# then neither — this one printed "behind origin/main" and was read and ignored +# in the same minute. +# +# Commit-time severity only (see `profile` in tool/commit.sh): being behind the +# base is a *merge* problem, and demanding a rebase to record work that is not +# going anywhere yet would just teach everybody --no-verify. The gates do not +# run here either — they belong to pre-push, where a minute is affordable and a +# red CI is what it buys back. +# +# git commit --no-verify # the escape hatch, when you mean it +set -euo pipefail +root="$(git rev-parse --show-toplevel)" + +# Mid-rebase, mid-merge, mid-cherry-pick: `git commit` runs this hook while HEAD +# is detached and the branch state is meaningless. Every answer it could give +# there is about a tree that exists for the next few seconds. +git_dir="$(git rev-parse --git-dir)" +for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD; do + [[ -e "$git_dir/$state" ]] && exit 0 +done + +exec "$root/tool/commit.sh" --no-check diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 000000000..d377224d3 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# The stricter half: everything that has to be true before anyone else sees it. +# +# Where the rebase check becomes a blocker, and where the CI gates run. A push +# is the last moment a mistake is still free — after it, a bad commit message +# can only be fixed by a rebase and a force-push, and a red CI costs a round +# trip for everybody watching the PR. +# +# The gates are content-hash cached, so a push right after a green `tool/check.sh` +# costs about a second. +# +# git push --no-verify # the escape hatch, when you mean it +set -euo pipefail +root="$(git rev-parse --show-toplevel)" +exec "$root/tool/commit.sh" --push diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c4be9ebf2..de294b400 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -28,7 +28,7 @@ ## 檢查清單 -- [ ] `tool/check_commits.sh origin/main..HEAD` 通過 +- [ ] `tool/check/commits.sh origin/main..HEAD` 通過 —— commit 訊息就是更新日誌,格式見 [commit.md](../commit.md) - [ ] **一個 commit 一件事**(這條 gate 驗不了,靠自己和 review) - [ ] `mise exec -- flutter analyze` 與 `mise exec -- flutter test` 通過 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 993be4c1e..1438e50c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,22 +27,67 @@ jobs: # repair is a rebase and a force-push — and the later that is discovered, # the more there is to rebase. - name: Commit message gate + env: + # Through the environment, like every other value here: a base ref is + # a branch name, and on a fork's pull request whoever opened it chose + # that name. + EVENT: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BEFORE: ${{ github.event.before }} run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - range="origin/${{ github.base_ref }}..HEAD" - git fetch --quiet origin "${{ github.base_ref }}" + if [ "$EVENT" = pull_request ]; then + # The branch's own tip, not the checked-out merge commit: a pull + # request builds a synthetic merge of the branch into the base, and + # every commit this gate judges has to be one somebody wrote. + git fetch --quiet origin "$BASE_REF" + range="$(git rev-parse "origin/$BASE_REF")..$HEAD_SHA" else # A push event names what it replaced. On the first push to a new # branch the before-sha is all zeroes, so fall back to the last # commit alone. - before="${{ github.event.before }}" - case "$before" in + case "$BEFORE" in ''|0000000000000000000000000000000000000000) range="HEAD~1..HEAD" ;; - *) range="$before..HEAD" ;; + *) range="$BEFORE..HEAD" ;; esac fi echo "checking $range" - bash tool/check_commits.sh "$range" + bash tool/check/commits.sh "$range" + + # Rebased, not merged, and not behind. + # + # Two reasons, and neither is taste. A merge commit is invisible to the + # gate above — `check_commits.sh` walks with `--no-merges`, because a + # merge message is generated rather than written — so anything that + # arrives through one is never judged. And a branch that is behind was + # tested against a main that no longer exists; the gates that passed + # describe a tree nobody will ever have. + - name: Branch is rebased on the base + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + git fetch --quiet origin "$BASE_REF" + base="$(git rev-parse "origin/$BASE_REF")" + + if merges="$(git rev-list --merges "$base..$HEAD_SHA")" && + [ -n "$merges" ]; then + echo "::error::this branch merges $BASE_REF instead of rebasing onto it" + git --no-pager log --format=' %h %s' --merges "$base..$HEAD_SHA" + printf '\n git rebase origin/%s\n git push --force-with-lease\n' "$BASE_REF" + exit 1 + fi + + if ! git merge-base --is-ancestor "$base" "$HEAD_SHA"; then + echo "::error::this branch is behind $BASE_REF; rebase before merging" + printf ' %s is %s commit(s) ahead of this branch\n' \ + "$BASE_REF" "$(git rev-list --count "$HEAD_SHA..$base")" + printf '\n git fetch origin\n git rebase origin/%s\n git push --force-with-lease\n' "$BASE_REF" + exit 1 + fi + + echo "branch gate: rebased on $BASE_REF, no merge commits" # The commits on the branch are not what lands on main. This repo # squash-merges, so GitHub builds the merged commit out of the PR's title @@ -69,7 +114,7 @@ jobs: printf '%s\n\n' "$PR_TITLE" printf '%s\n' "$PR_BODY" } > /tmp/pr_message.txt - bash tool/check_commits.sh --message /tmp/pr_message.txt + bash tool/check/commits.sh --message /tmp/pr_message.txt # The workflows themselves, because a broken one does not fail — it is # *refused*. GitHub interpolates a `run` block whole before bash sees it, @@ -84,25 +129,32 @@ jobs: # Architecture gate — pure grep, no toolchain, fails fast. - name: Layering gate - run: bash tool/check_layering.sh + run: bash tool/check/layering.sh # Localization gate — bash + python3 (preinstalled), no toolchain either: # ARB parity + no hardcoded UI strings. See CLAUDE.md → Localization. - name: Localization gate - run: bash tool/check_l10n.sh + run: bash tool/check/l10n.sh # Storage gate — settings via the typed SettingsStore; sqflite only in # the store that owns each table. - name: Storage gate - run: bash tool/check_storage.sh + run: bash tool/check/storage.sh # Every channel sound must resolve, or the channel is rejected on device. - name: Notification-sound gate - run: bash tool/check_notification_sounds.sh + run: bash tool/check/notification_sounds.sh # Lockfile must not embed a machine-local path (pubspec_overrides leak). - name: pubspec.lock path gate - run: bash tool/check_pubspec_lock.sh + run: bash tool/check/pubspec_lock.sh + + # Every workflow has a script under tool/; the docs and this file may not + # tell anyone to type the toolchain directly. A copied `flutter test` runs + # whatever SDK the shell cached, and running against the wrong SDK looks + # exactly like running against the right one. + - name: Tooling + run: bash tool/check/tooling.sh # Flutter/Dart come from mise.toml, so CI uses the exact pinned version # developers run locally — one source of truth. v4 = Node 24 runtime @@ -111,10 +163,13 @@ jobs: uses: jdx/mise-action@v4 - name: Install dependencies - run: mise exec -- flutter pub get + run: bash tool/dev/deps.sh - - name: Format - run: mise exec -- dart format --set-exit-if-changed lib test + # The same script a developer runs. CI naming its own commands is how CI + # and the local checklist drift apart, and the drift is only ever found + # by a red PR on a branch that was green. + - name: Format and analyze + run: bash tool/dev/analyze.sh # Committed *.freezed.dart / *.g.dart must match a fresh build — a stale # generated file (e.g. after editing a model without regenerating) fails @@ -126,11 +181,11 @@ jobs: # fails: the file is `skip-worktree`, so a developer's own copy has the # symbol and their `flutter analyze` passes while CI's does not. - name: Build info - run: bash tool/check_build_info.sh + run: bash tool/check/build_info.sh - name: Codegen is up to date run: | - mise exec -- dart run build_runner build --delete-conflicting-outputs + bash tool/dev/codegen.sh # build_info.g.dart is excluded because it *cannot* match: it holds # the hash of the commit it is committed in. git diff --exit-code -- . ':!lib/core/build_info.g.dart' || { @@ -140,9 +195,6 @@ jobs: exit 1 } - - name: Analyze - run: mise exec -- flutter analyze - # The ETag-cache tests run SQLite on the host via sqflite_common_ffi; # ubuntu ships libsqlite3.so.0 but not the unversioned symlink its loader # needs, so install the dev package. @@ -150,4 +202,4 @@ jobs: run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev - name: Test - run: mise exec -- flutter test + run: bash tool/dev/test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 974e27c52..2ff733ef5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ # Every commit on main is a snapshot; every tag is a release. # -# The three values a build carries come from `tool/version.sh` and nowhere +# The three values a build carries come from `tool/release/version.sh` and nowhere # else — no human edits a version, and `pubspec.yaml`'s stays a placeholder for # local runs. See that script for why the label, the train and the ordinal are # three separate things. @@ -26,6 +26,7 @@ jobs: label: ${{ steps.v.outputs.label }} train: ${{ steps.v.outputs.train }} code: ${{ steps.v.outputs.code }} + date: ${{ steps.v.outputs.date }} prerelease: ${{ steps.v.outputs.prerelease }} steps: # Full history: the label counts this week's commits and the train reads @@ -36,17 +37,18 @@ jobs: - id: v run: | - eval "$(tool/version.sh)" + eval "$(tool/release/version.sh)" echo "label=$DPIP_LABEL" >> "$GITHUB_OUTPUT" echo "train=$DPIP_TRAIN" >> "$GITHUB_OUTPUT" echo "code=$DPIP_CODE" >> "$GITHUB_OUTPUT" + echo "date=$DPIP_DATE" >> "$GITHUB_OUTPUT" # A tag is a release; anything else is a snapshot. if [ "${GITHUB_REF_TYPE}" = "tag" ]; then echo "prerelease=false" >> "$GITHUB_OUTPUT" else echo "prerelease=true" >> "$GITHUB_OUTPUT" fi - echo "::notice::$DPIP_LABEL — $DPIP_TRAIN ($DPIP_CODE)" + echo "::notice::$DPIP_LABEL — $DPIP_TRAIN ($DPIP_CODE, built $DPIP_DATE)" # The ordinal is the one value that must never repeat or go backwards: a # store that has accepted a code refuses every build at or below it, @@ -56,7 +58,7 @@ jobs: # # This only sees what *this* workflow published. A build uploaded by hand # is invisible to it — which is how 408283049 reached TestFlight ahead of - # the scheme. The per-store floors in tool/version.sh are what cover + # the scheme. The per-store floors in tool/release/version.sh are what cover # those; raise one whenever something is shipped outside this workflow. - name: Ordinal must be higher than the last published one env: @@ -95,7 +97,7 @@ jobs: runs-on: macos-latest runs-on: ${{ matrix.runs-on }} steps: - # Full history: tool/gen_build_info.sh counts commits and reads tags. + # Full history: tool/release/build_info.sh counts commits and reads tags. - uses: actions/checkout@v5 with: fetch-depth: 0 @@ -141,9 +143,9 @@ jobs: # The committed copy is a stub; the real values come from git. CI also # passes them with --dart-define below, so this is belt and braces — but # it is what makes the file compile at all. - - run: bash tool/gen_build_info.sh + - run: bash tool/release/build_info.sh - - run: mise exec -- flutter pub get + - run: bash tool/dev/deps.sh # Without these the release build silently falls back to the *debug* # signing config (see `buildTypes.release` in android/app/build.gradle.kts), @@ -190,16 +192,18 @@ jobs: # two that can disagree. DPIP_LABEL: ${{ needs.version.outputs.label }} run: | - # One number for both stores — see tool/version.sh for why they can + # One number for both stores — see tool/release/version.sh for why they can # be aligned. It is also what the release advertises and what the # update check compares, so nothing anywhere has to translate. COMMON="--build-name=${{ needs.version.outputs.train }} \ --build-number=${{ needs.version.outputs.code }} \ --dart-define=DPIP_LABEL=${{ needs.version.outputs.label }} \ - --dart-define=DPIP_CODE=${{ needs.version.outputs.code }}" + --dart-define=DPIP_CODE=${{ needs.version.outputs.code }} \ + --dart-define=DPIP_TRAIN=${{ needs.version.outputs.train }} \ + --dart-define=DPIP_DATE=${{ needs.version.outputs.date }}" if [ "${{ matrix.platform }}" = "android" ]; then - mise exec -- flutter build apk --release $COMMON - mise exec -- flutter build appbundle --release $COMMON + bash tool/dev/build.sh android $COMMON + bash tool/dev/build.sh bundle $COMMON mv build/app/outputs/flutter-apk/app-release.apk \ "DPIP-${{ needs.version.outputs.label }}.apk" else @@ -212,7 +216,7 @@ jobs: # The Flutter step still has to run first: it writes # FLUTTER_BUILD_NUMBER into ios/Flutter/Generated.xcconfig, which is # where Info.plist's CFBundleVersion comes from. - mise exec -- flutter build ios --release --no-codesign $COMMON + bash tool/dev/build.sh ios $COMMON AUTH=(-allowProvisioningUpdates -authenticationKeyPath "$RUNNER_TEMP/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8" -authenticationKeyID "${{ secrets.APPSTORE_API_KEY_ID }}" @@ -323,17 +327,17 @@ jobs: # The ordinal rides in the body as an HTML comment: GitHub renders it as # nothing, so the note stays a note while the app still has something # unambiguous to compare. See `buildCodeOf` in update_check.dart. - # Built from the commit messages themselves — see tool/release_notes.sh + # Built from the commit messages themselves — see tool/release/notes.sh # for why a release accumulates across the snapshots it followed while a # snapshot is only a delta. - name: Release notes run: | if [ "${GITHUB_REF_TYPE}" = "tag" ]; then - bash tool/release_notes.sh \ + bash tool/release/notes.sh \ "${{ needs.version.outputs.label }}" \ "${{ needs.version.outputs.code }}" --release > NOTES.md else - bash tool/release_notes.sh \ + bash tool/release/notes.sh \ "${{ needs.version.outputs.label }}" \ "${{ needs.version.outputs.code }}" > NOTES.md fi @@ -348,7 +352,7 @@ jobs: else # The bare label. `v[0-9]*` is what makes a build a release, so a # snapshot named `26w33a` is already in its own namespace — and it - # is the name tool/version.sh counts to work out the next letter. + # is the name tool/release/version.sh counts to work out the next letter. # An earlier `snapshot/` prefix put the tags somewhere the counter # did not look, so every run computed `a` and this step died on a # tag that already existed. diff --git a/.metadata b/.metadata index b15f453e2..8dd69bd43 100644 --- a/.metadata +++ b/.metadata @@ -13,11 +13,14 @@ project_type: app migration: platforms: - platform: root - create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - - platform: macos - create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + create_revision: e1fd963c6f6922bd32afde2e9698a363cd0406d2 + base_revision: e1fd963c6f6922bd32afde2e9698a363cd0406d2 + - platform: android + create_revision: e1fd963c6f6922bd32afde2e9698a363cd0406d2 + base_revision: e1fd963c6f6922bd32afde2e9698a363cd0406d2 + - platform: ios + create_revision: e1fd963c6f6922bd32afde2e9698a363cd0406d2 + base_revision: e1fd963c6f6922bd32afde2e9698a363cd0406d2 # User provided section diff --git a/AGENTS.md b/AGENTS.md index 6c04c0f05..ad923be2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,30 +19,80 @@ other files point here, and this file points at them. ## Toolchain -Flutter and Dart are pinned by **mise** — run tools through it, so CI and every -laptop use the same version: +**mise is required.** Not preferred — required. Without it the scripts refuse to +run and tell you how to install it, because there is nothing to pin against and +a build off the wrong SDK looks exactly like a build off the right one. + +Flutter and Dart are pinned in `mise.toml`, and **every workflow has a script +under `tool/`**. Never type the toolchain yourself — not `flutter`, not `dart`, +and not `mise exec`. A shell's PATH is resolved once and `mise activate` caches +it, so a toolchain bump leaves the old SDK on PATH until the session is +replaced, and a run against the wrong SDK announces nothing: it builds, it runs, +its tests pass. + +Three things enforce it, none of which relies on anybody remembering: + +| Where | What it refuses | +|---|---| +| `require_mise` in `tool/dev/_lib.sh` | no mise, no `mise.toml`, or a flutter that resolves outside mise's own installs — the last one is the dangerous case, because `mise exec` will happily forward to a system SDK | +| `tool/check/tooling.sh` | a bare toolchain command in the docs or CI; and, for every script in `tool/`, one that does not parse, is not executable, has no shebang, or reaches `flutter` / `dart` / `mise exec` without going through `pinned` | +| `tool/run.sh` | runs both before it starts anything (0.34 s) | ```sh -mise exec -- flutter analyze +tool/dev/analyze.sh ``` -- After changing `@freezed` / `@JsonSerializable` models: - `mise exec -- dart run build_runner build --delete-conflicting-outputs` -- After editing ARB files, localizations regenerate on the next build - (`generate: true`); by hand with `mise exec -- flutter gen-l10n` -- Format with `mise exec -- dart format lib test tool` +| Do this | Run | +|---|---| +| Start the app | `tool/run.sh` (see [Running](#running)) | +| Run the tests | `tool/dev/test.sh` | +| Format + analyze | `tool/dev/analyze.sh` | +| Reformat in place | `tool/dev/format.sh` | +| Resolve dependencies | `tool/dev/deps.sh` (`--offline` when pub.dev stalls) | +| Regenerate after `@freezed` / `@JsonSerializable` edits | `tool/dev/codegen.sh` | +| Regenerate l10n by hand (a build does it anyway) | `tool/dev/l10n.sh` | +| Throw away the build output | `tool/dev/clean.sh` | +| Release build | `tool/dev/build.sh {android\|bundle\|ios}` | +| Everything CI runs | `tool/check.sh` (content-hash cached: ~1 s when nothing changed) | +| Before writing any commit | `tool/commit.sh` — and `.githooks/pre-commit` runs it whether you do or not (see [commit.md](commit.md)) | +| One-time git-hook setup | `tool/dev/setup.sh` (`tool/run.sh` does it for you) | + +`tool/` is organised by what a script is for: `dev/` daily workflows, `check/` +the CI gates, `release/` versioning and notes, `gen/` asset and code +generators, `internal/` pieces other scripts call and nobody runs by hand. ## Running ```sh -mise exec -- flutter run -d "iPhone 17 Pro" +tool/run.sh -d "iPhone 17 Pro" ``` +On Windows, `tool\run.ps1 -d "Pixel 9"` — or `bash tool/run.sh` under Git Bash +or WSL, which is the one that colours the log. `run.ps1` deliberately does not +pipe: `$LASTEXITCODE` is unreliable when a native command feeds a cmdlet +(PowerShell/PowerShell#19848), and a wrapper that reports a failed build as a +success is worse than an uncoloured one. + +**This is the only supported way to start the app.** Every other way of +starting it is wrong in a way nothing tells you about, so **a debug build +started any other way refuses to run** and prints the command to use instead. + +Arguments pass through untouched, and hot reload still works: the tool reads +`supportsColor` from stdout and its keystrokes from stdin, and a pipe only +touches the first. + Select the device with `-d `. A bare `flutter run ios` treats `ios` as a target Dart file and fails with `Target file "ios" not found`. -- If `flutter run` / `pub get` stalls at **Downloading packages**, resolve from - the local cache first: `mise exec -- flutter pub get --offline`, then re-run. +- `tool/run.sh` runs the pinned `flutter run` and pipes it through + `tool/internal/colorize_logs.sh`. Colour is added by the pipe, not by the app: on iOS an escape sequence cannot + survive the trip, because the platform's log path escapes the escape + character and even a terminal that supports ANSI then prints it + (flutter/flutter#20663). `dart:developer`'s `log` does deliver them, but + truncates past ~128 characters — which is where the diagnostic lines are. The + pipe has neither problem, and drops the `flutter: ` prefix as well. +- If a launch stalls at **Downloading packages**, resolve from the local cache + first with `tool/dev/deps.sh --offline`, then re-run. - The visible simulator window in Xcode 26+ is **DeviceHub.app** — it replaced `Simulator.app`, and `open -a Simulator` no longer works. `flutter run` boots the simulator headless, so open it separately to see or touch anything: @@ -64,7 +114,7 @@ New(en-US): ``` - **Each `Category(locale):` line is one changelog entry**, extracted by - `tool/release_notes.sh` with a single regular expression. Categories are + `tool/release/notes.sh` with a single regular expression. Categories are `New` / `Optimization` / `Fix`; `zh-Hant` and `en-US` are required and the app's other locales are optional. - **There is no prose body.** Why it was done, what was tried, what bit you — @@ -75,6 +125,10 @@ New(en-US): - The category is **declared, not inferred from the type** — so a user-visible fix that lives in a `chore:` commit still reaches the changelog, which the old type-derived mapping silently dropped. +- **Rebase, never merge, and never leave the branch behind.** CI refuses both: + a merge commit is invisible to the gate (`--no-merges`), so anything arriving + through one is never judged, and a branch that is behind was tested against a + main that no longer exists. `git rebase origin/main` and force-with-lease. - **One thing per commit.** No gate can check this — whether two changes are the same thing is a judgement — so it is on you and on review. - **Never** add a `Co-Authored-By:` trailer, `Generated with …`, 🤖, a model @@ -87,20 +141,28 @@ New(en-US): Everything CI runs, in order. All of it must be clean: ```sh -tool/check_commits.sh origin/main..HEAD -tool/check_layering.sh -tool/check_l10n.sh -tool/check_storage.sh -tool/check_pubspec_lock.sh -tool/check_notification_sounds.sh -mise exec -- dart format --set-exit-if-changed lib test tool -mise exec -- dart run build_runner build --delete-conflicting-outputs # then git diff --exit-code -mise exec -- flutter analyze -mise exec -- flutter test +tool/check.sh +``` + +That is the whole list, and it is the same list `.github/workflows/ci.yml` +runs — CI calls these scripts rather than naming the commands itself, so the +two cannot drift. Individually, if you want to fail faster: + +```sh +tool/check/commits.sh origin/main..HEAD +tool/check/layering.sh +tool/check/l10n.sh +tool/check/storage.sh +tool/check/pubspec_lock.sh +tool/check/notification_sounds.sh +tool/check/tooling.sh +tool/dev/analyze.sh +tool/dev/codegen.sh # then git diff --exit-code +tool/dev/test.sh ``` The bash gates need only bash and python3, so they fail fast without the -toolchain. `.github/workflows/ci.yml` runs the same list and must stay green; +toolchain. `.github/workflows/ci.yml` must stay green; `android.yml` / `ios.yml` build artifacts and `review.yml` adds an automated PR review. @@ -110,7 +172,7 @@ estimator, update those goldens deliberately. ## Versions -Nobody edits a version by hand. `tool/version.sh` derives all three values from +Nobody edits a version by hand. `tool/release/version.sh` derives all three values from git state and CI passes them to the build: | | | | @@ -122,7 +184,7 @@ git state and CI passes them to the build: Every commit on `main` publishes a snapshot; a `v*` tag publishes a release. `pubspec.yaml`'s `version:` is a placeholder for local runs only. -Read the header of `tool/version.sh` before changing any of it. Every constant +Read the header of `tool/release/version.sh` before changing any of it. Every constant there is a fact about what has already shipped to a store, and a store refuses, permanently, any build whose ordinal is not above the last it accepted — deleting the build does not release the number. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dabe69c02..28c203a3a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -58,7 +58,7 @@ lib/ - App-wide state several features consume lives outside any one of them — `core/settings/` for `ExperimentalSettings`, for instance. -All five are enforced by `tool/check_layering.sh`, which CI runs first. +All five are enforced by `tool/check/layering.sh`, which CI runs first. ## Conventions @@ -222,7 +222,7 @@ batch is rejected the service falls back to registering them individually, so one bad channel costs only itself and is named in the log instead of leaving the app with no channels *and* no push transport. (`initialize` needs at least one channel, so the fallback seeds itself with the first one that is -accepted.) `tool/check_notification_sounds.sh` catches an unresolvable +accepted.) `tool/check/notification_sounds.sh` catches an unresolvable `resource://raw/` before it ships. Taps funnel through `NotificationTaps` (core carries the channel key; `app/` maps it to an `AppRoutes` tab). **External, not code:** upload an APNs auth key to the Firebase console for iOS; push only works on a physical @@ -280,6 +280,6 @@ are synchronous (the table is loaded into memory once at bootstrap); writes are async and log rather than throw. Add a setting = add one `SettingKey`; never change an existing key string without a migration. `shared_preferences` is **not a dependency** — nothing may import it — and only a table's owning -store may import `sqflite`; both enforced by `tool/check_storage.sh`. +store may import `sqflite`; both enforced by `tool/check/storage.sh`. - Every file starts with a doc comment; one public declaration = one clear responsibility. diff --git a/CLAUDE.md b/CLAUDE.md index cf7a6e4d7..5653ea551 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,8 +29,23 @@ gate or the analyzer will tell you. → [ARCHITECTURE.md § Calibrated time](ARCHITECTURE.md#calibrated-time) - **A safety-critical feed that is `stale` or `offline` must never be presented as current.** → [ARCHITECTURE.md § Realtime feeds](ARCHITECTURE.md#realtime-feeds) -- **Run tools through `mise exec --`**, or you are testing a different Flutter - than CI is. → [AGENTS.md § Toolchain](AGENTS.md#toolchain) +- **mise is required, and every workflow has a script under `tool/`.** Never + type the toolchain: not `flutter`, not `dart`, not `mise exec`. A shell's PATH + is resolved once and `mise activate` caches it, so a toolchain bump leaves the + old SDK on PATH until the session is replaced — and a build against the wrong + SDK announces nothing at all: it compiles, it runs, its tests pass. + `tool/dev/test.sh`, `tool/dev/analyze.sh`, `tool/check.sh`. + → [AGENTS.md § Toolchain](AGENTS.md#toolchain) +- **Start the app with `tool/run.sh`** (`tool\run.ps1` on Windows), never + anything else. A debug build refuses to start otherwise — both + alternatives run, and the difference is the SDK resolved and whether the log + is readable, neither of which is visible at the time. + → [AGENTS.md § Running](AGENTS.md#running) +- **Run `tool/commit.sh` before every commit.** It reads and prints only. A + commit message here is the changelog and cannot be edited once pushed, so + every problem it names — behind the base, a merge commit, an unstaged new + file, a half-translated ARB — is one that costs a rebase to fix afterwards. + → [commit.md § 提交前必須跑](commit.md) - **No `Co-Authored-By`, no tool attribution, ever.** → [AGENTS.md § Commits](AGENTS.md#commits) diff --git a/DESIGN.md b/DESIGN.md index 20e4e3e5e..ed9f5a95d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -115,7 +115,7 @@ all (nor mixed precipitation, nor hail), so weather surfaces draw from `core/weather/weather_icons.dart` — a 6.7 KB subset of Material Symbols Outlined bundled as a *font asset*, not a package. **Never hand-write an `IconData` codepoint**: that file and the font are both generated by -`tool/build_weather_icons.py` from Google's `.codepoints` manifest, because a +`tool/gen/weather_icons.py` from Google's `.codepoints` manifest, because a guessed codepoint is a valid `IconData` that silently draws the wrong picture — `rainy` was once declared as `0xf07c2`, which is `Icons.severe_cold`, and every rainy hour rendered a snowflake. Add a glyph by adding its Material @@ -136,7 +136,7 @@ generated code is in `lib/l10n/gen/`. Each ARB **self-describes** with a `AppLocalizations.supportedLocales` + that key — never a hardcoded list. So a language is added by just dropping in `app_.arb` (with `languageName`); the home/fallback locale is the one constant in -`core/settings/locale_config.dart`. Enforced by `tool/check_l10n.sh` (a CI +`core/settings/locale_config.dart`. Enforced by `tool/check/l10n.sh` (a CI gate, no packages): ARB key-parity with the template + no hardcoded CJK/kana/Hangul/Thai string literals in `features/*/presentation/**` or `shared/widgets/**`. A genuinely non-display or throwaway literal is exempted diff --git a/README.md b/README.md index 85d4c5f5f..2fdbd6f82 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,13 @@ TREM-Net 由 [ExpTech Studio](https://exptech.dev/) 建置與維運,自 2022 也可以從 [Release 頁面](https://github.com/ExpTechTW/DPIP/releases/latest)取得 Android 安裝檔手動安裝。請注意 Release 頁面同時包含快照版本,那些未經完整審查。 +想搶先體驗新功能?加入**測試版**: + +- [Android 測試版](https://play.google.com/apps/testing/com.exptech.dpip) —— 開啟 Google Play 的測試版申請頁 +- [iOS 測試版(TestFlight)](https://testflight.apple.com/join/8aPWtOxk) —— 需要在 iPhone、iPad 或 Mac 上先安裝 TestFlight + +測試版可能包含尚未完整審查的功能,遇到問題歡迎到 [Issues](https://github.com/ExpTechTW/DPIP/issues) 回報。 + ## 翻譯 DPIP 介面目前有 10 種語言,翻譯在 [Crowdin](https://crowdin.com/project/dpip) 上進行,挑一個你熟悉的語言就能開始。 @@ -87,24 +94,58 @@ DPIP 介面目前有 10 種語言,翻譯在 [Crowdin](https://crowdin.com/proj ## 參與開發 -工具鏈由 [mise](https://mise.jdx.dev/) 釘選版本,跑起來只要四步: +工具鏈由 [mise](https://mise.jdx.dev/) 釘選版本: ```bash git clone https://github.com/ExpTechTW/DPIP.git cd DPIP -mise install # 安裝 mise.toml 釘選的 Flutter -bash tool/setup.sh # 一次性設定:git hooks、產生建置資訊 -mise exec -- flutter pub get -mise exec -- flutter run +mise install # 安裝 mise.toml 釘選的 Flutter +tool/dev/deps.sh # 取得套件 ``` +git hooks 由 `tool/run.sh` 第一次啟動時自動裝好,不用另外做。只想建置不想跑的話,`tool/dev/setup.sh` 可以單獨裝。 + +啟動: + +| 系統 | 指令 | +|---|---| +| macOS、Linux | `tool/run.sh -d <裝置>` | +| Windows | `tool\run.ps1 -d <裝置>`(或用 Git Bash/WSL 跑 `bash tool/run.sh`,日誌會上色) | + +**一定要用這個腳本。** debug 版本偵測到不是這樣啟動會拒絕執行並印出正確指令 —— 直接跑起來的話,用到的是你 shell 快取的那個 Flutter 而不是 `mise.toml` 釘選的那個,而且當下不會有任何徵兆。 + 建置成安裝檔: ```bash -mise exec -- flutter build apk --release # Android -mise exec -- flutter build ios --no-codesign # iOS(不含簽章) +tool/dev/build.sh android # APK +tool/dev/build.sh bundle # AAB(Play 實際收的格式) +tool/dev/build.sh ios # iOS(不含簽章) ``` +其餘每件事也都有腳本,`tool/` 底下分類放好: + +| 要做什麼 | 指令 | +|---|---| +| 跑測試 | `tool/dev/test.sh` | +| 格式化 + 靜態分析 | `tool/dev/analyze.sh` | +| 只格式化 | `tool/dev/format.sh` | +| 重新產生 l10n | `tool/dev/l10n.sh` | +| 重新產生 codegen | `tool/dev/codegen.sh` | +| 砍掉重建 | `tool/dev/clean.sh` | +| 跑完 CI 會跑的每一道關卡 | `tool/check.sh` | + +**mise 是必要條件,不是建議。** 沒有 mise 就不能建置這個專案 —— 腳本會直接拒絕執行並告訴你怎麼裝。 + +**絕對不要自己打 `flutter`、`dart` 或 `mise exec`。** 工具鏈只在 `tool/dev/_lib.sh` 一個地方指定。理由不是整潔:shell 的 PATH 只解析一次,`mise activate` 會把它快取起來,所以升級工具鏈之後舊的 SDK 還留在 PATH 上 —— 而**用錯 SDK 一樣建得起來、跑得起來、測試也會過**,差別要到幾天後變成一個沒人重現得出來的失敗才浮現。 + +三道防線: + +| 誰 | 擋什麼 | +|---|---| +| `tool/dev/_lib.sh` 的 `require_mise` | 沒裝 mise、沒有 `mise.toml`、或 flutter 解析到 mise 以外的路徑,一律拒絕執行 | +| `tool/check/tooling.sh` | 文件與 CI 裡出現裸指令;`tool/` 裡任何腳本語法錯、沒有執行權限、或直接呼叫 `flutter` / `dart` / `mise exec` | +| `tool/run.sh` | 啟動前把上面兩項都跑一次(0.34 秒) | + > [!NOTE] > Android 需要 JDK 17 以上(Android Studio 內建的即可)。iOS 已改用 Swift Package Manager,不需要 CocoaPods。 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8cbb89aaf..78f72ec6c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -27,7 +27,7 @@ if (keystorePropertiesFile.exists()) { /// can then never be restyled, reordered or renamed without the ordinal /// moving with it, and a label that is not three integers cannot exist at all. /// -/// Both now come from `tool/version.sh` by way of CI, which is the one place +/// Both now come from `tool/release/version.sh` by way of CI, which is the one place /// that decides. Locally, where neither is set, the build falls back to /// Flutter's own numbers so `flutter run` keeps working. /// Play's ordinal, straight from Flutter's `--build-number`. @@ -35,7 +35,7 @@ if (keystorePropertiesFile.exists()) { /// One source, not two. It used to also read a `DPIP_CODE` environment /// variable, which meant two ways to set the same field and no rule about /// which won — and the two carry different numbers now that each store has its -/// own floor (`tool/version.sh`). Play's is deliberately the small one: it has +/// own floor (`tool/release/version.sh`). Play's is deliberately the small one: it has /// published nothing, and a code that turns out to be too low fails the /// *upload*, before anything is served, which costs one line to fix. val dpipVersionCode: Int = flutter.versionCode diff --git a/commit.md b/commit.md index c2506433b..2a3bb8b48 100644 --- a/commit.md +++ b/commit.md @@ -1,10 +1,76 @@ # Commit 格式 -**commit 訊息就是更新日誌。** `tool/release_notes.sh` 直接讀這些訊息產生 GitHub +**commit 訊息就是更新日誌。** `tool/release/notes.sh` 直接讀這些訊息產生 GitHub release 的內容,所以一則寫壞的 commit 會在使用者讀得到的地方留下一個洞——而 commit 訊息推出去之後**改不了**,唯一的修法是 rebase。 -`tool/check_commits.sh` 是 CI gate,不合格直接失敗。 +`tool/check/commits.sh` 是 CI gate,不合格直接失敗。 + +--- + +## 提交前必須跑 `tool/commit.sh` + +```sh +tool/commit.sh +``` + +**git hook 會自動跑,不用記得。** `tool/dev/setup.sh` 裝好 `.githooks` 之後 +(`tool/run.sh` 第一次啟動時會自動裝),`pre-commit` 和 `pre-push` 各跑一次, +而且**真的會擋下來**: + +| hook | 跑什麼 | 擋什麼 | +|---|---|---| +| `pre-commit` | `tool/commit.sh --no-check` | 提交時就該修的:stage 到建置產物、`main` 分支、有 merge commit、現有 commit 訊息不合格 | +| `pre-push` | `tool/commit.sh --push` | 上面全部,**外加落後 base**,以及 CI 的每一道 gate | + +**落後 base 在提交時只是警告、推送時才是 blocker。** 這兩個不是同一個問題:落後擋的是 +*合併*,不是提交。要求每次提交前都先 rebase,等於一個下午 rebase 五次來記錄一份還沒 +要去任何地方的工作 —— 然後大家就學會 `--no-verify` 了。 + +真的要繞過時:`git commit --no-verify` / `git push --no-verify`。 + +手動跑也可以,**agent 尤其**: 它只讀不寫 —— 不會 commit、不會 +stage、不會 fetch、不改任何檔案 —— 但它會把「現在提交會出什麼事」一次講完: + +| 它會講 | 為什麼你需要在提交**之前**知道 | +|---|---| +| 你在哪個分支、HEAD 是什麼、base 多久沒動 | 在 `main` 上提交、或拿一個上禮拜的 `origin/main` 判斷落後幾則,兩個都是白做工 | +| 落後 base 幾則、有沒有 merge commit | 兩個都會被 CI 擋,而且都只能用 rebase 修 | +| staged / unstaged / untracked 各是什麼 | 未追蹤的檔案 CI 看不到 —— 少 stage 一個新檔案,只會在 runner 上失敗 | +| 有沒有 stage 到不該進版控的東西 | `build/`、`.dart_tool/`、`build_info.g.dart` | +| 這次改動碰到哪些 feature 與範圍 | 挑 ``;跨太多區就不要寫 scope | +| 是不是只碰單一平台 | 提醒你補 `Platform:` trailer | +| ARB 只改了一部分 | 少一個語系的 key 會無聲退回英文 | +| 訊息格式的樣板與三個會無聲失敗的規則 | 條目數對不齊、忘了寫 `Category` 行、署名 | +| 現有 commit 過不過 gate | 過不了只能 rebase,越早知道越便宜 | +| CI 的每一道 gate 過不過 | 推上去才發現,要多花一次 push、一次等待,通常還要一次 rebase | + +**它預設會把 `.github/workflows/ci.yml` 的內容跑過一遍**(`tool/check.sh`),所以 +不會發生「推上去才知道會紅」。跑得起來是因為有內容雜湊快取: + +| | | +|---|---| +| 全新/有改動 | 約 50 秒 | +| 樹沒動過 | **約 1 秒** | + +快取的 key 是那一步真正會讀到的檔案的**內容**雜湊 —— 不是 mtime。`dart format`、 +切分支、checkout 都會動到 mtime 卻沒有改變程式碼的意思,而一個 `git switch` 之後 +就把整個測試套件重跑一次的快取,是沒有人會留著的快取。改一個 byte 就會重跑;改完 +又改回去也還是命中(每個檢查保留最近 8 組 key)。 + +**只有成功會被快取。** 失敗重跑很便宜,失敗被藏起來不便宜。 + +```sh +tool/commit.sh --message <草稿檔> # 額外驗一份還沒提交的訊息 +tool/commit.sh --no-check # 只看簡報,不跑 gate +DPIP_NO_CACHE=1 tool/commit.sh # 什麼都不信,全部重跑 +``` + +提交完**再跑一次** —— 這時它檢查的是你剛寫的那一則。 + +它不會替你判斷「這是不是一件事」,那件事沒有辦法自動判斷(見 +[一個 commit 一件事](#一個-commit-一件事));它只會在改動跨了太多 feature、 +或文件和程式混在一起的時候,提醒你停一下。 --- @@ -76,7 +142,7 @@ feat(mesh): show the radio's own packet counters ## 更新日誌條目 **這是這份格式存在的理由。** 說明區塊裡的每一行 `Category(locale): 文字` 都是 -更新日誌的一個條目,`tool/release_notes.sh` 用一條正則表達式把它們抓出來。 +更新日誌的一個條目,`tool/release/notes.sh` 用一條正則表達式把它們抓出來。 ``` feat(map): overlay radar echo on the map @@ -176,7 +242,7 @@ Android 14 requires a foregroundServiceType on every start… | 摘要裡有 **and** | `show one language and draw the tags locally` | | 摘要在**列舉** | `cut releases from tags, notes from commits, symbols from both` | | 說明分成**互不相關的兩段** | 第一段講語言、第二段講圖示 | -| 一個檔案是**文件**、另一個是**程式** | `AGENTS.md` + `tool/check_commits.sh` | +| 一個檔案是**文件**、另一個是**程式** | `AGENTS.md` + `tool/check/commits.sh` | | 需要**兩個 type** 才講得清楚 | 一半是 `fix` 一半是 `feat` | 這五個都不是硬性錯誤——`fix: stop A and B from racing` 是合法的,兩者是同一個 @@ -271,8 +337,13 @@ ci: cache the Swift package resolution 相符就找同語言、再找不到就退回英文(`
` 是 HTML,app 的 Markdown 渲染器 不支援,不處理的話十種語言會全部攤在同一頁)。 -每一項後面標上提交者,CI 解析成 GitHub `@帳號`——**是 GitHub 帳號,不是 git 的 -顯示名稱**,顯示名稱 @ 不到任何人。 +每一項後面標上**真正寫它的人**,以及該則 commit 的連結。 + +歸屬不是取 commit 的 author:GitHub squash 一個 PR 時會把作者設成按下合併的人。 +`41a3c1e8 Fix eew (#534)` 的作者是合併者,而它的每一行都是別人寫的。所以摘要帶 +`(#N)` 時,作者取自**那個 PR 自己的 commits**,再併入 `Co-authored-by:` trailer; +都沒有才退回 commit 的 author。是 GitHub 帳號,不是 git 顯示名稱 —— 顯示名稱 @ +不到任何人。 | | 涵蓋範圍 | 為什麼 | |---|---|---| @@ -296,10 +367,7 @@ ci: cache the Swift package resolution 署名是 GitHub 帳號,CI 透過 API 解析。**不是 git 的顯示名稱**——顯示名稱 @ 不到 任何人,而且用名字去搜會搜出不只一個帳號,猜錯比不標更糟。 -實際長相:[pre-release-example.md](pre-release-example.md) 與 -[release-example.md](release-example.md),兩份都是 `tool/release_notes.sh` 真的 -產出來的(範例的署名用 `DPIP_NOTE_AUTHOR` 指定,因為臨時 repo 的 commit 不存在 -於 GitHub,API 查不到)。 +實際長相就是上面各節的範例,`tool/release/notes.sh` 直接照這個格式輸出。 > **squash 會壓縮條目數。** 正則是逐行抓的,所以 squash 不會像舊格式那樣把內容 > 弄壞——但四則 commit 的條目會全部掛在同一個作者和同一個快照下。要保留就用 @@ -307,6 +375,28 @@ ci: cache the Swift package resolution --- +## 合併前必須 rebase + +CI 會擋兩件事(`ci.yml` 的「Branch is rebased on the base」): + +| 擋什麼 | 為什麼 | +|---|---| +| 分支裡有 **merge commit** | `check_commits.sh` 用 `--no-merges` 走訪——merge 訊息是產生的不是寫的——所以**任何從 merge 進來的東西都不會被檢查**。用 merge 就等於繞過整個 gate | +| 分支**落後** base | 它是對著一個已經不存在的 main 測過的;通過的那些 gate 描述的是一棵沒有人會拿到的樹 | + +```sh +git fetch origin +git rebase origin/main +git push --force-with-lease +``` + +**PR 裡任何一則不合格,整個 CI 就失敗。** gate 走的是分支自己的頂端 +(`github.event.pull_request.head.sha`)而不是 checkout 出來的合併節點—— +pull request 會建一個分支併入 base 的合成 merge,而這個 gate 判的每一則都必須 +是有人真的寫過的。 + +--- + ## 不合格怎麼辦 CI 會印出哪一則、哪裡不對。因為訊息無法事後修改: @@ -322,5 +412,5 @@ git push --force-with-lease 推之前先在本機驗: ```sh -tool/check_commits.sh origin/main..HEAD +tool/check/commits.sh origin/main..HEAD ``` diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e8d7affd2..11c5949ca 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", - "version" : "5.3.1" + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" } }, { diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 8956bdb40..051805a6d 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -12,6 +12,7 @@ import 'package:dpip/features/location/presentation/pages/region_manage_page.dar import 'package:dpip/features/location/presentation/pages/region_select_page.dart'; import 'package:dpip/features/changelog/presentation/pages/changelog_page.dart'; import 'package:dpip/features/changelog/presentation/pages/version_notes_page.dart'; +import 'package:dpip/features/release_highlights/presentation/pages/release_highlights_page.dart'; import 'package:dpip/features/log/presentation/pages/log_page.dart'; import 'package:dpip/features/map/presentation/pages/map_page.dart'; import 'package:dpip/features/data/presentation/pages/moon_page.dart'; @@ -32,6 +33,7 @@ import 'package:dpip/features/settings/presentation/pages/default_map_layer_page import 'package:dpip/features/settings/presentation/pages/language_page.dart'; import 'package:dpip/features/settings/presentation/pages/permissions_page.dart'; import 'package:dpip/features/sponsor/presentation/pages/sponsor_page.dart'; +import 'package:dpip/features/status/presentation/pages/server_status_page.dart'; import 'package:dpip/features/weather/presentation/pages/weather_ranking_page.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; @@ -190,6 +192,11 @@ final GoRouter appRouter = GoRouter( name: AppRoutes.versionNotes, builder: (_, _) => const VersionNotesPage(), ), + GoRoute( + path: AppRoutes.releaseHighlightsPath, + name: AppRoutes.releaseHighlights, + builder: (_, _) => const ReleaseHighlightsPage(), + ), GoRoute( path: AppRoutes.developerPath, name: AppRoutes.developer, @@ -249,6 +256,11 @@ final GoRouter appRouter = GoRouter( name: AppRoutes.sponsor, builder: (_, _) => const SponsorPage(), ), + GoRoute( + path: AppRoutes.serverStatusPath, + name: AppRoutes.serverStatus, + builder: (_, _) => const ServerStatusPage(), + ), ], ); diff --git a/lib/app/shell/main_shell.dart b/lib/app/shell/main_shell.dart index 21399c498..a7f6a6fae 100644 --- a/lib/app/shell/main_shell.dart +++ b/lib/app/shell/main_shell.dart @@ -4,6 +4,7 @@ import 'package:dpip/features/home/presentation/home_sheet_extent.dart'; import 'package:dpip/features/home/presentation/home_reset_signal.dart'; import 'package:dpip/features/changelog/presentation/widgets/update_prompt.dart'; import 'package:dpip/core/meshtastic/mesh_unread.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/permissions/permission_health.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; @@ -100,13 +101,18 @@ class _MainShellState extends State with RouteAware { final needsPermissionAttention = context.select( (health) => health.needsAttention, ); - // Unread mesh messages ride the same dot: both mean "the More tab holds - // something you have not dealt with", and two dots on one icon say - // nothing more than one. + // Endpoint health joins the same dot: a service host the client has + // stopped reaching is as much "something the More tab holds for you" as a + // missing permission. Two dots on one icon say nothing more than one. + final endpointAttention = context.select( + (health) => health.needsAttention, + ); + // Unread mesh messages ride the same dot. final hasMeshUnread = context.select( (unread) => unread.hasUnread, ); - final moreAttention = needsPermissionAttention || hasMeshUnread; + final moreAttention = + needsPermissionAttention || endpointAttention || hasMeshUnread; // Reset Home's sheet as we *leave* Home — while it is hidden — so it is back // at rest (chrome shown) whenever Home is next shown, by a nav tap or a diff --git a/lib/app/theme/app_gold.dart b/lib/app/theme/app_gold.dart index c9afb4387..065860075 100644 --- a/lib/app/theme/app_gold.dart +++ b/lib/app/theme/app_gold.dart @@ -33,22 +33,19 @@ import 'package:flutter/material.dart'; @immutable class AppGold { const AppGold({ - required this.fillStart, - required this.fillEnd, + required this.fill, required this.ink, required this.badge, required this.onBadge, required this.edge, - required this.glow, }); - /// Card gradient, top-left → bottom-right. Two stops of the same hue at - /// different lightness: a metal reads as a *sheen*, and a sheen is a - /// gradient across one hue, never a blend of two. - final Color fillStart; - final Color fillEnd; + /// The card's flat fill — one stop now, not a gradient: the support card + /// sits on the same tonal plane as its neighbours, and the ranking is + /// carried by the gold colour alone. + final Color fill; - /// Title and body ink on [fillStart]/[fillEnd]. + /// Title and body ink on [fill]. final Color ink; /// The filled circular badge, and the mark inside it — the strongest @@ -59,34 +56,26 @@ class AppGold { /// Hairline along the card's edge, catching the light at the top. final Color edge; - /// The cast under the card. Warm, not grey: a neutral drop shadow makes gold - /// look printed on, a gold one makes it look lit. - final Color glow; - /// Champagne on white: the fill has to be pale enough for dark ink, so the /// *ink* carries the metal — a deep bronze reads as gold leaf where a bright /// yellow would read as a highlighter. static AppGold get light => AppGold( - fillStart: const Color(0xFFFDF2D0).vision, - fillEnd: const Color(0xFFF3D89A).vision, + fill: const Color(0xFFFDF2D0).vision, ink: const Color(0xFF4A3208).vision, badge: const Color(0xFF87610F).vision, onBadge: const Color(0xFFFFF8E6).vision, edge: const Color(0x33A9822B).vision, - glow: const Color(0x2E8A6A1F).vision, ); /// Deep amber on near-black: the fill carries the metal here, because a pale /// champagne on a dark page reads as plain cream. The ink lifts to a light /// gold so it stays legible on it. static AppGold get dark => AppGold( - fillStart: const Color(0xFF4A3811).vision, - fillEnd: const Color(0xFF2E230C).vision, + fill: const Color(0xFF4A3811).vision, ink: const Color(0xFFF7DFA5).vision, badge: const Color(0xFFE8C46A).vision, onBadge: const Color(0xFF3A2A06).vision, edge: const Color(0x40E8C46A).vision, - glow: const Color(0x33C9A34A).vision, ); /// The palette for the ambient theme. diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index a956ce8c7..d0ae56796 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart' show kReleaseMode; +import 'package:flutter/foundation.dart' show kDebugMode, kReleaseMode; import 'package:dpip/app/app.dart'; import 'package:dpip/core/di/core_providers.dart'; @@ -14,6 +14,7 @@ import 'package:dpip/core/logging/log_store.dart'; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/platform/background_location.dart'; import 'package:dpip/core/network/dio_client.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; import 'package:dpip/core/storage/app_storage_scan.dart'; @@ -53,6 +54,7 @@ import 'package:dpip/core/settings/color_vision_controller.dart'; import 'package:dpip/core/settings/display_settings.dart'; import 'package:dpip/core/settings/theme_controller.dart'; import 'package:dpip/features/changelog/changelog_providers.dart'; +import 'package:dpip/features/release_highlights/release_highlights_providers.dart'; import 'package:dpip/features/disaster_map/disaster_map_providers.dart'; import 'package:dpip/features/earthquake/earthquake_providers.dart'; import 'package:dpip/features/events/events_providers.dart'; @@ -60,6 +62,7 @@ import 'package:dpip/features/home/home_providers.dart'; import 'package:dpip/features/meshtastic/meshtastic_providers.dart'; import 'package:dpip/features/notification/notification_providers.dart'; import 'package:dpip/features/sponsor/sponsor_providers.dart'; +import 'package:dpip/features/status/status_providers.dart'; import 'package:dpip/features/typhoon/typhoon_providers.dart'; import 'package:dpip/features/weather/weather_providers.dart'; import 'package:dpip/firebase_options.dart'; @@ -103,11 +106,59 @@ Stream _weatherIconLicense() async* { ); } +/// Set by `tool/run.sh` and `tool/run.ps1`, which is how the app is started. +/// +/// Any value counts, deliberately. `bool.fromEnvironment` reads only the exact +/// string `true` and answers `false` to everything else — including `1`, which +/// is what the script passed at first, so the guard fired on the very launch +/// that had obeyed it. +const bool _launchedByTool = String.fromEnvironment('DPIP_RUN_SH') != ''; + +/// Refuses to start when it was not, and says what to run instead. +/// +/// Debug only — a release build is produced by `flutter build` in CI, which +/// never sets this and must never be blocked by it. +/// +/// A refusal rather than a warning, because both alternatives are wrong +/// *invisibly*. A bare `flutter run` resolves whatever SDK the shell's PATH +/// cached, and `mise activate` does not refresh that when mise.toml changes — +/// so the app builds against a different SDK than CI with nothing to show for +/// it. `mise exec -- flutter run` fixes the SDK and still leaves the log +/// unreadable, because colour has to be added by the pipe (see +/// tool/internal/colorize_logs.sh). A warning written into a log nobody can +/// read yet is not much of a warning. +/// +/// The instructions name every shell: this runs on the *device*, so it cannot +/// see which machine launched it. +void _refuseUnlessLaunchedByTool() { + if (!kDebugMode || _launchedByTool) return; + const message = + 'DPIP must be started through its run script.\n' + '\n' + ' macOS / Linux tool/run.sh -d \n' + ' Windows tool\\run.ps1 -d \n' + ' (or, for a coloured log, Git Bash / WSL:\n' + ' bash tool/run.sh -d )\n' + '\n' + '`flutter run` and `mise exec -- flutter run` both start it, and both\n' + "are wrong in ways nothing tells you about: the first builds against\n" + "whatever SDK your shell's PATH cached rather than the one mise.toml\n" + 'pins, and neither colours the log — that is added by the pipe the\n' + 'script provides, not by the app.\n' + '\n' + 'See AGENTS.md -> Running.'; + // Through `Log` like everything else — it reaches the console as one plain + // line per entry, which is exactly the output being asked for here. + Log.error(message); + exit(1); +} + Future bootstrap() async { WidgetsFlutterBinding.ensureInitialized(); Log.installErrorHandlers(); Log.info('DPIP starting up'); + _refuseUnlessLaunchedByTool(); // The bundled weather glyphs are Material Symbols (Apache-2.0). Registering // the licence puts it in the app's own 開放原始碼授權 page (More → licences), @@ -150,7 +201,8 @@ Future bootstrap() async { final mapLayerOrder = MapLayerOrderController(settings); final cache = await cacheFuture; final dio = createDio(etagCache: cache?.etag, usage: cache?.usage); - final apiClient = ApiClient(dio, regions); + final endpointHealth = EndpointHealthMonitor(); + final apiClient = ApiClient(dio, regions, endpointHealth); // MapLibre asks Dart for every ExpTech tile before it asks the network, so // this must be bound before the first map is built. final mapTileCache = cache == null @@ -307,6 +359,7 @@ Future bootstrap() async { database: AppDatabase(durable: durable, cache: cache?.db), tleStore: TleStore(durable), meshGateway: DpipMeshGatewayImpl(meshtastic, () => meshLink.dpipChannel), + endpointHealth: endpointHealth, etagCache: cache?.etag, networkUsage: cache?.usage, mapTileCache: mapTileCache, @@ -339,8 +392,10 @@ Future bootstrap() async { ...changelogProviders(deps), ...notificationProviders(deps), ...meshtasticProviders(deps), + ...releaseHighlightsProviders(deps), ...sponsorProviders(), ...homeProviders(), + ...statusProviders(deps), ], ), ); diff --git a/lib/core/build_info.g.dart b/lib/core/build_info.g.dart index 3a9137c9f..76f185ab2 100644 --- a/lib/core/build_info.g.dart +++ b/lib/core/build_info.g.dart @@ -1,20 +1,22 @@ // GENERATED — do not edit by hand. Written by tool/gen_build_info.sh (run by the // git hooks in .githooks/; set up once with tool/setup.sh). Holds what git knows // about this build, so a debug build can name itself without CI's --dart-define. -// -// What is *committed* here is a stub, on purpose. The file is `skip-worktree` -// locally so a regenerated copy never dirties the tree — which also means a -// real value written here would be frozen at whoever last cleared that flag, -// and a clone that has not run tool/setup.sh would confidently report someone -// else's build. Empty values fall back to the platform's own version instead. library; /// Short git commit hash of HEAD at generation time ('unknown' outside a repo). -const String kGitCommit = 'unknown'; +const String kGitCommit = '3b26f51d'; /// The label tool/version.sh derives for HEAD — '26w33b', '26.1'. Empty when /// git could not answer, in which case the platform's own version is used. -const String kBuildLabel = ''; +const String kBuildLabel = '26w34b'; /// The ordinal that goes with it; 0 when git could not answer. -const int kBuildCode = 0; +const int kBuildCode = 426000342; + +/// The train number version.sh derives — '26.1'. Apple is told this and +/// never the label; the More page version card shows it as the big number. +const String kBuildTrain = '26.1'; + +/// The day the build was cut, 'yy-MM-dd' in Taipei time — the badge date on +/// the More page version card. Empty when git could not answer. +const String kBuildDate = '26-08-17'; diff --git a/lib/core/di/core_providers.dart b/lib/core/di/core_providers.dart index 254e74016..2957707d4 100644 --- a/lib/core/di/core_providers.dart +++ b/lib/core/di/core_providers.dart @@ -15,7 +15,10 @@ import 'package:dpip/core/meshtastic/mesh_alerts.dart'; import 'package:dpip/core/meshtastic/mesh_link.dart'; import 'package:dpip/core/meshtastic/mesh_node_store.dart'; import 'package:dpip/core/meshtastic/mesh_unread.dart'; +import 'package:dpip/core/diagnostics/dump_uploader.dart'; +import 'package:dpip/core/diagnostics/haste_api.dart'; import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; import 'package:dpip/core/network/region_selection.dart'; @@ -76,6 +79,13 @@ List coreProviders(SharedDeps deps) => [ Provider.value(value: deps.meshStore), Provider.value(value: deps.meshGateway), Provider.value(value: deps.apiClient), + // Where a diagnostics dump goes. The contract rather than the paste service + // behind it, so a page depends on "somewhere to send this" and not on Haste. + Provider.value(value: HasteApi(deps.apiClient)), + // Fed by ApiClient on every request outcome; read by the 伺服器狀態 page. + ChangeNotifierProvider.value( + value: deps.endpointHealth, + ), // Nullable — absent when the cache DB couldn't open; read by the Debug page. Provider.value(value: deps.etagCache), Provider.value(value: deps.networkUsage), diff --git a/lib/core/di/shared_deps.dart b/lib/core/di/shared_deps.dart index d896ffa19..b0671febb 100644 --- a/lib/core/di/shared_deps.dart +++ b/lib/core/di/shared_deps.dart @@ -13,6 +13,7 @@ import 'package:dpip/core/meshtastic/mesh_link.dart'; import 'package:dpip/core/meshtastic/mesh_node_store.dart'; import 'package:dpip/core/meshtastic/mesh_unread.dart'; import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; import 'package:dpip/core/network/region_selection.dart'; @@ -74,6 +75,7 @@ class SharedDeps { required this.meshUnread, this.meshStore, required this.meshGateway, + required this.endpointHealth, this.etagCache, this.networkUsage, this.mapTileCache, @@ -182,6 +184,10 @@ class SharedDeps { /// DPIP disaster payloads in and out of the mesh — the seam feeds use. final DpipMeshGateway meshGateway; + /// Client-side health of the multi-active endpoints — fed by [apiClient], + /// rendered by the More → 伺服器狀態 screen. + final EndpointHealthMonitor endpointHealth; + /// On-disk ETag HTTP cache (also provided) — null if the cache DB couldn't be /// opened. Exposed for the Debug page's cache stats. final EtagCacheStore? etagCache; diff --git a/lib/core/diagnostics/debug_dump.dart b/lib/core/diagnostics/debug_dump.dart new file mode 100644 index 000000000..06f547980 --- /dev/null +++ b/lib/core/diagnostics/debug_dump.dart @@ -0,0 +1,42 @@ +/// The text a diagnostics dump uploads. +library; + +/// The most a paste may carry. +/// +/// Not a limit of the service — a limit on what anybody will read, and on what +/// a chat window will show without collapsing. The diagnostics are the part +/// that cannot be trimmed (every row answers a question somebody asks), so the +/// log takes whatever is left. +const int dumpLimit = 4000; + +const String _diagnosticsHeading = '=== 除錯資訊 ==='; +const String _logHeading = '=== 日誌紀錄 ==='; + +/// Builds the dump: diagnostics whole, then as much log as still fits. +/// +/// [logLines] is newest first — the order the store and Talker's history both +/// return — and lines are taken from the front, because the end of a log is +/// the part that explains what just happened. They are written oldest first, +/// so the result reads forwards. +/// +/// The diagnostics are never cut. If they alone exceed [limit] the log is +/// dropped entirely rather than a row of the diagnostics being lost: a partial +/// diagnostic reads as a complete one and is answered as if it were. +String buildDump({ + required String diagnostics, + required List logLines, + int limit = dumpLimit, +}) { + final head = '$_diagnosticsHeading\n${diagnostics.trim()}\n\n$_logHeading\n'; + final taken = []; + var used = head.length; + + for (final line in logLines) { + // `+ 1` for the newline this line brings with it. + if (used + line.length + 1 > limit) break; + used += line.length + 1; + taken.add(line); + } + + return '$head${taken.reversed.join('\n')}'.trimRight(); +} diff --git a/lib/core/diagnostics/diagnostics_report.dart b/lib/core/diagnostics/diagnostics_report.dart new file mode 100644 index 000000000..ddae73ec7 --- /dev/null +++ b/lib/core/diagnostics/diagnostics_report.dart @@ -0,0 +1,464 @@ +/// The diagnostics half of a debug dump: what this build, this device and this +/// install currently are. +/// +/// **Deliberately English-only.** The labels here are pasted into bug reports +/// and read by maintainers, so a fixed vocabulary is worth more than a +/// localized one — and the values beside them (`arm64`, `release`, an APNs +/// token) never translate anyway. +/// +/// It sits in core rather than on the Developer page because two screens ask +/// for it now: the page that renders it row by row, and the More menu's one-tap +/// dump. A second copy would drift, and the copy that drifts is the one being +/// pasted into the bug report. +library; + +import 'dart:io'; + +import 'package:dpip/core/build_info.g.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/network/etag_cache_store.dart'; +import 'package:dpip/core/network/network_usage_store.dart'; +import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:dpip/core/platform/background_execution.dart'; +import 'package:dpip/core/platform/background_location.dart'; +import 'package:dpip/core/platform/device_info.dart'; +import 'package:dpip/core/platform/unused_app_restrictions.dart'; +import 'package:dpip/core/storage/app_database.dart'; +import 'package:dpip/core/storage/app_storage_scan.dart'; +import 'package:dpip/core/version/app_build.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +/// One labelled diagnostic value. A null value renders as a dash — the field +/// was asked for and the platform had no answer, which is itself information. +typedef DiagnosticsField = ({String label, String? value}); + +/// A titled group of fields: one card on the Developer page, one `[Heading]` +/// block in a pasted dump. +typedef DiagnosticsSection = ({String title, List fields}); + +/// Everything one [DiagnosticsCollector.collect] pass gathered. +/// +/// The last four are raw readings the sections already summarise in words; the +/// Developer page draws its charts from them rather than scanning twice. +typedef DiagnosticsReport = ({ + List sections, + List? usageHistory, + List? usageWeek, + StorageScan storage, + List tables, +}); + +/// Labels that never leave the device. +/// +/// Dropped rather than starred out: these are the values in the dump that +/// identify a person or authorise a push to them, and a dump is pasted into +/// places its author does not control. One list, because a second copy is a +/// list that drifts — and the copy that drifts is the one that leaks. +const Set diagnosticsRedactedLabels = { + 'Identifier', + 'FCM token', + 'APNs token', +}; + +/// The sections as the text that gets pasted. +/// +/// Labels in [redacted] are dropped entirely rather than starred out: a device +/// identifier and a push token are the two values in here that identify a +/// person, and a dump is pasted into places its author does not control. +String diagnosticsText( + List sections, { + Set redacted = const {}, +}) { + final buffer = StringBuffer('DPIP diagnostics'); + for (final section in sections) { + final fields = [ + for (final field in section.fields) + if (!redacted.contains(field.label)) field, + ]; + if (fields.isEmpty) continue; + buffer.writeln('\n[${section.title}]'); + for (final field in fields) { + buffer.writeln('${field.label}: ${field.value ?? '—'}'); + } + } + return buffer.toString().trim(); +} + +/// Formats a cache hit rate as `NN% (hits/total)`, or a dash when the window +/// saw no cacheable request at all — 0% would read as "the cache is failing". +String _formatRate(double rate, int hits, int total) => + total == 0 ? '—' : '${(rate * 100).toStringAsFixed(0)}% ($hits/$total)'; + +/// A platform bool as text. Null (the platform did not answer, e.g. the channel +/// is absent) is a dash rather than "no": not knowing is not the same as off. +String _yesNo(Object? value) => switch (value) { + true => 'yes', + false => 'no', + _ => '—', +}; + +/// The last report attempt as ` ago · ok (200)`, or why there is none. +/// +/// The age matters more than the timestamp: "4 minutes ago" and "6 days ago" +/// are the difference between working and dead, and the failed case is worth +/// showing rather than hiding — a device that fires and gets a 500 needs a +/// different fix from one that never fires. +String _lastReport(Map d) { + final at = d['lastReportAt']; + if (at is! int) return 'never'; + final when = DateTime.fromMillisecondsSinceEpoch(at); + final age = DateTime.now().difference(when); + final ok = d['lastReportOk'] == true; + final code = d['lastReportCode']; + return '${_age(age)} ago · ${_outcome(ok, code)}'; +} + +/// A negative code is a reason the request was never made, not an HTTP status. +/// It has to read as one: `failed (-2)` sends whoever pastes it looking for a +/// network fault that never happened. +String _outcome(bool ok, Object? code) => switch (code) { + -2 => 'no push token', + -3 => 'no app version', + -1 => 'could not reach the server', + final int c when c > 0 => ok ? 'ok ($c)' : 'failed ($c)', + _ => ok ? 'ok' : 'failed', +}; + +/// How many times the OS woke the background path, by which spine woke it. +/// +/// This is the row that separates the two failures a single `Last report: +/// never` collapses into: the OS never called us, or it called and every call +/// bailed out. They need opposite fixes. +String _wakes(Map d) { + final parts = [ + for (final (label, key) in const [ + ('geofence', 'wakeGeofence'), + ('alarm', 'wakeAlarm'), + ('boot', 'wakeBoot'), + ]) + if (d[key] case final int n when n > 0) '$label $n', + ]; + return parts.isEmpty ? 'never woken' : parts.join(' · '); +} + +String _age(Duration d) { + if (d.inMinutes < 1) return 'moments'; + if (d.inHours < 1) return '${d.inMinutes} min'; + if (d.inDays < 1) return '${d.inHours} h'; + return '${d.inDays} d'; +} + +/// Where the geofence / region sits, to 4 dp (~11 m — finer than either radius +/// and coarse enough not to be a precise home address in a pasted bug report). +String _centre(Map d) { + final lat = d['centreLat']; + final lng = d['centreLng']; + if (lat is! double || lng is! double) return '—'; + return '${lat.toStringAsFixed(4)}, ${lng.toStringAsFixed(4)}'; +} + +String get _osName => Platform.isIOS + ? 'iOS' + : Platform.isAndroid + ? 'Android' + : Platform.operatingSystem; + +String get _buildMode { + if (kReleaseMode) return 'release'; + if (kProfileMode) return 'profile'; + return 'debug'; +} + +/// Android's hibernation state in the dump's own vocabulary. `unavailable` is +/// reported as "n/a" rather than as a problem: a device too old for the API, +/// or without the Play services that back-port it, has nothing to change. +String _keptActive(UnusedAppRestrictions status) => switch (status) { + UnusedAppRestrictions.exempt => 'yes', + UnusedAppRestrictions.restricted => 'NO — will be hibernated', + UnusedAppRestrictions.unavailable => 'n/a', +}; + +Future _fcmToken() async { + try { + return await FirebaseMessaging.instance.getToken(); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'dev: FCM token'); + return null; + } +} + +Future _apnsToken() async { + try { + return await FirebaseMessaging.instance.getAPNSToken(); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'dev: APNs token'); + return null; + } +} + +/// Reads every subsystem that can explain a support question. +/// +/// Takes its services rather than reaching for a locator, so a test can hand it +/// fakes and so the read happens where the caller decides — the page does it +/// once after its first frame, the More menu does it on the tap. +class DiagnosticsCollector { + const DiagnosticsCollector({ + required this.notifications, + required this.database, + required this.backgroundLocation, + this.etagCache, + this.networkUsage, + }); + + final NotificationService notifications; + final AppDatabase database; + final BackgroundLocationService backgroundLocation; + + /// Null when the cache database could not be opened — the rows it feeds then + /// read as dashes rather than zeroes. + final EtagCacheStore? etagCache; + final NetworkUsageStore? networkUsage; + + /// One pass over every source. Sequential on purpose: these are cheap reads + /// that each touch a different subsystem, and running them together only + /// makes a slow one harder to find. + Future collect() async { + final info = await PackageInfo.fromPlatform(); + final cacheStats = await etagCache?.stats(); + final usage = await networkUsage?.stats(); + final usageHistory = await networkUsage?.history(); + final usageWeek = await networkUsage?.history( + hours: 24 * 7, + bucketHours: 6, + ); + final storage = await const StorageScanner().scan(); + final tables = await database.tableStats(); + final device = await DeviceInfoService.load(); + final bgLocation = await backgroundLocation.diagnostics(); + // The two states that silently end background reporting while every + // permission above still reads "granted" — and the two that were missing + // from the dump people paste when asking why they got no alert. + final execution = await BackgroundExecutionService().status(); + final unusedApp = await UnusedAppRestrictionsService().status(); + // Track the build by the git commit it was built from (kGitCommit is kept + // current by the .githooks generator — see tool/dev/setup.sh), falling + // back to the platform build number outside a repo. + final buildRef = kGitCommit == 'unknown' ? info.buildNumber : kGitCommit; + // Show the platform's own push token: FCM on Android, APNs on iOS. + final fcmToken = Platform.isAndroid + ? (notifications.token ?? await _fcmToken()) + : null; + final apnsToken = Platform.isIOS ? await _apnsToken() : null; + + final sections = [ + ( + title: 'App', + fields: [ + // The build's own name — `26w33a`, `26.1` — which is the only + // version a user is ever asked for and the only one that is unique + // per build. It appears nowhere else: Apple is told the train + // (`26.1.0`) because it rejects anything with a letter in it, so + // every snapshot looks identical in TestFlight and in Settings → + // General → About. This row is where a tester finds out which one + // they actually have. + (label: 'Version', value: AppBuild.label), + // What the platform believes, kept beside it: it is what App Store + // Connect and Play show, so a support conversation needs both. + ( + label: 'Store version', + value: '${info.version} (${info.buildNumber})', + ), + (label: 'Build', value: buildRef), + (label: 'Build mode', value: _buildMode), + ], + ), + ( + title: 'Platform', + fields: [ + (label: 'OS', value: _osName), + (label: 'OS version', value: device.osVersion), + if (device.sdkInt != null) + (label: 'Android API level', value: '${device.sdkInt}'), + (label: 'Locale', value: Platform.localeName), + ], + ), + ( + title: 'Device', + fields: [ + (label: 'Manufacturer', value: device.manufacturer), + (label: 'Model', value: device.model), + (label: 'Identifier', value: device.identifier), + ], + ), + ( + title: 'Push', + fields: [ + if (Platform.isAndroid) (label: 'FCM token', value: fcmToken), + if (Platform.isIOS) (label: 'APNs token', value: apnsToken), + ], + ), + // Whether the app is still being told where the device is while it is + // closed — which is what decides whether a disaster alert reaches the + // right township. Every value here comes from the platform rather than + // from what Dart believes it asked for: the failure this exists to catch + // is precisely the one where the app thinks it armed something and the OS + // is delivering nothing. `Armed` is the answer that matters; the rest + // says why it is what it is. + ( + title: 'Background location', + fields: [ + (label: 'Requested', value: _yesNo(bgLocation['enabled'])), + ( + label: 'Authorization', + value: bgLocation['authorization'] as String?, + ), + (label: 'Armed', value: _yesNo(bgLocation['armed'])), + // Why nothing is armed, when that is the answer. Without it the rows + // above read "Requested: yes / Armed: no" and stop, which is the + // shape this bug arrived in: a state with no stated cause. + if (bgLocation['blocked'] != null) + (label: 'Blocked by', value: bgLocation['blocked'] as String?), + (label: 'Spine', value: bgLocation['spine'] as String?), + (label: 'Push token held', value: _yesNo(bgLocation['hasToken'])), + // Wakes and reports are separate counts on purpose. "Woke 40 times, + // reported never" and "never woke" are different faults with + // different fixes, and a single last-report row cannot tell them + // apart — it says `never` for both. + (label: 'Wakes', value: _wakes(bgLocation)), + if (bgLocation['lastGeofenceError'] != null) + ( + label: 'Geofence error', + value: bgLocation['lastGeofenceError'] as String?, + ), + (label: 'Last report', value: _lastReport(bgLocation)), + (label: 'Centred on', value: _centre(bgLocation)), + (label: 'Detail', value: bgLocation['detail'] as String?), + ( + label: 'Background execution', + value: !execution.known + ? 'unknown' + : execution.restricted + ? (execution.lockedByPolicy + ? 'blocked by policy' + : 'RESTRICTED') + : 'allowed', + ), + if (execution.standbyBucket != null) + (label: 'Standby bucket', value: execution.standbyBucket), + (label: 'Kept active', value: _keptActive(unusedApp)), + if (execution.vendorManaged) + ( + label: 'Vendor power manager', + value: + '${execution.manufacturer} — not detectable, check by hand', + ), + ], + ), + ( + title: 'ETag cache', + fields: [ + ( + label: 'Entries', + value: cacheStats == null ? '—' : '${cacheStats.rows}', + ), + ( + label: 'Size on disk', + value: cacheStats == null ? '—' : formatBytes(cacheStats.bytes), + ), + ], + ), + // Which table is actually costing the space. "The database is 40 MB" is + // not something anyone can act on; "mesh_node_metrics is 38 MB across + // 900,000 rows" names both the table and the retention window that is + // wrong. + ( + title: 'SQLite tables', + fields: [ + (label: 'Tables', value: '${tables.length} across two files'), + (label: 'Rows', value: '${tables.fold(0, (sum, t) => sum + t.rows)}'), + ( + label: 'Measured', + value: tables.isEmpty + ? '—' + : tables.first.onDisk + ? 'on-disk pages (dbstat)' + // Says so explicitly: the payload sum excludes indexes and + // page overhead, so it reads lower than the file itself and + // the difference is not a leak. + : 'payload only (no dbstat)', + ), + ], + ), + // What iOS Settings ("文件與資料") and Android Settings report, split + // into the app's own categories. The SQLite body budget (350 MB) is not + // the whole story — the DB file carries page overhead and the OS-level + // caches are separate. + ( + title: 'Storage', + fields: [ + (label: 'Total on disk', value: formatBytes(storage.totalBytes)), + for (final slice in storageBreakdown(storage)) + ( + label: slice.label, + value: + '${formatBytes(slice.bytes)} ' + '(${(slice.bytes / storage.totalBytes * 100).toStringAsFixed(1)}%)', + ), + // Why the total is what it is: the biggest individual files. A + // runaway in tmp (MapLibre's transient tile work, aborted native + // writes) shows up here by name long before the pie chart explains + // anything. + if (storage.files.isNotEmpty) ...[ + (label: 'Largest files', value: null), + for (final file in storage.files.take(8)) + (label: file.shortPath, value: formatBytes(file.bytes)), + ], + ], + ), + // Every figure here is the same pair of trailing windows, so they can be + // read against each other. + ( + title: 'Network usage', + fields: [ + ( + label: 'Downloaded · last 24h', + value: usage == null ? '—' : formatBytes(usage.last24h), + ), + ( + label: 'Downloaded · last 7d', + value: usage == null ? '—' : formatBytes(usage.last7d), + ), + ( + label: 'Traffic saved · last 24h', + value: usage == null ? '—' : formatBytes(usage.saved24h), + ), + ( + label: 'Traffic saved · last 7d', + value: usage == null ? '—' : formatBytes(usage.saved7d), + ), + ( + label: 'Hit rate · last 24h', + value: usage == null + ? '—' + : _formatRate(usage.hitRate24h, usage.hits24h, usage.total24h), + ), + ( + label: 'Hit rate · last 7d', + value: usage == null + ? '—' + : _formatRate(usage.hitRate7d, usage.hits7d, usage.total7d), + ), + ], + ), + ]; + return ( + sections: sections, + usageHistory: usageHistory, + usageWeek: usageWeek, + storage: storage, + tables: tables, + ); + } +} diff --git a/lib/core/diagnostics/dump_uploader.dart b/lib/core/diagnostics/dump_uploader.dart new file mode 100644 index 000000000..7b3677d4d --- /dev/null +++ b/lib/core/diagnostics/dump_uploader.dart @@ -0,0 +1,12 @@ +/// Where a diagnostics dump goes. +library; + +/// Uploads a dump and answers with the URL to read it at, or null when the +/// service replied with nothing usable. +/// +/// An interface so the developer page can ask for the upload without reaching +/// into `data/` for the service that performs it — the page cares that a dump +/// becomes a link, not which paste service is behind it. +abstract interface class DumpUploader { + Future upload(String content); +} diff --git a/lib/core/diagnostics/haste_api.dart b/lib/core/diagnostics/haste_api.dart new file mode 100644 index 000000000..278547372 --- /dev/null +++ b/lib/core/diagnostics/haste_api.dart @@ -0,0 +1,43 @@ +/// Uploads a diagnostics dump to ExpTech's paste service. +library; + +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/diagnostics/dump_uploader.dart'; + +/// Posts a dump and returns the URL to read it at. +/// +/// A paste rather than an attachment because of where these end up: a bug +/// report in Discord or an issue, where 4000 characters of log pasted inline +/// buries everything around it and an attached file is not read at all. +class HasteApi implements DumpUploader { + const HasteApi(this._client); + + final ApiClient _client; + + static const String _endpoint = 'https://haste.exptech.dev/api/pastes'; + + /// `https://haste.exptech.dev/`, or null when the reply carried no key. + /// + /// Absolute URL and no tier: this is not one of the app's regional APIs, so + /// it has no failover and no ETag revalidation to take part in. + @override + Future upload(String content) async { + final body = await _client.postAbsolute( + _endpoint, + data: {'content': content, 'language': 'log'}, + headers: const {'Content-Type': 'application/json'}, + ); + if (body is! Map) return null; + // Built from `key` rather than taken from `url`, because the service + // answers with `http://` — and a plain-HTTP link is the wrong thing to + // hand somebody along with a diagnostics dump. + final key = body['key']; + if (key is String && key.isNotEmpty) { + return 'https://haste.exptech.dev/$key'; + } + final url = body['url']; + return url is String && url.isNotEmpty + ? url.replaceFirst(RegExp('^http://'), 'https://') + : null; + } +} diff --git a/lib/core/geo/town_boundaries.dart b/lib/core/geo/town_boundaries.dart index 2884dd26d..46ba0f432 100644 --- a/lib/core/geo/town_boundaries.dart +++ b/lib/core/geo/town_boundaries.dart @@ -17,7 +17,7 @@ import 'package:flutter/services.dart' show rootBundle; /// box overlaps the query cell, so a lookup tests ~1–3 polygons instead of 367. /// /// The bundled asset (`town_boundaries.bin.gz`) is a gzipped **delta + zig-zag -/// varint** encoding of the polygons (see `tool/build_town_boundaries.dart`): +/// varint** encoding of the polygons (see `tool/gen/town_boundaries.dart`): /// coordinates are ≤4-decimal, so each is stored as the `×1e4` integer delta /// from the previous vertex, LEB128-packed — ~72% smaller than the float-text /// JSON, and lossless, so the golden lookups are unchanged. [fromBinary] decodes @@ -73,7 +73,7 @@ class TownBoundaries { } /// Builds from the bundled compact binary (`gzip` → delta-varint; see the - /// class doc and `tool/build_town_boundaries.dart`). The bounding box is + /// class doc and `tool/gen/town_boundaries.dart`). The bounding box is /// recomputed from the vertices rather than stored. factory TownBoundaries.fromBinary(Uint8List bytes) => TownBoundaries.fromDecoded(_decodeTable(bytes)); diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 8c6afbf50..7a5e624b5 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:dpip/core/logging/crash_sink.dart'; import 'package:dpip/core/logging/log_store.dart'; @@ -16,9 +17,25 @@ abstract final class Log { /// "how long after launch" (e.g. bootstrap-ready and first-frame markers). static final Stopwatch sinceStart = Stopwatch()..start(); - /// The underlying Talker instance — used by the log screen and error hooks. - static final Talker talker = Talker( - settings: TalkerSettings(useConsoleLogs: kDebugMode), + static final TalkerSettings _settings = TalkerSettings( + useConsoleLogs: kDebugMode, + // The tag on every line, in the log screen and in the console alike. + // Upper case because it is a label, not prose, and it reads as a column + // when a hundred lines are scanned for the one that is not `INFO`. + // `WARN` rather than `WARNING` so the five that matter are within a + // character of each other and the messages after them line up. + // + // Display only: the `level` column stores the enum's own name, so a + // stored line still parses back to its [LogLevel]. + titles: { + TalkerKey.verbose: 'VERBOSE', + TalkerKey.debug: 'DEBUG', + TalkerKey.info: 'INFO', + TalkerKey.warning: 'WARN', + TalkerKey.error: 'ERROR', + TalkerKey.critical: 'CRITICAL', + TalkerKey.exception: 'EXCEPTION', + }, ); /// Optional crash-reporting destination. When set (in `bootstrap`), handled @@ -32,6 +49,61 @@ abstract final class Log { static StreamSubscription? _bridge; + /// Held so the screen's history can be replaced from the table, and so + /// clearing it clears the table too. Talker builds one itself otherwise, + /// and keeps it private. + static final _PersistedHistory _history = _PersistedHistory(_settings); + + /// Whether to colour the level tag in console output. + /// + /// flutter run --dart-define=DPIP_LOG_COLOR=true + /// + /// Off by default, opt-in, **and never on iOS**. Two different reasons, and + /// only one of them is about terminals: + /// + /// * Whether an escape sequence renders is the window's business, and the + /// app cannot see the window — the bytes are written on the device and + /// read by whatever is attached to `flutter run`. VS Code's Debug + /// Console prints them literally; its integrated terminal renders them. + /// Same build, different window, so it has to be a choice. + /// * On iOS it never arrives intact regardless. The platform's log path + /// escapes the escape character itself, so even a terminal that does + /// support ANSI receives a backslash followed by the sequence and prints + /// it — flutter/flutter#20663. Turning the flag on there does nothing + /// but add noise, so it does not turn on. + /// + /// Not a font, either: a font supplies glyphs, and an escape sequence is an + /// instruction the terminal either acts on or prints. + static final bool enableConsoleColor = + const bool.fromEnvironment('DPIP_LOG_COLOR') && !Platform.isIOS; + + /// Console output, one plain line per entry. + /// + /// The default draws every line inside a box and paints it with ANSI escapes. + /// Neither survives the trip: `flutter run` prefixes each line with + /// `flutter: `, so a three-line box becomes three prefixed lines around one + /// message, and the escapes arrive as the literal text `^[[38;5;4m` because + /// nothing on that pipe interprets them. What was meant as colour reads as + /// noise, and the message is the only part anyone wanted. + /// + /// Colour is not lost so much as never delivered — turn `enableColors` back + /// on if the output is ever read somewhere that renders it. + static final TalkerLogger _logger = TalkerLogger( + settings: TalkerLoggerSettings(enableColors: enableConsoleColor), + formatter: const TagFormatter(), + ); + + /// The underlying Talker instance — used by the log screen and error hooks. + static final Talker talker = Talker( + settings: _settings, + history: _history, + logger: _logger, + ); + + /// How many lines the screen can show — Talker's own history ceiling, so + /// reading more out of the database only evicts what was just read. + static int get historyLimit => _settings.maxHistoryItems; + /// Starts persisting every line to [store]. /// /// Bridged off Talker's stream rather than added to each of the methods @@ -41,17 +113,47 @@ abstract final class Log { static void persistTo(LogStore logStore) { store = logStore; _bridge?.cancel(); - _bridge = talker.stream.listen((data) { - logStore.add( - StoredLog( - time: data.time, - level: data.logLevel?.name ?? 'info', - message: data.displayMessage, - error: (data.exception ?? data.error)?.toString(), - stackTrace: data.stackTrace?.toString(), - ), - ); - }); + _bridge = talker.stream.listen((data) => logStore.add(_stored(data))); + // Everything logged before the database opened is in memory and nowhere + // else — the startup lines, and whatever went wrong while opening it. + // Those are precisely the lines that explain a crash during launch, and + // they were never written. Subscribing first and copying after means a + // line arriving in between is stored twice rather than lost. + for (final data in List.of(talker.history)) { + logStore.add(_stored(data)); + } + } + + static StoredLog _stored(TalkerData data) => StoredLog( + time: data.time, + level: data.logLevel?.name ?? 'info', + message: data.displayMessage, + error: (data.exception ?? data.error)?.toString(), + stackTrace: data.stackTrace?.toString(), + ); + + /// Replaces the screen's history with what is on disk. + /// + /// The database is the authority: every line goes through [persistTo], and + /// what was logged before it opened is copied in there, so memory holds + /// nothing the table does not. Keeping both and merging them was the source + /// of every ordering and eviction problem this screen had — a replayed line + /// evicting the running session, older lines sitting after newer ones, + /// duplicates on each visit. + /// + /// [lines] is newest first, the order the store returns. + static void reload(Iterable lines) { + // What `_handleLogData` does on the way past, and what skipping the logger + // skips: the screen groups its filter chips by `key` and colours a card by + // it, so a line that arrives without a title and pen derived from that key + // is uncounted, uncoloured, and labelled `log`. + for (final data in lines) { + final key = data.key; + if (key == null) continue; + data.title = talker.settings.getTitleByKey(key); + data.pen = talker.settings.getPenByKey(key, fallbackPen: data.pen); + } + _history.replaceAll(lines.toList().reversed); } /// Writes anything still buffered — call when the app goes to the @@ -91,6 +193,79 @@ abstract final class Log { talker.history.removeWhere((entry) => entry.time.isBefore(cutoff)); } + /// How many times one error may be reported before it is taken for a loop, + /// and the window it has to repeat in to count. + static const _repeatLimit = 8; + static const _repeatWindow = Duration(seconds: 5); + + /// Signature -> (times seen, when the window opened). Capped, because the + /// keys come from error text and an app that produces endless *distinct* + /// errors must not also leak memory. + static final Map _repeats = {}; + + /// Debug-only asserts raised from inside a package, said once and then + /// dropped for the rest of the session. + /// + /// Keyed on the summary rather than on the package, because there is nothing + /// here that names the package: the framework raises these from inside the + /// offending widget's own `build`, and the widget that *wrapped* it built in + /// a different Element and is not on the stack. So this cannot distinguish + /// a package's occurrence from one of ours — which is why the entry is still + /// logged once, with its summary, instead of being dropped silently. + /// + /// Delete an entry when its package fixes it; the log line is the reminder. + static const List<(String, String)> _knownBenignAsserts = [ + ( + 'ListTile background color or ink splashes may be invisible', + 'talker_flutter draws its Actions sheet as a coloured box with bare ' + 'ListTiles inside it (still true on 5.1.20), so opening that sheet ' + 'reports once per row. Debug-only, and the sheet renders correctly. ' + 'If this appears anywhere but the log screen, it is ours.', + ), + ]; + + static final Set _saidAsserts = {}; + + /// Whether an error should be reported, or has become its own cause. + /// + /// Reporting an error is not free of consequence here: it goes to Talker, + /// whose stream the log screen rebuilds on and the persister writes to disk + /// from. So a fault raised *while rendering that screen* — a layout overflow + /// is the everyday one — re-enters through the rebuild it just caused, and + /// each turn adds a Crashlytics report and a database write. The screen + /// stops responding, which is what a user reports as "tapping the log + /// freezes the app". + /// + /// Avoiding one known overflow does not fix that; only breaking the loop + /// does. A distinct error is always reported — this drops the *repeat*. + static bool _admitError(String signature) { + final now = sinceStart.elapsed; + final prior = _repeats[signature]; + if (prior == null || now - prior.$2 > _repeatWindow) { + if (_repeats.length > 64) _repeats.clear(); + _repeats[signature] = (1, now); + return true; + } + final seen = prior.$1 + 1; + _repeats[signature] = (seen, prior.$2); + if (seen == _repeatLimit + 1) { + // Once, and through `info` rather than an error, so saying "this is + // looping" cannot itself be the next turn of the loop. + talker.info( + 'error repeated $_repeatLimit times, suppressing: $signature', + ); + } + return seen <= _repeatLimit; + } + + /// Forgets what has been seen — for tests, and for anywhere that genuinely + /// wants a repeated error reported again. + @visibleForTesting + static void resetErrorRepeats() { + _repeats.clear(); + _saidAsserts.clear(); + } + /// Routes uncaught Flutter and async errors into the log and the [crashSink] /// (as fatal reports). /// @@ -106,11 +281,30 @@ abstract final class Log { (details.exception as PlatformException).code == 'recreating_view') { return; } + // A known defect in somebody else's widget. One line, then silence — + // this one arrives once per row of a sheet, and the sheet it floods is + // the log screen itself. + final summary = details.summary.toString(); + for (final (marker, explanation) in _knownBenignAsserts) { + if (!summary.contains(marker)) continue; + if (_saidAsserts.add(marker)) talker.warning('$summary $explanation'); + return; + } + // The library and summary rather than the stack: a layout fault reports + // a different stack every frame while being the same fault. + // + // Checked before the console dump below, not after. Left after it, the + // suppression covered the log, the crash report and the database — but + // not the terminal, which kept printing the same fault every frame while + // the stored record stayed clean. That is the shape the flood took: the + // data was fine and the console was unusable. + if (!_admitError('${details.library}/$summary')) return; // Overriding onError replaces the framework's own console presentation, // whose dump carries the diagnostics our summary drops — for a layout // fault (e.g. a RenderFlex overflow) that includes *which* widget and its // creation `file:line`. Keep that rich dump in debug so such errors stay // locatable; release stays quiet (presentError is a near no-op there). + // The first few still print, which is what makes the fault findable. if (kDebugMode) FlutterError.presentError(details); talker.handle( details.exception, @@ -125,9 +319,123 @@ abstract final class Log { ); }; PlatformDispatcher.instance.onError = (error, stack) { + if (!_admitError(error.runtimeType.toString())) return true; talker.handle(error, stack); crashSink?.report(error, stack, fatal: true); return true; }; } } + +/// Talker's history, with the stored log tied to it and replay kept in its +/// place. +/// +/// Two things the default could not do. +/// +/// **Clearing.** The screen's clear button calls `talker.cleanHistory()`, which +/// empties the in-memory list and nothing else — so the log came straight back +/// on the next visit, replayed out of the table it was never removed from. +/// +/// **Replay.** The default appends and evicts from the front, so replaying a +/// day of stored lines pushed the *live* session out — `DPIP starting up` +/// among them — and left the older lines sitting where the newer ones should +/// be. That is backwards twice over: the stored log is on disk and can be read +/// again, the running session cannot, and lines older than everything in +/// memory belong in front of it. Replay therefore inserts at the front and +/// only into free space. +class _PersistedHistory implements TalkerHistory { + _PersistedHistory(this._settings); + + final TalkerSettings _settings; + final _entries = []; + + @override + List get history => _entries; + + @override + void write(TalkerData data) { + if (!_settings.useHistory || !_settings.enabled) return; + if (_entries.length >= _settings.maxHistoryItems) _entries.removeAt(0); + _entries.add(data); + } + + /// Oldest first. Used when the screen loads the stored log, which is the + /// whole truth rather than an addition to it. + void replaceAll(Iterable lines) { + _entries + ..clear() + ..addAll(lines); + while (_entries.length > _settings.maxHistoryItems) { + _entries.removeAt(0); + } + } + + @override + void clean() { + _entries.clear(); + // Synchronous, and the delete is not, so the write is started and not + // waited on. Nothing reads the table in between, and a failure there is + // already swallowed — reporting a logging failure through the logger is + // how a write loop starts. + unawaited(Log.store?.clear() ?? Future.value()); + } +} + +/// One line: the level tag, then the message. +/// +/// With colour on, only the tag is painted. A fully coloured line is harder to +/// read than a plain one, and the tag is the part being scanned for; it is +/// also short, so a leak into a window that cannot render it costs one token +/// rather than the whole line. +/// The widest tag DPIP writes, so every colon lands in the same column and the +/// messages read as one. +const int _tagWidth = 10; // `[CRITICAL]` + +/// One log line, in the shape both the console and the dump use. +/// +/// [5:32:38][INFO] : Firebase initialized +/// [5:32:39][DEBUG] : [rts] SSE served by {"location":"lb-tpe1"} +/// +/// One shape for both, so a line pasted out of a terminal and a line pasted +/// out of an uploaded dump are the same line — nobody has to learn two. +String logLine({ + required String tag, + required DateTime time, + required String message, +}) { + final clock = + '${time.hour}:${time.minute.toString().padLeft(2, '0')}' + ':${time.second.toString().padLeft(2, '0')}'; + return '[$clock]${'[$tag]'.padRight(_tagWidth)}: $message'; +} + +/// Rewrites Talker's own line into [logLine]'s shape, and colours the tag. +/// +/// Talker hands a formatter the finished string rather than the entry, and its +/// shape is fixed: `[TITLE] | TIME | message`. Rebuilding from that is a parse, +/// which is why the pattern is pinned by a test — if Talker ever changes the +/// layout, the test says so instead of the terminal. +class TagFormatter implements LoggerFormatter { + const TagFormatter(); + + /// `[INFO] | 5:32:38 655ms | message` + static final RegExp _talkerLine = RegExp( + r'^\[([^\]]+)\] \| (\d{1,2}):(\d{2}):(\d{2})[^|]*\| ', + ); + + @override + String fmt(LogDetails details, TalkerLoggerSettings settings) { + final raw = details.message?.toString() ?? ''; + final match = _talkerLine.firstMatch(raw); + if (match == null) return raw; + + final tag = match.group(1)!; + final clock = '${match.group(2)}:${match.group(3)}:${match.group(4)}'; + final message = raw.substring(match.end); + final head = '[$clock]${'[$tag]'.padRight(_tagWidth)}'; + if (!settings.enableColors) return '$head: $message'; + // Only the tag is painted — see [Log.enableConsoleColor]. + return '[$clock]${details.pen.write('[$tag]'.padRight(_tagWidth))}' + ': $message'; + } +} diff --git a/lib/core/logging/log_store.dart b/lib/core/logging/log_store.dart index 71f10e1f4..17d16588c 100644 --- a/lib/core/logging/log_store.dart +++ b/lib/core/logging/log_store.dart @@ -29,15 +29,20 @@ const String logTable = 'logs'; /// How long a line is kept. const Duration logRetention = Duration(hours: 24); -/// A count backstop under the age rule, because the age rule trusts a clock. +/// The hard ceiling on stored lines, enforced on every write. /// -/// A device whose clock jumps forward makes every stored line look older than -/// the window, and the age delete empties the table — throwing away the -/// diagnostic record of the launch being investigated, which is the one thing -/// this table exists for. Keeping the newest rows regardless means no clock -/// event can leave it empty; it also caps a burst that outruns the hourly -/// sweep. Comfortably above a normal day, so it only bites in those two cases. -const int logMaxRows = 20000; +/// Two jobs. It is a backstop under the age rule, because the age rule trusts +/// a clock: a device whose clock jumps forward makes every stored line look +/// older than the window, and the age delete empties the table — throwing away +/// the record of the launch being investigated, which is the one thing this +/// table exists for. Keeping the newest rows regardless means no clock event +/// can leave it empty. +/// +/// And it bounds a burst. A fault that logs every frame writes faster than any +/// sweep runs, so the cap is applied in the same transaction as the insert +/// rather than only on the hourly pass — the table cannot exceed this between +/// sweeps, only within one batch. +const int logMaxRows = 5000; /// One persisted line. class StoredLog { @@ -175,6 +180,15 @@ class LogStore { _now().toUtc().subtract(logRetention).millisecondsSinceEpoch, ], ); + // The count ceiling in the same transaction as the insert, so a burst + // cannot outrun it. `id` rather than `time` because it is the primary + // key and monotonic: a clock that steps backwards would otherwise make + // the newest rows look like the oldest and delete them. + await txn.rawDelete( + 'DELETE FROM $logTable WHERE id NOT IN (' + 'SELECT id FROM $logTable ORDER BY id DESC LIMIT ?)', + [logMaxRows], + ); }); } on Object { // Deliberately silent: reporting a logging failure through the logger diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index a146c5f20..b92c4ee79 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/network/region_selection.dart'; /// A live byte stream plus the handle that aborts it — the result of @@ -34,11 +35,16 @@ class BytePayload { /// Paths are region-agnostic and begin at the version segment, e.g. /// `/v2/trem/rts` (the `api`/`static` role is part of the host subdomain). class ApiClient { - const ApiClient(this._dio, this._regions); + const ApiClient(this._dio, this._regions, [this._health]); final Dio _dio; final RegionSelection _regions; + /// Optional observer of per-host outcomes. When set, every retryable failure + /// and every success is reported — the More → 伺服器狀態 screen uses it to + /// show which region the client is actually seeing. + final EndpointHealthMonitor? _health; + /// GET [path] on [tier] with failover; returns the decoded body. Future get( ApiTier tier, @@ -105,6 +111,26 @@ class ApiClient { return response.data; } + /// Absolute-URL POST with JSON decode + ETag (no region failover). + /// + /// For third-party hosts (e.g. the Grafana status dashboard) whose query body + /// is a compile-time constant — the ETag interceptor treats the URL as the + /// content key and caches unconditionally, exactly like an immutable tile. + Future postAbsolute( + String url, { + Object? data, + Map? headers, + CancelToken? cancelToken, + }) async { + final response = await _dio.request( + url, + data: data, + cancelToken: cancelToken, + options: Options(method: 'POST', headers: headers), + ); + return response.data; + } + static BytePayload _bytePayload(Response response) { final data = response.data; final Uint8List bytes = switch (data) { @@ -138,7 +164,8 @@ class ApiClient { /// timeouts, 5xx): a 4xx is a client error that would repeat on every region, /// and a cancellation is deliberate, so both throw immediately without trying /// the next host. Every failover is logged so a silent region switch is - /// visible. Pass a [cancelToken] to abort a superseded request. + /// visible, and reported to [_health] so the status screen can show it too. + /// Pass a [cancelToken] to abort a superseded request. Future> request( ApiTier tier, String path, { @@ -151,14 +178,17 @@ class ApiClient { final hosts = hostsFor(tier); for (var i = 0; i < hosts.length; i++) { try { - return await _dio.request( + final response = await _dio.request( '${hosts[i]}$path', data: data, queryParameters: query, cancelToken: cancelToken, options: (options ?? Options()).copyWith(method: method), ); + _health?.success(tier, hosts[i], path); + return response; } on DioException catch (e) { + _health?.failure(tier, hosts[i], path); final isLastHost = i == hosts.length - 1; if (isLastHost || !_isRetryable(e)) rethrow; Log.warning( @@ -204,8 +234,12 @@ class ApiClient { headers: headers, ), ); + // A connected SSE is a host that answered — the stream itself may + // later end or error (the caller reconnects), but the host is up. + _health?.success(tier, hosts[i], path); return StreamedResponse(response.data!.stream, cancelToken.cancel); } on DioException catch (e) { + _health?.failure(tier, hosts[i], path); final isLastHost = i == hosts.length - 1; if (isLastHost || !_isRetryable(e)) rethrow; Log.warning( diff --git a/lib/core/network/endpoint_health.dart b/lib/core/network/endpoint_health.dart new file mode 100644 index 000000000..deef9b8f9 --- /dev/null +++ b/lib/core/network/endpoint_health.dart @@ -0,0 +1,247 @@ +/// Client-side health of the multi-active API endpoints, per service × tier × +/// host. +/// +/// The app reaches region-pinned hosts (`api.lb-tpe1.exptech.dev`, …) instead +/// of DNS-balanced bare hosts, so *it* is the only thing that can observe which +/// region is actually answering. [ApiClient] feeds every request outcome into +/// the monitor: a retryable failure (connection drop, timeout, 5xx) marks the +/// tried host down-ish, a success marks it up. +/// +/// Outcomes are bucketed by **service × tier × host**: the same region carries +/// different services (EEW/RTS on `lbApi`, radar lists on +/// `coreExclusiveApi` → `api.core-tnn1`), and one service's dead host may be +/// another's healthy one. The More → 伺服器狀態 screen renders this map as a +/// table — rows are services, columns are tier groups, cells are the regions +/// each service was observed on. +library; + +import 'package:dpip/core/network/api_region.dart'; +import 'package:flutter/foundation.dart'; + +/// The service a request carried — derived from the path so [ApiClient]'s +/// callers never have to name it. +enum EndpointService { + eew, + rts, + radar, + satellite, + qpesums, + wind, + dpm, + weather, + rain, + lightning, + typhoon, + report, + tremStation, + event, + location, + notify, + other; + + /// Maps a wire path to the service it belongs to. The first segment od the + /// path after `/api/` decides; the families share the `ApiPaths` constants. + static EndpointService ofPath(String path) { + if (path.startsWith('/api/v2/eq/eew')) return eew; + if (path.startsWith('/api/v2/trem/rts')) return rts; + if (path.contains('/tiles/radar')) return radar; + if (path.contains('/tiles/satellite')) return satellite; + if (path.contains('/tiles/qpesums')) return qpesums; + if (path.contains('/tiles/wind') || path.startsWith('/api/v1/wind')) { + return wind; + } + if (path.contains('/tiles/dpm')) return dpm; + if (path.startsWith('/api/v5/meteor/weather')) return weather; + if (path.startsWith('/api/v5/meteor/rain')) return rain; + if (path.startsWith('/api/v5/meteor/lightning')) return lightning; + if (path.startsWith('/api/v5/meteor/typhoon')) return typhoon; + if (path.startsWith('/api/v2/eq/report')) return report; + if (path.startsWith('/api/v1/trem/')) return tremStation; + if (path.startsWith('/api/v1/dpip')) return event; + if (path.startsWith('/api/v2/location')) return location; + if (path.startsWith('/api/v2/notify')) return notify; + return other; + } +} + +/// How [EndpointHealthMonitor] currently judges one service host. +enum EndpointState { + /// No request has touched this host since the app started. + unknown, + + /// The last request to this host succeeded and it has no consecutive + /// failure streak. + healthy, + + /// The last request failed once — a blip, not yet a determination. + degraded, + + /// Multiple consecutive retryable failures — the client considers the host + /// unreachable and will keep failing over around it. + down, +} + +/// One service host's observed behaviour since app start. +@immutable +class EndpointHealth { + const EndpointHealth({ + required this.service, + required this.tier, + required this.host, + required this.state, + required this.lastSuccess, + required this.lastFailure, + required this.consecutiveFailures, + }); + + /// The service the requests carried (EEW, RTS, radar lists…). + final EndpointService service; + + /// The service tier the requests hit (EEW/RTS on `lbApi`, radar on + /// `coreExclusiveApi` …). + final ApiTier tier; + + /// Host without scheme, e.g. `api.lb-tpe1.exptech.dev`. + final String host; + + final EndpointState state; + + /// Last time a request to this host completed successfully. Null if none. + final DateTime? lastSuccess; + + /// Last time a retryable failure was observed on this host. Null if none. + final DateTime? lastFailure; + + /// Consecutive retryable failures since the last success (or since start). + final int consecutiveFailures; + + /// Uppercase region code the host lives in — `api.lb-tpe1.exptech.dev` → + /// `TPE1`. Also covers the static hosts (`static.core-tnn1…`) and legacy + /// `api-1` (no region → the host's own last segment). + String get regionCode { + final core = RegExp(r'-(tpe1|khh1|tyo1|tnn1)\.').firstMatch(host); + if (core != null) return core.group(1)!.toUpperCase(); + return host.split('.').first.toUpperCase(); + } +} + +/// Tracks per-service-host request outcomes so the UI can show which region is +/// being preferred and which one the client has stopped trusting. +class EndpointHealthMonitor extends ChangeNotifier { + final Map _hosts = {}; + + /// An API request to [hostUrl] on [tier] for [path] completed with a 2xx/3xx. + /// The URL is keyed by hostname (scheme stripped). + void success(ApiTier tier, String hostUrl, String path) { + final key = _keyOf(EndpointService.ofPath(path), tier, hostUrl); + final s = _hosts.putIfAbsent(key, _HostState.new); + final changed = + s.lastSuccess == null || + s.consecutiveFailures > 0 || + s.state != EndpointState.healthy; + s.consecutiveFailures = 0; + s.lastSuccess = _now(); + s.state = EndpointState.healthy; + if (changed) notifyListeners(); + } + + /// A retryable failure (transport fault, timeout, 5xx) hit [hostUrl] on + /// [tier] for [path]. + /// + /// Non-retryable outcomes (4xx, cancellation, certificate errors) never reach + /// here — they are the client's problem, not the host's. + void failure(ApiTier tier, String hostUrl, String path) { + final key = _keyOf(EndpointService.ofPath(path), tier, hostUrl); + final s = _hosts.putIfAbsent(key, _HostState.new); + s.consecutiveFailures++; + s.lastFailure = _now(); + s.state = s.consecutiveFailures >= 2 + ? EndpointState.down + : EndpointState.degraded; + notifyListeners(); + } + + /// Health for [service] × [tier] × [host], or null if no request has touched + /// it yet. + EndpointHealth? of(EndpointService service, ApiTier tier, String host) { + final s = _hosts[_keyOf(service, tier, host)]; + if (s == null) return null; + return EndpointHealth( + service: service, + tier: tier, + host: host, + state: s.state, + lastSuccess: s.lastSuccess, + lastFailure: s.lastFailure, + consecutiveFailures: s.consecutiveFailures, + ); + } + + /// All known service hosts, first-seen order. + List get entries => [ + for (final e in _hosts.entries) _entryOf(e.key, e.value), + ]; + + /// Whether any service host is judged unhealthy (down or still-degraded) — + /// what the More tab's dot and the status card's dot watch. + bool get needsAttention { + for (final s in _hosts.values) { + if (s.state == EndpointState.down || s.state == EndpointState.degraded) { + return true; + } + } + return false; + } + + /// Aggregate across all observed service hosts: `down` if any is down, + /// `degraded` if any is degraded and none down, healthy if every observed + /// host is healthy, unknown when nothing has been observed yet. + EndpointState get summary { + var degraded = false; + for (final s in _hosts.values) { + if (s.state == EndpointState.down) return EndpointState.down; + if (s.state == EndpointState.degraded) degraded = true; + } + if (degraded) return EndpointState.degraded; + return _hosts.isEmpty ? EndpointState.unknown : EndpointState.healthy; + } + + static String _keyOf(EndpointService service, ApiTier tier, String hostUrl) => + '${service.name}\u0000${tier.name}\u0000${_hostOf(hostUrl)}'; + + EndpointHealth _entryOf(String key, _HostState s) { + final first = key.indexOf('\u0000'); + final serviceName = key.substring(0, first); + final rest = key.substring(first + 1); + final sep = rest.indexOf('\u0000'); + final tierName = rest.substring(0, sep); + final host = rest.substring(sep + 1); + return EndpointHealth( + service: EndpointService.values.byName(serviceName), + tier: ApiTier.values.byName(tierName), + host: host, + state: s.state, + lastSuccess: s.lastSuccess, + lastFailure: s.lastFailure, + consecutiveFailures: s.consecutiveFailures, + ); + } + + static DateTime _now() => DateTime.now(); + + static String _hostOf(String url) { + final scheme = url.indexOf('://'); + if (scheme == -1) return url; + var host = url.substring(scheme + 3); + final slash = host.indexOf('/'); + if (slash != -1) host = host.substring(0, slash); + return host; + } +} + +class _HostState { + EndpointState state = EndpointState.unknown; + DateTime? lastSuccess; + DateTime? lastFailure; + int consecutiveFailures = 0; +} diff --git a/lib/core/network/etag_interceptor.dart b/lib/core/network/etag_interceptor.dart index 316e5d492..b322c06ab 100644 --- a/lib/core/network/etag_interceptor.dart +++ b/lib/core/network/etag_interceptor.dart @@ -41,10 +41,22 @@ class EtagInterceptor extends Interceptor { /// Synthetic ETag for a cached immutable-tile `404` (empty body). static const String negativeTileEtag = 'W/"404"'; - static bool _cacheable(RequestOptions o) => - o.method.toUpperCase() == 'GET' && - o.responseType != ResponseType.stream && - !isUncacheablePath(o.uri.path); + /// Whether a request may enter the store. + /// + /// GET is the default cacheable verb; POST is only cached for status-exptech + /// dashboards, whose query body is a constant baked into the client and whose + /// URL therefore pins the result — content-addressed, like an immutable tile. + static bool _cacheable(RequestOptions o) { + if (o.method.toUpperCase() == 'GET') { + return o.responseType != ResponseType.stream && + !isUncacheablePath(o.uri.path); + } + if (o.method.toUpperCase() == 'POST') { + return o.uri.host == 'status.exptech.dev' && + o.responseType != ResponseType.stream; + } + return false; + } /// Paths that must never enter the ETag store (live / unique / personal). static bool isUncacheablePath(String path) { @@ -84,6 +96,8 @@ class EtagInterceptor extends Interceptor { '${ApiPaths.tiles}/wind/', '${ApiPaths.dpm}/', '/gh/exptechtw/map-assets/', // glyph PBFs (jsDelivr) + 'avatars.githubusercontent.com/', // contributor avatars (content-addressed) + 'scweb.cwa.gov.tw/', // CWA report image (filename embeds origin time) ]; /// Whether [uri] names a content-addressed asset — see @@ -186,6 +200,7 @@ class EtagInterceptor extends Interceptor { if (_cacheable(options)) { final url = options.uri.toString(); final binary = _isBytes(options); + final post = options.method.toUpperCase() == 'POST'; if (response.statusCode == 304) { if (binary) { final cached = await _store.readBytes(url); @@ -246,14 +261,19 @@ class EtagInterceptor extends Interceptor { ? _downBytes(response, encoded: jsonBody) : _downBytes(response); final immutable = - binary && response.data != null && isImmutableTile(options.uri); + post || + (binary && response.data != null && isImmutableTile(options.uri)); var etag = response.headers.value('etag'); if (immutable) { - // URL pins content — ignore server ETag, always store under URL hash. + // POST (a dashboard query whose body is a constant) and URL-pinned + // tiles both carry their content in the URL — ignore any server ETag + // and always store under the URL hash. etag = etagFromUrl(options.uri); response.headers.set('etag', etag); } - // Non-immutable: ETag only — no ETag ⇒ no store. + // Non-immutable: ETag only — no ETag ⇒ no store. Immutable responses + // always set the synthetic URL-hash ETag above, so this guard doubles + // as "immutable or server-etagged". if (etag != null && response.data != null) { if (binary) { final bytes = _asBytes(response.data); @@ -312,6 +332,31 @@ class EtagInterceptor extends Interceptor { unawaited(usage.record(down: 0, hit: false, saved: 0)); } } + + // Offline fallback for status-dashboard POSTs: the query body is a + // constant, so the URL pins the content and a previously stored 200 is a + // perfectly good answer when the network refuses another one. This is the + // only place a POST is served from cache — online, the request always goes + // out and the fresh 200 replaces the entry. + if (!_isBytes(options) && + options.method.toUpperCase() == 'POST' && + status == null && + options.uri.host == 'status.exptech.dev') { + final cached = await _store.readJson(options.uri.toString()); + if (cached != null) { + handler.resolve( + Response( + requestOptions: options, + statusCode: 200, + data: cached.data, + headers: Headers.fromMap({ + 'etag': [cached.etag], + }), + ), + ); + return; + } + } handler.next(err); } } diff --git a/lib/core/platform/background_location.dart b/lib/core/platform/background_location.dart index 0c42c62ff..0febbabc5 100644 --- a/lib/core/platform/background_location.dart +++ b/lib/core/platform/background_location.dart @@ -107,6 +107,8 @@ class BackgroundLocationService { 'background location (native)$when: ${line.substring(tab + 1)}', ); } + } on MissingPluginException { + // Unsupported platform / test harness — nothing to drain. } on Object catch (error, stackTrace) { Log.handle(error, stackTrace, 'background location breadcrumbs'); } diff --git a/lib/core/version/app_build.dart b/lib/core/version/app_build.dart index da285a35c..e68658b97 100644 --- a/lib/core/version/app_build.dart +++ b/lib/core/version/app_build.dart @@ -21,7 +21,8 @@ /// /// 1. **CI's `--dart-define`** — the authority for a published build. /// 2. **`lib/core/build_info.g.dart`** — written by the git hooks from the same -/// `tool/version.sh`, so a local `flutter run` names itself correctly too. A +/// `tool/release/version.sh`, so a local `flutter run` names itself +/// correctly too. A /// debug build otherwise fell back to the pubspec placeholder and reported /// `26.1.0 (1)`, a version that exists nowhere. /// 3. **The platform's own version** — a build made outside a repository, where @@ -40,17 +41,39 @@ abstract final class AppBuild { /// What CI stamped in, empty on a local build. static const String _definedLabel = String.fromEnvironment('DPIP_LABEL'); static const int _definedCode = int.fromEnvironment('DPIP_CODE'); + static const String _definedTrain = String.fromEnvironment('DPIP_TRAIN'); + static const String _definedDate = String.fromEnvironment('DPIP_DATE'); /// What the git hooks wrote, empty outside a repository. static String get _generatedLabel => kBuildLabel; static int get _generatedCode => kBuildCode; + /// The train number this build rides — the release a snapshot is heading + /// toward, e.g. `26.1`. Apple is told this and never the label. The More + /// page version card shows it as the big number, above the label. + static String get train => _train; + + /// The version the platform itself records for this build — what the OS + /// shows under Settings → app. For a local debug run that is the pubspec + /// placeholder (`26.1.0`); CI stamps `--build-name` on iOS and `DPIP_LABEL` + /// on Android, so a published build reports the train (`26.1`) instead. + /// The version card prints it as the release's fine-print line. + static String? get platformVersion => _platformVersion; + + /// The day this build was cut, `yy-MM-dd` in Taipei time (e.g. `26-08-17`). + /// Shown beside the type badge on the More page version card. Empty when + /// git could not answer, in which case the badge shows no date. + static String get buildDate => + _definedDate.isNotEmpty ? _definedDate : kBuildDate; + static String get _bestLabel => _definedLabel.isNotEmpty ? _definedLabel : _generatedLabel; static int get _bestCode => _definedCode > 0 ? _definedCode : _generatedCode; static String? _label; static int? _code; + static String? _platformVersion; + static String _train = _definedTrain.isNotEmpty ? _definedTrain : kBuildTrain; /// Reads the platform's own version, for the builds CI did not stamp. /// @@ -58,6 +81,15 @@ abstract final class AppBuild { /// [label] falls back to whatever was defined and [code] to 0. static Future ensureLoaded() async { if (_label != null) return; + String platformVersion = ''; + try { + final info = await PackageInfo.fromPlatform(); + platformVersion = info.version; + } on Object { + // A version readout is never worth failing a launch over. The platform + // version line simply stays empty for that build. + } + _platformVersion = platformVersion; if (_bestLabel.isNotEmpty && _bestCode > 0) { _label = _bestLabel; _code = _bestCode; @@ -99,8 +131,15 @@ abstract final class AppBuild { } /// Test seam — sets both halves directly. - static void debugSet({required String label, required int code}) { + static void debugSet({ + required String label, + required int code, + String? train, + String? platformVersion, + }) { _label = label; _code = code; + if (train != null) _train = train; + if (platformVersion != null) _platformVersion = platformVersion; } } diff --git a/lib/core/weather/weather_icons.dart b/lib/core/weather/weather_icons.dart index 97cfcd0ff..a73ff7b9d 100644 --- a/lib/core/weather/weather_icons.dart +++ b/lib/core/weather/weather_icons.dart @@ -1,6 +1,6 @@ /// Weather glyphs, from the bundled `DpipWeatherIcons` subset font. /// -/// **Generated by `tool/build_weather_icons.py` — do not edit by hand.** +/// **Generated by `tool/gen/weather_icons.py` — do not edit by hand.** /// Every codepoint here was read from Google's `.codepoints` manifest in the /// same run that built the font, because the alternative is guessing: this file /// previously declared `rainy` as `0xf07c2`, which is `Icons.severe_cold` in diff --git a/lib/features/changelog/data/changelog_api.dart b/lib/features/changelog/data/changelog_api.dart index 3e30ce3cb..e58ee500d 100644 --- a/lib/features/changelog/data/changelog_api.dart +++ b/lib/features/changelog/data/changelog_api.dart @@ -1,8 +1,11 @@ /// GitHub Releases API for the DPIP changelog. library; +import 'dart:typed_data'; + import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/features/changelog/domain/changelog_repository.dart'; +import 'package:dpip/features/changelog/domain/release_note.dart'; /// Fetches release notes from GitHub. Absolute URL so [EtagInterceptor] can /// still revalidate (`If-None-Match` / `304`). @@ -71,6 +74,14 @@ class ChangelogApi { return releases; } + /// The avatar bytes for [login]. `avatars.githubusercontent.com` answers any + /// login with its 64px picture; bytes round-trip through the ETag store so + /// revisits are local. + Future getAvatarBytes(String login) async { + final payload = await _client.getBytesAbsolute(avatarUrlFor(login)); + return payload.bytes; + } + static const Map _headers = { 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', diff --git a/lib/features/changelog/data/changelog_repository_impl.dart b/lib/features/changelog/data/changelog_repository_impl.dart index c45344564..d2e459e1f 100644 --- a/lib/features/changelog/data/changelog_repository_impl.dart +++ b/lib/features/changelog/data/changelog_repository_impl.dart @@ -1,6 +1,8 @@ /// [ChangelogRepository] backed by [ChangelogApi]. library; +import 'dart:typed_data'; + import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/network/api_exception.dart'; import 'package:dpip/features/changelog/data/changelog_api.dart'; @@ -20,6 +22,10 @@ class ChangelogRepositoryImpl implements ChangelogRepository { return parseReleases(raw); }); + @override + Future> avatarBytes(String login) => + guardResult(() => _api.getAvatarBytes(login)); + /// Skips unmappable entries so one bad release never blanks the list. static List parseReleases(List raw) { final notes = []; diff --git a/lib/features/changelog/domain/changelog_repository.dart b/lib/features/changelog/domain/changelog_repository.dart index 7ff8558d3..a98595525 100644 --- a/lib/features/changelog/domain/changelog_repository.dart +++ b/lib/features/changelog/domain/changelog_repository.dart @@ -1,6 +1,8 @@ /// Changelog repository contract. library; +import 'dart:typed_data'; + import 'package:dpip/core/error/result.dart'; import 'package:dpip/features/changelog/domain/release_note.dart'; @@ -18,4 +20,11 @@ abstract class ChangelogRepository { /// so the list only grows, and a page that returns fewer than /// [ChangelogApi.pageSize] entries is the last one. Future>> releases({int page}); + + /// The avatar bytes for [login], fetched through the app's Dio stack so the + /// ETag store caches it like any other asset (the URL is content-addressed: + /// `avatars.githubusercontent.com/` always means the same picture). + /// + /// The bytes are the 64px avatar, rendered by the UI with `Image.memory`. + Future> avatarBytes(String login); } diff --git a/lib/features/changelog/domain/release_note.dart b/lib/features/changelog/domain/release_note.dart index 6dc9e1135..a2ce0378d 100644 --- a/lib/features/changelog/domain/release_note.dart +++ b/lib/features/changelog/domain/release_note.dart @@ -6,6 +6,28 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'release_note.freezed.dart'; part 'release_note.g.dart'; +/// A GitHub user who contributed to a release — the avatar strip under each +/// changelog card. +@freezed +abstract class ReleaseContributor with _$ReleaseContributor { + const factory ReleaseContributor({ + /// Login, e.g. `whes1015`. + required String login, + + /// The user's GitHub profile. + @Default('') String htmlUrl, + }) = _ReleaseContributor; + + factory ReleaseContributor.fromJson(Map json) => + _$ReleaseContributorFromJson(json); +} + +/// GitHub serves any login's avatar at a straight URL — no API call involved, +/// and the URL is content-addressed (a login always means the same picture), so +/// the ETag store treats it like an immutable tile. +String avatarUrlFor(String login) => + 'https://avatars.githubusercontent.com/$login?size=64'; + /// One GitHub release, trimmed to what the changelog UI needs. @freezed abstract class ReleaseNote with _$ReleaseNote { @@ -33,3 +55,25 @@ abstract class ReleaseNote with _$ReleaseNote { factory ReleaseNote.fromJson(Map json) => _$ReleaseNoteFromJson(json); } + +/// The distinct `@login` handles mentioned in a release body. +/// +/// Every changelog line ends with `— @login` (some also carry a per-line +/// snapshot tag like `· 26w33a`, which the regex deliberately leaves alone), +/// so the contributor strip needs no extra API call — it is parsed from the +/// same body the note already fetched. +List contributorsFromBody(String body) { + final logins = {}; + for (final match in _atHandle.allMatches(body)) { + logins.add(match.group(1)!); + } + final out = []; + for (final login in logins) { + out.add( + ReleaseContributor(login: login, htmlUrl: 'https://github.com/$login'), + ); + } + return out; +} + +final RegExp _atHandle = RegExp(r'@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)'); diff --git a/lib/features/changelog/domain/release_note.freezed.dart b/lib/features/changelog/domain/release_note.freezed.dart index 171bb21e9..af514d7e2 100644 --- a/lib/features/changelog/domain/release_note.freezed.dart +++ b/lib/features/changelog/domain/release_note.freezed.dart @@ -13,6 +13,276 @@ part of 'release_note.dart'; // dart format off T _$identity(T value) => value; +/// @nodoc +mixin _$ReleaseContributor { + +/// Login, e.g. `whes1015`. + String get login;/// The user's GitHub profile. + String get htmlUrl; +/// Create a copy of ReleaseContributor +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ReleaseContributorCopyWith get copyWith => _$ReleaseContributorCopyWithImpl(this as ReleaseContributor, _$identity); + + /// Serializes this ReleaseContributor to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ReleaseContributor&&(identical(other.login, login) || other.login == login)&&(identical(other.htmlUrl, htmlUrl) || other.htmlUrl == htmlUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,login,htmlUrl); + +@override +String toString() { + return 'ReleaseContributor(login: $login, htmlUrl: $htmlUrl)'; +} + + +} + +/// @nodoc +abstract mixin class $ReleaseContributorCopyWith<$Res> { + factory $ReleaseContributorCopyWith(ReleaseContributor value, $Res Function(ReleaseContributor) _then) = _$ReleaseContributorCopyWithImpl; +@useResult +$Res call({ + String login, String htmlUrl +}); + + + + +} +/// @nodoc +class _$ReleaseContributorCopyWithImpl<$Res> + implements $ReleaseContributorCopyWith<$Res> { + _$ReleaseContributorCopyWithImpl(this._self, this._then); + + final ReleaseContributor _self; + final $Res Function(ReleaseContributor) _then; + +/// Create a copy of ReleaseContributor +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? login = null,Object? htmlUrl = null,}) { + return _then(ReleaseContributor( +login: null == login ? _self.login : login // ignore: cast_nullable_to_non_nullable +as String,htmlUrl: null == htmlUrl ? _self.htmlUrl : htmlUrl // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ReleaseContributor]. +extension ReleaseContributorPatterns on ReleaseContributor { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ReleaseContributor value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ReleaseContributor() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ReleaseContributor value) $default,){ +final _that = this; +switch (_that) { +case _ReleaseContributor(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ReleaseContributor value)? $default,){ +final _that = this; +switch (_that) { +case _ReleaseContributor() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String login, String htmlUrl)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ReleaseContributor() when $default != null: +return $default(_that.login,_that.htmlUrl);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String login, String htmlUrl) $default,) {final _that = this; +switch (_that) { +case _ReleaseContributor(): +return $default(_that.login,_that.htmlUrl);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String login, String htmlUrl)? $default,) {final _that = this; +switch (_that) { +case _ReleaseContributor() when $default != null: +return $default(_that.login,_that.htmlUrl);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ReleaseContributor implements ReleaseContributor { + const _ReleaseContributor({required this.login, this.htmlUrl = ''}); + factory _ReleaseContributor.fromJson(Map json) => _$ReleaseContributorFromJson(json); + +/// Login, e.g. `whes1015`. +@override final String login; +/// The user's GitHub profile. +@override@JsonKey() final String htmlUrl; + +/// Create a copy of ReleaseContributor +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ReleaseContributorCopyWith<_ReleaseContributor> get copyWith => __$ReleaseContributorCopyWithImpl<_ReleaseContributor>(this, _$identity); + +@override +Map toJson() { + return _$ReleaseContributorToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ReleaseContributor&&(identical(other.login, login) || other.login == login)&&(identical(other.htmlUrl, htmlUrl) || other.htmlUrl == htmlUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,login,htmlUrl); + +@override +String toString() { + return 'ReleaseContributor(login: $login, htmlUrl: $htmlUrl)'; +} + + +} + +/// @nodoc +abstract mixin class _$ReleaseContributorCopyWith<$Res> implements $ReleaseContributorCopyWith<$Res> { + factory _$ReleaseContributorCopyWith(_ReleaseContributor value, $Res Function(_ReleaseContributor) _then) = __$ReleaseContributorCopyWithImpl; +@override @useResult +$Res call({ + String login, String htmlUrl +}); + + + + +} +/// @nodoc +class __$ReleaseContributorCopyWithImpl<$Res> + implements _$ReleaseContributorCopyWith<$Res> { + __$ReleaseContributorCopyWithImpl(this._self, this._then); + + final _ReleaseContributor _self; + final $Res Function(_ReleaseContributor) _then; + +/// Create a copy of ReleaseContributor +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? login = null,Object? htmlUrl = null,}) { + return _then(_ReleaseContributor( +login: null == login ? _self.login : login // ignore: cast_nullable_to_non_nullable +as String,htmlUrl: null == htmlUrl ? _self.htmlUrl : htmlUrl // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + /// @nodoc mixin _$ReleaseNote { diff --git a/lib/features/changelog/domain/release_note.g.dart b/lib/features/changelog/domain/release_note.g.dart index c18aa13b2..68af439bc 100644 --- a/lib/features/changelog/domain/release_note.g.dart +++ b/lib/features/changelog/domain/release_note.g.dart @@ -6,6 +6,15 @@ part of 'release_note.dart'; // JsonSerializableGenerator // ************************************************************************** +_ReleaseContributor _$ReleaseContributorFromJson(Map json) => + _ReleaseContributor( + login: json['login'] as String, + htmlUrl: json['htmlUrl'] as String? ?? '', + ); + +Map _$ReleaseContributorToJson(_ReleaseContributor instance) => + {'login': instance.login, 'htmlUrl': instance.htmlUrl}; + _ReleaseNote _$ReleaseNoteFromJson(Map json) => _ReleaseNote( tagName: json['tag_name'] as String, name: json['name'] as String? ?? '', diff --git a/lib/features/changelog/domain/update_check.dart b/lib/features/changelog/domain/update_check.dart index 83245d21a..6328d79b1 100644 --- a/lib/features/changelog/domain/update_check.dart +++ b/lib/features/changelog/domain/update_check.dart @@ -52,8 +52,26 @@ enum UpdateChannel { UpdateChannel channelFor({ required List releases, required String currentVersion, + int currentBuild = 0, InstallSource installSource = InstallSource.unknown, }) { + // The ordinal first, because it is the only value that identifies one build. + // `AppVersion.tryParse` stops at the first letter, so every snapshot of a + // year collapses to the same number — `26w34a` and `26w40a` both parse to + // `26`. Matching on that finds *a* release of the right year rather than the + // one running, and it only ever gave the right channel because every `26w**` + // release happens to be a pre-release. + if (currentBuild > 0) { + for (final release in releases) { + if (buildCodeOf(release) == currentBuild) { + return release.prerelease + ? UpdateChannel.preRelease + : UpdateChannel.stable; + } + } + } + + // Then the version string, for builds from before the ordinal existed. final current = AppVersion.tryParse(currentVersion); if (current != null) { for (final release in releases) { @@ -64,6 +82,12 @@ UpdateChannel channelFor({ } } } + + // Neither matched — the running build is not in the page that was fetched, + // which a snapshot falls out of within days. TestFlight is proof of a + // pre-release; Play is not, because internal, open testing and production + // all install through `com.android.vending`, so a tester there would be put + // on the stable channel and offered a build older than the one they run. return installSource == InstallSource.testFlight ? UpdateChannel.preRelease : UpdateChannel.stable; @@ -211,6 +235,7 @@ ReleaseNote? findUpdate({ final channel = channelFor( releases: releases, currentVersion: currentVersion, + currentBuild: currentBuild, installSource: installSource, ); diff --git a/lib/features/changelog/presentation/pages/changelog_page.dart b/lib/features/changelog/presentation/pages/changelog_page.dart index 41afb0597..0892df7f2 100644 --- a/lib/features/changelog/presentation/pages/changelog_page.dart +++ b/lib/features/changelog/presentation/pages/changelog_page.dart @@ -10,6 +10,7 @@ import 'package:dpip/core/error/result.dart'; import 'package:dpip/features/changelog/domain/changelog_repository.dart'; import 'package:dpip/features/changelog/domain/release_note.dart'; import 'package:dpip/features/changelog/domain/update_check.dart'; +import 'package:dpip/features/changelog/presentation/widgets/release_contributors.dart'; import 'package:dpip/features/changelog/presentation/widgets/release_note_markdown.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; @@ -395,6 +396,36 @@ class _ReleaseTile extends StatelessWidget { ) : const SizedBox(width: double.infinity), ), + // The GitHub release footer — divider, then a single row holding the + // contributor badges and the button to the release's own page. + // Always at the card's foot, expanded or not, so the strip + // reads as part of the release the way GitHub's page does. + Divider( + height: 1, + thickness: 1, + color: colors.outlineVariant.withValues(alpha: 0.55), + ), + if (contributorsFromBody(note.body).isNotEmpty || + note.htmlUrl.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + child: Row( + children: [ + ContributorStrip( + body: note.body, + padding: EdgeInsets.zero, + ), + const Spacer(), + if (note.htmlUrl.isNotEmpty) + _GitHubButton(url: note.htmlUrl), + ], + ), + ), ], ), ), @@ -543,3 +574,51 @@ class _TypeChip extends StatelessWidget { ); } } + +/// Compact pill link to the release's own page on GitHub, styled to sit +/// alongside the contributor badges. +class _GitHubButton extends StatelessWidget { + const _GitHubButton({required this.url}); + + final String url; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Material( + color: colors.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(999), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _open(context), + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 6, 12, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.open_in_new, size: 13, color: colors.onSurfaceVariant), + const SizedBox(width: 4), + Text( + AppLocalizations.of(context).changelogOpenOnGitHub, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } + + Future _open(BuildContext context) async { + final uri = Uri.tryParse(url); + if (uri == null) return; + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (e, st) { + Log.handle(e, st, 'changelog release link failed: $url'); + } + } +} diff --git a/lib/features/changelog/presentation/pages/version_notes_page.dart b/lib/features/changelog/presentation/pages/version_notes_page.dart index 029ba2c7f..8cea043cb 100644 --- a/lib/features/changelog/presentation/pages/version_notes_page.dart +++ b/lib/features/changelog/presentation/pages/version_notes_page.dart @@ -16,6 +16,7 @@ import 'package:dpip/core/version/app_build.dart'; import 'package:dpip/features/changelog/domain/changelog_repository.dart'; import 'package:dpip/features/changelog/domain/release_note.dart'; import 'package:dpip/features/changelog/domain/update_check.dart'; +import 'package:dpip/features/changelog/presentation/widgets/release_contributors.dart'; import 'package:dpip/features/changelog/presentation/widgets/release_note_markdown.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; @@ -162,7 +163,8 @@ class _Header extends StatelessWidget { } /// The release note body, rendered like the changelog's expanded tile so a -/// user sees the same typography in both places. +/// user sees the same typography in both places, with the contributor strip +/// below. class _Body extends StatelessWidget { const _Body({required this.body, required this.accent}); @@ -181,17 +183,34 @@ class _Body extends StatelessWidget { ), clipBehavior: Clip.antiAlias, child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: MarkdownBody( - data: body, - selectable: true, - styleSheet: releaseNoteStyleSheet(theme, colors, accent), - softLineBreak: true, - // Without this the platform tags become Image.network — a fetch, for - // a decoration, on a page read when the network is what failed. - imageBuilder: platformTagIcon, - builders: releaseNoteBuilders(colors), - onTapLink: (text, href, title) => _openLink(href), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: MarkdownBody( + data: body, + selectable: true, + styleSheet: releaseNoteStyleSheet(theme, colors, accent), + softLineBreak: true, + // Without this the platform tags become Image.network — a + // fetch, for a decoration, on a page read when the network is + // what failed. + imageBuilder: platformTagIcon, + builders: releaseNoteBuilders(colors), + onTapLink: (text, href, title) => _openLink(href), + ), + ), + if (contributorsFromBody(body).isNotEmpty) ...[ + Divider( + height: 1, + thickness: 1, + color: colors.outlineVariant.withValues(alpha: 0.55), + ), + ContributorStrip(body: body), + ], + ], ), ), ); diff --git a/lib/features/changelog/presentation/widgets/release_contributors.dart b/lib/features/changelog/presentation/widgets/release_contributors.dart new file mode 100644 index 000000000..74ad64440 --- /dev/null +++ b/lib/features/changelog/presentation/widgets/release_contributors.dart @@ -0,0 +1,223 @@ +/// The contributor strip under a changelog entry — one badge per `@handle` +/// mentioned in the body: avatar + name on a pill background. +/// +/// Avatars come from [ChangelogRepository.avatarBytes], so the bytes round-trip +/// the app's ETag store (URL-addressed, like map tiles — revisiting a card is +/// a local read, not a network round trip). Each badge is tappable and opens +/// the contributor's GitHub profile. +library; + +import 'dart:typed_data'; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/changelog/domain/changelog_repository.dart'; +import 'package:dpip/features/changelog/domain/release_note.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// A stack of overlapping contributor avatars, each tappable. +/// +/// Avatars come from [ChangelogRepository.avatarBytes], so the bytes round-trip +/// the app's ETag store (URL-addressed, like map tiles — revisiting a card is +/// a local read, not a network round trip). +class ContributorStrip extends StatelessWidget { + const ContributorStrip({ + super.key, + required this.body, + this.padding = const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + AppSpacing.md, + ), + }); + + /// The release body to scan for `@login` handles. + final String body; + + /// Outside padding. When the strip shares a row with the release's GitHub + /// button, the row owns the spacing and this is `EdgeInsets.zero`. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final contributors = contributorsFromBody(body); + if (contributors.isEmpty) return const SizedBox.shrink(); + const maxAvatars = 5; + final shown = contributors.take(maxAvatars); + final hidden = contributors.length - shown.length; + return Padding( + padding: padding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: _stackWidth(shown.length), + height: _Avatar.size, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final (i, contributor) in shown.indexed) + Positioned( + left: i * (_Avatar.size - _overlap), + child: _ContributorAvatar(contributor: contributor), + ), + ], + ), + ), + if (hidden > 0) ...[ + const SizedBox(width: AppSpacing.xs), + _MoreChip(count: hidden), + ], + ], + ), + ); + } +} + +/// How much of each avatar the next one covers. +const _overlap = 14.0; + +double _stackWidth(int count) => + count == 0 ? 0 : (count - 1) * (_Avatar.size - _overlap) + _Avatar.size; + +/// The tail of a full stack — `+N` for everyone the pile could not hold, +/// tapping it opens the first hidden profile. +class _MoreChip extends StatelessWidget { + const _MoreChip({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + width: _Avatar.size - 4, + height: _Avatar.size - 4, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.surfaceContainerHighest, + border: Border.all(color: colors.surface, width: 2), + ), + child: Text( + '+$count', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontSize: 9, + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +/// One avatar circle: loads its bytes via the repository (ETag-cached), then +/// paints them; until then it shows the login's initial. Opens the profile on +/// tap. +class _ContributorAvatar extends StatelessWidget { + const _ContributorAvatar({required this.contributor}); + + final ReleaseContributor contributor; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Tooltip( + message: contributor.login, + child: Material( + color: colors.surfaceContainerHighest, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _open(context), + child: _Avatar(contributor: contributor), + ), + ), + ); + } + + Future _open(BuildContext context) async { + final uri = Uri.tryParse(contributor.htmlUrl); + if (uri == null) return; + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } on Object { + // A dead profile link is a cosmetic failure — never surface an error for + // what is, after all, a decorative strip. + } + } +} + +/// One avatar circle: loads its bytes via the repository (ETag-cached), then +/// paints them; until then it shows the login's initial. +class _Avatar extends StatefulWidget { + const _Avatar({required this.contributor}); + + /// Diameter of the circle, including the border. + static const size = 28.0; + + final ReleaseContributor contributor; + + @override + State<_Avatar> createState() => _AvatarState(); +} + +class _AvatarState extends State<_Avatar> { + Uint8List? _bytes; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final result = await context.read().avatarBytes( + widget.contributor.login, + ); + if (!mounted) return; + setState(() { + _bytes = switch (result) { + Ok(:final value) => value, + Err() => null, + }; + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + width: _Avatar.size, + height: _Avatar.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.surfaceContainerHighest, + border: Border.all( + color: colors.surface, // the card behind, so overlaps read as layers + width: 2, + ), + ), + child: CircleAvatar( + radius: _Avatar.size / 2 - 2, + backgroundColor: colors.surfaceContainerHighest, + foregroundImage: _bytes == null ? null : MemoryImage(_bytes!), + child: _bytes == null + ? Text( + widget.contributor.login.isEmpty + ? '?' + : widget.contributor.login[0].toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontSize: 10, + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ) + : null, + ), + ); + } +} diff --git a/lib/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart index 74dfbdc44..de642886a 100644 --- a/lib/features/earthquake/presentation/pages/report_detail_page.dart +++ b/lib/features/earthquake/presentation/pages/report_detail_page.dart @@ -4,6 +4,7 @@ library; import 'dart:async'; +import 'dart:typed_data'; import 'package:dpip/app/theme/app_motion.dart'; import 'package:dpip/app/theme/app_radius.dart'; @@ -12,6 +13,7 @@ import 'package:dpip/core/geo/town.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/models/lat_lng.dart' as geo; +import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; @@ -1452,6 +1454,11 @@ class _TownChip extends StatelessWidget { /// 地震報告圖 — CWA's rendered report image; falls back to a plain message when /// it hasn't been generated yet or fails to load. +/// +/// The bytes go through [ApiClient.getBytesAbsolute], so the shared ETag store +/// caches the picture like any map tile — `Image.network` would hit Flutter's +/// image pipeline, which has no SQLite store behind it and refetches on every +/// visit. class _ReportImageCard extends StatefulWidget { const _ReportImageCard({required this.report}); @@ -1462,7 +1469,10 @@ class _ReportImageCard extends StatefulWidget { } class _ReportImageCardState extends State<_ReportImageCard> { - bool _failed = false; + late final Future _bytes = context + .read() + .getBytesAbsolute(widget.report.reportImageUrl.toString()) + .then((payload) => payload.bytes); /// Placeholder height while loading / on failure — a guess, not the real /// aspect ratio (that varies per event), so it's only a skeleton size; the @@ -1474,48 +1484,58 @@ class _ReportImageCardState extends State<_ReportImageCard> { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).colorScheme; - if (_failed) { - return ClipRRect( - borderRadius: AppRadius.medium, - child: Container( - height: _placeholderHeight, - width: double.infinity, - color: colors.surfaceContainer, - alignment: Alignment.center, - child: Text( - l10n.reportDetailImageUnavailable, - style: TextStyle(color: colors.onSurfaceVariant), - ), - ), - ); - } - - return ClipRRect( - borderRadius: AppRadius.medium, - child: Image.network( - widget.report.reportImageUrl.toString(), - // No forced aspect ratio / BoxFit.cover — the report image's real - // proportions vary per event, so this sizes to the image's own - // natural aspect ratio at full width instead of cropping or padding. - width: double.infinity, - fit: BoxFit.contain, - loadingBuilder: (context, child, progress) { - if (progress == null) return child; - return Container( - height: _placeholderHeight, - width: double.infinity, - color: colors.surfaceContainer, - alignment: Alignment.center, - child: const InlineLoading(size: 36), + return FutureBuilder( + future: _bytes, + builder: (context, snapshot) { + if (snapshot.hasError) { + return ClipRRect( + borderRadius: AppRadius.medium, + child: Container( + height: _placeholderHeight, + width: double.infinity, + color: colors.surfaceContainer, + alignment: Alignment.center, + child: Text( + l10n.reportDetailImageUnavailable, + style: TextStyle(color: colors.onSurfaceVariant), + ), + ), ); - }, - errorBuilder: (context, error, stackTrace) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _failed = true); - }); - return const SizedBox.shrink(); - }, - ), + } + if (!snapshot.hasData) { + return ClipRRect( + borderRadius: AppRadius.medium, + child: Container( + height: _placeholderHeight, + width: double.infinity, + color: colors.surfaceContainer, + alignment: Alignment.center, + child: const InlineLoading(size: 36), + ), + ); + } + return ClipRRect( + borderRadius: AppRadius.medium, + child: Image.memory( + snapshot.data!, + // No forced aspect ratio / BoxFit.cover — the report image's real + // proportions vary per event, so this sizes to the image's own + // natural aspect ratio at full width instead of cropping or padding. + width: double.infinity, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) => Container( + height: _placeholderHeight, + width: double.infinity, + color: colors.surfaceContainer, + alignment: Alignment.center, + child: Text( + l10n.reportDetailImageUnavailable, + style: TextStyle(color: colors.onSurfaceVariant), + ), + ), + ), + ); + }, ); } } diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 86f24e639..168f9049c 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -1,5 +1,6 @@ import 'dart:ui' show ImageFilter; +import 'package:dpip/app/theme/app_gold.dart'; import 'package:dpip/app/theme/app_glass.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/home_area.dart'; @@ -16,6 +17,7 @@ import 'package:dpip/features/home/presentation/widgets/home_map_backdrop.dart'; import 'package:dpip/features/home/presentation/widgets/home_monitor_banner.dart'; import 'package:dpip/features/home/presentation/widgets/home_sheet.dart'; import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; @@ -277,7 +279,10 @@ class _HomePageState extends State { // the bar itself stays feature-agnostic). Both dials sit at 0 for // most of the sheet's travel (they only move in its top ~15%), so // this selects the pair rather than rebuilding the badge carousel - // on every tick of the drag. + // on every tick of the drag. The gold support strip sits in the + // same overlay tree, flush under the bar — one shared + // listenable/selector, so the pair rebuilds as one subtree and + // never fights over the semantics tree mid-frame. Positioned( top: 0, left: 0, @@ -297,19 +302,33 @@ class _HomePageState extends State { blend: HomeChrome.regionBlend(sheetExtent.value), dismiss: HomeChrome.regionDismiss(sheetExtent.value), ), - builder: (context, dials, _) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - RegionBar( - blend: dials.blend, - dismiss: dials.dismiss, - skyIsLight: skyIsLightFrom(sky, weatherMode), - ), - // Quick link to 強震監視器 — same dials as the - // region bar above it, so the two move as one - // piece of chrome as the sheet rises. - HomeMonitorBanner(dismiss: dials.dismiss), - ], + builder: (context, dials, _) => SafeArea( + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RegionBar( + blend: dials.blend, + dismiss: dials.dismiss, + skyIsLight: skyIsLightFrom(sky, weatherMode), + ), + // Quick link to 強震監視器 — same dials as the + // region bar above it, so the two move as one + // piece of chrome as the sheet rises. + // + // Above the support pill on purpose: this one + // only renders while an alert is active, and an + // alert must not be pushed down the screen by a + // donation prompt. When nothing is happening it + // draws nothing, so the pill sits directly under + // the bar anyway. + HomeMonitorBanner(dismiss: dials.dismiss), + _GoldSupportBar( + blend: dials.blend, + dismiss: dials.dismiss, + ), + ], + ), ), ), ), @@ -322,6 +341,72 @@ class _HomePageState extends State { } } +/// The gold support ask on Home, floating below the region bar. +/// +/// Same tap-to-sponsor intent as the More tab's support card, but in miniature +/// and on the page the user opens the app into. It rides the region bar's own +/// dials so it fades and slides away with the chrome as the sheet climbs — a +/// money ask that behaves like the rest of the bar, not an ad that refuses to +/// leave. Rendered as a floating pill over the map, not another stacked bar. +class _GoldSupportBar extends StatelessWidget { + const _GoldSupportBar({required this.blend, required this.dismiss}); + + final double blend; + final double dismiss; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final gold = AppGold.of(context); + final hidden = (blend + dismiss).clamp(0.0, 1.0); + return IgnorePointer( + ignoring: hidden > 0.9, + child: Opacity( + opacity: 1 - hidden, + child: FractionalTranslation( + translation: Offset(0, -6 * dismiss), + child: Material( + color: gold.badge, + borderRadius: BorderRadius.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.pushNamed(AppRoutes.sponsor), + child: SizedBox( + height: 30, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: Text( + l10n.sponsorTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: gold.onBadge, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 2), + Icon( + Icons.chevron_right, + size: 16, + color: gold.onBadge.withValues(alpha: 0.85), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + /// Resolves the home backdrop's inputs from the experimental force setting and /// the station's live reading. /// diff --git a/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart index 7009068f8..53c9ee84c 100644 --- a/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart +++ b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart @@ -873,7 +873,7 @@ class _Drops { /// /// [kernel] is `particle_blurred` (64×64, alpha = coverage) and [normalMap] is /// `drop_normal` (128×128, a hemisphere normal encoded `n·0.5+0.5`) — both -/// generated by `tool/gen_particle_sprites.py`. The reference samples each at +/// generated by `tool/gen/particle_sprites.py`. The reference samples each at /// `gl_PointCoord` over the same point quad and writes the **signed** normal /// with the kernel's alpha; the sign cannot survive an 8-bit unsigned surface, /// so the pair splits it: diff --git a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart index 3af1740eb..5fdb77724 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart @@ -199,9 +199,10 @@ class _RainOnCardState extends State Log.handle(error, stackTrace, 'Failed to load shader $_asset'); } // Swap the procedural stand-in for the baked sprite the moment the two - // source textures decode. Both are generated by `tool/gen_particle_ - // sprites.py` — the kernel is a blurred disc, the normal map an analytic - // hemisphere, so they are the same *functions* the engine samples. + // source textures decode. Both are generated by + // `tool/gen/particle_sprites.py` — the kernel is a blurred disc, the + // normal map an analytic hemisphere, so they are the same *functions* + // the engine samples. try { // Shared across every card and every mount, like [_fallbackSprites]: // each mounted card used to decode both textures and bake its own diff --git a/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart index 4043c123a..800e616af 100644 --- a/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart @@ -27,7 +27,7 @@ import 'package:flutter/services.dart' show rootBundle; /// viewer's latitude, so dawn keyframes land at dawn year-round. /// /// Clouds are authored volumetric sprites with baked normal maps -/// (`assets/weather/clouds/`, generated by `tool/gen_cloud_sprites.py`), lit by +/// (`assets/weather/clouds/`, generated by `tool/gen/cloud_sprites.py`), lit by /// sampling the sky LUT — the environment-light model, and the reason /// cloud colour always agrees with the sky behind it. /// diff --git a/lib/features/log/presentation/pages/log_page.dart b/lib/features/log/presentation/pages/log_page.dart index 9131077eb..acf68b1a9 100644 --- a/lib/features/log/presentation/pages/log_page.dart +++ b/lib/features/log/presentation/pages/log_page.dart @@ -1,19 +1,29 @@ import 'dart:async'; +import 'package:flutter/material.dart'; + +import 'package:talker_flutter/talker_flutter.dart'; + import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/logging/log_store.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; -import 'package:flutter/material.dart'; -import 'package:talker_flutter/talker_flutter.dart'; +import 'package:dpip/shared/widgets/loading_view.dart'; /// In-app log viewer. Reachable from the More tab; pushed as a full-screen /// route. /// -/// Shows Talker's live view of *this* session, and on open replays the last 24 -/// hours from the `logs` table into it — so the screen covers the launch that -/// crashed, not just the one you are looking at. The replay happens once per -/// visit and is skipped when the history already reaches back that far, which -/// is the common case for a session that has been running a while. +/// This is Talker's own screen, not a layout of ours. The hand-rolled one grew +/// because `TalkerScreen`'s header could overflow, and an overflow is routed +/// through `Log.handle` into the very stream the screen rebuilds on — a loop +/// that ended in a hang. That loop is now cut where it starts: `Log` reports a +/// repeated fault a few times and then drops it, so a layout fault costs a few +/// lines instead of the app. Rebuilding the screen ourselves bought nothing +/// after that, and cost the search, the level filter, the sharing and the +/// settings that come with the real one. +/// +/// What stays ours is the replay: on open, the last 24 hours are pulled out of +/// the `logs` table into Talker's history, so the screen covers the launch that +/// crashed and not only the one you are looking at. class LogPage extends StatefulWidget { const LogPage({super.key}); @@ -22,79 +32,87 @@ class LogPage extends StatefulWidget { } class _LogPageState extends State { - @override - void initState() { - super.initState(); - unawaited(_replayPersisted()); - } + late final Future _loaded = _loadPersisted(); - /// Pulls the persisted log into Talker's history, oldest first, so the - /// screen reads in the order things happened. + /// Loads the stored log into the screen. /// - /// Anything already in memory is skipped by timestamp: a session that has - /// been open all day would otherwise show every line twice. - Future _replayPersisted() async { + /// The table is the whole record, not an addition to what is in memory: + /// every line is persisted, and the ones from before the database opened + /// are copied in when it does. So this replaces rather than merges — which + /// is what removed the ordering and eviction faults that merging kept + /// producing, and the duplicate a visit used to leave behind. + Future _loadPersisted() async { final store = Log.store; if (store == null) return; // Flush first, or the newest lines — the ones the user came to read — are // still sitting in the write buffer. await store.flush(); - final oldestInMemory = Log.talker.history.isEmpty - ? null - : Log.talker.history.first.time; - final stored = await store.recent(limit: 2000); - for (final entry in stored.reversed) { - if (oldestInMemory != null && !entry.time.isBefore(oldestInMemory)) { - continue; - } - Log.talker.logCustom(_PersistedLog(entry)); - } - if (mounted) setState(() {}); + final stored = await store.recent(limit: Log.historyLimit); + Log.reload([for (final entry in stored) PersistedLog(entry)]); } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - return TalkerScreen( - talker: Log.talker, - appBarTitle: AppLocalizations.of(context).appLogs, - theme: TalkerScreenTheme( - backgroundColor: colors.surface, - textColor: colors.onSurface, - cardColor: colors.surfaceContainerHighest, - ), + final l10n = AppLocalizations.of(context); + final theme = TalkerScreenTheme( + backgroundColor: colors.surface, + textColor: colors.onSurface, + cardColor: colors.surfaceContainer, + ); + // Built only once the replay is in, because Talker reads its history when + // the screen builds and writing to it afterwards would not show. + return FutureBuilder( + future: _loaded, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar(title: Text(l10n.appLogs)), + body: const Center(child: InlineLoading()), + ); + } + return TalkerScreen( + talker: Log.talker, + appBarTitle: l10n.appLogs, + theme: theme, + // Collapsed: a log this screen exists to scan is read by its + // summaries, and an expanded card is mostly stack trace. + isLogsExpanded: false, + ); + }, ); } } -/// A replayed line, tagged so it is visibly from an earlier session rather -/// than something that just happened. -class _PersistedLog extends TalkerLog { - _PersistedLog(this.entry) - : super(entry.message, time: entry.time, stackTrace: null); - - final StoredLog entry; - - @override - String get title => entry.level; +/// A line read back out of the `logs` table. +/// +/// Its level is carried across, not invented. Talker colours a card and the +/// level filter narrows by `logLevel`, so a replayed line that arrives without +/// one is uncoloured, uncounted, and grouped under `undefined` with every +/// other level — the screen keys its filter chips and its card colours on +/// `TalkerData.key`, not on the level or the title. +class PersistedLog extends TalkerLog { + PersistedLog(StoredLog entry) : this._(entry, _level(entry.level)); - @override - AnsiPen get pen => switch (entry.level) { - 'error' || 'critical' => AnsiPen()..red(), - 'warning' => AnsiPen()..yellow(), - 'debug' => AnsiPen()..gray(), - _ => AnsiPen()..blue(), - }; + PersistedLog._(StoredLog entry, LogLevel level) + : super( + entry.error == null + ? entry.message + : '${entry.message}\n${entry.error}', + time: entry.time, + // The screen counts its filter chips by `key` and colours a card by + // it, so a replayed line needs the same one a live line of that level + // would have had. `Log.replay` fills in the title and pen from it. + key: TalkerKey.fromLogLevel(level), + logLevel: level, + stackTrace: null, + ); - @override - String generateTextMessage({ - TimeFormat timeFormat = TimeFormat.timeAndSeconds, - }) { - return [ - '[${entry.level}] ${entry.time.toIso8601String()}', - entry.message, - ?entry.error, - ?entry.stackTrace, - ].join('\n'); - } + /// Unknown names fall to `info` rather than being dropped: a line whose + /// level cannot be read is still a line somebody needs to see. + static LogLevel _level(String name) => LogLevel.values.firstWhere( + (level) => level.name == name, + orElse: () => LogLevel.info, + ); } diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index bdbf100be..79b613564 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -4,6 +4,7 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/meshtastic/mesh_unread.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/region_store.dart'; @@ -11,6 +12,7 @@ import 'package:dpip/core/version/app_build.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/default_map_layer_ui.dart'; import 'package:dpip/core/permissions/permission_health.dart'; +import 'package:dpip/shared/diagnostics/dump_action.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/widgets/section_header.dart'; import 'package:flutter/material.dart'; @@ -145,6 +147,11 @@ class MorePage extends StatelessWidget { title: l10n.moreDeveloper, onTap: () => context.pushNamed(AppRoutes.developer), ), + // Directly under the page it dumps. Everything a report needs + // is on that page already, and it was still being retyped row by + // row — this sends the whole thing, plus the log that explains + // it, and hands back one link. + const _DumpTile(), ], ), SectionHeader(l10n.moreSectionLinks), @@ -162,12 +169,6 @@ class MorePage extends StatelessWidget { host: 'report.exptech.dev', url: 'https://report.exptech.dev/', ), - _MoreLinkTile( - icon: Icons.dns_outlined, - title: l10n.moreServerStatus, - host: 'status.exptech.dev', - url: 'https://status.exptech.dev/status', - ), _MoreLinkTile( icon: Icons.smart_display_outlined, title: l10n.moreYoutube, @@ -206,6 +207,56 @@ class MorePage extends StatelessWidget { ), ], ), + // The bleeding-edge builds, one per store, each with its own opt-in. + SectionHeader(l10n.moreSectionBeta), + _MoreGroup( + children: [ + _MoreLinkTile( + icon: Icons.android, + title: l10n.moreAndroidBeta, + host: 'play.google.com', + url: 'https://play.google.com/apps/testing/com.exptech.dpip', + ), + _MoreLinkTile( + icon: Icons.apple, + title: l10n.moreTestFlight, + host: 'testflight.apple.com', + url: 'https://testflight.apple.com/join/8aPWtOxk', + ), + ], + ), + // The people who make DPIP run — the same list as the README. + SectionHeader(l10n.moreSectionPartners), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.sm, + ), + child: Text( + l10n.morePartnersNote, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + _MoreGroup( + children: [ + _MoreLinkTile( + icon: Icons.business_outlined, + title: l10n.morePartnerGeoscience, + host: 'geoscience.com.tw', + url: 'https://www.geoscience.com.tw/', + ), + _MoreLinkTile( + icon: Icons.cloud_outlined, + title: l10n.morePartnerTwds, + host: 'twds.com.tw', + url: 'https://www.twds.com.tw/', + ), + ], + ), SectionHeader(l10n.moreSectionAbout), _MoreGroup( children: [ @@ -283,6 +334,7 @@ class _MoreTile extends StatelessWidget { required this.title, this.subtitle, this.alert = false, + this.trailing, required this.onTap, }); @@ -294,6 +346,11 @@ class _MoreTile extends StatelessWidget { /// should act on. Off for every row that is merely a destination. final bool alert; + /// Replaces the chevron. A row that *does* something rather than going + /// somewhere passes its own — a chevron on it promises a page that never + /// opens. + final Widget? trailing; + final VoidCallback onTap; @override @@ -302,7 +359,7 @@ class _MoreTile extends StatelessWidget { leading: alert ? Badge(child: Icon(icon)) : Icon(icon), title: Text(title), subtitle: subtitle == null ? null : Text(subtitle!), - trailing: const Icon(Icons.chevron_right), + trailing: trailing ?? const Icon(Icons.chevron_right), onTap: onTap, ); } @@ -596,38 +653,59 @@ Future openExternalLink(BuildContext context, String url) async { class _HeroCards extends StatelessWidget { const _HeroCards(); - static const double _height = 256; + /// Height of the left version card — it leads the block, so it gets to + /// declare its own height (its column uses a Spacer, which needs a bounded + /// height) while the small cards beside it are shorter by design. + static const double _versionHeight = 176; + + /// Height of a small card (Discord, announcement, status) and, matching it, + /// the full-width support card below. + static const double _smallCardHeight = 56; @override Widget build(BuildContext context) { - return SizedBox( - height: _height, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - 0, - AppSpacing.lg, - AppSpacing.md, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Expanded(flex: 1, child: _VersionCard()), - const SizedBox(width: AppSpacing.md), - Expanded( - flex: 1, - child: Column( - children: const [ - Expanded(flex: 2, child: _SupportCallout()), - SizedBox(height: AppSpacing.xs), - Expanded(flex: 1, child: _DiscordCallout()), - SizedBox(height: AppSpacing.xs), - Expanded(flex: 1, child: _AnnouncementCard()), - ], + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: SizedBox( + height: _versionHeight, + child: const _VersionCard(), + ), ), - ), - ], - ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + children: const [ + SizedBox( + height: _smallCardHeight, + child: _DiscordCallout(), + ), + SizedBox(height: AppSpacing.xs), + SizedBox( + height: _smallCardHeight, + child: _AnnouncementCard(), + ), + SizedBox(height: AppSpacing.xs), + SizedBox(height: _smallCardHeight, child: _StatusCard()), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + SizedBox(height: _smallCardHeight, child: const _SupportCallout()), + ], ), ); } @@ -641,9 +719,11 @@ class _HeroCards extends StatelessWidget { /// as everything else reads as another menu row, whatever weight it is given. /// Gold is what makes it read as *paid*. /// -/// The construction is the same either way: a one-hue gradient for the sheen, a -/// warm cast underneath so it looks lit rather than printed on, a hairline -/// along the edge, and a filled badge carrying the most saturated step. +/// It shares the row construction of the two cards under it — badge, label, +/// trailing arrow — so the right column reads as one aligned stack; the +/// ranking is carried by the gold alone, rendered flat: a warm champagne +/// fill, a hairline along the edge, and a filled badge holding the most +/// saturated step. class _SupportCallout extends StatelessWidget { const _SupportCallout(); @@ -654,22 +734,12 @@ class _SupportCallout extends StatelessWidget { final gold = AppGold.of(context); return DecoratedBox( decoration: BoxDecoration( + color: gold.fill, borderRadius: AppRadius.large, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [gold.fillStart, gold.fillEnd], - ), - // A warm cast rather than a grey drop shadow — it lifts the card off - // the page and reads as light on metal, not as a floating rectangle. - boxShadow: [ - BoxShadow( - color: gold.glow, - blurRadius: 18, - offset: const Offset(0, 6), - ), - ], border: Border.all(color: gold.edge), + // No gradient: the card sits on the same tonal plane as its two + // neighbours, and the ranking is carried by the gold colour alone — + // the badge is what reads as paid, not the sheen. ), child: Material( type: MaterialType.transparency, @@ -677,32 +747,39 @@ class _SupportCallout extends StatelessWidget { borderRadius: AppRadius.large, onTap: () => context.pushNamed(AppRoutes.sponsor), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.sm, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, children: [ // Filled, not outlined: the one active affordance on a page // whose every other row is an outlined icon. Container( - width: 44, - height: 44, + width: 34, + height: 34, decoration: BoxDecoration( shape: BoxShape.circle, color: gold.badge, ), - child: Icon(Icons.favorite, color: gold.onBadge, size: 24), + child: Icon(Icons.favorite, color: gold.onBadge, size: 19), ), - Text( - l10n.sponsorTitle, - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - color: gold.ink, + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + l10n.sponsorTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: gold.ink, + ), ), ), + const SizedBox(width: AppSpacing.xs), + Icon( + Icons.chevron_right, + size: 14, + color: gold.ink.withValues(alpha: 0.7), + ), ], ), ), @@ -714,9 +791,10 @@ class _SupportCallout extends StatelessWidget { /// The page's second call to action, directly under [_SupportCallout]. /// -/// Deliberately one step down: the same badge-and-two-lines construction, but -/// a flat secondary container with no gradient and no shadow. That is what -/// makes the ranking legible — if this card also glowed, neither would lead. +/// Deliberately one step down: the same badge-and-label row as the callout +/// above, but a flat secondary container with no gradient and no shadow. That +/// is what makes the ranking legible — if this card also glowed, neither would +/// lead. class _DiscordCallout extends StatelessWidget { const _DiscordCallout(); @@ -736,7 +814,7 @@ class _DiscordCallout extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, children: [ Container( width: 34, @@ -748,7 +826,7 @@ class _DiscordCallout extends StatelessWidget { child: Icon(Icons.discord, color: colors.onSecondary, size: 19), ), const SizedBox(width: AppSpacing.sm), - Flexible( + Expanded( child: Text( l10n.moreDiscord, maxLines: 1, @@ -798,7 +876,7 @@ class _AnnouncementCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, children: [ Container( width: 34, @@ -814,7 +892,7 @@ class _AnnouncementCard extends StatelessWidget { ), ), const SizedBox(width: AppSpacing.sm), - Flexible( + Expanded( child: Text( l10n.moreAnnouncements, maxLines: 1, @@ -839,6 +917,76 @@ class _AnnouncementCard extends StatelessWidget { } } +/// Server status, directly under the announcement card — the same flat, +/// quiet construction, because a status check is a passive read and needs no +/// more weight than a link. +class _StatusCard extends StatelessWidget { + const _StatusCard(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + // The same dot the More tab carries: a service host the client has + // stopped reaching is as actionable as a missing permission. + final alert = context.select( + (health) => health.needsAttention, + ); + return Material( + color: colors.surfaceContainerHigh, + borderRadius: AppRadius.large, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.pushNamed(AppRoutes.serverStatus), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Badge( + isLabelVisible: alert, + smallSize: 7, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.surfaceContainerHighest, + ), + child: Icon( + Icons.dns_outlined, + color: colors.onSurfaceVariant, + size: 19, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + l10n.moreServerStatus, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + ), + const SizedBox(width: AppSpacing.xs), + Icon( + Icons.chevron_right, + size: 14, + color: colors.onSurfaceVariant.withValues(alpha: 0.6), + ), + ], + ), + ), + ), + ); + } +} + /// This install's identity — the logo, the label, and which train it belongs /// to — sized to lead the block. /// @@ -853,10 +1001,12 @@ class _AnnouncementCard extends StatelessWidget { /// the channel under the current scheme (a release label is `\d+\.\d+`, a /// snapshot is anything else). /// -/// The layout borrows the phone's "About" page voice — a pale wash behind the -/// build (rather than a flat fill), the mark small at the top, and the -/// version number as the thing the eye lands on — so the number, not the -/// card, is what reads first. +/// The layout borrows the phone's "About" page voice — the mark small at the +/// top, and the version number as the thing the eye lands on — so the number, +/// not the card, is what reads first. The card itself is flat, one tonal +/// surface like the three cards across from it, and carries no gradient of its +/// own: the only colour beyond text and badge is the [ShaderMask] gradient the +/// number is painted in. class _VersionCard extends StatelessWidget { const _VersionCard(); @@ -870,6 +1020,30 @@ class _VersionCard extends StatelessWidget { static const Color _stableColor = Color(0xFF2E7D32); static const Color _snapshotColor = Color(0xFFEF6C00); + /// The number's gradient, derived from the version string itself so every + /// build wears its own colours — 26w34a is one pair, 26w34b another — and + /// any one build stays stable across reloads. Two hues off the golden + /// angle (137.5°) harmonise regardless of the hash's starting point; the + /// lightness flips with the theme so the glyphs read on the card surface. + static List _hashGradient(String seed, Brightness brightness) { + var h = 7; + for (final rune in seed.runes) { + h = (h * 31 + rune) & 0x7fffffff; + } + final base = h % 360; + const saturation = 0.62; + final light = brightness == Brightness.dark ? 0.70 : 0.46; + return [ + HSLColor.fromAHSL(1, base.toDouble(), saturation, light).toColor(), + HSLColor.fromAHSL( + 1, + (base + 137.508) % 360, + saturation, + light - 0.10, + ).toColor(), + ]; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -878,90 +1052,186 @@ class _VersionCard extends StatelessWidget { final label = AppBuild.label; final stable = _releaseLabel.hasMatch(label); final typeColor = stable ? _stableColor : _snapshotColor; - return DecoratedBox( - decoration: BoxDecoration( + final train = AppBuild.train; + return Material( + color: colors.surfaceContainer, + borderRadius: AppRadius.large, + clipBehavior: Clip.antiAlias, + child: InkWell( borderRadius: AppRadius.large, - // A lineage wash from the brand colour down to the surface — the same - // idea as the About page's card, kept inside the scheme. - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - colors.primaryContainer, - colors.surfaceContainerHigh.withValues(alpha: 0.6), - ], - ), - ), - child: Material( - type: MaterialType.transparency, - child: InkWell( - borderRadius: AppRadius.large, - onTap: () => context.pushNamed(AppRoutes.versionNotes), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - ClipRRect( - borderRadius: AppRadius.small, - child: Image.asset( - 'assets/DPIP.png', - width: 36, - height: 36, - ), + onTap: () => context.pushNamed(AppRoutes.releaseHighlights), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ClipRRect( + borderRadius: AppRadius.small, + child: Image.asset( + 'assets/DPIP.png', + width: 36, + height: 36, ), - const Spacer(), - Icon( - Icons.chevron_right, - size: 20, - color: colors.onSurfaceVariant.withValues(alpha: 0.7), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'DPIP', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + Text( + l10n.moreTagline, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w400, + ), + ), + ], ), - ], - ), - const Spacer(), - Text( - 'DPIP', - style: theme.textTheme.labelLarge?.copyWith( - color: colors.onSurfaceVariant, - fontWeight: FontWeight.w600, ), - ), - const SizedBox(height: 2), - Text( - label, - style: theme.textTheme.headlineSmall?.copyWith( + const SizedBox(width: AppSpacing.xs), + Icon( + Icons.chevron_right, + size: 20, + color: colors.onSurfaceVariant.withValues(alpha: 0.7), + ), + ], + ), + const Spacer(), + // Gradient number, not a plain rect fill: the one stroke of + // colour the flat card permits. White under srcIn — the gradient + // takes over the glyphs entirely. + ShaderMask( + shaderCallback: (rect) => LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: _hashGradient(train, theme.brightness), + ).createShader(rect), + child: Text( + train, + style: theme.textTheme.displayMedium?.copyWith( fontWeight: FontWeight.w800, - color: colors.onSurface, + color: Colors.white, fontFeatures: const [FontFeature.tabularFigures()], + height: 1, ), ), - const SizedBox(height: AppSpacing.sm), - // The chip carries the type colour directly — green against - // the pale wash for a release, orange for a snapshot. - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: 3, - ), - decoration: BoxDecoration( - color: typeColor.withValues(alpha: 0.88), - borderRadius: AppRadius.small, - ), - child: Text( - stable ? l10n.moreVersionStable : l10n.moreVersionSnapshot, - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w700, - color: Colors.white, + ), + // Fine print below the number: a snapshot is named for the week + // it was cut, so it prints its own label; a release's label is + // identical to the train above, so it prints the platform's + // recorded version instead (what Settings → app shows). + const SizedBox(height: 2), + Text( + stable ? (AppBuild.platformVersion ?? train) : label, + style: theme.textTheme.headlineSmall?.copyWith( + color: colors.onSurface, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: AppSpacing.sm), + // Same treatment as the changelog's type chip: tinted wash, + // hairline of the same hue, coloured label — not a solid fill, + // which is the one marker the changelog never uses. The badge + // is followed by the day the build was cut. + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: typeColor.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: typeColor.withValues(alpha: 0.45), + ), + ), + child: Text( + stable + ? l10n.moreVersionStable + : l10n.moreVersionSnapshot, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + color: typeColor, + letterSpacing: 0.2, + ), ), ), - ), - ], - ), + if (AppBuild.buildDate.isNotEmpty) ...[ + const SizedBox(width: AppSpacing.sm), + Text( + AppBuild.buildDate, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ], ), ), ), ); } } + +/// Uploads the diagnostics and the log, and shows the link. +/// +/// Stateful only to hold the spinner: the collection reads a dozen subsystems +/// and the upload is a round trip, so without one the row looks like it did +/// nothing and gets tapped again — which uploads twice. +class _DumpTile extends StatefulWidget { + const _DumpTile(); + + @override + State<_DumpTile> createState() => _DumpTileState(); +} + +class _DumpTileState extends State<_DumpTile> { + bool _running = false; + + Future _run() async { + if (_running) return; + setState(() => _running = true); + try { + await runDiagnosticsDump(context); + } finally { + if (mounted) setState(() => _running = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return _MoreTile( + icon: Icons.ios_share_outlined, + title: l10n.moreDumpDiagnostics, + subtitle: l10n.moreDumpDiagnosticsHint, + // Sized either way, so the row does not shift when the spinner arrives. + trailing: SizedBox.square( + dimension: 18, + child: _running + ? const CircularProgressIndicator(strokeWidth: 2) + : null, + ), + onTap: _run, + ); + } +} diff --git a/lib/features/release_highlights/data/release_highlight_repository.dart b/lib/features/release_highlights/data/release_highlight_repository.dart new file mode 100644 index 000000000..4bb596e74 --- /dev/null +++ b/lib/features/release_highlights/data/release_highlight_repository.dart @@ -0,0 +1,42 @@ +/// Loads the current version's highlight cards from the content package. +/// +/// Each version's cards live as Dart source in `package:dpip_release_highlights` +/// (`lib//{normal,advanced}.dart`) — the *current* version's files +/// are imported below. Older versions stay in the package as the archive and +/// are never compiled into a build. When a new version ships, replace these two +/// imports with the new version's; nothing else changes. +/// +/// Content is authored as JSON at `release_highlights//…/cards.json` +/// and compiled to Dart by `tool/json_to_dart_highlights.py`. +library; + +import 'package:dpip/features/release_highlights/domain/release_highlight.dart'; +import 'package:dpip_release_highlights/26.1/advanced.dart' as current_advanced; +import 'package:dpip_release_highlights/26.1/normal.dart' as current_normal; + +/// Stateless loader that assembles [HighlightDeck]s from the current version's +/// Dart content. +class ReleaseHighlightRepositoryImpl implements ReleaseHighlightRepository { + const ReleaseHighlightRepositoryImpl(); + + @override + HighlightDeck load(HighlightKind kind) => switch (kind) { + HighlightKind.normal => HighlightDeck( + kind: kind, + title: current_normal.title, + subtitle: current_normal.subtitle, + cards: [ + for (final c in current_normal.cards) ReleaseHighlightCard.fromJson(c), + ], + ), + HighlightKind.advanced => HighlightDeck( + kind: kind, + title: current_advanced.title, + subtitle: current_advanced.subtitle, + cards: [ + for (final c in current_advanced.cards) + ReleaseHighlightCard.fromJson(c), + ], + ), + }; +} diff --git a/lib/features/release_highlights/domain/release_highlight.dart b/lib/features/release_highlights/domain/release_highlight.dart new file mode 100644 index 000000000..619db63d7 --- /dev/null +++ b/lib/features/release_highlights/domain/release_highlight.dart @@ -0,0 +1,153 @@ +/// Version-highlight card models. +/// +/// The in-app cards are rendered from structured multi-locale content that +/// ships as Dart source in the `dpip_release_highlights` content package — +/// each version's cards live at `release_highlights//` in the repo and +/// are compiled into the app only when that version is current. UI chrome +/// (titles, section names, buttons) lives in ARB; the article body lives in the +/// content package, keyed by locale so every language reads its own copy. +/// +/// A new version is authored as JSON under `release_highlights//…`, +/// converted to Dart by `tool/json_to_dart_highlights.py`, and the app's +/// repository imports the new version's files. Older versions stay in the +/// package as the archive; unimported, they are never compiled into a build. +library; + +import 'package:json_annotation/json_annotation.dart'; + +part 'release_highlight.g.dart'; + +/// Kind of highlight deck — which tab of the version-highlights page it fills. +enum HighlightKind { normal, advanced } + +/// Where a deck comes from — abstracted so the page depends on the domain, not +/// on the content-package loading. +abstract interface class ReleaseHighlightRepository { + /// The current version's deck for [kind]. + /// + /// Content is compiled into the app (imported from the content package), so + /// loading never touches the disk or the network and cannot fail. + HighlightDeck load(HighlightKind kind); +} + +/// One deck of cards for one kind, already decoded. +class HighlightDeck { + const HighlightDeck({ + required this.kind, + required this.title, + required this.subtitle, + required this.cards, + }); + + final HighlightKind kind; + + /// Deck title, every locale. + final LocalizedText title; + + /// Deck subtitle, every locale. + final LocalizedText subtitle; + + final List cards; +} + +/// A multi-locale string: locale → text. Keys are BCP-47 tags with +/// underscores (`zh_Hant`, `en`, `ja`, …). +typedef LocalizedText = Map; + +/// A single highlight card — one theme, one headline, one icon, optional big +/// number and detail rows. +@JsonSerializable() +class ReleaseHighlightCard { + const ReleaseHighlightCard({ + required this.id, + required this.icon, + required this.title, + this.headline, + this.body, + this.stat, + this.statLabel, + this.highlights = const [], + this.details = const [], + this.stats = const [], + }); + + factory ReleaseHighlightCard.fromJson(Map json) => + _$ReleaseHighlightCardFromJson(json); + + Map toJson() => _$ReleaseHighlightCardToJson(this); + + /// Stable identifier (also used as the icon's semantic label key). + final String id; + + /// Material icon name, e.g. `bolt`, `data_saver`. Resolved by the UI layer — + /// never hand-write a codepoint (DESIGN.md → Icons). + final String icon; + + /// Card title, in every shipped locale. + final LocalizedText title; + + /// One-line hook, in every shipped locale. + final LocalizedText? headline; + + /// Longer body, in every shipped locale. + final LocalizedText? body; + + /// Big stat number, in every shipped locale. + final LocalizedText? stat; + + /// Caption under the big number, in every shipped locale. + final LocalizedText? statLabel; + + /// Short bullet highlights, each localized across the shipped locales. + final List highlights; + + /// Key/value technical rows, in every shipped locale. + final List details; + + /// Pure-number stat rows (the "verifiable numbers" card), in every locale. + final List stats; + + /// True when [details] is non-empty — a technical card, not a marketing one. + bool get isTechnical => details.isNotEmpty; +} + +/// One key/value row in a technical card, both sides localized. +@JsonSerializable() +class HighlightDetail { + const HighlightDetail({required this.key, required this.value}); + + factory HighlightDetail.fromJson(Map json) => + _$HighlightDetailFromJson(json); + + Map toJson() => _$HighlightDetailToJson(this); + + final LocalizedText key; + final LocalizedText value; +} + +/// One labelled number in the "verifiable numbers" card. +@JsonSerializable() +class HighlightStat { + const HighlightStat({required this.value, required this.label}); + + factory HighlightStat.fromJson(Map json) => + _$HighlightStatFromJson(json); + + Map toJson() => _$HighlightStatToJson(this); + + final LocalizedText value; + final LocalizedText label; +} + +/// Picks the active locale's copy of a [field], falling back to zh_Hant (the +/// authoring locale) and then to whatever key exists. +/// +/// [tag] is the BCP-47 tag with underscores (`zh_Hant`, `en`, `ja`, …). The +/// UI layer derives it from `Localizations.localeOf`, keeping domain pure. +String localized(LocalizedText field, String tag) { + final exact = field[tag]; + if (exact != null) return exact; + final base = field[tag.split('_').first]; + if (base != null) return base; + return field['zh_Hant'] ?? field.values.firstOrNull ?? ''; +} diff --git a/lib/features/release_highlights/domain/release_highlight.g.dart b/lib/features/release_highlights/domain/release_highlight.g.dart new file mode 100644 index 000000000..b0a3d5191 --- /dev/null +++ b/lib/features/release_highlights/domain/release_highlight.g.dart @@ -0,0 +1,75 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'release_highlight.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ReleaseHighlightCard _$ReleaseHighlightCardFromJson( + Map json, +) => ReleaseHighlightCard( + id: json['id'] as String, + icon: json['icon'] as String, + title: Map.from(json['title'] as Map), + headline: (json['headline'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + body: (json['body'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + stat: (json['stat'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + statLabel: (json['statLabel'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + highlights: + (json['highlights'] as List?) + ?.map((e) => Map.from(e as Map)) + .toList() ?? + const [], + details: + (json['details'] as List?) + ?.map((e) => HighlightDetail.fromJson(e as Map)) + .toList() ?? + const [], + stats: + (json['stats'] as List?) + ?.map((e) => HighlightStat.fromJson(e as Map)) + .toList() ?? + const [], +); + +Map _$ReleaseHighlightCardToJson( + ReleaseHighlightCard instance, +) => { + 'id': instance.id, + 'icon': instance.icon, + 'title': instance.title, + 'headline': instance.headline, + 'body': instance.body, + 'stat': instance.stat, + 'statLabel': instance.statLabel, + 'highlights': instance.highlights, + 'details': instance.details.map((e) => e.toJson()).toList(), + 'stats': instance.stats.map((e) => e.toJson()).toList(), +}; + +HighlightDetail _$HighlightDetailFromJson(Map json) => + HighlightDetail( + key: Map.from(json['key'] as Map), + value: Map.from(json['value'] as Map), + ); + +Map _$HighlightDetailToJson(HighlightDetail instance) => + {'key': instance.key, 'value': instance.value}; + +HighlightStat _$HighlightStatFromJson(Map json) => + HighlightStat( + value: Map.from(json['value'] as Map), + label: Map.from(json['label'] as Map), + ); + +Map _$HighlightStatToJson(HighlightStat instance) => + {'value': instance.value, 'label': instance.label}; diff --git a/lib/features/release_highlights/presentation/pages/release_highlights_page.dart b/lib/features/release_highlights/presentation/pages/release_highlights_page.dart new file mode 100644 index 000000000..bf2cb59ef --- /dev/null +++ b/lib/features/release_highlights/presentation/pages/release_highlights_page.dart @@ -0,0 +1,128 @@ +/// The version-highlights page — this version's release cards. +/// +/// Version card on More leads here (alongside the version notes). The page +/// holds two tabs: the general-audience deck (what changed, in plain words) +/// and the advanced deck (how the internals work, with file/line references). +library; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/features/release_highlights/domain/release_highlight.dart'; +import 'package:dpip/features/release_highlights/presentation/widgets/highlight_card.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +/// The page behind the version card's chevron. +class ReleaseHighlightsPage extends StatelessWidget { + const ReleaseHighlightsPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Text(l10n.releaseHighlightsTitle), + actions: [ + IconButton( + icon: const Icon(Icons.article_outlined), + tooltip: l10n.releaseHighlightsSeeNotes, + onPressed: () => context.pushNamed(AppRoutes.versionNotes), + ), + ], + bottom: TabBar( + tabs: [ + Tab(text: l10n.releaseHighlightsTabNormal), + Tab(text: l10n.releaseHighlightsTabAdvanced), + ], + ), + ), + body: TabBarView( + children: const [ + _DeckList(kind: HighlightKind.normal), + _DeckList(kind: HighlightKind.advanced), + ], + ), + ), + ); + } +} + +class _DeckList extends StatelessWidget { + const _DeckList({required this.kind}); + + final HighlightKind kind; + + @override + Widget build(BuildContext context) { + final repo = context.read(); + final deck = repo.load(kind); + final tag = localeTagOf(context); + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xl + MediaQuery.paddingOf(context).bottom, + ), + children: [ + _DeckHeader(deck: deck, tag: tag), + const SizedBox(height: AppSpacing.md), + for (final card in deck.cards) ...[ + HighlightCard(card: card), + const SizedBox(height: AppSpacing.md), + ], + ], + ); + } +} + +class _DeckHeader extends StatelessWidget { + const _DeckHeader({required this.deck, required this.tag}); + + final HighlightDeck deck; + final String tag; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [colors.primaryContainer, colors.secondaryContainer], + ), + borderRadius: AppRadius.large, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localized(deck.title, tag), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + color: colors.onPrimaryContainer, + letterSpacing: -0.4, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + localized(deck.subtitle, tag), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onPrimaryContainer.withValues(alpha: 0.85), + height: 1.6, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/release_highlights/presentation/widgets/highlight_card.dart b/lib/features/release_highlights/presentation/widgets/highlight_card.dart new file mode 100644 index 000000000..442074759 --- /dev/null +++ b/lib/features/release_highlights/presentation/widgets/highlight_card.dart @@ -0,0 +1,361 @@ +/// Renders a single highlight card. +/// +/// The visual system follows DESIGN.md: tonal `surfaceContainer` cards with +/// [AppRadius] rounding, Material icon in a tinted disc, a headline, a body in +/// `bodyMedium`, and — where present — a big stat number in `displaySmall`. +/// Technical cards additionally render key/value rows so a developer gets the +/// facts without scrolling the English out of view. +library; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/features/release_highlights/domain/release_highlight.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; + +/// The tag of the active locale, e.g. `zh_Hant`, `en`. +String localeTagOf(BuildContext context) => + Localizations.localeOf(context).toLanguageTag().replaceAll('-', '_'); + +/// Material icon for a card's [ReleaseHighlightCard.icon] name. +/// +/// Missing names degrade to a question mark instead of crashing the build — +/// an unknown icon is a content typo, not a reason to fail. +IconData highlightIcon(String name) => switch (name) { + 'bolt' => Icons.bolt, + 'data_saver' => Icons.data_saver_on, + 'battery_saver' => Icons.battery_saver, + 'rocket_launch' => Icons.rocket_launch, + 'map' => Icons.map_outlined, + 'my_location' => Icons.my_location, + 'access_time' => Icons.access_time, + 'shield' => Icons.shield_outlined, + 'verified' => Icons.verified_outlined, + 'route' => Icons.alt_route, + 'stream' => Icons.stream, + 'storage' => Icons.storage_outlined, + 'layers' => Icons.layers_outlined, + 'query_stats' => Icons.query_stats, + 'rule' => Icons.rule, + 'receipt_long' => Icons.receipt_long_outlined, + 'straighten' => Icons.straighten, + 'bug_report' => Icons.bug_report_outlined, + _ => Icons.question_mark, +}; + +/// One card in the highlight deck. +class HighlightCard extends StatelessWidget { + const HighlightCard({super.key, required this.card}); + + final ReleaseHighlightCard card; + + @override + Widget build(BuildContext context) { + final tag = localeTagOf(context); + final label = localized(card.title, tag); + return Material( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: AppRadius.medium, + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Header(card: card, label: label, tag: tag), + if (card.headline != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + localized(card.headline!, tag), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ], + if (card.body != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + localized(card.body!, tag), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + height: 1.6, + ), + ), + ], + if (card.stat != null || card.statLabel != null) ...[ + const SizedBox(height: AppSpacing.md), + _BigStat( + stat: card.stat == null ? null : localized(card.stat!, tag), + label: card.statLabel == null + ? null + : localized(card.statLabel!, tag), + ), + ], + if (card.highlights.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + for (final h in card.highlights) + _HighlightRow(text: localized(h, tag)), + ], + if (card.details.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + _DetailRows(details: card.details, tag: tag), + ], + if (card.stats.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + _StatGrid(stats: card.stats, tag: tag), + ], + ], + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.card, required this.label, required this.tag}); + + final ReleaseHighlightCard card; + final String label; + final String tag; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final icon = highlightIcon(card.icon); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: AppRadius.small, + ), + child: Icon(icon, color: colors.onPrimaryContainer, size: 22), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + ), + ), + if (card.isTechnical) ...[ + const SizedBox(height: 2), + Text( + AppLocalizations.of(context).highlightCardTechnical, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ], + ), + ), + ], + ); + } +} + +class _BigStat extends StatelessWidget { + const _BigStat({this.stat, this.label}); + + final String? stat; + final String? label; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: AppRadius.small, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (stat != null) + Text( + stat!, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w800, + color: colors.primary, + fontFeatures: const [FontFeature.tabularFigures()], + height: 1.1, + ), + ), + if (label != null) ...[ + const SizedBox(height: 2), + Text( + label!, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant, height: 1.4), + ), + ], + ], + ), + ); + } +} + +class _HighlightRow extends StatelessWidget { + const _HighlightRow({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.check_circle, size: 18, color: colors.primary), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(height: 1.5), + ), + ), + ], + ), + ); + } +} + +class _DetailRows extends StatelessWidget { + const _DetailRows({required this.details, required this.tag}); + + final List details; + final String tag; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final rows = []; + for (var i = 0; i < details.length; i++) { + final d = details[i]; + rows.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 96, + child: Text( + localized(d.key, tag), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + localized(d.value, tag), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.onSurface, height: 1.5), + ), + ), + ], + ), + ), + ); + if (i < details.length - 1) { + rows.add( + Divider(height: 1, thickness: 1, color: colors.outlineVariant), + ); + } + } + return Column(children: rows); + } +} + +class _StatGrid extends StatelessWidget { + const _StatGrid({required this.stats, required this.tag}); + + final List stats; + final String tag; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // Two columns when there is room; one otherwise. `/2` never divides + // oddly because each tile is a full row on narrow screens. + final twoColumns = constraints.maxWidth >= 320; + final columnCount = twoColumns ? 2 : 1; + final tiles = []; + for (final s in stats) { + tiles.add(_StatTile(stat: s, tag: tag)); + if (twoColumns && tiles.length.isOdd) { + tiles.add(const SizedBox(width: AppSpacing.sm)); + } + if (tiles.length % columnCount == 0) { + tiles.add(const SizedBox(height: AppSpacing.sm)); + } + } + return Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: tiles, + ); + }, + ); + } +} + +class _StatTile extends StatelessWidget { + const _StatTile({required this.stat, required this.tag}); + + final HighlightStat stat; + final String tag; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + width: 150, + padding: const EdgeInsets.all(AppSpacing.sm), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: AppRadius.small, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localized(stat.value, tag), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + color: colors.primary, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 2), + Text( + localized(stat.label, tag), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant, height: 1.4), + ), + ], + ), + ); + } +} diff --git a/lib/features/release_highlights/release_highlights_providers.dart b/lib/features/release_highlights/release_highlights_providers.dart new file mode 100644 index 000000000..be33c4cf1 --- /dev/null +++ b/lib/features/release_highlights/release_highlights_providers.dart @@ -0,0 +1,15 @@ +/// Release-highlights feature providers. +library; + +import 'package:dpip/core/di/shared_deps.dart'; +import 'package:dpip/features/release_highlights/data/release_highlight_repository.dart'; +import 'package:dpip/features/release_highlights/domain/release_highlight.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; + +/// Exposes [ReleaseHighlightRepository] for the version-highlights page. +List releaseHighlightsProviders(SharedDeps deps) => [ + Provider( + create: (_) => const ReleaseHighlightRepositoryImpl(), + ), +]; diff --git a/lib/features/settings/presentation/pages/developer_page.dart b/lib/features/settings/presentation/pages/developer_page.dart index ab6d684aa..1cf23a5f9 100644 --- a/lib/features/settings/presentation/pages/developer_page.dart +++ b/lib/features/settings/presentation/pages/developer_page.dart @@ -8,20 +8,14 @@ /// an APNs token) never translate anyway. library; -import 'dart:io'; - import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; -import 'package:dpip/core/build_info.g.dart'; +import 'package:dpip/core/diagnostics/diagnostics_report.dart'; import 'package:dpip/core/logging/log.dart'; -import 'package:dpip/core/version/app_build.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; import 'package:dpip/core/notifications/notification_service.dart'; -import 'package:dpip/core/platform/background_execution.dart'; import 'package:dpip/core/platform/background_location.dart'; -import 'package:dpip/core/platform/unused_app_restrictions.dart'; -import 'package:dpip/core/platform/device_info.dart'; import 'package:dpip/core/storage/app_database.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/storage/app_storage_scan.dart'; @@ -30,12 +24,10 @@ import 'package:dpip/features/settings/presentation/widgets/storage_breakdown.da import 'package:dpip/shared/map/map_tile_cache.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:dpip/shared/widgets/section_header.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; /// One labelled diagnostic value. @@ -44,79 +36,6 @@ typedef _Field = ({String label, String? value}); /// Shared by the row, the dialog title, and its confirm button. const String _clearCacheTitle = 'Clear cache'; -/// Formats a cache hit rate as `NN% (hits/total)`, or a dash when the window -/// saw no cacheable request at all — 0% would read as "the cache is failing". -String _formatRate(double rate, int hits, int total) => - total == 0 ? '—' : '${(rate * 100).toStringAsFixed(0)}% ($hits/$total)'; - -/// A platform bool as text. Null (the platform did not answer, e.g. the channel -/// is absent) is a dash rather than "no": not knowing is not the same as off. -String _yesNo(Object? value) => switch (value) { - true => 'yes', - false => 'no', - _ => '—', -}; - -/// The last report attempt as ` ago · ok (200)`, or why there is none. -/// -/// The age matters more than the timestamp: "4 minutes ago" and "6 days ago" -/// are the difference between working and dead, and the failed case is worth -/// showing rather than hiding — a device that fires and gets a 500 needs a -/// different fix from one that never fires. -String _lastReport(Map d) { - final at = d['lastReportAt']; - if (at is! int) return 'never'; - final when = DateTime.fromMillisecondsSinceEpoch(at); - final age = DateTime.now().difference(when); - final ok = d['lastReportOk'] == true; - final code = d['lastReportCode']; - return '${_age(age)} ago · ${_outcome(ok, code)}'; -} - -/// A negative code is a reason the request was never made, not an HTTP status. -/// It has to read as one: `failed (-2)` sends whoever pastes it looking for a -/// network fault that never happened. -String _outcome(bool ok, Object? code) => switch (code) { - -2 => 'no push token', - -3 => 'no app version', - -1 => 'could not reach the server', - final int c when c > 0 => ok ? 'ok ($c)' : 'failed ($c)', - _ => ok ? 'ok' : 'failed', -}; - -/// How many times the OS woke the background path, by which spine woke it. -/// -/// This is the row that separates the two failures a single `Last report: -/// never` collapses into: the OS never called us, or it called and every call -/// bailed out. They need opposite fixes. -String _wakes(Map d) { - final parts = [ - for (final (label, key) in const [ - ('geofence', 'wakeGeofence'), - ('alarm', 'wakeAlarm'), - ('boot', 'wakeBoot'), - ]) - if (d[key] case final int n when n > 0) '$label $n', - ]; - return parts.isEmpty ? 'never woken' : parts.join(' · '); -} - -String _age(Duration d) { - if (d.inMinutes < 1) return 'moments'; - if (d.inHours < 1) return '${d.inMinutes} min'; - if (d.inDays < 1) return '${d.inHours} h'; - return '${d.inDays} d'; -} - -/// Where the geofence / region sits, to 4 dp (~11 m — finer than either radius -/// and coarse enough not to be a precise home address in a pasted bug report). -String _centre(Map d) { - final lat = d['centreLat']; - final lng = d['centreLng']; - if (lat is! double || lng is! double) return '—'; - return '${lat.toStringAsFixed(4)}, ${lng.toStringAsFixed(4)}'; -} - class DeveloperPage extends StatefulWidget { const DeveloperPage({super.key}); @@ -125,7 +44,7 @@ class DeveloperPage extends StatefulWidget { } class _DeveloperPageState extends State { - List<({String title, List<_Field> fields})>? _sections; + List? _sections; List? _usageHistory; List? _usageWeek; StorageScan? _storage; @@ -137,296 +56,31 @@ class _DeveloperPageState extends State { static const int _unlockVersionTaps = 10; int _versionTaps = 0; - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _load()); - } - Future _load() async { - // Capture provided services before any await. - final notifications = context.read(); - final etagCache = context.read(); - final networkUsage = context.read(); - final database = context.read(); - final backgroundLocation = context.read(); - - final info = await PackageInfo.fromPlatform(); - final cacheStats = await etagCache?.stats(); - final usage = await networkUsage?.stats(); - final usageHistory = await networkUsage?.history(); - final usageWeek = await networkUsage?.history( - hours: 24 * 7, - bucketHours: 6, + // Services read before the first await, while the element is certainly + // still mounted. + final collector = DiagnosticsCollector( + notifications: context.read(), + database: context.read(), + backgroundLocation: context.read(), + etagCache: context.read(), + networkUsage: context.read(), ); - final storage = await const StorageScanner().scan(); - final tables = await database.tableStats(); - final device = await DeviceInfoService.load(); - final bgLocation = await backgroundLocation.diagnostics(); - // The two states that silently end background reporting while every - // permission above still reads "granted" — and the two that were missing - // from the dump people paste when asking why they got no alert. - final execution = await BackgroundExecutionService().status(); - final unusedApp = await UnusedAppRestrictionsService().status(); - // Track the build by the git commit it was built from (kGitCommit is kept - // current by the .githooks generator — see tool/setup.sh), falling back to - // the platform build number outside a repo. - final buildRef = kGitCommit == 'unknown' ? info.buildNumber : kGitCommit; - // Show the platform's own push token: FCM on Android, APNs on iOS. - final fcmToken = Platform.isAndroid - ? (notifications.token ?? await _fcmToken()) - : null; - final apnsToken = Platform.isIOS ? await _apnsToken() : null; - - final sections = <({String title, List<_Field> fields})>[ - ( - title: 'App', - fields: [ - // The build's own name — `26w33a`, `26.1` — which is the only - // version a user is ever asked for and the only one that is unique - // per build. It appears nowhere else: Apple is told the train - // (`26.1.0`) because it rejects anything with a letter in it, so - // every snapshot looks identical in TestFlight and in Settings → - // General → About. This row is where a tester finds out which one - // they actually have. - (label: 'Version', value: AppBuild.label), - // What the platform believes, kept beside it: it is what App Store - // Connect and Play show, so a support conversation needs both. - ( - label: 'Store version', - value: '${info.version} (${info.buildNumber})', - ), - (label: 'Build', value: buildRef), - (label: 'Build mode', value: _buildMode), - ], - ), - ( - title: 'Platform', - fields: [ - (label: 'OS', value: _osName), - (label: 'OS version', value: device.osVersion), - if (device.sdkInt != null) - (label: 'Android API level', value: '${device.sdkInt}'), - (label: 'Locale', value: Platform.localeName), - ], - ), - ( - title: 'Device', - fields: [ - (label: 'Manufacturer', value: device.manufacturer), - (label: 'Model', value: device.model), - (label: 'Identifier', value: device.identifier), - ], - ), - ( - title: 'Push', - fields: [ - if (Platform.isAndroid) (label: 'FCM token', value: fcmToken), - if (Platform.isIOS) (label: 'APNs token', value: apnsToken), - ], - ), - // Whether the app is still being told where the device is while it is - // closed — which is what decides whether a disaster alert reaches the - // right township. Every value here comes from the platform rather than - // from what Dart believes it asked for: the failure this exists to catch - // is precisely the one where the app thinks it armed something and the OS - // is delivering nothing. `Armed` is the answer that matters; the rest - // says why it is what it is. - ( - title: 'Background location', - fields: [ - (label: 'Requested', value: _yesNo(bgLocation['enabled'])), - ( - label: 'Authorization', - value: bgLocation['authorization'] as String?, - ), - (label: 'Armed', value: _yesNo(bgLocation['armed'])), - // Why nothing is armed, when that is the answer. Without it the rows - // above read "Requested: yes / Armed: no" and stop, which is the - // shape this bug arrived in: a state with no stated cause. - if (bgLocation['blocked'] != null) - (label: 'Blocked by', value: bgLocation['blocked'] as String?), - (label: 'Spine', value: bgLocation['spine'] as String?), - (label: 'Push token held', value: _yesNo(bgLocation['hasToken'])), - // Wakes and reports are separate counts on purpose. "Woke 40 times, - // reported never" and "never woke" are different faults with - // different fixes, and a single last-report row cannot tell them - // apart — it says `never` for both. - (label: 'Wakes', value: _wakes(bgLocation)), - if (bgLocation['lastGeofenceError'] != null) - ( - label: 'Geofence error', - value: bgLocation['lastGeofenceError'] as String?, - ), - (label: 'Last report', value: _lastReport(bgLocation)), - (label: 'Centred on', value: _centre(bgLocation)), - (label: 'Detail', value: bgLocation['detail'] as String?), - ( - label: 'Background execution', - value: !execution.known - ? 'unknown' - : execution.restricted - ? (execution.lockedByPolicy - ? 'blocked by policy' - : 'RESTRICTED') - : 'allowed', - ), - if (execution.standbyBucket != null) - (label: 'Standby bucket', value: execution.standbyBucket), - (label: 'Kept active', value: _keptActive(unusedApp)), - if (execution.vendorManaged) - ( - label: 'Vendor power manager', - value: - '${execution.manufacturer} — not detectable, check by hand', - ), - ], - ), - ( - title: 'ETag cache', - fields: [ - ( - label: 'Entries', - value: cacheStats == null ? '—' : '${cacheStats.rows}', - ), - ( - label: 'Size on disk', - value: cacheStats == null ? '—' : formatBytes(cacheStats.bytes), - ), - ], - ), - // Which table is actually costing the space. "The database is 40 MB" is - // not something anyone can act on; "mesh_node_metrics is 38 MB across - // 900,000 rows" names both the table and the retention window that is - // wrong. - ( - title: 'SQLite tables', - fields: [ - (label: 'Tables', value: '${tables.length} across two files'), - (label: 'Rows', value: '${tables.fold(0, (sum, t) => sum + t.rows)}'), - ( - label: 'Measured', - value: tables.isEmpty - ? '—' - : tables.first.onDisk - ? 'on-disk pages (dbstat)' - // Says so explicitly: the payload sum excludes indexes and - // page overhead, so it reads lower than the file itself and - // the difference is not a leak. - : 'payload only (no dbstat)', - ), - ], - ), - // What iOS Settings ("文件與資料") and Android Settings report, split - // into the app's own categories. The SQLite body budget (350 MB) is not - // the whole story — the DB file carries page overhead and the OS-level - // caches are separate. - ( - title: 'Storage', - fields: [ - (label: 'Total on disk', value: formatBytes(storage.totalBytes)), - for (final slice in storageBreakdown(storage)) - ( - label: slice.label, - value: - '${formatBytes(slice.bytes)} ' - '(${(slice.bytes / storage.totalBytes * 100).toStringAsFixed(1)}%)', - ), - // Why the total is what it is: the biggest individual files. A - // runaway in tmp (MapLibre's transient tile work, aborted native - // writes) shows up here by name long before the pie chart explains - // anything. - if (storage.files.isNotEmpty) ...[ - (label: 'Largest files', value: null), - for (final file in storage.files.take(8)) - (label: file.shortPath, value: formatBytes(file.bytes)), - ], - ], - ), - // Every figure here is the same pair of trailing windows, so they can be - // read against each other. - ( - title: 'Network usage', - fields: [ - ( - label: 'Downloaded · last 24h', - value: usage == null ? '—' : formatBytes(usage.last24h), - ), - ( - label: 'Downloaded · last 7d', - value: usage == null ? '—' : formatBytes(usage.last7d), - ), - ( - label: 'Traffic saved · last 24h', - value: usage == null ? '—' : formatBytes(usage.saved24h), - ), - ( - label: 'Traffic saved · last 7d', - value: usage == null ? '—' : formatBytes(usage.saved7d), - ), - ( - label: 'Hit rate · last 24h', - value: usage == null - ? '—' - : _formatRate(usage.hitRate24h, usage.hits24h, usage.total24h), - ), - ( - label: 'Hit rate · last 7d', - value: usage == null - ? '—' - : _formatRate(usage.hitRate7d, usage.hits7d, usage.total7d), - ), - ], - ), - ]; - if (mounted) { - setState(() { - _sections = sections; - _usageHistory = usageHistory; - _usageWeek = usageWeek; - _storage = storage; - _tables = tables; - }); - } - } - - String get _osName => Platform.isIOS - ? 'iOS' - : Platform.isAndroid - ? 'Android' - : Platform.operatingSystem; - - String get _buildMode { - if (kReleaseMode) return 'release'; - if (kProfileMode) return 'profile'; - return 'debug'; - } - - /// Android's hibernation state in the dump's own vocabulary. `unavailable` is - /// reported as "n/a" rather than as a problem: a device too old for the API, - /// or without the Play services that back-port it, has nothing to change. - static String _keptActive(UnusedAppRestrictions status) => switch (status) { - UnusedAppRestrictions.exempt => 'yes', - UnusedAppRestrictions.restricted => 'NO — will be hibernated', - UnusedAppRestrictions.unavailable => 'n/a', - }; - - Future _fcmToken() async { - try { - return await FirebaseMessaging.instance.getToken(); - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'dev: FCM token'); - return null; - } + final report = await collector.collect(); + if (!mounted) return; + setState(() { + _sections = report.sections; + _usageHistory = report.usageHistory; + _usageWeek = report.usageWeek; + _storage = report.storage; + _tables = report.tables; + }); } - Future _apnsToken() async { - try { - return await FirebaseMessaging.instance.getAPNSToken(); - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'dev: APNs token'); - return null; - } + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } /// Empties the cache **and** the accounting that describes it. @@ -482,9 +136,19 @@ class _DeveloperPageState extends State { // NSURLCache keeps its own copy of responses behind the app's back. await const StorageScanner().clearSystemHttpCache(); // iOS tmp is where transient native work (MapLibre tile handling, - // aborted snapshot writes) accumulates — nothing the app owns lives - // there, so it can be dropped wholesale. - await const StorageScanner().clearTmp(); + // aborted snapshot writes) accumulates, so in a release build it can be + // dropped wholesale. + // + // Not in debug, where that is false and dangerous: `flutter run` puts + // the DevFS there — the live `main.dart.dill` this process is running + // from sits in `tmp/DPIP/`. The native side wipes the whole + // directory, so pressing this while attached takes the kernel and the + // synced asset bundle with it. The dills grow back; the assets do not, + // because the tool decides what to resend from host state and never + // learns the device copy went away — a shader edit hot-reloads and then + // silently reverts. `tool/run.sh` sweeps the leftovers instead, from + // outside, where nothing is live. + if (kReleaseMode) await const StorageScanner().clearTmp(); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'dev: clear cache'); } @@ -498,8 +162,6 @@ class _DeveloperPageState extends State { } /// Labels omitted from the clipboard dump (still shown on screen). - static const _redactedCopyLabels = {'Identifier', 'FCM token', 'APNs token'}; - /// Labels that get their own copy button — long push tokens are the one /// case worth lifting out of a diagnostics screenshot on their own; every /// other row is still covered by "Copy all". @@ -556,21 +218,17 @@ class _DeveloperPageState extends State { } void _copyAll() { + final text = _diagnosticsText(); + if (text != null) _copy(text); + } + + /// The diagnostics as one block — what `Copy all` copies and what a dump + /// carries above the log. + /// The redacted dump, or null before the first read has landed. + String? _diagnosticsText() { final sections = _sections; - if (sections == null) return; - final buffer = StringBuffer('DPIP diagnostics'); - for (final section in sections) { - final fields = [ - for (final field in section.fields) - if (!_redactedCopyLabels.contains(field.label)) field, - ]; - if (fields.isEmpty) continue; - buffer.writeln('\n[${section.title}]'); - for (final field in fields) { - buffer.writeln('${field.label}: ${field.value ?? '—'}'); - } - } - _copy(buffer.toString().trim()); + if (sections == null) return null; + return diagnosticsText(sections, redacted: diagnosticsRedactedLabels); } @override diff --git a/lib/features/status/data/cloudflare_status_api.dart b/lib/features/status/data/cloudflare_status_api.dart new file mode 100644 index 000000000..3c8d4f098 --- /dev/null +++ b/lib/features/status/data/cloudflare_status_api.dart @@ -0,0 +1,68 @@ +/// Cloudflare status page API — the public `api/v2/components.json` feed. +library; + +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/features/status/domain/cloudflare_status.dart'; + +/// Fetches the live Cloudflare component statuses. +/// +/// A plain GET through [ApiClient.getAbsolute], so the ETag interceptor +/// revalidates against whatever validator Cloudflare serves and a revisit with +/// no network can read the last good copy from SQLite. The feed is global — +/// no region failover applies. +class CloudflareStatusApi { + const CloudflareStatusApi(this._client); + + final ApiClient _client; + + static const String url = + 'https://www.cloudflarestatus.com/api/v2/components.json'; + + /// Fetches the raw components JSON (a Map) for the repository to map; + /// throws on transport failure so [guardResult] folds it. + Future getComponents() => _client.getAbsolute(url); +} + +/// Maps the raw Cloudflare reply into a [CloudflareStatus], keeping only the +/// Taipei / Kaohsiung ingress components the app depends on. +/// +/// Layout: `{ page: {...}, components: [ { name, status, updated_at, ... } ] }`. +/// Exposed for tests. +CloudflareStatus parseCloudflareStatus(Object? body, {DateTime? at}) { + final components = (body is Map) ? body['components'] : null; + final list = switch (components) { + final List list => list, + _ => const [], + }; + + final kept = []; + for (final raw in list) { + if (raw is! Map) continue; + final name = raw['name']; + final nameStr = name is String ? name : ''; + final lower = nameStr.toLowerCase(); + if (!lower.contains('taipei') && !lower.contains('kaohsiung')) continue; + final status = raw['status']; + final updated = raw['updated_at']; + kept.add( + CloudflareComponent( + name: nameStr, + state: CloudflareComponentState.of(status is String ? status : ''), + updatedAt: updated is String + ? (DateTime.tryParse(updated) ?? + DateTime.fromMillisecondsSinceEpoch(0)) + : DateTime.fromMillisecondsSinceEpoch(0), + ), + ); + } + + // The status page lists them in stable order; the app wants Taipei first. + kept.sort((a, b) { + final aTaipei = a.name.toLowerCase().contains('taipei'); + final bTaipei = b.name.toLowerCase().contains('taipei'); + if (aTaipei != bTaipei) return aTaipei ? -1 : 1; + return a.name.compareTo(b.name); + }); + + return CloudflareStatus(components: kept, recordedAt: at ?? DateTime.now()); +} diff --git a/lib/features/status/data/cloudflare_status_repository_impl.dart b/lib/features/status/data/cloudflare_status_repository_impl.dart new file mode 100644 index 000000000..b2f8418a9 --- /dev/null +++ b/lib/features/status/data/cloudflare_status_repository_impl.dart @@ -0,0 +1,20 @@ +/// [CloudflareStatusRepository] backed by the Cloudflare status page API. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/network/api_exception.dart'; +import 'package:dpip/features/status/data/cloudflare_status_api.dart'; +import 'package:dpip/features/status/domain/cloudflare_status.dart'; +import 'package:dpip/features/status/domain/cloudflare_status_repository.dart'; + +class CloudflareStatusRepositoryImpl implements CloudflareStatusRepository { + const CloudflareStatusRepositoryImpl(this._api); + + final CloudflareStatusApi _api; + + @override + Future> status() => guardResult(() async { + final body = await _api.getComponents(); + return parseCloudflareStatus(body); + }); +} diff --git a/lib/features/status/data/server_status_api.dart b/lib/features/status/data/server_status_api.dart new file mode 100644 index 000000000..9733c4b95 --- /dev/null +++ b/lib/features/status/data/server_status_api.dart @@ -0,0 +1,120 @@ +/// ExpTech status dashboard API — the Grafana `/ds/query` endpoint. +library; + +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/features/status/domain/server_status.dart'; + +/// Fetches the live status snapshot from Grafana via a constant query. +/// +/// The query body is fixed at compile time, so the URL pins the content — the +/// ETag interceptor caches it as an immutable tile (URL-keyed, unconditional +/// store). A revisit that still has network gets the current numbers; a revisit +/// without one could read the SQLite copy straight from the interceptor. +class ServerStatusApi { + const ServerStatusApi(this._client); + + final ApiClient _client; + + static const String url = 'https://status.exptech.dev/api/ds/query'; + + /// The query the More → 伺服器狀態 screen runs. "now-1m…now" is irrelevant + /// for instant queries; each refId resolves to one scalar in + /// `results..frames[0].data.values[1][0]`. + static const Map query = { + 'queries': [ + { + 'refId': 'status', + 'datasource': {'uid': 'PBFA97CFB590B2093'}, + 'expr': 'count(up{job="nginx"} == 0) or vector(0)', + 'instant': true, + }, + { + 'refId': 'error_rate_5xx', + 'datasource': {'uid': 'PBFA97CFB590B2093'}, + 'expr': + 'topk(1, 100 * sum by (instance) ' + '(rate(nginx_http_responses_total{code="5xx"}[1m])) / ' + 'clamp_min(sum by (instance) ' + '(rate(nginx_http_responses_total[1m])), 0.001))', + 'instant': true, + }, + { + 'refId': 'avg_latency', + 'datasource': {'uid': 'PBFA97CFB590B2093'}, + 'expr': + 'topk(1, 1000 * sum by (instance) ' + '(rate(nginx_http_request_duration_seconds_total[1m])) / ' + 'clamp_min(sum by (instance) ' + '(rate(nginx_http_requests_total[1m])), 0.001))', + 'instant': true, + }, + ], + 'from': 'now-1m', + 'to': 'now', + }; + + /// Fetches the snapshot. Returns the raw decoded JSON (a Map) for the + /// repository to map; throws on transport failure so [guardResult] folds it. + Future getStatus() => _client.postAbsolute( + url, + data: query, + headers: const {'Content-Type': 'application/json'}, + ); +} + +/// Maps the raw Grafana reply into a [ServerStatus] — the three refIds each +/// carry a single scalar plus an optional `instance` label. +/// +/// Layout, per refId: `results..frames[0].data.values[1][0]` is the +/// value and `results..frames[0].schema.fields[1].labels.instance` the +/// host. Exposed for tests. +ServerStatus parseStatus(Object? body, {DateTime? at}) { + final results = (body is Map) ? body['results'] : null; + if (results is! Map) { + throw const FormatException('status dashboard: missing results'); + } + num scalar(String refId) { + final frame = _frame(results[refId]); + final values = frame['data']?['values']; + if (values is! List || values.length < 2) return 0; + final row = values[1]; + if (row is! List || row.isEmpty) return 0; + final raw = row[0]; + if (raw == null) return 0; + if (raw is num) return raw; + if (raw is String) return num.tryParse(raw) ?? 0; + return 0; + } + + String? instance(String refId) { + final frame = _frame(results[refId]); + final fields = frame['schema']?['fields']; + if (fields is! List || fields.length < 2) return null; + final labels = fields[1]?['labels']; + if (labels is! Map) return null; + final name = labels['instance']; + return name is String ? name : null; + } + + return ServerStatus( + recordedAt: at ?? DateTime.now(), + down: StatusMetric(value: scalar('status')), + errorRate: StatusMetric( + value: scalar('error_rate_5xx'), + // The curl one-liner multiplies by 100, so the rate is a percent. + instance: instance('error_rate_5xx'), + ), + latency: StatusMetric( + value: scalar('avg_latency'), + instance: instance('avg_latency'), + ), + ); +} + +Map _frame(Object? entry) { + if (entry is! Map) return const {}; + final frames = entry['frames']; + if (frames is! List || frames.isEmpty) return const {}; + final frame = frames.first; + return frame is Map ? Map.from(frame) : const {}; +} diff --git a/lib/features/status/data/server_status_repository_impl.dart b/lib/features/status/data/server_status_repository_impl.dart new file mode 100644 index 000000000..07d55d495 --- /dev/null +++ b/lib/features/status/data/server_status_repository_impl.dart @@ -0,0 +1,20 @@ +/// [ServerStatusRepository] backed by the Grafana dashboard API. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/network/api_exception.dart'; +import 'package:dpip/features/status/data/server_status_api.dart'; +import 'package:dpip/features/status/domain/server_status.dart'; +import 'package:dpip/features/status/domain/server_status_repository.dart'; + +class ServerStatusRepositoryImpl implements ServerStatusRepository { + const ServerStatusRepositoryImpl(this._api); + + final ServerStatusApi _api; + + @override + Future> status() => guardResult(() async { + final body = await _api.getStatus(); + return parseStatus(body); + }); +} diff --git a/lib/features/status/domain/cloudflare_status.dart b/lib/features/status/domain/cloudflare_status.dart new file mode 100644 index 000000000..042c8a220 --- /dev/null +++ b/lib/features/status/domain/cloudflare_status.dart @@ -0,0 +1,54 @@ +/// Cloudflare status snapshot — the CDN the app's hosts sit behind. +library; + +/// The Cloudflare status-page states, in the order the API reports them. +enum CloudflareComponentState { + operational, + degradedPerformance, + partialOutage, + majorOutage, + unknown; + + static CloudflareComponentState of(String raw) => switch (raw) { + 'operational' => operational, + 'degraded_performance' => degradedPerformance, + 'partial_outage' => partialOutage, + 'major_outage' => majorOutage, + _ => unknown, + }; +} + +/// One Cloudflare ingress the app cares about — the Taipei / Kaohsiung pops +/// that answer for the DPIP hosts. +class CloudflareComponent { + const CloudflareComponent({ + required this.name, + required this.state, + required this.updatedAt, + }); + + /// The display name from the status page, e.g. `Taipei - (TPE)`. + final String name; + + final CloudflareComponentState state; + + /// When the status page last changed this component. + final DateTime updatedAt; +} + +/// A snapshot of the Cloudflare components the app depends on. +class CloudflareStatus { + const CloudflareStatus({required this.components, required this.recordedAt}); + + /// The observed Taipei / Kaohsiung components, Taipei first. + final List components; + + /// When the snapshot was fetched. + final DateTime recordedAt; + + /// Whether every observed component is operating normally. Empty means we + /// do not know — not that everything is fine. + bool get allOperational => + components.isNotEmpty && + components.every((c) => c.state == CloudflareComponentState.operational); +} diff --git a/lib/features/status/domain/cloudflare_status_repository.dart b/lib/features/status/domain/cloudflare_status_repository.dart new file mode 100644 index 000000000..ca69c01f0 --- /dev/null +++ b/lib/features/status/domain/cloudflare_status_repository.dart @@ -0,0 +1,11 @@ +/// Cloudflare status repository contract. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/status/domain/cloudflare_status.dart'; + +/// Fetches the Cloudflare status-page snapshot for the regions the app uses. +abstract class CloudflareStatusRepository { + /// The current Taipei / Kaohsiung component statuses. + Future> status(); +} diff --git a/lib/features/status/domain/server_status.dart b/lib/features/status/domain/server_status.dart new file mode 100644 index 000000000..f17bda01b --- /dev/null +++ b/lib/features/status/domain/server_status.dart @@ -0,0 +1,54 @@ +/// Server status snapshot from the ExpTech status dashboard. +library; + +/// One Grafana query result — a single number plus the instance (host) it was +/// measured on, when the query reports one. +class StatusMetric { + const StatusMetric({required this.value, this.instance}); + + /// The raw numeric value — meaning depends on the metric: + /// `down` node count, 5xx error *rate* (0–1), latency in ms. + final num value; + + /// The host that answered (`instance` label, e.g. `lb-tpe1`), when the query + /// topk's by instance. Null when the dashboard did not report one. + final String? instance; +} + +/// The dashboard's three health signals, together with the instant they were +/// observed. +class ServerStatus { + const ServerStatus({ + required this.recordedAt, + required this.down, + required this.errorRate, + required this.latency, + }); + + /// When the query ran. + final DateTime recordedAt; + + /// How many `nginx` jobs are down (`count(up==0)`). Zero means all up. + final StatusMetric down; + + /// Top 5xx-error-rate instance over the last minute, as a 0–1 rate. + final StatusMetric errorRate; + + /// Top average latency over the last minute, in milliseconds. + final StatusMetric latency; + + /// Whether every service reports healthy. + bool get allUp => down.value == 0; + + /// A coarse 0–2 health score from the three signals, for a summary colour. + StatusHealth get health { + if (!allUp) return StatusHealth.down; + if (errorRate.value >= 0.1 || latency.value >= 50) { + return StatusHealth.degraded; + } + return StatusHealth.ok; + } +} + +/// Aggregate health of the whole status snapshot. +enum StatusHealth { ok, degraded, down } diff --git a/lib/features/status/domain/server_status_repository.dart b/lib/features/status/domain/server_status_repository.dart new file mode 100644 index 000000000..cafce9369 --- /dev/null +++ b/lib/features/status/domain/server_status_repository.dart @@ -0,0 +1,16 @@ +/// Server status repository contract. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/status/domain/server_status.dart'; + +/// Fetches the ExpTech status dashboard snapshot. +/// +/// The underlying graphite URL is content-addressed for our purposes: the query +/// body is a compile-time constant, so the same URL always means the same query +/// and the ETag store treats it like an immutable tile — a revisit is a local +/// SQLite read, not a round trip to Grafana. +abstract class ServerStatusRepository { + /// The current dashboard snapshot. + Future> status(); +} diff --git a/lib/features/status/presentation/pages/server_status_page.dart b/lib/features/status/presentation/pages/server_status_page.dart new file mode 100644 index 000000000..739229856 --- /dev/null +++ b/lib/features/status/presentation/pages/server_status_page.dart @@ -0,0 +1,1135 @@ +/// 伺服器狀態 — the live ExpTech dashboard plus what local health the app can +/// see from here. +/// +/// Top block: a full-width link out to the web dashboard, then three Grafana +/// metrics fetched through the app's Dio stack, so the ETag store caches the +/// same constant query and a revisit without network can still show the last +/// good snapshot. Bottom block: the client's own reading of the multi-active +/// endpoints — which service × region actually answers — fed by [ApiClient] as +/// requests succeed or fail over. +library; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/features/status/domain/cloudflare_status.dart'; +import 'package:dpip/features/status/domain/cloudflare_status_repository.dart'; +import 'package:dpip/features/status/domain/server_status.dart'; +import 'package:dpip/features/status/domain/server_status_repository.dart'; +import 'package:dpip/shared/widgets/async_view.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ServerStatusPage extends StatelessWidget { + const ServerStatusPage({ + super.key, + this.repository, + this.cloudflareRepository, + }); + + /// Injectable for tests; defaults to the provider-registered implementation. + final ServerStatusRepository? repository; + + /// Injectable for tests; defaults to the provider-registered implementation. + final CloudflareStatusRepository? cloudflareRepository; + + /// The web dashboard the status card used to jump to. + static const String _webUrl = 'https://status.exptech.dev/status'; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final repo = repository ?? context.read(); + final cloudflareRepo = + cloudflareRepository ?? context.read(); + return Scaffold( + appBar: AppBar(title: Text(l10n.moreServerStatus)), + body: ListView( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xl + MediaQuery.paddingOf(context).bottom, + ), + children: [ + // The old jump target, kept as a full-width entry at the top: the + // in-app snapshot is a summary, the web page has the history. + _WebDashboardCard(url: _webUrl), + const SizedBox(height: AppSpacing.xl), + + // ── ExpTech Status ────────────────────────────────────────────── + _SectionHeader(title: l10n.serverStatusExpTech), + const SizedBox(height: AppSpacing.sm), + AsyncView( + future: repo.status, + builder: (context, status) => _StatusGrid(status: status), + ), + const SizedBox(height: AppSpacing.xl), + + // ── Cloudflare Status ─────────────────────────────────────────── + // The CDN every ExpTech host sits behind. + _SectionHeader(title: l10n.serverStatusCloudflare), + const SizedBox(height: AppSpacing.sm), + AsyncView( + future: cloudflareRepo.status, + builder: (context, status) => _CloudflareGrid(status: status), + ), + const SizedBox(height: AppSpacing.xl), + + // ── 本機狀態 ──────────────────────────────────────────────────── + _SectionHeader(title: l10n.serverStatusLocal), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.serverStatusLocalBody, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + // The client's own reading of the multi-active endpoints, fed by + // ApiClient as requests succeed or fail over. + const _ClientEndpoints(), + ], + ), + ); + } +} + +/// A small section header used to separate the status sources on the page. +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: AppSpacing.sm), + Text( + title, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ); + } +} + +/// Full-width entry to the web status dashboard — the "old jump button". +class _WebDashboardCard extends StatelessWidget { + const _WebDashboardCard({required this.url}); + + final String url; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Material( + color: colors.secondaryContainer, + borderRadius: AppRadius.large, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _open(context), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + AppSpacing.xs, + ), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.secondary.withValues(alpha: 0.15), + ), + child: Icon( + Icons.open_in_browser_outlined, + color: colors.onSecondaryContainer, + size: 20, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.serverStatusWeb, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colors.onSecondaryContainer, + ), + ), + Text( + l10n.serverStatusWebUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSecondaryContainer.withValues( + alpha: 0.8, + ), + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.xs), + Icon( + Icons.open_in_new, + size: 16, + color: colors.onSecondaryContainer.withValues(alpha: 0.7), + ), + ], + ), + ), + ), + ); + } + + Future _open(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final failed = AppLocalizations.of(context).moreLinkOpenFailed; + try { + final ok = await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + if (!ok) throw Exception('launchUrl returned false for $url'); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'open external link $url'); + messenger.showSnackBar(SnackBar(content: Text(failed))); + } + } +} + +/// Renders [EndpointHealthMonitor] as four fixed tables — LB API / LB Static +/// / Core API / Core Static — one per user-facing category (as named in the +/// More → 伺服器狀態 screen). This is the "本機狀態" block: server metrics come +/// from Grafana, but whether *this* client can actually reach each service × +/// region is a question only the client can answer. +/// +/// Each table lists **all** the services and region columns its category can +/// ever carry (per api.md and who actually calls what). The probe is passive — +/// a cell shows that host's icon only when a request has actually touched it; +/// an untouched cell is an em-dash, not a hidden row. +/// +/// The four categories are judged by host name: an `api.*` host is API, a +/// `static.*` host is Static; LB is the `lb-*` family, Core is the `core-*` +/// family plus the legacy `api-1` host. Exclusive TNN1 tiers land in the same +/// group as their sibling (`api.core-tnn1` → Core API, `static.core-tnn1` → +/// Core Static). +class _ClientEndpoints extends StatelessWidget { + const _ClientEndpoints(); + + /// The four fixed tables. `services` and `regions` are the full set the + /// category can carry; `supported` is the api.md truth — which region serves + /// which service. A supported-but-unprobed cell shows 未探測, an unsupported + /// one shows 不支援 instead of pretending there is a host there to try. + static const _groups = [ + _Group( + titleKey: 'endpointTierLbApi', + isStatic: false, + isCore: false, + regions: ['TPE1', 'KHH1'], + services: [EndpointService.eew, EndpointService.rts], + supported: { + 'TPE1': [EndpointService.eew, EndpointService.rts], + 'KHH1': [EndpointService.eew, EndpointService.rts], + }, + ), + _Group( + titleKey: 'endpointTierLbStatic', + isStatic: true, + isCore: false, + regions: ['TPE1', 'KHH1'], + // Basemap/terrain tiles are fetched straight by MapLibre, never through + // ApiClient, so these cells stay 未探測 — there is a host, the probe + // just never goes through the client. + services: [EndpointService.other], + supported: { + 'TPE1': [EndpointService.other], + 'KHH1': [EndpointService.other], + }, + ), + _Group( + titleKey: 'endpointTierCoreApi', + isStatic: false, + isCore: true, + regions: ['TYO1', 'TNN1', 'API-1'], + services: [ + // Historical replay + reports are multi-active across tyo1/tnn1; the + // meteor family, location, notify and the TNN1-exclusive list endpoints + // only answer on api.core-tnn1; trem-station/events/RTS history only + // on the legacy api-1 host. + EndpointService.eew, + EndpointService.report, + EndpointService.radar, + EndpointService.satellite, + EndpointService.qpesums, + EndpointService.wind, + EndpointService.weather, + EndpointService.rain, + EndpointService.lightning, + EndpointService.typhoon, + EndpointService.location, + EndpointService.notify, + EndpointService.tremStation, + EndpointService.event, + EndpointService.rts, + ], + supported: { + 'TYO1': [EndpointService.eew, EndpointService.report], + // The exclusive api.core-tnn1 family (meteor, tiles lists, location, + // notify) has no tyo1 sibling. + 'TNN1': [ + EndpointService.eew, + EndpointService.report, + EndpointService.radar, + EndpointService.satellite, + EndpointService.qpesums, + EndpointService.wind, + EndpointService.weather, + EndpointService.rain, + EndpointService.lightning, + EndpointService.typhoon, + EndpointService.location, + EndpointService.notify, + ], + // The legacy api-1 host only carries the old strong-motion/event/history + // endpoints. + 'API-1': [ + EndpointService.tremStation, + EndpointService.event, + EndpointService.rts, + ], + }, + ), + _Group( + titleKey: 'endpointTierCoreStatic', + isStatic: true, + isCore: true, + regions: ['TYO1', 'TNN1'], + services: [ + // Tile/static-snapshot side of each family. + EndpointService.radar, + EndpointService.satellite, + EndpointService.qpesums, + EndpointService.wind, + EndpointService.dpm, + EndpointService.weather, + EndpointService.rain, + EndpointService.lightning, + EndpointService.typhoon, + ], + // api.md: every static host is core-tnn1. TYO1 has no static side at all, + // so its whole column is 不支援 rather than an em-dash that implies a + // host we simply have not probed yet. + supported: { + 'TNN1': [ + EndpointService.radar, + EndpointService.satellite, + EndpointService.qpesums, + EndpointService.wind, + EndpointService.dpm, + EndpointService.weather, + EndpointService.rain, + EndpointService.lightning, + EndpointService.typhoon, + ], + }, + ), + ]; + + @override + Widget build(BuildContext context) { + final monitor = context.watch(); + final entries = monitor.entries; + final summary = monitor.summary; + + // Group the observations by the four fixed categories. A group with no + // hits is still rendered — its cells are all em-dashes. + final groupContents = <_Group, List>{}; + for (final h in entries) { + for (final g in _groups) { + if (g.matches(h)) { + groupContents.putIfAbsent(g, () => []).add(h); + break; + } + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SummaryBanner(summary: summary), + const SizedBox(height: AppSpacing.md), + _Legend(), + const SizedBox(height: AppSpacing.md), + for (final g in _groups) ...[ + _ServiceTable( + title: g.title(context), + group: g, + hits: groupContents[g] ?? const [], + ), + if (g != _groups.last) const SizedBox(height: AppSpacing.md), + ], + ], + ); + } +} + +/// Legend explaining the four icon states a cell can carry. +class _Legend extends StatelessWidget { + const _Legend(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final entries = [ + ( + Icons.check_circle, + Theme.of(context).colorScheme.primary, + l10n.endpointStateOk, + ), + ( + Icons.error, + Theme.of(context).colorScheme.error, + l10n.endpointStateDown, + ), + ( + Icons.help_outline, + Theme.of(context).colorScheme.outline, + l10n.statusLegendUnprobed, + ), + ( + Icons.block, + Theme.of(context).colorScheme.outlineVariant, + l10n.statusLegendUnsupported, + ), + ]; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Wrap( + spacing: AppSpacing.md, + runSpacing: AppSpacing.xs, + children: [ + for (final (icon, color, label) in entries) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ); + } +} + +/// One of the four fixed tables: LB API / LB Static / Core API / Core Static. +class _Group { + const _Group({ + required this.titleKey, + required this.isStatic, + required this.isCore, + required this.regions, + required this.services, + required this.supported, + }); + + final String titleKey; + + /// Whether this group serves static assets (`static.*` hosts). + final bool isStatic; + + /// Whether this group is the Core family (or its legacy `api-1` member). + final bool isCore; + + /// The region columns this table always shows. + final List regions; + + /// Every service this category can carry, in display order. + final List services; + + /// Which region serves which service — the api.md support matrix. A key + /// missing from [supported] means that region does not carry that service. + final Map> supported; + + /// Whether [region] actually runs [service] on this tier family. + bool supports(EndpointService service, String region) => + supported[region]?.contains(service) ?? false; + + /// Whether [h] belongs in this group, judged by its host. + bool matches(EndpointHealth h) { + final host = h.host.toLowerCase(); + // The legacy host carries no lb/core/static marker in its name — it is + // Core's API machine by definition (user-facing categorisation). + if (host.startsWith('api-1.')) return isCore && !isStatic; + final isStaticHost = host.startsWith('static.'); + if (isStaticHost != isStatic) return false; + final isCoreHost = host.contains('.core-'); + return isCoreHost == isCore; + } + + String title(BuildContext context) { + final l10n = context.l10n; + return switch (titleKey) { + 'endpointTierLbApi' => l10n.endpointTierLbApi, + 'endpointTierLbStatic' => l10n.endpointTierLbStatic, + 'endpointTierCoreApi' => l10n.endpointTierCoreApi, + 'endpointTierCoreStatic' => l10n.endpointTierCoreStatic, + 'endpointTierCoreExclusiveApi' => l10n.endpointTierCoreExclusiveApi, + 'endpointTierCoreStaticExclusive' => l10n.endpointTierCoreStaticExclusive, + 'endpointTierLegacyApi' => l10n.endpointTierLegacyApi, + _ => titleKey, + }; + } +} + +class _ServiceTable extends StatelessWidget { + const _ServiceTable({ + required this.title, + required this.group, + required this.hits, + }); + + final String title; + + /// The fixed category this table renders; [group.services] and + /// [group.regions] are the full row/column set. + final _Group group; + + /// The observed hosts that belong to this table (may be empty). + final List hits; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + // Cell content: service × region → health (first hit seen wins). A cell + // with no hit is rendered as an em-dash, the honest "probe never touched + // this combination" answer. + final cell = <(EndpointService, String), EndpointHealth>{}; + for (final h in hits) { + cell.putIfAbsent((h.service, h.regionCode), () => h); + } + + final rows = >[]; + + // Header row: corner cell + region names. + rows.add([ + _corner(context), + for (final r in group.regions) _headerCell(context, r), + ]); + + // A cell with no hit is one of two honest answers depending on whether the + // region actually serves this service: 未探測 when it should (a host exists + // the probe simply has not touched), 不支援 when it should not (api.md says + // the region has no such host at all — e.g. every TYO1 static cell). + for (final s in group.services) { + rows.add([ + _serviceCell(context, s), + for (final r in group.regions) + group.supports(s, r) + ? _probeCell(context, cell[(s, r)]) + : _unsupportedCell(context), + ]); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Text( + title, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ), + Container( + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + ), + child: Column( + children: [ + for (final row in rows) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < row.length; i++) + Expanded(child: row[i]), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _corner(BuildContext context) => const SizedBox(height: 8); + + Widget _headerCell(BuildContext context, String text) => Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ); + + Widget _serviceCell(BuildContext context, EndpointService s) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + vertical: AppSpacing.sm, + ), + child: Text( + _serviceLabel(context, s), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ); + + /// One service × region cell: a status icon for that host, or a question-mark + /// icon when the probe never touched this combination. + Widget _probeCell(BuildContext context, EndpointHealth? h) { + if (h == null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Icon( + Icons.help_outline, + size: 16, + color: Theme.of(context).colorScheme.outline, + ), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: _RegionChip(region: h.regionCode, state: h.state, host: h.host), + ); + } + + /// A cell for a service × region combination api.md says the region cannot + /// carry (e.g. every TYO1 static cell) — a blocked icon that stays visually + /// distinct from 未探測's question mark. + Widget _unsupportedCell(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Icon( + Icons.block, + size: 16, + color: Theme.of(context).colorScheme.outlineVariant, + ), + ); + + String _serviceLabel(BuildContext context, EndpointService s) { + final l10n = context.l10n; + return switch (s) { + EndpointService.eew => l10n.endpointServiceEew, + EndpointService.rts => l10n.endpointServiceRts, + EndpointService.radar => l10n.endpointServiceRadar, + EndpointService.satellite => l10n.endpointServiceSatellite, + EndpointService.qpesums => l10n.endpointServiceQpesums, + EndpointService.wind => l10n.endpointServiceWind, + EndpointService.dpm => l10n.endpointServiceDpm, + EndpointService.weather => l10n.endpointServiceWeather, + EndpointService.rain => l10n.endpointServiceRain, + EndpointService.lightning => l10n.endpointServiceLightning, + EndpointService.typhoon => l10n.endpointServiceTyphoon, + EndpointService.report => l10n.endpointServiceReport, + EndpointService.tremStation => l10n.endpointServiceTremStation, + EndpointService.event => l10n.endpointServiceEvent, + EndpointService.location => l10n.endpointServiceLocation, + EndpointService.notify => l10n.endpointServiceNotify, + EndpointService.other => l10n.endpointServiceOther, + }; + } +} + +/// One status cell inside a service × tier table: the state as an icon — +/// check for healthy, warning for a blip, error for down, question for +/// never-touched — coloured by the host's state. +class _RegionChip extends StatelessWidget { + const _RegionChip({ + required this.region, + required this.state, + required this.host, + }); + + final String region; + final EndpointState state; + final String host; + + @override + Widget build(BuildContext context) { + final (color, label) = _stateColor(context, state); + final icon = switch (state) { + EndpointState.healthy => Icons.check_circle, + EndpointState.degraded => Icons.warning_amber_rounded, + EndpointState.down => Icons.error, + EndpointState.unknown => Icons.help_outline, + }; + return Tooltip( + message: '$region · $host\n$label', + child: SizedBox(height: 24, child: Icon(icon, size: 18, color: color)), + ); + } +} + +(Color, String) _stateColor(BuildContext context, EndpointState state) { + final colors = Theme.of(context).colorScheme; + return switch (state) { + EndpointState.down => (colors.error, context.l10n.endpointStateDown), + EndpointState.degraded => ( + colors.tertiary, + context.l10n.endpointStateDegraded, + ), + EndpointState.healthy => (colors.primary, context.l10n.endpointStateOk), + EndpointState.unknown => ( + colors.outline, + context.l10n.endpointStateUnknown, + ), + }; +} + +extension on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this); +} + +class _SummaryBanner extends StatelessWidget { + const _SummaryBanner({required this.summary}); + + final EndpointState summary; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = context.colorScheme; + final (color, fg, label, icon) = switch (summary) { + EndpointState.down => ( + colors.errorContainer, + colors.onErrorContainer, + l10n.endpointHealthDown, + Icons.error_outline, + ), + EndpointState.degraded => ( + colors.tertiaryContainer, + colors.onTertiaryContainer, + l10n.endpointHealthDegraded, + Icons.warning_amber_outlined, + ), + EndpointState.healthy => ( + colors.primaryContainer, + colors.onPrimaryContainer, + l10n.endpointHealthOk, + Icons.check_circle_outline, + ), + EndpointState.unknown => ( + colors.surfaceContainerHigh, + colors.onSurfaceVariant, + l10n.endpointHealthUnknown, + Icons.help_outline, + ), + }; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration(color: color, borderRadius: AppRadius.medium), + child: Row( + children: [ + Icon(icon, color: fg, size: 20), + const SizedBox(width: AppSpacing.sm), + Text( + label, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(color: fg, fontWeight: FontWeight.w600), + ), + ], + ), + ); + } +} + +class _StatusGrid extends StatelessWidget { + const _StatusGrid({required this.status}); + + final ServerStatus status; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Column( + children: [ + _healthBanner(context), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: _metricCard( + context, + title: l10n.serverStatusDown, + value: '${status.down.value}', + subtitle: _maybeInstance(status.down.instance), + color: status.allUp + ? context.colorScheme.primary + : context.colorScheme.error, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _metricCard( + context, + title: l10n.serverStatusErrorRate, + value: '${status.errorRate.value.toStringAsFixed(2)}%', + subtitle: _maybeInstance(status.errorRate.instance), + color: _threeTone(context, status.errorRate.value, 0.1, 0.3), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _metricCard( + context, + title: l10n.serverStatusLatency, + value: '${status.latency.value.toStringAsFixed(0)}ms', + subtitle: _maybeInstance(status.latency.instance), + color: _threeTone(context, status.latency.value, 10, 50), + ), + ), + ], + ), + ], + ); + } + + String _maybeInstance(String? instance) => + (instance?.isEmpty ?? true) ? '—' : instance!; + + Widget _healthBanner(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = context.colorScheme; + final (color, fg, label, icon) = switch (status.health) { + StatusHealth.ok => ( + colors.primaryContainer, + colors.onPrimaryContainer, + l10n.serverStatusAllUp, + Icons.check_circle_outline, + ), + StatusHealth.degraded => ( + colors.tertiaryContainer, + colors.onTertiaryContainer, + l10n.serverStatusDegraded, + Icons.warning_amber_outlined, + ), + StatusHealth.down => ( + colors.errorContainer, + colors.onErrorContainer, + l10n.serverStatusDown, + Icons.error_outline, + ), + }; + final t = status.recordedAt.toLocal(); + final hh = t.hour.toString().padLeft(2, '0'); + final mm = t.minute.toString().padLeft(2, '0'); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration(color: color, borderRadius: AppRadius.medium), + child: Row( + children: [ + Icon(icon, color: fg, size: 20), + const SizedBox(width: AppSpacing.sm), + Text( + label, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(color: fg, fontWeight: FontWeight.w600), + ), + const Spacer(), + Text( + '${l10n.serverStatusUpdated} $hh:$mm', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: fg), + ), + ], + ), + ); + } + + Widget _metricCard( + BuildContext context, { + required String title, + required String value, + required String subtitle, + required Color color, + }) { + final colors = context.colorScheme; + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant), + ), + const SizedBox(height: AppSpacing.sm), + Text( + value, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(color: color, fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.xs), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant.withValues(alpha: 0.8), + ), + ), + ], + ), + ); + } +} + +/// The Cloudflare Status block: one card per observed Taipei / Kaohsiung +/// component, each with its state as an icon and a coloured banner. +class _CloudflareGrid extends StatelessWidget { + const _CloudflareGrid({required this.status}); + + final CloudflareStatus status; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = context.colorScheme; + final ( + bannerColor, + bannerFg, + bannerLabel, + bannerIcon, + ) = switch (status.allOperational) { + true => ( + colors.primaryContainer, + colors.onPrimaryContainer, + l10n.serverStatusCloudflareAllOperational, + Icons.check_circle_outline, + ), + false => ( + colors.errorContainer, + colors.onErrorContainer, + l10n.serverStatusCloudflareOutage, + Icons.error_outline, + ), + }; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: bannerColor, + borderRadius: AppRadius.medium, + ), + child: Row( + children: [ + Icon(bannerIcon, color: bannerFg, size: 20), + const SizedBox(width: AppSpacing.sm), + Text( + bannerLabel, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(color: bannerFg, fontWeight: FontWeight.w600), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.sm), + if (status.components.isEmpty) + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + ), + child: Text( + l10n.serverStatusCloudflareNone, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant), + ), + ) + else + for (final component in status.components) ...[ + _CloudflareTile(component: component), + if (component != status.components.last) + const SizedBox(height: AppSpacing.xs), + ], + ], + ); + } +} + +class _CloudflareTile extends StatelessWidget { + const _CloudflareTile({required this.component}); + + final CloudflareComponent component; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = context.colorScheme; + final (color, label) = switch (component.state) { + CloudflareComponentState.operational => ( + colors.primary, + l10n.serverStatusCloudflareOperational, + ), + CloudflareComponentState.degradedPerformance => ( + colors.tertiary, + l10n.serverStatusCloudflareDegraded, + ), + CloudflareComponentState.partialOutage => ( + colors.tertiary, + l10n.serverStatusCloudflarePartial, + ), + CloudflareComponentState.majorOutage => ( + colors.error, + l10n.serverStatusCloudflareMajor, + ), + CloudflareComponentState.unknown => ( + colors.outline, + l10n.serverStatusCloudflareUnknown, + ), + }; + final icon = switch (component.state) { + CloudflareComponentState.operational => Icons.check_circle, + CloudflareComponentState.degradedPerformance || + CloudflareComponentState.partialOutage => Icons.warning_amber_rounded, + CloudflareComponentState.majorOutage => Icons.error, + CloudflareComponentState.unknown => Icons.help_outline, + }; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + ), + child: Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + component.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + _updatedLabel(context, l10n, component), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 4, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: color, fontWeight: FontWeight.w700), + ), + ), + ], + ), + ); + } + + String _updatedLabel( + BuildContext context, + AppLocalizations l10n, + CloudflareComponent component, + ) { + final t = component.updatedAt.toLocal(); + final hh = t.hour.toString().padLeft(2, '0'); + final mm = t.minute.toString().padLeft(2, '0'); + return '${l10n.serverStatusUpdated} $hh:$mm'; + } +} + +extension on BuildContext { + ThemeData get theme => Theme.of(this); + ColorScheme get colorScheme => theme.colorScheme; +} + +Color _threeTone(BuildContext context, num value, double warn, double bad) { + final colors = context.colorScheme; + if (value >= bad) return colors.error; + if (value >= warn) return colors.tertiary; + return colors.primary; +} diff --git a/lib/features/status/status_providers.dart b/lib/features/status/status_providers.dart new file mode 100644 index 000000000..92fe851a9 --- /dev/null +++ b/lib/features/status/status_providers.dart @@ -0,0 +1,26 @@ +/// Status feature providers. +library; + +import 'package:dpip/core/di/shared_deps.dart'; +import 'package:dpip/features/status/data/cloudflare_status_api.dart'; +import 'package:dpip/features/status/data/cloudflare_status_repository_impl.dart'; +import 'package:dpip/features/status/data/server_status_api.dart'; +import 'package:dpip/features/status/data/server_status_repository_impl.dart'; +import 'package:dpip/features/status/domain/cloudflare_status_repository.dart'; +import 'package:dpip/features/status/domain/server_status_repository.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; + +/// Exposes the status repositories for the More → 伺服器狀態 screen. +/// +/// The page depends on the domain interfaces only; the concrete Grafana / +/// Cloudflare implementations live here, at the feature root, so the page +/// never imports a data layer. +List statusProviders(SharedDeps deps) => [ + Provider.value( + value: ServerStatusRepositoryImpl(ServerStatusApi(deps.apiClient)), + ), + Provider.value( + value: CloudflareStatusRepositoryImpl(CloudflareStatusApi(deps.apiClient)), + ), +]; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8c0736cf7..c7faa9e74 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -240,7 +240,9 @@ "updateOpenPlayStore": "Play Store", "updateDownload": "Download", "changelogShowSnapshots": "Show snapshots", - "@changelogShowSnapshots": { "description": "Changelog action that reveals pre-release snapshots" }, + "@changelogShowSnapshots": { + "description": "Changelog action that reveals pre-release snapshots" + }, "changelogTitle": "Changelog", "reportFilterOrderDesc": "Descending", "meshtasticExcludeMqttSubtitle": "Nodes bridged over the internet, not heard by radio", @@ -1307,6 +1309,13 @@ "description": "Himawari CO₂-band channel (B16, 13.3 µm) layer name" }, "moreSectionApp": "Get the app", + "moreSectionBeta": "Beta", + "moreAndroidBeta": "Android beta", + "moreTestFlight": "iOS beta (TestFlight)", + "moreSectionPartners": "Partners", + "morePartnersNote": "Listed in order of partnership. Thank you to the individuals and companies whose contributions to disaster preparedness made DPIP possible.", + "morePartnerGeoscience": "Geoscience", + "morePartnerTwds": "TWDS", "reportFilterIntensityInfoLegacyBody": "Only levels 0–7. No 5− / 5+ / 6− / 6+ split.", "@notifyTsunami": { "description": "Notify channel title" @@ -2047,6 +2056,7 @@ "description": "Himawari sea-surface-temperature (ACSPO L3C) layer name" }, "changelogBodyEmpty": "No notes for this release.", + "changelogOpenOnGitHub": "View on GitHub", "radarGlobalOutline": "National borders", "@mapMyLocation": { "description": "Map control that centers the camera on the device GPS fix" @@ -2718,6 +2728,59 @@ "lightningLegendCg": "Cloud-to-ground · {minutes} min", "skyTimeAuto": "Auto", "appLogs": "App logs", + "serverStatusLocal": "Local status", + "serverStatusLocalBody": "Metrics come from the dashboard. Below is this device's own view of the multi-active endpoints (LB / Core per region): it passively records the traffic each endpoint actually serves, so a cell with no data means nothing was observed through this device yet.", + "serverStatusAllUp": "All services operational", + "serverStatusDegraded": "Services degraded", + "serverStatusDown": "Service down", + "serverStatusErrorRate": "5xx error rate", + "serverStatusLatency": "Avg latency", + "serverStatusUpdated": "Updated", + "serverStatusWeb": "Server status", + "serverStatusWebUrl": "status.exptech.dev", + "serverStatusExpTech": "ExpTech status", + "serverStatusCloudflare": "Cloudflare status", + "serverStatusCloudflareAllOperational": "All regions operational", + "serverStatusCloudflareOutage": "Cloudflare regional issue", + "serverStatusCloudflareNone": "No regions to show.", + "serverStatusCloudflareOperational": "Operational", + "serverStatusCloudflareDegraded": "Degraded", + "serverStatusCloudflarePartial": "Partial outage", + "serverStatusCloudflareMajor": "Major outage", + "serverStatusCloudflareUnknown": "Unknown", + "endpointTierLbApi": "LB API", + "endpointTierLbStatic": "LB Static", + "endpointTierCoreApi": "Core API", + "endpointTierCoreStatic": "Core Static", + "endpointTierCoreExclusiveApi": "Core-exclusive API (radar / weather / wind)", + "endpointTierCoreStaticExclusive": "Core-exclusive static", + "endpointTierLegacyApi": "Legacy API (api-1)", + "endpointHealthOk": "Local connections healthy", + "endpointHealthDegraded": "Some endpoints unstable", + "endpointHealthDown": "Local connections failing", + "endpointHealthUnknown": "No observations yet", + "endpointStateOk": "OK", + "endpointStateDegraded": "Unstable", + "endpointStateDown": "Failing", + "endpointStateUnknown": "Unknown", + "endpointLastSuccessNever": "never succeeded", + "endpointServiceEew": "EEW", + "endpointServiceRts": "RTS", + "endpointServiceRadar": "Radar", + "endpointServiceSatellite": "Satellite", + "endpointServiceQpesums": "QPE", + "endpointServiceWind": "Wind", + "endpointServiceDpm": "Disaster points", + "endpointServiceWeather": "Weather", + "endpointServiceRain": "Rain", + "endpointServiceLightning": "Lightning", + "endpointServiceTyphoon": "Typhoon", + "endpointServiceReport": "EQ reports", + "endpointServiceTremStation": "Tremor station", + "endpointServiceEvent": "Events", + "endpointServiceLocation": "Location", + "endpointServiceNotify": "Notifications", + "endpointServiceOther": "Other", "feedConnecting": "Connecting…", "notifyBannerDisabled": "Notifications are off — you won't receive disaster alerts.", "@meshtasticNoNodes": { @@ -2775,12 +2838,17 @@ "description": "AED city / district row label" }, "moreAnnouncements": "Announcements", + "moreTagline": "Disaster Prevention Information Platform", "moreVersionStable": "Release", "moreVersionNotes": "This version", + "releaseHighlightsTitle": "What changed in this release", + "releaseHighlightsTabNormal": "For users", + "releaseHighlightsTabAdvanced": "Deep dive", + "releaseHighlightsEmpty": "Nothing here yet.", + "releaseHighlightsSeeNotes": "Full release notes", + "highlightCardTechnical": "Technical", "moreVersionNotesEmpty": "No changelog for this build", "moreVersionSnapshot": "Snapshot", - "moreVersionStable": "Release", - "moreVersionSnapshot": "Snapshot", "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", "@meshtasticScanning": { "description": "Scan in progress" @@ -3905,5 +3973,31 @@ "type": "int" } } - } + }, + "moreDumpDiagnostics": "Dump debug info and logs", + "@moreDumpDiagnostics": { + "description": "More menu row that uploads a debug dump" + }, + "moreDumpDiagnosticsHint": "Uploads them and copies a link to paste into a report", + "@moreDumpDiagnosticsHint": { + "description": "Subtitle of the debug-dump row" + }, + "dumpUploaded": "Uploaded", + "@dumpUploaded": { + "description": "Title of the dialog shown after a debug dump uploads" + }, + "dumpLinkCopied": "The link is on your clipboard", + "@dumpLinkCopied": { + "description": "Says the uploaded dump link is already on the clipboard" + }, + "dumpCopyAgain": "Copy again", + "@dumpCopyAgain": { + "description": "Button that copies the dump link to the clipboard again" + }, + "dumpUploadFailed": "Upload failed — try again", + "@dumpUploadFailed": { + "description": "Shown when a debug dump could not be uploaded" + }, + "statusLegendUnprobed": "Not yet probed", + "statusLegendUnsupported": "Not offered" } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 92b9a8420..e8c7c7154 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -445,6 +445,13 @@ "dpmAddress": "Address", "weatherRankingMergeCounty": "Lalawigan", "moreSectionApp": "Kunin ang app", + "moreSectionBeta": "Bersyon ng pagsubok", + "moreAndroidBeta": "Bersyon ng pagsubok sa Android", + "moreTestFlight": "Bersyon ng pagsubok sa iOS (TestFlight)", + "moreSectionPartners": "Mga kasosyo", + "morePartnersNote": "Nakaayos ayon sa tamang panahon ng pakikipagtulungan. Salamat sa mga indibidwal at kompanyang nag-ambag sa paghahanda sa kalamidad; ang kanilang kontribusyon ang nagbigay-daan sa DPIP.", + "morePartnerGeoscience": "Geoscience", + "morePartnerTwds": "TWDS", "reportFilterIntensityInfoLegacyBody": "Antas 0–7 lang; walang 5−/5+/6−/6+.", "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", "qpesumsOverlayMenuTooltip": "Mga opsyon sa layer ng pagtataya ng pag-ulan", @@ -679,6 +686,7 @@ "meshtasticPreset": "Modem preset", "dataSectionSeismic": "Seismic", "changelogBodyEmpty": "Walang tala para sa release na ito.", + "changelogOpenOnGitHub": "Tingnan sa GitHub", "radarGlobalOutline": "Mga hangganan ng bansa", "notifyEew": "Emergency na alerto sa lindol", "regionNationwide": "Buong bansa", @@ -954,6 +962,59 @@ "lightningLegendCg": "Ulap–lupa · {minutes} min", "skyTimeAuto": "Awtomatiko", "appLogs": "Mga log ng app", + "serverStatusLocal": "Katayuan ng device", + "serverStatusLocalBody": "Ang mga sukatan ng server ay mula sa dashboard. Nasa ibaba ang aktwal na paghusga ng device na ito sa mga multi-active na endpoint (LB / Core bawat rehiyon): pasibo lang itong nagtatala ng trapikong talagang pinapadala; kung hindi pa ito naantig ng device, lalabas ang 'Hindi pa nasuri'.", + "serverStatusAllUp": "Lahat ng serbisyo ay normal", + "serverStatusDegraded": "Bumaba ang pagganap", + "serverStatusDown": "May problema ang serbisyo", + "serverStatusErrorRate": "Rate ng error na 5xx", + "serverStatusLatency": "Karaniwang latency", + "serverStatusUpdated": "Na-update", + "serverStatusWeb": "Katayuan ng server", + "serverStatusWebUrl": "status.exptech.dev", + "serverStatusExpTech": "Katayuan ng ExpTech", + "serverStatusCloudflare": "Katayuan ng Cloudflare", + "serverStatusCloudflareAllOperational": "Normal ang lahat ng lugar", + "serverStatusCloudflareOutage": "May problema ang Cloudflare sa ilang lugar", + "serverStatusCloudflareNone": "Walang lugar na maipapakita.", + "serverStatusCloudflareOperational": "Normal", + "serverStatusCloudflareDegraded": "Bumaba ang pagganap", + "serverStatusCloudflarePartial": "Bahagyang pagkaantala", + "serverStatusCloudflareMajor": "Malaking pagkaantala", + "serverStatusCloudflareUnknown": "Hindi alam", + "endpointTierLbApi": "LB API", + "endpointTierLbStatic": "LB Static", + "endpointTierCoreApi": "Core API", + "endpointTierCoreStatic": "Core Static", + "endpointTierCoreExclusiveApi": "Core-eksklusibong API (radar / panahon / hangin)", + "endpointTierCoreStaticExclusive": "Core-eksklusibong static", + "endpointTierLegacyApi": "Legacy API (api-1)", + "endpointHealthOk": "Normal ang koneksyon", + "endpointHealthDegraded": "May endpoint na hindi matatag", + "endpointHealthDown": "May problema ang koneksyon", + "endpointHealthUnknown": "Wala pang datos", + "endpointStateOk": "Normal", + "endpointStateDegraded": "Hindi matatag", + "endpointStateDown": "May problema", + "endpointStateUnknown": "Hindi alam", + "endpointLastSuccessNever": "hindi pa nagtagumpay", + "endpointServiceEew": "EEW", + "endpointServiceRts": "RTS", + "endpointServiceRadar": "Radar", + "endpointServiceSatellite": "Satellite", + "endpointServiceQpesums": "QPE", + "endpointServiceWind": "Wind", + "endpointServiceDpm": "Disaster points", + "endpointServiceWeather": "Weather", + "endpointServiceRain": "Rain", + "endpointServiceLightning": "Lightning", + "endpointServiceTyphoon": "Typhoon", + "endpointServiceReport": "EQ reports", + "endpointServiceTremStation": "Tremor station", + "endpointServiceEvent": "Events", + "endpointServiceLocation": "Location", + "endpointServiceNotify": "Notifications", + "endpointServiceOther": "Other", "feedConnecting": "Kumokonekta…", "notifyBannerDisabled": "Naka-off ang mga notification — hindi ka makakatanggap ng mga alerto sa sakuna.", "@meshtasticNoNodes": { @@ -980,8 +1041,15 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "JMA grayscale — colder is whiter", "moreAnnouncements": "Mga Anunsyo", + "moreTagline": "Platform para sa Integral na Impormasyon sa Kalamidad", "moreVersionStable": "Pormal na bersyon", "moreVersionNotes": "Kasalukuyang bersyon", + "releaseHighlightsSeeNotes": "Buong tala ng release", + "releaseHighlightsTitle": "Ano ang nagbago", + "releaseHighlightsTabNormal": "Para sa mga user", + "releaseHighlightsTabAdvanced": "Mas malalim", + "releaseHighlightsEmpty": "Wala pang laman.", + "highlightCardTechnical": "Teknikal", "moreVersionNotesEmpty": "Walang changelog para sa build na ito", "moreVersionSnapshot": "Bersyon ng pagsubok", "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", @@ -1867,5 +1935,13 @@ "type": "int" } } - } + }, + "moreDumpDiagnostics": "I-upload ang debug info at mga log", + "moreDumpDiagnosticsHint": "Iuupload at kokopyahin ang link para ilakip sa ulat", + "dumpUploaded": "Na-upload", + "dumpLinkCopied": "Nakopya ang link sa clipboard", + "dumpCopyAgain": "Kopyahin ulit", + "dumpUploadFailed": "Nabigong mag-upload", + "statusLegendUnprobed": "Hindi pa nasuri", + "statusLegendUnsupported": "Hindi suportado" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index c2cd07d8b..8038831ba 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -445,6 +445,13 @@ "dpmAddress": "Alamat", "weatherRankingMergeCounty": "Kabupaten", "moreSectionApp": "Dapatkan aplikasi", + "moreSectionBeta": "Versi uji", + "moreAndroidBeta": "Versi uji Android", + "moreTestFlight": "Versi uji iOS (TestFlight)", + "moreSectionPartners": "Mitra", + "morePartnersNote": "Urut sesuai waktu kemitraan. Terima kasih kepada para individu dan perusahaan yang berkontribusi pada penanggulangan bencana; kontribusi mereka membuat DPIP menjadi mungkin.", + "morePartnerGeoscience": "Geoscience", + "morePartnerTwds": "TWDS", "reportFilterIntensityInfoLegacyBody": "Hanya tingkat 0–7, tanpa pemisahan 5−/5+/6−/6+.", "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", "qpesumsOverlayMenuTooltip": "Opsi lapisan prakiraan curah hujan", @@ -679,6 +686,7 @@ "meshtasticPreset": "Modem preset", "dataSectionSeismic": "Seismik", "changelogBodyEmpty": "Tidak ada catatan untuk rilis ini.", + "changelogOpenOnGitHub": "Lihat di GitHub", "radarGlobalOutline": "Batas negara", "notifyEew": "Peringatan gempa darurat", "regionNationwide": "Seluruh negeri", @@ -954,6 +962,59 @@ "lightningLegendCg": "Awan–tanah · {minutes} mnt", "skyTimeAuto": "Otomatis", "appLogs": "Log aplikasi", + "serverStatusLocal": "Status perangkat", + "serverStatusLocalBody": "Metrik server berasal dari dasbor. Di bawah ini adalah penilaian koneksi aktual perangkat ini ke endpoint multi-aktif (LB / Core tiap wilayah): aplikasi hanya mencatat lalu lintas yang benar-benar dikirim, jika endpoint belum pernah disentuh perangkat ini akan ditampilkan 'Belum diperiksa'.", + "serverStatusAllUp": "Semua layanan normal", + "serverStatusDegraded": "Kinerja menurun", + "serverStatusDown": "Layanan bermasalah", + "serverStatusErrorRate": "Tingkat error 5xx", + "serverStatusLatency": "Latensi rata-rata", + "serverStatusUpdated": "Diperbarui", + "serverStatusWeb": "Status server", + "serverStatusWebUrl": "status.exptech.dev", + "serverStatusExpTech": "Status ExpTech", + "serverStatusCloudflare": "Status Cloudflare", + "serverStatusCloudflareAllOperational": "Semua wilayah normal", + "serverStatusCloudflareOutage": "Cloudflare beberapa wilayah bermasalah", + "serverStatusCloudflareNone": "Tidak ada wilayah untuk ditampilkan.", + "serverStatusCloudflareOperational": "Normal", + "serverStatusCloudflareDegraded": "Kinerja menurun", + "serverStatusCloudflarePartial": "Gangguan sebagian", + "serverStatusCloudflareMajor": "Gangguan besar", + "serverStatusCloudflareUnknown": "Tidak diketahui", + "endpointTierLbApi": "LB API", + "endpointTierLbStatic": "LB Static", + "endpointTierCoreApi": "Core API", + "endpointTierCoreStatic": "Core Static", + "endpointTierCoreExclusiveApi": "Core eksklusif API (radar / cuaca / angin)", + "endpointTierCoreStaticExclusive": "Core eksklusif statis", + "endpointTierLegacyApi": "API lama (api-1)", + "endpointHealthOk": "Koneksi normal", + "endpointHealthDegraded": "Ada endpoint tidak stabil", + "endpointHealthDown": "Koneksi bermasalah", + "endpointHealthUnknown": "Belum ada data", + "endpointStateOk": "Normal", + "endpointStateDegraded": "Tidak stabil", + "endpointStateDown": "Bermasalah", + "endpointStateUnknown": "Tidak diketahui", + "endpointLastSuccessNever": "belum berhasil", + "endpointServiceEew": "EEW", + "endpointServiceRts": "RTS", + "endpointServiceRadar": "Radar", + "endpointServiceSatellite": "Satellite", + "endpointServiceQpesums": "QPE", + "endpointServiceWind": "Wind", + "endpointServiceDpm": "Disaster points", + "endpointServiceWeather": "Weather", + "endpointServiceRain": "Rain", + "endpointServiceLightning": "Lightning", + "endpointServiceTyphoon": "Typhoon", + "endpointServiceReport": "EQ reports", + "endpointServiceTremStation": "Tremor station", + "endpointServiceEvent": "Events", + "endpointServiceLocation": "Location", + "endpointServiceNotify": "Notifications", + "endpointServiceOther": "Other", "feedConnecting": "Menghubungkan…", "notifyBannerDisabled": "Notifikasi mati — Anda tidak akan menerima peringatan bencana.", "@meshtasticNoNodes": { @@ -980,8 +1041,15 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "JMA grayscale — colder is whiter", "moreAnnouncements": "Pengumuman", + "moreTagline": "Platform Integrasi Informasi Bencana", "moreVersionStable": "Versi resmi", "moreVersionNotes": "Versi saat ini", + "releaseHighlightsSeeNotes": "Catatan rilis lengkap", + "releaseHighlightsTitle": "Yang berubah", + "releaseHighlightsTabNormal": "Untuk pengguna", + "releaseHighlightsTabAdvanced": "Mendalam", + "releaseHighlightsEmpty": "Belum ada konten.", + "highlightCardTechnical": "Teknis", "moreVersionNotesEmpty": "Tidak ada changelog untuk build ini", "moreVersionSnapshot": "Versi uji", "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", @@ -1867,5 +1935,13 @@ "type": "int" } } - } + }, + "moreDumpDiagnostics": "Unggah info debug dan log", + "moreDumpDiagnosticsHint": "Mengunggah lalu menyalin tautan untuk dilampirkan ke laporan", + "dumpUploaded": "Terunggah", + "dumpLinkCopied": "Tautan disalin ke papan klip", + "dumpCopyAgain": "Salin lagi", + "dumpUploadFailed": "Gagal mengunggah", + "statusLegendUnprobed": "Belum diperiksa", + "statusLegendUnsupported": "Tidak tersedia" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index f20af76c0..44dfaef37 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -445,6 +445,13 @@ "dpmAddress": "住所", "weatherRankingMergeCounty": "県市", "moreSectionApp": "アプリを入手", + "moreSectionBeta": "テスト版", + "moreAndroidBeta": "Android テスト版", + "moreTestFlight": "iOS テスト版(TestFlight)", + "moreSectionPartners": "パートナー", + "morePartnersNote": "提携順に表示しています。防災への貢献で DPIP を支えてくださった個人・企業の皆様に感謝します。", + "morePartnerGeoscience": "Geoscience", + "morePartnerTwds": "TWDS", "reportFilterIntensityInfoLegacyBody": "震度は 0–7 のみ。5弱/5強/6弱/6強の区分はありません。", "mapLayerSatelliteSst": "ひまわり 海面水温", "qpesumsOverlayMenuTooltip": "定量降水予報レイヤー設定", @@ -679,6 +686,7 @@ "meshtasticPreset": "Modem preset", "dataSectionSeismic": "地震", "changelogBodyEmpty": "このリリースの説明はありません。", + "changelogOpenOnGitHub": "GitHub で見る", "radarGlobalOutline": "国境線", "notifyEew": "緊急地震速報", "regionNationwide": "全国", @@ -954,6 +962,59 @@ "lightningLegendCg": "対地 · {minutes} 分以内", "skyTimeAuto": "自動", "appLogs": "アプリログ", + "serverStatusLocal": "デバイスの状態", + "serverStatusLocalBody": "サーバー指標はダッシュボードからのものです。以下は本機のマルチアクティブエンドポイント(LB / Core 各リージョン)への実際の接続判断です:本機が実際に送受信したトラフィックだけを受動的に記録するため、まだ触れていないエンドポイントは「未探知」と表示されます。", + "serverStatusAllUp": "すべて正常", + "serverStatusDegraded": "パフォーマンス低下", + "serverStatusDown": "サービス異常", + "serverStatusErrorRate": "5xx エラー率", + "serverStatusLatency": "平均遅延", + "serverStatusUpdated": "更新", + "serverStatusWeb": "サーバー状態", + "serverStatusWebUrl": "status.exptech.dev", + "serverStatusExpTech": "ExpTech ステータス", + "serverStatusCloudflare": "Cloudflare ステータス", + "serverStatusCloudflareAllOperational": "全リージョン正常", + "serverStatusCloudflareOutage": "Cloudflare の一部リージョンで異常", + "serverStatusCloudflareNone": "表示できるリージョンがありません。", + "serverStatusCloudflareOperational": "正常", + "serverStatusCloudflareDegraded": "性能低下", + "serverStatusCloudflarePartial": "部分停止", + "serverStatusCloudflareMajor": "大規模停止", + "serverStatusCloudflareUnknown": "不明", + "endpointTierLbApi": "LB API", + "endpointTierLbStatic": "LB Static", + "endpointTierCoreApi": "Core API", + "endpointTierCoreStatic": "Core Static", + "endpointTierCoreExclusiveApi": "Core 専用 API(レーダー / 気象 / 風)", + "endpointTierCoreStaticExclusive": "Core 専用静的リソース", + "endpointTierLegacyApi": "レガシー API(api-1)", + "endpointHealthOk": "接続正常", + "endpointHealthDegraded": "不安定なエンドポイントあり", + "endpointHealthDown": "接続異常", + "endpointHealthUnknown": "観測データなし", + "endpointStateOk": "正常", + "endpointStateDegraded": "不安定", + "endpointStateDown": "異常", + "endpointStateUnknown": "不明", + "endpointLastSuccessNever": "未成功", + "endpointServiceEew": "EEW", + "endpointServiceRts": "RTS", + "endpointServiceRadar": "Radar", + "endpointServiceSatellite": "Satellite", + "endpointServiceQpesums": "QPE", + "endpointServiceWind": "Wind", + "endpointServiceDpm": "Disaster points", + "endpointServiceWeather": "Weather", + "endpointServiceRain": "Rain", + "endpointServiceLightning": "Lightning", + "endpointServiceTyphoon": "Typhoon", + "endpointServiceReport": "EQ reports", + "endpointServiceTremStation": "Tremor station", + "endpointServiceEvent": "Events", + "endpointServiceLocation": "Location", + "endpointServiceNotify": "Notifications", + "endpointServiceOther": "Other", "feedConnecting": "接続中…", "notifyBannerDisabled": "通知がオフです — 災害警報を受け取れません。", "@meshtasticNoNodes": { @@ -980,8 +1041,15 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "気象庁の赤外画像の慣例:温度が低いほど白", "moreAnnouncements": "お知らせ", + "moreTagline": "防災情報統合プラットフォーム", "moreVersionStable": "正式版", "moreVersionNotes": "現在のバージョン", + "releaseHighlightsSeeNotes": "完全なリリースノート", + "releaseHighlightsTitle": "今回の更新", + "releaseHighlightsTabNormal": "変更点", + "releaseHighlightsTabAdvanced": "技術詳細", + "releaseHighlightsEmpty": "まだコンテンツがありません。", + "highlightCardTechnical": "技術詳細", "moreVersionNotesEmpty": "このビルドの更新履歴が見つかりません", "moreVersionSnapshot": "テスト版", "mapLayerSatelliteTransparentNoData": "データなし(陸上) = 透明", @@ -1867,5 +1935,13 @@ "type": "int" } } - } + }, + "moreDumpDiagnostics": "デバッグ情報とログを送信", + "moreDumpDiagnosticsHint": "アップロードしてリンクをコピーします", + "dumpUploaded": "アップロードしました", + "dumpLinkCopied": "リンクをクリップボードにコピーしました", + "dumpCopyAgain": "もう一度コピー", + "dumpUploadFailed": "アップロードに失敗しました", + "statusLegendUnprobed": "未探知", + "statusLegendUnsupported": "非対応" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6463b3abf..d785105cc 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -445,6 +445,13 @@ "dpmAddress": "주소", "weatherRankingMergeCounty": "현시", "moreSectionApp": "앱 다운로드", + "moreSectionBeta": "테스트 버전", + "moreAndroidBeta": "Android 테스트 버전", + "moreTestFlight": "iOS 테스트 버전 (TestFlight)", + "moreSectionPartners": "파트너", + "morePartnersNote": "파트너십 순서대로 표시됩니다. 재난 예방에 기여한 개인과 기업에 감사드립니다. 그들의 기여 더봉에 DPIP가 가능했습니다.", + "morePartnerGeoscience": "Geoscience", + "morePartnerTwds": "TWDS", "reportFilterIntensityInfoLegacyBody": "진도는 0–7만 있으며 5약/5강/6약/6강 구분이 없습니다.", "mapLayerSatelliteSst": "히마와리 해수면 온도", "qpesumsOverlayMenuTooltip": "정량 강수 예보 레이어 옵션", @@ -679,6 +686,7 @@ "meshtasticPreset": "Modem preset", "dataSectionSeismic": "지진", "changelogBodyEmpty": "이 릴리스에 대한 설명이 없습니다.", + "changelogOpenOnGitHub": "GitHub에서 보기", "radarGlobalOutline": "국경", "notifyEew": "긴급 지진 경보", "regionNationwide": "전국", @@ -954,6 +962,59 @@ "lightningLegendCg": "대지로 · {minutes}분 이내", "skyTimeAuto": "자동", "appLogs": "앱 로그", + "serverStatusLocal": "기기 상태", + "serverStatusLocalBody": "서버 지표는 대시보드에서 가져옵니다. 아래는 이 기기의 멀티 액티브 엔드포인트(LB / Core 각 지역)에 대한 실제 연결 판단입니다. 기기가 실제로 주고받은 트래픽만 수동적으로 기록하므로, 아직 접촉하지 않은 엔드포인트는 '탐지 안 됨'으로 표시됩니다.", + "serverStatusAllUp": "모든 서비스 정상", + "serverStatusDegraded": "성능 저하", + "serverStatusDown": "서비스 이상", + "serverStatusErrorRate": "5xx 오류율", + "serverStatusLatency": "평균 지연", + "serverStatusUpdated": "업데이트", + "serverStatusWeb": "서버 상태", + "serverStatusWebUrl": "status.exptech.dev", + "serverStatusExpTech": "ExpTech 상태", + "serverStatusCloudflare": "Cloudflare 상태", + "serverStatusCloudflareAllOperational": "모든 리전 정상", + "serverStatusCloudflareOutage": "Cloudflare 일부 리전 이상", + "serverStatusCloudflareNone": "표시할 리전이 없습니다.", + "serverStatusCloudflareOperational": "정상", + "serverStatusCloudflareDegraded": "성능 저하", + "serverStatusCloudflarePartial": "부분 중단", + "serverStatusCloudflareMajor": "대규모 중단", + "serverStatusCloudflareUnknown": "알 수 없음", + "endpointTierLbApi": "LB API", + "endpointTierLbStatic": "LB Static", + "endpointTierCoreApi": "Core API", + "endpointTierCoreStatic": "Core Static", + "endpointTierCoreExclusiveApi": "Core 전용 API (레이다 / 기상 / 바람)", + "endpointTierCoreStaticExclusive": "Core 전용 정적 리소스", + "endpointTierLegacyApi": "레거시 API (api-1)", + "endpointHealthOk": "연결 정상", + "endpointHealthDegraded": "불안정한 엔드포인트 있음", + "endpointHealthDown": "연결 이상", + "endpointHealthUnknown": "관측 데이터 없음", + "endpointStateOk": "정상", + "endpointStateDegraded": "불안정", + "endpointStateDown": "이상", + "endpointStateUnknown": "알 수 없음", + "endpointLastSuccessNever": "미성공", + "endpointServiceEew": "EEW", + "endpointServiceRts": "RTS", + "endpointServiceRadar": "Radar", + "endpointServiceSatellite": "Satellite", + "endpointServiceQpesums": "QPE", + "endpointServiceWind": "Wind", + "endpointServiceDpm": "Disaster points", + "endpointServiceWeather": "Weather", + "endpointServiceRain": "Rain", + "endpointServiceLightning": "Lightning", + "endpointServiceTyphoon": "Typhoon", + "endpointServiceReport": "EQ reports", + "endpointServiceTremStation": "Tremor station", + "endpointServiceEvent": "Events", + "endpointServiceLocation": "Location", + "endpointServiceNotify": "Notifications", + "endpointServiceOther": "Other", "feedConnecting": "연결 중…", "notifyBannerDisabled": "알림이 꺼져 있어 재난 경보를 받을 수 없습니다.", "@meshtasticNoNodes": { @@ -980,8 +1041,15 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "기상청 적외 영상 관례:온도가 낮을수록 흰색", "moreAnnouncements": "공지사항", + "moreTagline": "재해 정보 통합 플랫폼", "moreVersionStable": "정식 버전", "moreVersionNotes": "현재 버전", + "releaseHighlightsSeeNotes": "전체 릴리스 노트", + "releaseHighlightsTitle": "이번 업데이트", + "releaseHighlightsTabNormal": "변경된 점", + "releaseHighlightsTabAdvanced": "기술 세부", + "releaseHighlightsEmpty": "아직 내용이 없습니다.", + "highlightCardTechnical": "기술 세부", "moreVersionNotesEmpty": "이 빌드의 업데이트 내역을 찾을 수 없습니다", "moreVersionSnapshot": "테스트 버전", "mapLayerSatelliteTransparentNoData": "데이터 없음(육지) = 투명", @@ -1867,5 +1935,13 @@ "type": "int" } } - } + }, + "moreDumpDiagnostics": "디버그 정보 및 로그 업로드", + "moreDumpDiagnosticsHint": "업로드한 뒤 링크를 복사합니다", + "dumpUploaded": "업로드됨", + "dumpLinkCopied": "링크를 클립보드에 복사했습니다", + "dumpCopyAgain": "다시 복사", + "dumpUploadFailed": "업로드하지 못했습니다", + "statusLegendUnprobed": "탐지 안 됨", + "statusLegendUnsupported": "미지원" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 4db238132..75a9a986c 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -445,6 +445,13 @@ "dpmAddress": "ที่อยู่", "weatherRankingMergeCounty": "อำเภอ/เมือง", "moreSectionApp": "ดาวน์โหลดแอป", + "moreSectionBeta": "เวอร์ชันทดสอบ", + "moreAndroidBeta": "เวอร์ชันทดอบ Android", + "moreTestFlight": "เวอร์ชันทดอบ iOS (TestFlight)", + "moreSectionPartners": "พันธมิตร", + "morePartnersNote": "เรียงตามลำดับคู่ความร่วมมือ ขอบคุณบุคคลและบริษัทที่มีส่วนร่วมในการป้องกันภัยพิบัติ การสนับสนุนของพวกเขาทำให้ DPIP เกิดขึ้นได้", + "morePartnerGeoscience": "Geoscience", + "morePartnerTwds": "TWDS", "reportFilterIntensityInfoLegacyBody": "มีระดับ 0–7 เท่านั้น ไม่แยก 5−/5+/6−/6+", "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", "qpesumsOverlayMenuTooltip": "ตัวเลือกชั้นพยากรณ์น้ำฝน", @@ -679,6 +686,7 @@ "meshtasticPreset": "Modem preset", "dataSectionSeismic": "แผ่นดินไหว", "changelogBodyEmpty": "ไม่มีคำอธิบายสำหรับรุ่นนี้", + "changelogOpenOnGitHub": "ดูบน GitHub", "radarGlobalOutline": "เส้นแบ่งเขตประเทศ", "notifyEew": "การเตือนแผ่นดินไหวฉุกเฉิน", "regionNationwide": "ทั่วประเทศ", @@ -954,6 +962,59 @@ "lightningLegendCg": "เมฆสู่พื้น · {minutes} นาที", "skyTimeAuto": "อัตโนมัติ", "appLogs": "บันทึกแอป", + "serverStatusLocal": "สถานะอุปกรณ์", + "serverStatusLocalBody": "ตัวชี้วัดเซิร์ฟเวอร์มาจากแดชบอร์ด ด้านล่างคือการตัดสินการเชื่อมต่อจริงของเครื่องนี้ต่อเอนด์พอยต์แบบ multi-active (LB / Core แต่ละภูมิภาค): แอปบันทึกเฉพาะทราฟฟิกที่เครื่องนี้รับส่งจริงโดยไม่รบกวน ถ้ายังไม่เคยแตะเอนด์พอยต์นั้นจะแสดง 'ยังไม่ตรวจ'", + "serverStatusAllUp": "บริการทั้งหมดปกติ", + "serverStatusDegraded": "ประสิทธิภาพลดลง", + "serverStatusDown": "บริการผิดปกติ", + "serverStatusErrorRate": "อัตราข้อผิดพลาด 5xx", + "serverStatusLatency": "ความหน่วงเฉลี่ย", + "serverStatusUpdated": "อัปเดต", + "serverStatusWeb": "สถานะเซิร์ฟเวอร์", + "serverStatusWebUrl": "status.exptech.dev", + "serverStatusExpTech": "สถานะ ExpTech", + "serverStatusCloudflare": "สถานะ Cloudflare", + "serverStatusCloudflareAllOperational": "ทุกภูมิภาคปกติ", + "serverStatusCloudflareOutage": "Cloudflare บางภูมิภาคผิดปกติ", + "serverStatusCloudflareNone": "ไม่มีภูมิภาคให้แสดง", + "serverStatusCloudflareOperational": "ปกติ", + "serverStatusCloudflareDegraded": "ประสิทธิภาพลดลง", + "serverStatusCloudflarePartial": "หยุดบางส่วน", + "serverStatusCloudflareMajor": "หยุดบริการขนาดใหญ่", + "serverStatusCloudflareUnknown": "ไม่ทราบ", + "endpointTierLbApi": "LB API", + "endpointTierLbStatic": "LB Static", + "endpointTierCoreApi": "Core API", + "endpointTierCoreStatic": "Core Static", + "endpointTierCoreExclusiveApi": "Core เฉพาะ API (เรดาร์ / อากาศ / ลม)", + "endpointTierCoreStaticExclusive": "Core เฉพาะทรัพยากรคงที่", + "endpointTierLegacyApi": "API เดิม (api-1)", + "endpointHealthOk": "การเชื่อมต่อปกติ", + "endpointHealthDegraded": "มีจุดเชื่อมต่อไม่เสถียร", + "endpointHealthDown": "การเชื่อมต่อผิดปกติ", + "endpointHealthUnknown": "ยังไม่มีข้อมูล", + "endpointStateOk": "ปกติ", + "endpointStateDegraded": "ไม่เสถียร", + "endpointStateDown": "ผิดปกติ", + "endpointStateUnknown": "ไม่ทราบ", + "endpointLastSuccessNever": "ยังไม่สำเร็จ", + "endpointServiceEew": "EEW", + "endpointServiceRts": "RTS", + "endpointServiceRadar": "Radar", + "endpointServiceSatellite": "Satellite", + "endpointServiceQpesums": "QPE", + "endpointServiceWind": "Wind", + "endpointServiceDpm": "Disaster points", + "endpointServiceWeather": "Weather", + "endpointServiceRain": "Rain", + "endpointServiceLightning": "Lightning", + "endpointServiceTyphoon": "Typhoon", + "endpointServiceReport": "EQ reports", + "endpointServiceTremStation": "Tremor station", + "endpointServiceEvent": "Events", + "endpointServiceLocation": "Location", + "endpointServiceNotify": "Notifications", + "endpointServiceOther": "Other", "feedConnecting": "กำลังเชื่อมต่อ…", "notifyBannerDisabled": "ปิดการแจ้งเตือนอยู่ — คุณจะไม่ได้รับการเตือนภัยพิบัติ", "@meshtasticNoNodes": { @@ -980,8 +1041,15 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "JMA grayscale — colder is whiter", "moreAnnouncements": "ประกาศ", + "moreTagline": "แพลตฟอร์มรวมข้อมูลป้องกันภัยพิบัติ", "moreVersionStable": "เวอร์ชันเต็ม", "moreVersionNotes": "เวอร์ชันปัจจุบัน", + "releaseHighlightsSeeNotes": "ดูบันทึกทั้งหมด", + "releaseHighlightsTitle": "สิ่งที่เปลี่ยนแปลง", + "releaseHighlightsTabNormal": "สำหรับผู้ใช้", + "releaseHighlightsTabAdvanced": "เจาะลึก", + "releaseHighlightsEmpty": "ยังไม่มีเนื้อหา", + "highlightCardTechnical": "เทคนิค", "moreVersionNotesEmpty": "ไม่พบประวัติการอัปเดตสำหรับบิลด์นี้", "moreVersionSnapshot": "เวอร์ชันทดสอบ", "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", @@ -1867,5 +1935,13 @@ "type": "int" } } - } + }, + "moreDumpDiagnostics": "อัปโหลดข้อมูลดีบักและบันทึก", + "moreDumpDiagnosticsHint": "อัปโหลดแล้วคัดลอกลิงก์เพื่อแนบในรายงาน", + "dumpUploaded": "อัปโหลดแล้ว", + "dumpLinkCopied": "คัดลอกลิงก์ไปยังคลิปบอร์ดแล้ว", + "dumpCopyAgain": "คัดลอกอีกครั้ง", + "dumpUploadFailed": "อัปโหลดไม่สำเร็จ", + "statusLegendUnprobed": "ยังไม่ตรวจ", + "statusLegendUnsupported": "ไม่รองรับ" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index ae0ec8efb..e94a080af 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -445,6 +445,13 @@ "dpmAddress": "Địa chỉ", "weatherRankingMergeCounty": "Huyện/thành", "moreSectionApp": "Tải ứng dụng", + "moreSectionBeta": "Bản thử nghiệm", + "moreAndroidBeta": "Bản thử nghiệm Android", + "moreTestFlight": "Bản thử nghiệm iOS (TestFlight)", + "moreSectionPartners": "Đối tác", + "morePartnersNote": "Theo thứ tự hợp tác. Xin cảm ơn các cá nhân và công ty đã đóng góp cho công tác phòng chống thiên tai, nhờ đó DPIP mới có thể ra đời.", + "morePartnerGeoscience": "Geoscience", + "morePartnerTwds": "TWDS", "reportFilterIntensityInfoLegacyBody": "Chỉ có mức 0–7, không tách 5−/5+/6−/6+.", "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", "qpesumsOverlayMenuTooltip": "Tùy chọn lớp dự báo mưa định lượng", @@ -679,6 +686,7 @@ "meshtasticPreset": "Modem preset", "dataSectionSeismic": "Địa chấn", "changelogBodyEmpty": "Không có ghi chú cho bản phát hành này.", + "changelogOpenOnGitHub": "Xem trên GitHub", "radarGlobalOutline": "Biên giới quốc gia", "notifyEew": "Cảnh báo động đất khẩn cấp", "regionNationwide": "Toàn quốc", @@ -954,6 +962,59 @@ "lightningLegendCg": "Mây–đất · {minutes} phút", "skyTimeAuto": "Tự động", "appLogs": "Nhật ký ứng dụng", + "serverStatusLocal": "Trạng thái thiết bị", + "serverStatusLocalBody": "Chỉ số máy chủ đến từ bảng điều khiển. Dưới đây là đánh giá kết nối thực tế của máy này với các endpoint đa hoạt động (LB / Core từng khu vực): ứng dụng chỉ ghi nhận thụ động lưu lượng thực tế, nếu endpoint chưa từng được máy này truy cập sẽ hiển thị 'Chưa dò'.", + "serverStatusAllUp": "Tất cả dịch vụ hoạt động", + "serverStatusDegraded": "Hiệu suất giảm", + "serverStatusDown": "Dịch vụ lỗi", + "serverStatusErrorRate": "Tỷ lệ lỗi 5xx", + "serverStatusLatency": "Độ trễ trung bình", + "serverStatusUpdated": "Cập nhật", + "serverStatusWeb": "Trạng thái máy chủ", + "serverStatusWebUrl": "status.exptech.dev", + "serverStatusExpTech": "Trạng thái ExpTech", + "serverStatusCloudflare": "Trạng thái Cloudflare", + "serverStatusCloudflareAllOperational": "Tất cả khu vực hoạt động", + "serverStatusCloudflareOutage": "Cloudflare có khu vực bất thường", + "serverStatusCloudflareNone": "Không có khu vực nào để hiển thị.", + "serverStatusCloudflareOperational": "Hoạt động", + "serverStatusCloudflareDegraded": "Hiệu suất giảm", + "serverStatusCloudflarePartial": "Gián đoạn một phần", + "serverStatusCloudflareMajor": "Gián đoạn lớn", + "serverStatusCloudflareUnknown": "Không rõ", + "endpointTierLbApi": "LB API", + "endpointTierLbStatic": "LB Static", + "endpointTierCoreApi": "Core API", + "endpointTierCoreStatic": "Core Static", + "endpointTierCoreExclusiveApi": "Core độc quyền API (radar / thời tiết / gió)", + "endpointTierCoreStaticExclusive": "Core độc quyền tĩnh", + "endpointTierLegacyApi": "API kế thừa (api-1)", + "endpointHealthOk": "Kết nối bình thường", + "endpointHealthDegraded": "Có máy chủ không ổn định", + "endpointHealthDown": "Kết nối bất thường", + "endpointHealthUnknown": "Chưa có dữ liệu", + "endpointStateOk": "Bình thường", + "endpointStateDegraded": "Không ổn định", + "endpointStateDown": "Bất thường", + "endpointStateUnknown": "Không rõ", + "endpointLastSuccessNever": "chưa thành công", + "endpointServiceEew": "EEW", + "endpointServiceRts": "RTS", + "endpointServiceRadar": "Radar", + "endpointServiceSatellite": "Satellite", + "endpointServiceQpesums": "QPE", + "endpointServiceWind": "Wind", + "endpointServiceDpm": "Disaster points", + "endpointServiceWeather": "Weather", + "endpointServiceRain": "Rain", + "endpointServiceLightning": "Lightning", + "endpointServiceTyphoon": "Typhoon", + "endpointServiceReport": "EQ reports", + "endpointServiceTremStation": "Tremor station", + "endpointServiceEvent": "Events", + "endpointServiceLocation": "Location", + "endpointServiceNotify": "Notifications", + "endpointServiceOther": "Other", "feedConnecting": "Đang kết nối…", "notifyBannerDisabled": "Thông báo đã tắt — bạn sẽ không nhận được cảnh báo thiên tai.", "@meshtasticNoNodes": { @@ -980,8 +1041,15 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "JMA grayscale — colder is whiter", "moreAnnouncements": "Thông báo", + "moreTagline": "Nền tảng tích hợp thông tin phòng chống thiên tai", "moreVersionStable": "Bản chính thức", "moreVersionNotes": "Phiên bản hiện tại", + "releaseHighlightsSeeNotes": "Xem ghi chú đầy đủ", + "releaseHighlightsTitle": "Thay đổi trong bản này", + "releaseHighlightsTabNormal": "Cho người dùng", + "releaseHighlightsTabAdvanced": "Đi sâu", + "releaseHighlightsEmpty": "Chưa có nội dung.", + "highlightCardTechnical": "Kỹ thuật", "moreVersionNotesEmpty": "Không tìm thấy nhật ký cập nhật cho bản này", "moreVersionSnapshot": "Bản thử nghiệm", "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", @@ -1867,5 +1935,13 @@ "type": "int" } } - } + }, + "moreDumpDiagnostics": "Tải lên thông tin gỡ lỗi và nhật ký", + "moreDumpDiagnosticsHint": "Tải lên rồi sao chép liên kết để đính kèm vào báo cáo", + "dumpUploaded": "Đã tải lên", + "dumpLinkCopied": "Đã sao chép liên kết vào bảng nhớ tạm", + "dumpCopyAgain": "Sao chép lại", + "dumpUploadFailed": "Tải lên thất bại", + "statusLegendUnprobed": "Chưa dò", + "statusLegendUnsupported": "Không có" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 29f17b7da..c6ec2d80f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -104,7 +104,7 @@ "aedType": "場所類型", "termsOfService": "服務條款", "typhoonLegendCircle25": "十級風暴風圈", - "sponsorTitle": "支持 DPIP", + "sponsorTitle": "支援 DPIP", "mapNavSatellite": "衛星", "homeRainTrendUpdated": "更新 {time}", "onboardingNext": "下一步", @@ -265,7 +265,7 @@ }, "restroomCategoryLabel": "類別", "sponsorRestoring": "正在恢復購買…", - "sponsorIntro": "DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支持能幫助我們維持伺服器運作並持續開發。", + "sponsorIntro": "DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支援能幫助我們維持伺服器運作並持續開發。", "shelterAddressLabel": "地址", "typhoonLabelStormAvg": "十級風平均暴風半徑", "@meshtasticHardware": { @@ -445,6 +445,13 @@ "dpmAddress": "地址", "weatherRankingMergeCounty": "縣市", "moreSectionApp": "取得 App", + "moreSectionBeta": "測試版", + "moreAndroidBeta": "Android 測試版", + "moreTestFlight": "iOS 測試版(TestFlight)", + "moreSectionPartners": "合作夥伴", + "morePartnersNote": "依合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。", + "morePartnerGeoscience": "巨科資訊有限公司", + "morePartnerTwds": "台灣數位串流有限公司", "reportFilterIntensityInfoLegacyBody": "震度僅 0–7,沒有 5弱/5強/6弱/6強。", "mapLayerSatelliteSst": "ひまわり 海表溫度", "qpesumsOverlayMenuTooltip": "定量降水預報圖層選項", @@ -679,6 +686,7 @@ "meshtasticPreset": "調變預設", "dataSectionSeismic": "地震", "changelogBodyEmpty": "此版本沒有說明。", + "changelogOpenOnGitHub": "在 GitHub 查看", "radarGlobalOutline": "國界", "notifyEew": "緊急地震速報", "regionNationwide": "全國", @@ -766,7 +774,7 @@ "reportDetailOriginTime": "發震時間", "trendNoData": "沒有趨勢資料", "onboardingPermLocation": "定位", - "sponsorCalloutBody": "沒有廣告,你的支持讓伺服器持續運作。", + "sponsorCalloutBody": "沒有廣告,你的支援讓伺服器持續運作。", "moreDiscordCalloutBody": "加入社群,直接和開發團隊交流。", "moreDiscord": "Discord 社群", "mapNavPressure": "氣壓", @@ -954,6 +962,59 @@ "lightningLegendCg": "對地 · {minutes} 分內", "skyTimeAuto": "自動", "appLogs": "App 日誌", + "serverStatusLocal": "本機狀態", + "serverStatusLocalBody": "伺服器指標來自控制台。下方是本機對多活端點(LB / Core 各區)的實際連線判斷:APP 只被動記錄本機實際播送的流量,若該端點從未被本機觸發,就會顯示未探測。", + "serverStatusAllUp": "所有服務正常", + "serverStatusDegraded": "服務效能下降", + "serverStatusDown": "服務異常", + "serverStatusErrorRate": "5xx 錯誤率", + "serverStatusLatency": "平均延遲", + "serverStatusUpdated": "更新於", + "serverStatusWeb": "伺服器狀態", + "serverStatusWebUrl": "status.exptech.dev", + "serverStatusExpTech": "ExpTech 状态", + "serverStatusCloudflare": "Cloudflare 状态", + "serverStatusCloudflareAllOperational": "所有区域正常", + "serverStatusCloudflareOutage": "Cloudflare 部分区域异常", + "serverStatusCloudflareNone": "目前没有可显示的区域。", + "serverStatusCloudflareOperational": "正常", + "serverStatusCloudflareDegraded": "性能下降", + "serverStatusCloudflarePartial": "部分中断", + "serverStatusCloudflareMajor": "大规模中断", + "serverStatusCloudflareUnknown": "未知", + "endpointTierLbApi": "LB API", + "endpointTierLbStatic": "LB Static", + "endpointTierCoreApi": "Core API", + "endpointTierCoreStatic": "Core Static", + "endpointTierCoreExclusiveApi": "Core 專屬 API(雷達 / 氣象 / 風場)", + "endpointTierCoreStaticExclusive": "Core 專屬靜態資源", + "endpointTierLegacyApi": "舊版 API(api-1)", + "endpointHealthOk": "本機連線正常", + "endpointHealthDegraded": "有端點連線不穩", + "endpointHealthDown": "本機連線異常", + "endpointHealthUnknown": "尚無觀測資料", + "endpointStateOk": "正常", + "endpointStateDegraded": "不穩", + "endpointStateDown": "異常", + "endpointStateUnknown": "未知", + "endpointLastSuccessNever": "尚未成功", + "endpointServiceEew": "地震速報", + "endpointServiceRts": "強震即時警報", + "endpointServiceRadar": "雷達", + "endpointServiceSatellite": "衛星", + "endpointServiceQpesums": "定量降水", + "endpointServiceWind": "風場", + "endpointServiceDpm": "災害點位", + "endpointServiceWeather": "天氣", + "endpointServiceRain": "降雨", + "endpointServiceLightning": "閃電", + "endpointServiceTyphoon": "颱風", + "endpointServiceReport": "地震報告", + "endpointServiceTremStation": "震度站", + "endpointServiceEvent": "事件", + "endpointServiceLocation": "定位", + "endpointServiceNotify": "通知", + "endpointServiceOther": "其他", "feedConnecting": "連線中…", "notifyBannerDisabled": "通知已關閉,將收不到災害警報。", "@meshtasticNoNodes": { @@ -980,8 +1041,15 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "氣象廳灰階慣例:溫度越低越白", "moreAnnouncements": "公告", + "moreTagline": "防災資訊整合平台", "moreVersionStable": "正式版", "moreVersionNotes": "目前版本", + "releaseHighlightsSeeNotes": "查看完整更新日誌", + "releaseHighlightsTitle": "本次更新", + "releaseHighlightsTabNormal": "做了哪些改變", + "releaseHighlightsTabAdvanced": "深入技術", + "releaseHighlightsEmpty": "目前沒有內容。", + "highlightCardTechnical": "技術細節", "moreVersionNotesEmpty": "找不到目前版本的更新日誌", "moreVersionSnapshot": "測試版", "mapLayerSatelliteTransparentNoData": "無資料(陸地) = 透明", @@ -1867,5 +1935,13 @@ "type": "int" } } - } + }, + "moreDumpDiagnostics": "傾印除錯資訊及日誌", + "moreDumpDiagnosticsHint": "上傳後複製連結", + "dumpUploaded": "已上傳", + "dumpLinkCopied": "連結已複製到剪貼簿", + "dumpCopyAgain": "再複製一次", + "dumpUploadFailed": "上傳失敗,請稍後再試", + "statusLegendUnprobed": "未探測", + "statusLegendUnsupported": "不支援" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index bf4880c45..f00a80de5 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -445,6 +445,13 @@ "dpmAddress": "地址", "weatherRankingMergeCounty": "县市", "moreSectionApp": "获取 App", + "moreSectionBeta": "测试版", + "moreAndroidBeta": "Android 测试版", + "moreTestFlight": "iOS 测试版(TestFlight)", + "moreSectionPartners": "合作伙伴", + "morePartnersNote": "按合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。", + "morePartnerGeoscience": "巨科资讯有限公司", + "morePartnerTwds": "台湾数位串流有限公司", "reportFilterIntensityInfoLegacyBody": "震度仅 0–7,没有 5弱/5强/6弱/6强。", "mapLayerSatelliteSst": "ひまわり 海表温度", "qpesumsOverlayMenuTooltip": "定量降水预报图层选项", @@ -679,6 +686,7 @@ "meshtasticPreset": "调变预设", "dataSectionSeismic": "地震", "changelogBodyEmpty": "此版本没有说明。", + "changelogOpenOnGitHub": "在 GitHub 查看", "radarGlobalOutline": "国界", "notifyEew": "紧急地震预警", "regionNationwide": "全国", @@ -954,6 +962,59 @@ "lightningLegendCg": "对地 · {minutes} 分钟内", "skyTimeAuto": "自动", "appLogs": "应用日志", + "serverStatusLocal": "本机状态", + "serverStatusLocalBody": "服务器指标来自控制台。下方是本机对多活端点(LB / Core 各区)的实际连接判断:APP 只被动记录本机实际播送的流量,若该端点从未被本机触发,就会显示未探测。", + "serverStatusAllUp": "所有服务正常", + "serverStatusDegraded": "服务性能下降", + "serverStatusDown": "服务异常", + "serverStatusErrorRate": "5xx 错误率", + "serverStatusLatency": "平均延迟", + "serverStatusUpdated": "更新于", + "serverStatusWeb": "服务器状态", + "serverStatusWebUrl": "status.exptech.dev", + "serverStatusExpTech": "ExpTech 状态", + "serverStatusCloudflare": "Cloudflare 状态", + "serverStatusCloudflareAllOperational": "所有区域正常", + "serverStatusCloudflareOutage": "Cloudflare 部分区域异常", + "serverStatusCloudflareNone": "目前没有可显示的区域。", + "serverStatusCloudflareOperational": "正常", + "serverStatusCloudflareDegraded": "性能下降", + "serverStatusCloudflarePartial": "部分中断", + "serverStatusCloudflareMajor": "大规模中断", + "serverStatusCloudflareUnknown": "未知", + "endpointTierLbApi": "LB API", + "endpointTierLbStatic": "LB Static", + "endpointTierCoreApi": "Core API", + "endpointTierCoreStatic": "Core Static", + "endpointTierCoreExclusiveApi": "Core 专属 API(雷达 / 气象 / 风场)", + "endpointTierCoreStaticExclusive": "Core 专属静态资源", + "endpointTierLegacyApi": "旧版 API(api-1)", + "endpointHealthOk": "本机连接正常", + "endpointHealthDegraded": "有端点连接不稳", + "endpointHealthDown": "本机连接异常", + "endpointHealthUnknown": "暂无观测数据", + "endpointStateOk": "正常", + "endpointStateDegraded": "不稳", + "endpointStateDown": "异常", + "endpointStateUnknown": "未知", + "endpointLastSuccessNever": "尚未成功", + "endpointServiceEew": "地震速報", + "endpointServiceRts": "強震即時警報", + "endpointServiceRadar": "雷達", + "endpointServiceSatellite": "衛星", + "endpointServiceQpesums": "定量降水", + "endpointServiceWind": "風場", + "endpointServiceDpm": "災害點位", + "endpointServiceWeather": "天氣", + "endpointServiceRain": "降雨", + "endpointServiceLightning": "閃電", + "endpointServiceTyphoon": "颱風", + "endpointServiceReport": "地震報告", + "endpointServiceTremStation": "震度站", + "endpointServiceEvent": "事件", + "endpointServiceLocation": "定位", + "endpointServiceNotify": "通知", + "endpointServiceOther": "其他", "feedConnecting": "连接中…", "notifyBannerDisabled": "通知已关闭,将收不到灾害警报。", "@meshtasticNoNodes": { @@ -980,8 +1041,15 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "气象厅灰度惯例:温度越低越白", "moreAnnouncements": "公告", + "moreTagline": "防灾信息整合平台", "moreVersionStable": "正式版", "moreVersionNotes": "当前版本", + "releaseHighlightsSeeNotes": "查看完整更新日志", + "releaseHighlightsTitle": "本次更新", + "releaseHighlightsTabNormal": "做了哪些改变", + "releaseHighlightsTabAdvanced": "深入技术", + "releaseHighlightsEmpty": "目前没有内容。", + "highlightCardTechnical": "技术细节", "moreVersionNotesEmpty": "找不到当前版本的更新日志", "moreVersionSnapshot": "測試版", "mapLayerSatelliteTransparentNoData": "无资料(陆地) = 透明", @@ -1867,5 +1935,13 @@ "type": "int" } } - } + }, + "moreDumpDiagnostics": "转储调试信息及日志", + "moreDumpDiagnosticsHint": "上传后复制链接,附在反馈里就不用贴一整页", + "dumpUploaded": "已上传", + "dumpLinkCopied": "链接已复制到剪贴板", + "dumpCopyAgain": "再复制一次", + "dumpUploadFailed": "上传失败,请稍后再试", + "statusLegendUnprobed": "未探测", + "statusLegendUnsupported": "不支持" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 0b40d162d..3e3351144 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -104,7 +104,7 @@ "aedType": "場所類型", "termsOfService": "服務條款", "typhoonLegendCircle25": "十級風暴風圈", - "sponsorTitle": "支持 DPIP", + "sponsorTitle": "支援 DPIP", "mapNavSatellite": "衛星", "homeRainTrendUpdated": "更新 {time}", "onboardingNext": "下一步", @@ -265,7 +265,7 @@ }, "restroomCategoryLabel": "類別", "sponsorRestoring": "正在恢復購買…", - "sponsorIntro": "DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支持能幫助我們維持伺服器運作並持續開發。", + "sponsorIntro": "DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支援能幫助我們維持伺服器運作並持續開發。", "shelterAddressLabel": "地址", "typhoonLabelStormAvg": "十級風平均暴風半徑", "@meshtasticHardware": { @@ -445,6 +445,13 @@ "dpmAddress": "地址", "weatherRankingMergeCounty": "縣市", "moreSectionApp": "取得 App", + "moreSectionBeta": "測試版", + "moreAndroidBeta": "Android 測試版", + "moreTestFlight": "iOS 測試版(TestFlight)", + "moreSectionPartners": "合作夥伴", + "morePartnersNote": "依合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。", + "morePartnerGeoscience": "巨科資訊有限公司", + "morePartnerTwds": "台灣數位串流有限公司", "reportFilterIntensityInfoLegacyBody": "震度僅 0–7,沒有 5弱/5強/6弱/6強。", "mapLayerSatelliteSst": "ひまわり 海表溫度", "qpesumsOverlayMenuTooltip": "定量降水預報圖層選項", @@ -679,6 +686,7 @@ "meshtasticPreset": "調變預設", "dataSectionSeismic": "地震", "changelogBodyEmpty": "此版本沒有說明。", + "changelogOpenOnGitHub": "在 GitHub 查看", "radarGlobalOutline": "國界", "notifyEew": "緊急地震速報", "regionNationwide": "全國", @@ -766,7 +774,7 @@ "reportDetailOriginTime": "發震時間", "trendNoData": "沒有趨勢資料", "onboardingPermLocation": "定位", - "sponsorCalloutBody": "沒有廣告,你的支持讓伺服器持續運作。", + "sponsorCalloutBody": "沒有廣告,你的支援讓伺服器持續運作。", "moreDiscordCalloutBody": "加入社群,直接和開發團隊交流。", "moreDiscord": "Discord 社群", "mapNavPressure": "氣壓", @@ -954,6 +962,59 @@ "lightningLegendCg": "對地 · {minutes} 分內", "skyTimeAuto": "自動", "appLogs": "App 日誌", + "serverStatusLocal": "本機狀態", + "serverStatusLocalBody": "伺服器指標來自控制台。下方是本機對多活端點(LB / Core 各區)的實際連線判斷:APP 只被動記錄本機實際播送的流量,若該端點從未被本機觸發,就會顯示未探測。", + "serverStatusAllUp": "所有服務正常", + "serverStatusDegraded": "服務效能下降", + "serverStatusDown": "服務異常", + "serverStatusErrorRate": "5xx 錯誤率", + "serverStatusLatency": "平均延遲", + "serverStatusUpdated": "更新於", + "serverStatusWeb": "伺服器狀態", + "serverStatusWebUrl": "status.exptech.dev", + "serverStatusExpTech": "ExpTech 狀態", + "serverStatusCloudflare": "Cloudflare 狀態", + "serverStatusCloudflareAllOperational": "所有區域正常", + "serverStatusCloudflareOutage": "Cloudflare 部分區域異常", + "serverStatusCloudflareNone": "目前沒有可顯示的區域。", + "serverStatusCloudflareOperational": "正常", + "serverStatusCloudflareDegraded": "效能下降", + "serverStatusCloudflarePartial": "部分中斷", + "serverStatusCloudflareMajor": "大規模中斷", + "serverStatusCloudflareUnknown": "未知", + "endpointTierLbApi": "LB API", + "endpointTierLbStatic": "LB Static", + "endpointTierCoreApi": "Core API", + "endpointTierCoreStatic": "Core Static", + "endpointTierCoreExclusiveApi": "Core 專屬 API(雷達 / 氣象 / 風場)", + "endpointTierCoreStaticExclusive": "Core 專屬靜態資源", + "endpointTierLegacyApi": "舊版 API(api-1)", + "endpointHealthOk": "本機連線正常", + "endpointHealthDegraded": "有端點連線不穩", + "endpointHealthDown": "本機連線異常", + "endpointHealthUnknown": "尚無觀測資料", + "endpointStateOk": "正常", + "endpointStateDegraded": "不穩", + "endpointStateDown": "異常", + "endpointStateUnknown": "未知", + "endpointLastSuccessNever": "尚未成功", + "endpointServiceEew": "地震速報", + "endpointServiceRts": "強震即時警報", + "endpointServiceRadar": "雷達", + "endpointServiceSatellite": "衛星", + "endpointServiceQpesums": "定量降水", + "endpointServiceWind": "風場", + "endpointServiceDpm": "災害點位", + "endpointServiceWeather": "天氣", + "endpointServiceRain": "降雨", + "endpointServiceLightning": "閃電", + "endpointServiceTyphoon": "颱風", + "endpointServiceReport": "地震報告", + "endpointServiceTremStation": "震度站", + "endpointServiceEvent": "事件", + "endpointServiceLocation": "定位", + "endpointServiceNotify": "通知", + "endpointServiceOther": "其他", "feedConnecting": "連接中…", "notifyBannerDisabled": "通知已關閉,將收不到災害警報。", "@meshtasticNoNodes": { @@ -980,8 +1041,15 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "氣象廳灰階慣例:溫度越低越白", "moreAnnouncements": "公告", + "moreTagline": "防災資訊整合平台", "moreVersionStable": "正式版", "moreVersionNotes": "目前版本", + "releaseHighlightsSeeNotes": "查看完整更新日誌", + "releaseHighlightsTitle": "本次更新", + "releaseHighlightsTabNormal": "做了哪些改變", + "releaseHighlightsTabAdvanced": "深入技術", + "releaseHighlightsEmpty": "目前沒有內容。", + "highlightCardTechnical": "技術細節", "moreVersionNotesEmpty": "找不到目前版本的更新日誌", "moreVersionSnapshot": "測試版", "mapLayerSatelliteTransparentNoData": "無資料(陸地) = 透明", @@ -1867,5 +1935,13 @@ "type": "int" } } - } + }, + "moreDumpDiagnostics": "傾印除錯資訊及日誌", + "moreDumpDiagnosticsHint": "上載後複製連結", + "dumpUploaded": "已上載", + "dumpLinkCopied": "連結已複製到剪貼簿", + "dumpCopyAgain": "再複製一次", + "dumpUploadFailed": "上載失敗,請稍後再試", + "statusLegendUnprobed": "未探測", + "statusLegendUnsupported": "不支援" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index bc212ae24..5bb5dd603 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -104,7 +104,7 @@ "aedType": "場所類型", "termsOfService": "服務條款", "typhoonLegendCircle25": "十級風暴風圈", - "sponsorTitle": "支持 DPIP", + "sponsorTitle": "支援 DPIP", "mapNavSatellite": "衛星", "homeRainTrendUpdated": "更新 {time}", "onboardingNext": "下一步", @@ -265,7 +265,7 @@ }, "restroomCategoryLabel": "類別", "sponsorRestoring": "正在恢復購買…", - "sponsorIntro": "DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支持能幫助我們維持伺服器運作並持續開發。", + "sponsorIntro": "DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支援能幫助我們維持伺服器運作並持續開發。", "shelterAddressLabel": "地址", "typhoonLabelStormAvg": "十級風平均暴風半徑", "@meshtasticHardware": { @@ -445,6 +445,13 @@ "dpmAddress": "地址", "weatherRankingMergeCounty": "縣市", "moreSectionApp": "取得 App", + "moreSectionBeta": "測試版", + "moreAndroidBeta": "Android 測試版", + "moreTestFlight": "iOS 測試版(TestFlight)", + "moreSectionPartners": "合作夥伴", + "morePartnersNote": "依合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。", + "morePartnerGeoscience": "巨科資訊有限公司", + "morePartnerTwds": "台灣數位串流有限公司", "reportFilterIntensityInfoLegacyBody": "震度僅 0–7,沒有 5弱/5強/6弱/6強。", "mapLayerSatelliteSst": "ひまわり 海表溫度", "qpesumsOverlayMenuTooltip": "定量降水預報圖層選項", @@ -679,6 +686,7 @@ "meshtasticPreset": "調變預設", "dataSectionSeismic": "地震", "changelogBodyEmpty": "此版本沒有說明。", + "changelogOpenOnGitHub": "在 GitHub 查看", "radarGlobalOutline": "國界", "notifyEew": "緊急地震速報", "regionNationwide": "全國", @@ -766,7 +774,7 @@ "reportDetailOriginTime": "發震時間", "trendNoData": "沒有趨勢資料", "onboardingPermLocation": "定位", - "sponsorCalloutBody": "沒有廣告,你的支持讓伺服器持續運作。", + "sponsorCalloutBody": "沒有廣告,你的支援讓伺服器持續運作。", "moreDiscordCalloutBody": "加入社群,直接和開發團隊交流。", "moreDiscord": "Discord 社群", "mapNavPressure": "氣壓", @@ -954,6 +962,59 @@ "lightningLegendCg": "對地 · {minutes} 分內", "skyTimeAuto": "自動", "appLogs": "App 日誌", + "serverStatusLocal": "本機狀態", + "serverStatusLocalBody": "伺服器指標來自控制台。下方是本機對多活端點(LB / Core 各區)的實際連線判斷:APP 只被動記錄本機實際播送的流量,若該端點從未被本機觸發,就會顯示未探測。", + "serverStatusAllUp": "所有服務正常", + "serverStatusDegraded": "服務效能下降", + "serverStatusDown": "服務異常", + "serverStatusErrorRate": "5xx 錯誤率", + "serverStatusLatency": "平均延遲", + "serverStatusUpdated": "更新於", + "serverStatusWeb": "伺服器狀態", + "serverStatusWebUrl": "status.exptech.dev", + "serverStatusExpTech": "ExpTech 狀態", + "serverStatusCloudflare": "Cloudflare 狀態", + "serverStatusCloudflareAllOperational": "所有區域正常", + "serverStatusCloudflareOutage": "Cloudflare 部分區域異常", + "serverStatusCloudflareNone": "目前沒有可顯示的區域。", + "serverStatusCloudflareOperational": "正常", + "serverStatusCloudflareDegraded": "效能下降", + "serverStatusCloudflarePartial": "部分中斷", + "serverStatusCloudflareMajor": "大規模中斷", + "serverStatusCloudflareUnknown": "未知", + "endpointTierLbApi": "LB API", + "endpointTierLbStatic": "LB Static", + "endpointTierCoreApi": "Core API", + "endpointTierCoreStatic": "Core Static", + "endpointTierCoreExclusiveApi": "Core 專屬 API(雷達 / 氣象 / 風場)", + "endpointTierCoreStaticExclusive": "Core 專屬靜態資源", + "endpointTierLegacyApi": "舊版 API(api-1)", + "endpointHealthOk": "本機連線正常", + "endpointHealthDegraded": "有端點連線不穩", + "endpointHealthDown": "本機連線異常", + "endpointHealthUnknown": "尚無觀測資料", + "endpointStateOk": "正常", + "endpointStateDegraded": "不穩", + "endpointStateDown": "異常", + "endpointStateUnknown": "未知", + "endpointLastSuccessNever": "尚未成功", + "endpointServiceEew": "地震速報", + "endpointServiceRts": "強震即時警報", + "endpointServiceRadar": "雷達", + "endpointServiceSatellite": "衛星", + "endpointServiceQpesums": "定量降水", + "endpointServiceWind": "風場", + "endpointServiceDpm": "災害點位", + "endpointServiceWeather": "天氣", + "endpointServiceRain": "降雨", + "endpointServiceLightning": "閃電", + "endpointServiceTyphoon": "颱風", + "endpointServiceReport": "地震報告", + "endpointServiceTremStation": "震度站", + "endpointServiceEvent": "事件", + "endpointServiceLocation": "定位", + "endpointServiceNotify": "通知", + "endpointServiceOther": "其他", "feedConnecting": "連線中…", "notifyBannerDisabled": "通知已關閉,將收不到災害警報。", "@meshtasticNoNodes": { @@ -980,8 +1041,15 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "氣象廳灰階慣例:溫度越低越白", "moreAnnouncements": "公告", + "moreTagline": "防災資訊整合平台", "moreVersionStable": "正式版", "moreVersionNotes": "目前版本", + "releaseHighlightsSeeNotes": "查看完整更新日誌", + "releaseHighlightsTitle": "本次更新", + "releaseHighlightsTabNormal": "做了哪些改變", + "releaseHighlightsTabAdvanced": "深入技術", + "releaseHighlightsEmpty": "目前沒有內容。", + "highlightCardTechnical": "技術細節", "moreVersionNotesEmpty": "找不到目前版本的更新日誌", "moreVersionSnapshot": "測試版", "mapLayerSatelliteTransparentNoData": "無資料(陸地) = 透明", @@ -1867,5 +1935,13 @@ "type": "int" } } - } + }, + "moreDumpDiagnostics": "傾印除錯資訊及日誌", + "moreDumpDiagnosticsHint": "上傳後複製連結", + "dumpUploaded": "已上傳", + "dumpLinkCopied": "連結已複製到剪貼簿", + "dumpCopyAgain": "再複製一次", + "dumpUploadFailed": "上傳失敗,請稍後再試", + "statusLegendUnprobed": "未探測", + "statusLegendUnsupported": "不支援" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 7997c0bd5..5d83e1179 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1839,6 +1839,48 @@ abstract class AppLocalizations { /// **'Get the app'** String get moreSectionApp; + /// No description provided for @moreSectionBeta. + /// + /// In en, this message translates to: + /// **'Beta'** + String get moreSectionBeta; + + /// No description provided for @moreAndroidBeta. + /// + /// In en, this message translates to: + /// **'Android beta'** + String get moreAndroidBeta; + + /// No description provided for @moreTestFlight. + /// + /// In en, this message translates to: + /// **'iOS beta (TestFlight)'** + String get moreTestFlight; + + /// No description provided for @moreSectionPartners. + /// + /// In en, this message translates to: + /// **'Partners'** + String get moreSectionPartners; + + /// No description provided for @morePartnersNote. + /// + /// In en, this message translates to: + /// **'Listed in order of partnership. Thank you to the individuals and companies whose contributions to disaster preparedness made DPIP possible.'** + String get morePartnersNote; + + /// No description provided for @morePartnerGeoscience. + /// + /// In en, this message translates to: + /// **'Geoscience'** + String get morePartnerGeoscience; + + /// No description provided for @morePartnerTwds. + /// + /// In en, this message translates to: + /// **'TWDS'** + String get morePartnerTwds; + /// No description provided for @reportFilterIntensityInfoLegacyBody. /// /// In en, this message translates to: @@ -2751,6 +2793,12 @@ abstract class AppLocalizations { /// **'No notes for this release.'** String get changelogBodyEmpty; + /// No description provided for @changelogOpenOnGitHub. + /// + /// In en, this message translates to: + /// **'View on GitHub'** + String get changelogOpenOnGitHub; + /// World-country-border overlay toggle in the map's reference-layer overlay menus. /// /// In en, this message translates to: @@ -3735,6 +3783,324 @@ abstract class AppLocalizations { /// **'App logs'** String get appLogs; + /// No description provided for @serverStatusLocal. + /// + /// In en, this message translates to: + /// **'Local status'** + String get serverStatusLocal; + + /// No description provided for @serverStatusLocalBody. + /// + /// In en, this message translates to: + /// **'Metrics come from the dashboard. Below is this device\'s own view of the multi-active endpoints (LB / Core per region): it passively records the traffic each endpoint actually serves, so a cell with no data means nothing was observed through this device yet.'** + String get serverStatusLocalBody; + + /// No description provided for @serverStatusAllUp. + /// + /// In en, this message translates to: + /// **'All services operational'** + String get serverStatusAllUp; + + /// No description provided for @serverStatusDegraded. + /// + /// In en, this message translates to: + /// **'Services degraded'** + String get serverStatusDegraded; + + /// No description provided for @serverStatusDown. + /// + /// In en, this message translates to: + /// **'Service down'** + String get serverStatusDown; + + /// No description provided for @serverStatusErrorRate. + /// + /// In en, this message translates to: + /// **'5xx error rate'** + String get serverStatusErrorRate; + + /// No description provided for @serverStatusLatency. + /// + /// In en, this message translates to: + /// **'Avg latency'** + String get serverStatusLatency; + + /// No description provided for @serverStatusUpdated. + /// + /// In en, this message translates to: + /// **'Updated'** + String get serverStatusUpdated; + + /// No description provided for @serverStatusWeb. + /// + /// In en, this message translates to: + /// **'Server status'** + String get serverStatusWeb; + + /// No description provided for @serverStatusWebUrl. + /// + /// In en, this message translates to: + /// **'status.exptech.dev'** + String get serverStatusWebUrl; + + /// No description provided for @serverStatusExpTech. + /// + /// In en, this message translates to: + /// **'ExpTech status'** + String get serverStatusExpTech; + + /// No description provided for @serverStatusCloudflare. + /// + /// In en, this message translates to: + /// **'Cloudflare status'** + String get serverStatusCloudflare; + + /// No description provided for @serverStatusCloudflareAllOperational. + /// + /// In en, this message translates to: + /// **'All regions operational'** + String get serverStatusCloudflareAllOperational; + + /// No description provided for @serverStatusCloudflareOutage. + /// + /// In en, this message translates to: + /// **'Cloudflare regional issue'** + String get serverStatusCloudflareOutage; + + /// No description provided for @serverStatusCloudflareNone. + /// + /// In en, this message translates to: + /// **'No regions to show.'** + String get serverStatusCloudflareNone; + + /// No description provided for @serverStatusCloudflareOperational. + /// + /// In en, this message translates to: + /// **'Operational'** + String get serverStatusCloudflareOperational; + + /// No description provided for @serverStatusCloudflareDegraded. + /// + /// In en, this message translates to: + /// **'Degraded'** + String get serverStatusCloudflareDegraded; + + /// No description provided for @serverStatusCloudflarePartial. + /// + /// In en, this message translates to: + /// **'Partial outage'** + String get serverStatusCloudflarePartial; + + /// No description provided for @serverStatusCloudflareMajor. + /// + /// In en, this message translates to: + /// **'Major outage'** + String get serverStatusCloudflareMajor; + + /// No description provided for @serverStatusCloudflareUnknown. + /// + /// In en, this message translates to: + /// **'Unknown'** + String get serverStatusCloudflareUnknown; + + /// No description provided for @endpointTierLbApi. + /// + /// In en, this message translates to: + /// **'LB API'** + String get endpointTierLbApi; + + /// No description provided for @endpointTierLbStatic. + /// + /// In en, this message translates to: + /// **'LB Static'** + String get endpointTierLbStatic; + + /// No description provided for @endpointTierCoreApi. + /// + /// In en, this message translates to: + /// **'Core API'** + String get endpointTierCoreApi; + + /// No description provided for @endpointTierCoreStatic. + /// + /// In en, this message translates to: + /// **'Core Static'** + String get endpointTierCoreStatic; + + /// No description provided for @endpointTierCoreExclusiveApi. + /// + /// In en, this message translates to: + /// **'Core-exclusive API (radar / weather / wind)'** + String get endpointTierCoreExclusiveApi; + + /// No description provided for @endpointTierCoreStaticExclusive. + /// + /// In en, this message translates to: + /// **'Core-exclusive static'** + String get endpointTierCoreStaticExclusive; + + /// No description provided for @endpointTierLegacyApi. + /// + /// In en, this message translates to: + /// **'Legacy API (api-1)'** + String get endpointTierLegacyApi; + + /// No description provided for @endpointHealthOk. + /// + /// In en, this message translates to: + /// **'Local connections healthy'** + String get endpointHealthOk; + + /// No description provided for @endpointHealthDegraded. + /// + /// In en, this message translates to: + /// **'Some endpoints unstable'** + String get endpointHealthDegraded; + + /// No description provided for @endpointHealthDown. + /// + /// In en, this message translates to: + /// **'Local connections failing'** + String get endpointHealthDown; + + /// No description provided for @endpointHealthUnknown. + /// + /// In en, this message translates to: + /// **'No observations yet'** + String get endpointHealthUnknown; + + /// No description provided for @endpointStateOk. + /// + /// In en, this message translates to: + /// **'OK'** + String get endpointStateOk; + + /// No description provided for @endpointStateDegraded. + /// + /// In en, this message translates to: + /// **'Unstable'** + String get endpointStateDegraded; + + /// No description provided for @endpointStateDown. + /// + /// In en, this message translates to: + /// **'Failing'** + String get endpointStateDown; + + /// No description provided for @endpointStateUnknown. + /// + /// In en, this message translates to: + /// **'Unknown'** + String get endpointStateUnknown; + + /// No description provided for @endpointLastSuccessNever. + /// + /// In en, this message translates to: + /// **'never succeeded'** + String get endpointLastSuccessNever; + + /// No description provided for @endpointServiceEew. + /// + /// In en, this message translates to: + /// **'EEW'** + String get endpointServiceEew; + + /// No description provided for @endpointServiceRts. + /// + /// In en, this message translates to: + /// **'RTS'** + String get endpointServiceRts; + + /// No description provided for @endpointServiceRadar. + /// + /// In en, this message translates to: + /// **'Radar'** + String get endpointServiceRadar; + + /// No description provided for @endpointServiceSatellite. + /// + /// In en, this message translates to: + /// **'Satellite'** + String get endpointServiceSatellite; + + /// No description provided for @endpointServiceQpesums. + /// + /// In en, this message translates to: + /// **'QPE'** + String get endpointServiceQpesums; + + /// No description provided for @endpointServiceWind. + /// + /// In en, this message translates to: + /// **'Wind'** + String get endpointServiceWind; + + /// No description provided for @endpointServiceDpm. + /// + /// In en, this message translates to: + /// **'Disaster points'** + String get endpointServiceDpm; + + /// No description provided for @endpointServiceWeather. + /// + /// In en, this message translates to: + /// **'Weather'** + String get endpointServiceWeather; + + /// No description provided for @endpointServiceRain. + /// + /// In en, this message translates to: + /// **'Rain'** + String get endpointServiceRain; + + /// No description provided for @endpointServiceLightning. + /// + /// In en, this message translates to: + /// **'Lightning'** + String get endpointServiceLightning; + + /// No description provided for @endpointServiceTyphoon. + /// + /// In en, this message translates to: + /// **'Typhoon'** + String get endpointServiceTyphoon; + + /// No description provided for @endpointServiceReport. + /// + /// In en, this message translates to: + /// **'EQ reports'** + String get endpointServiceReport; + + /// No description provided for @endpointServiceTremStation. + /// + /// In en, this message translates to: + /// **'Tremor station'** + String get endpointServiceTremStation; + + /// No description provided for @endpointServiceEvent. + /// + /// In en, this message translates to: + /// **'Events'** + String get endpointServiceEvent; + + /// No description provided for @endpointServiceLocation. + /// + /// In en, this message translates to: + /// **'Location'** + String get endpointServiceLocation; + + /// No description provided for @endpointServiceNotify. + /// + /// In en, this message translates to: + /// **'Notifications'** + String get endpointServiceNotify; + + /// No description provided for @endpointServiceOther. + /// + /// In en, this message translates to: + /// **'Other'** + String get endpointServiceOther; + /// A realtime feed is establishing its first data /// /// In en, this message translates to: @@ -3873,6 +4239,12 @@ abstract class AppLocalizations { /// **'Announcements'** String get moreAnnouncements; + /// No description provided for @moreTagline. + /// + /// In en, this message translates to: + /// **'Disaster Prevention Information Platform'** + String get moreTagline; + /// No description provided for @moreVersionStable. /// /// In en, this message translates to: @@ -3885,6 +4257,42 @@ abstract class AppLocalizations { /// **'This version'** String get moreVersionNotes; + /// No description provided for @releaseHighlightsTitle. + /// + /// In en, this message translates to: + /// **'What changed in this release'** + String get releaseHighlightsTitle; + + /// No description provided for @releaseHighlightsTabNormal. + /// + /// In en, this message translates to: + /// **'For users'** + String get releaseHighlightsTabNormal; + + /// No description provided for @releaseHighlightsTabAdvanced. + /// + /// In en, this message translates to: + /// **'Deep dive'** + String get releaseHighlightsTabAdvanced; + + /// No description provided for @releaseHighlightsEmpty. + /// + /// In en, this message translates to: + /// **'Nothing here yet.'** + String get releaseHighlightsEmpty; + + /// No description provided for @releaseHighlightsSeeNotes. + /// + /// In en, this message translates to: + /// **'Full release notes'** + String get releaseHighlightsSeeNotes; + + /// No description provided for @highlightCardTechnical. + /// + /// In en, this message translates to: + /// **'Technical'** + String get highlightCardTechnical; + /// No description provided for @moreVersionNotesEmpty. /// /// In en, this message translates to: @@ -5594,6 +6002,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'{n} hops'** String meshtasticTraceHops(int n); + + /// More menu row that uploads a debug dump + /// + /// In en, this message translates to: + /// **'Dump debug info and logs'** + String get moreDumpDiagnostics; + + /// Subtitle of the debug-dump row + /// + /// In en, this message translates to: + /// **'Uploads them and copies a link to paste into a report'** + String get moreDumpDiagnosticsHint; + + /// Title of the dialog shown after a debug dump uploads + /// + /// In en, this message translates to: + /// **'Uploaded'** + String get dumpUploaded; + + /// Says the uploaded dump link is already on the clipboard + /// + /// In en, this message translates to: + /// **'The link is on your clipboard'** + String get dumpLinkCopied; + + /// Button that copies the dump link to the clipboard again + /// + /// In en, this message translates to: + /// **'Copy again'** + String get dumpCopyAgain; + + /// Shown when a debug dump could not be uploaded + /// + /// In en, this message translates to: + /// **'Upload failed — try again'** + String get dumpUploadFailed; + + /// No description provided for @statusLegendUnprobed. + /// + /// In en, this message translates to: + /// **'Not yet probed'** + String get statusLegendUnprobed; + + /// No description provided for @statusLegendUnsupported. + /// + /// In en, this message translates to: + /// **'Not offered'** + String get statusLegendUnsupported; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 67a7b5e54..0d83ec155 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -937,6 +937,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get moreSectionApp => 'Get the app'; + @override + String get moreSectionBeta => 'Beta'; + + @override + String get moreAndroidBeta => 'Android beta'; + + @override + String get moreTestFlight => 'iOS beta (TestFlight)'; + + @override + String get moreSectionPartners => 'Partners'; + + @override + String get morePartnersNote => + 'Listed in order of partnership. Thank you to the individuals and companies whose contributions to disaster preparedness made DPIP possible.'; + + @override + String get morePartnerGeoscience => 'Geoscience'; + + @override + String get morePartnerTwds => 'TWDS'; + @override String get reportFilterIntensityInfoLegacyBody => 'Only levels 0–7. No 5− / 5+ / 6− / 6+ split.'; @@ -1424,6 +1446,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get changelogBodyEmpty => 'No notes for this release.'; + @override + String get changelogOpenOnGitHub => 'View on GitHub'; + @override String get radarGlobalOutline => 'National borders'; @@ -1964,6 +1989,167 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appLogs => 'App logs'; + @override + String get serverStatusLocal => 'Local status'; + + @override + String get serverStatusLocalBody => + 'Metrics come from the dashboard. Below is this device\'s own view of the multi-active endpoints (LB / Core per region): it passively records the traffic each endpoint actually serves, so a cell with no data means nothing was observed through this device yet.'; + + @override + String get serverStatusAllUp => 'All services operational'; + + @override + String get serverStatusDegraded => 'Services degraded'; + + @override + String get serverStatusDown => 'Service down'; + + @override + String get serverStatusErrorRate => '5xx error rate'; + + @override + String get serverStatusLatency => 'Avg latency'; + + @override + String get serverStatusUpdated => 'Updated'; + + @override + String get serverStatusWeb => 'Server status'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @override + String get serverStatusExpTech => 'ExpTech status'; + + @override + String get serverStatusCloudflare => 'Cloudflare status'; + + @override + String get serverStatusCloudflareAllOperational => 'All regions operational'; + + @override + String get serverStatusCloudflareOutage => 'Cloudflare regional issue'; + + @override + String get serverStatusCloudflareNone => 'No regions to show.'; + + @override + String get serverStatusCloudflareOperational => 'Operational'; + + @override + String get serverStatusCloudflareDegraded => 'Degraded'; + + @override + String get serverStatusCloudflarePartial => 'Partial outage'; + + @override + String get serverStatusCloudflareMajor => 'Major outage'; + + @override + String get serverStatusCloudflareUnknown => 'Unknown'; + + @override + String get endpointTierLbApi => 'LB API'; + + @override + String get endpointTierLbStatic => 'LB Static'; + + @override + String get endpointTierCoreApi => 'Core API'; + + @override + String get endpointTierCoreStatic => 'Core Static'; + + @override + String get endpointTierCoreExclusiveApi => + 'Core-exclusive API (radar / weather / wind)'; + + @override + String get endpointTierCoreStaticExclusive => 'Core-exclusive static'; + + @override + String get endpointTierLegacyApi => 'Legacy API (api-1)'; + + @override + String get endpointHealthOk => 'Local connections healthy'; + + @override + String get endpointHealthDegraded => 'Some endpoints unstable'; + + @override + String get endpointHealthDown => 'Local connections failing'; + + @override + String get endpointHealthUnknown => 'No observations yet'; + + @override + String get endpointStateOk => 'OK'; + + @override + String get endpointStateDegraded => 'Unstable'; + + @override + String get endpointStateDown => 'Failing'; + + @override + String get endpointStateUnknown => 'Unknown'; + + @override + String get endpointLastSuccessNever => 'never succeeded'; + + @override + String get endpointServiceEew => 'EEW'; + + @override + String get endpointServiceRts => 'RTS'; + + @override + String get endpointServiceRadar => 'Radar'; + + @override + String get endpointServiceSatellite => 'Satellite'; + + @override + String get endpointServiceQpesums => 'QPE'; + + @override + String get endpointServiceWind => 'Wind'; + + @override + String get endpointServiceDpm => 'Disaster points'; + + @override + String get endpointServiceWeather => 'Weather'; + + @override + String get endpointServiceRain => 'Rain'; + + @override + String get endpointServiceLightning => 'Lightning'; + + @override + String get endpointServiceTyphoon => 'Typhoon'; + + @override + String get endpointServiceReport => 'EQ reports'; + + @override + String get endpointServiceTremStation => 'Tremor station'; + + @override + String get endpointServiceEvent => 'Events'; + + @override + String get endpointServiceLocation => 'Location'; + + @override + String get endpointServiceNotify => 'Notifications'; + + @override + String get endpointServiceOther => 'Other'; + @override String get feedConnecting => 'Connecting…'; @@ -2044,12 +2230,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get moreAnnouncements => 'Announcements'; + @override + String get moreTagline => 'Disaster Prevention Information Platform'; + @override String get moreVersionStable => 'Release'; @override String get moreVersionNotes => 'This version'; + @override + String get releaseHighlightsTitle => 'What changed in this release'; + + @override + String get releaseHighlightsTabNormal => 'For users'; + + @override + String get releaseHighlightsTabAdvanced => 'Deep dive'; + + @override + String get releaseHighlightsEmpty => 'Nothing here yet.'; + + @override + String get releaseHighlightsSeeNotes => 'Full release notes'; + + @override + String get highlightCardTechnical => 'Technical'; + @override String get moreVersionNotesEmpty => 'No changelog for this build'; @@ -2939,4 +3146,29 @@ class AppLocalizationsEn extends AppLocalizations { String meshtasticTraceHops(int n) { return '$n hops'; } + + @override + String get moreDumpDiagnostics => 'Dump debug info and logs'; + + @override + String get moreDumpDiagnosticsHint => + 'Uploads them and copies a link to paste into a report'; + + @override + String get dumpUploaded => 'Uploaded'; + + @override + String get dumpLinkCopied => 'The link is on your clipboard'; + + @override + String get dumpCopyAgain => 'Copy again'; + + @override + String get dumpUploadFailed => 'Upload failed — try again'; + + @override + String get statusLegendUnprobed => 'Not yet probed'; + + @override + String get statusLegendUnsupported => 'Not offered'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 39e2e9897..e79e23af9 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -943,6 +943,28 @@ class AppLocalizationsFil extends AppLocalizations { @override String get moreSectionApp => 'Kunin ang app'; + @override + String get moreSectionBeta => 'Bersyon ng pagsubok'; + + @override + String get moreAndroidBeta => 'Bersyon ng pagsubok sa Android'; + + @override + String get moreTestFlight => 'Bersyon ng pagsubok sa iOS (TestFlight)'; + + @override + String get moreSectionPartners => 'Mga kasosyo'; + + @override + String get morePartnersNote => + 'Nakaayos ayon sa tamang panahon ng pakikipagtulungan. Salamat sa mga indibidwal at kompanyang nag-ambag sa paghahanda sa kalamidad; ang kanilang kontribusyon ang nagbigay-daan sa DPIP.'; + + @override + String get morePartnerGeoscience => 'Geoscience'; + + @override + String get morePartnerTwds => 'TWDS'; + @override String get reportFilterIntensityInfoLegacyBody => 'Antas 0–7 lang; walang 5−/5+/6−/6+.'; @@ -1433,6 +1455,9 @@ class AppLocalizationsFil extends AppLocalizations { @override String get changelogBodyEmpty => 'Walang tala para sa release na ito.'; + @override + String get changelogOpenOnGitHub => 'Tingnan sa GitHub'; + @override String get radarGlobalOutline => 'Mga hangganan ng bansa'; @@ -1974,6 +1999,169 @@ class AppLocalizationsFil extends AppLocalizations { @override String get appLogs => 'Mga log ng app'; + @override + String get serverStatusLocal => 'Katayuan ng device'; + + @override + String get serverStatusLocalBody => + 'Ang mga sukatan ng server ay mula sa dashboard. Nasa ibaba ang aktwal na paghusga ng device na ito sa mga multi-active na endpoint (LB / Core bawat rehiyon): pasibo lang itong nagtatala ng trapikong talagang pinapadala; kung hindi pa ito naantig ng device, lalabas ang \'Hindi pa nasuri\'.'; + + @override + String get serverStatusAllUp => 'Lahat ng serbisyo ay normal'; + + @override + String get serverStatusDegraded => 'Bumaba ang pagganap'; + + @override + String get serverStatusDown => 'May problema ang serbisyo'; + + @override + String get serverStatusErrorRate => 'Rate ng error na 5xx'; + + @override + String get serverStatusLatency => 'Karaniwang latency'; + + @override + String get serverStatusUpdated => 'Na-update'; + + @override + String get serverStatusWeb => 'Katayuan ng server'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @override + String get serverStatusExpTech => 'Katayuan ng ExpTech'; + + @override + String get serverStatusCloudflare => 'Katayuan ng Cloudflare'; + + @override + String get serverStatusCloudflareAllOperational => + 'Normal ang lahat ng lugar'; + + @override + String get serverStatusCloudflareOutage => + 'May problema ang Cloudflare sa ilang lugar'; + + @override + String get serverStatusCloudflareNone => 'Walang lugar na maipapakita.'; + + @override + String get serverStatusCloudflareOperational => 'Normal'; + + @override + String get serverStatusCloudflareDegraded => 'Bumaba ang pagganap'; + + @override + String get serverStatusCloudflarePartial => 'Bahagyang pagkaantala'; + + @override + String get serverStatusCloudflareMajor => 'Malaking pagkaantala'; + + @override + String get serverStatusCloudflareUnknown => 'Hindi alam'; + + @override + String get endpointTierLbApi => 'LB API'; + + @override + String get endpointTierLbStatic => 'LB Static'; + + @override + String get endpointTierCoreApi => 'Core API'; + + @override + String get endpointTierCoreStatic => 'Core Static'; + + @override + String get endpointTierCoreExclusiveApi => + 'Core-eksklusibong API (radar / panahon / hangin)'; + + @override + String get endpointTierCoreStaticExclusive => 'Core-eksklusibong static'; + + @override + String get endpointTierLegacyApi => 'Legacy API (api-1)'; + + @override + String get endpointHealthOk => 'Normal ang koneksyon'; + + @override + String get endpointHealthDegraded => 'May endpoint na hindi matatag'; + + @override + String get endpointHealthDown => 'May problema ang koneksyon'; + + @override + String get endpointHealthUnknown => 'Wala pang datos'; + + @override + String get endpointStateOk => 'Normal'; + + @override + String get endpointStateDegraded => 'Hindi matatag'; + + @override + String get endpointStateDown => 'May problema'; + + @override + String get endpointStateUnknown => 'Hindi alam'; + + @override + String get endpointLastSuccessNever => 'hindi pa nagtagumpay'; + + @override + String get endpointServiceEew => 'EEW'; + + @override + String get endpointServiceRts => 'RTS'; + + @override + String get endpointServiceRadar => 'Radar'; + + @override + String get endpointServiceSatellite => 'Satellite'; + + @override + String get endpointServiceQpesums => 'QPE'; + + @override + String get endpointServiceWind => 'Wind'; + + @override + String get endpointServiceDpm => 'Disaster points'; + + @override + String get endpointServiceWeather => 'Weather'; + + @override + String get endpointServiceRain => 'Rain'; + + @override + String get endpointServiceLightning => 'Lightning'; + + @override + String get endpointServiceTyphoon => 'Typhoon'; + + @override + String get endpointServiceReport => 'EQ reports'; + + @override + String get endpointServiceTremStation => 'Tremor station'; + + @override + String get endpointServiceEvent => 'Events'; + + @override + String get endpointServiceLocation => 'Location'; + + @override + String get endpointServiceNotify => 'Notifications'; + + @override + String get endpointServiceOther => 'Other'; + @override String get feedConnecting => 'Kumokonekta…'; @@ -2054,12 +2242,34 @@ class AppLocalizationsFil extends AppLocalizations { @override String get moreAnnouncements => 'Mga Anunsyo'; + @override + String get moreTagline => + 'Platform para sa Integral na Impormasyon sa Kalamidad'; + @override String get moreVersionStable => 'Pormal na bersyon'; @override String get moreVersionNotes => 'Kasalukuyang bersyon'; + @override + String get releaseHighlightsTitle => 'Ano ang nagbago'; + + @override + String get releaseHighlightsTabNormal => 'Para sa mga user'; + + @override + String get releaseHighlightsTabAdvanced => 'Mas malalim'; + + @override + String get releaseHighlightsEmpty => 'Wala pang laman.'; + + @override + String get releaseHighlightsSeeNotes => 'Buong tala ng release'; + + @override + String get highlightCardTechnical => 'Teknikal'; + @override String get moreVersionNotesEmpty => 'Walang changelog para sa build na ito'; @@ -2951,4 +3161,29 @@ class AppLocalizationsFil extends AppLocalizations { String meshtasticTraceHops(int n) { return '$n na relay'; } + + @override + String get moreDumpDiagnostics => 'I-upload ang debug info at mga log'; + + @override + String get moreDumpDiagnosticsHint => + 'Iuupload at kokopyahin ang link para ilakip sa ulat'; + + @override + String get dumpUploaded => 'Na-upload'; + + @override + String get dumpLinkCopied => 'Nakopya ang link sa clipboard'; + + @override + String get dumpCopyAgain => 'Kopyahin ulit'; + + @override + String get dumpUploadFailed => 'Nabigong mag-upload'; + + @override + String get statusLegendUnprobed => 'Hindi pa nasuri'; + + @override + String get statusLegendUnsupported => 'Hindi suportado'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 5dd818220..f1f8ac367 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -937,6 +937,28 @@ class AppLocalizationsId extends AppLocalizations { @override String get moreSectionApp => 'Dapatkan aplikasi'; + @override + String get moreSectionBeta => 'Versi uji'; + + @override + String get moreAndroidBeta => 'Versi uji Android'; + + @override + String get moreTestFlight => 'Versi uji iOS (TestFlight)'; + + @override + String get moreSectionPartners => 'Mitra'; + + @override + String get morePartnersNote => + 'Urut sesuai waktu kemitraan. Terima kasih kepada para individu dan perusahaan yang berkontribusi pada penanggulangan bencana; kontribusi mereka membuat DPIP menjadi mungkin.'; + + @override + String get morePartnerGeoscience => 'Geoscience'; + + @override + String get morePartnerTwds => 'TWDS'; + @override String get reportFilterIntensityInfoLegacyBody => 'Hanya tingkat 0–7, tanpa pemisahan 5−/5+/6−/6+.'; @@ -1424,6 +1446,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get changelogBodyEmpty => 'Tidak ada catatan untuk rilis ini.'; + @override + String get changelogOpenOnGitHub => 'Lihat di GitHub'; + @override String get radarGlobalOutline => 'Batas negara'; @@ -1965,6 +1990,169 @@ class AppLocalizationsId extends AppLocalizations { @override String get appLogs => 'Log aplikasi'; + @override + String get serverStatusLocal => 'Status perangkat'; + + @override + String get serverStatusLocalBody => + 'Metrik server berasal dari dasbor. Di bawah ini adalah penilaian koneksi aktual perangkat ini ke endpoint multi-aktif (LB / Core tiap wilayah): aplikasi hanya mencatat lalu lintas yang benar-benar dikirim, jika endpoint belum pernah disentuh perangkat ini akan ditampilkan \'Belum diperiksa\'.'; + + @override + String get serverStatusAllUp => 'Semua layanan normal'; + + @override + String get serverStatusDegraded => 'Kinerja menurun'; + + @override + String get serverStatusDown => 'Layanan bermasalah'; + + @override + String get serverStatusErrorRate => 'Tingkat error 5xx'; + + @override + String get serverStatusLatency => 'Latensi rata-rata'; + + @override + String get serverStatusUpdated => 'Diperbarui'; + + @override + String get serverStatusWeb => 'Status server'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @override + String get serverStatusExpTech => 'Status ExpTech'; + + @override + String get serverStatusCloudflare => 'Status Cloudflare'; + + @override + String get serverStatusCloudflareAllOperational => 'Semua wilayah normal'; + + @override + String get serverStatusCloudflareOutage => + 'Cloudflare beberapa wilayah bermasalah'; + + @override + String get serverStatusCloudflareNone => + 'Tidak ada wilayah untuk ditampilkan.'; + + @override + String get serverStatusCloudflareOperational => 'Normal'; + + @override + String get serverStatusCloudflareDegraded => 'Kinerja menurun'; + + @override + String get serverStatusCloudflarePartial => 'Gangguan sebagian'; + + @override + String get serverStatusCloudflareMajor => 'Gangguan besar'; + + @override + String get serverStatusCloudflareUnknown => 'Tidak diketahui'; + + @override + String get endpointTierLbApi => 'LB API'; + + @override + String get endpointTierLbStatic => 'LB Static'; + + @override + String get endpointTierCoreApi => 'Core API'; + + @override + String get endpointTierCoreStatic => 'Core Static'; + + @override + String get endpointTierCoreExclusiveApi => + 'Core eksklusif API (radar / cuaca / angin)'; + + @override + String get endpointTierCoreStaticExclusive => 'Core eksklusif statis'; + + @override + String get endpointTierLegacyApi => 'API lama (api-1)'; + + @override + String get endpointHealthOk => 'Koneksi normal'; + + @override + String get endpointHealthDegraded => 'Ada endpoint tidak stabil'; + + @override + String get endpointHealthDown => 'Koneksi bermasalah'; + + @override + String get endpointHealthUnknown => 'Belum ada data'; + + @override + String get endpointStateOk => 'Normal'; + + @override + String get endpointStateDegraded => 'Tidak stabil'; + + @override + String get endpointStateDown => 'Bermasalah'; + + @override + String get endpointStateUnknown => 'Tidak diketahui'; + + @override + String get endpointLastSuccessNever => 'belum berhasil'; + + @override + String get endpointServiceEew => 'EEW'; + + @override + String get endpointServiceRts => 'RTS'; + + @override + String get endpointServiceRadar => 'Radar'; + + @override + String get endpointServiceSatellite => 'Satellite'; + + @override + String get endpointServiceQpesums => 'QPE'; + + @override + String get endpointServiceWind => 'Wind'; + + @override + String get endpointServiceDpm => 'Disaster points'; + + @override + String get endpointServiceWeather => 'Weather'; + + @override + String get endpointServiceRain => 'Rain'; + + @override + String get endpointServiceLightning => 'Lightning'; + + @override + String get endpointServiceTyphoon => 'Typhoon'; + + @override + String get endpointServiceReport => 'EQ reports'; + + @override + String get endpointServiceTremStation => 'Tremor station'; + + @override + String get endpointServiceEvent => 'Events'; + + @override + String get endpointServiceLocation => 'Location'; + + @override + String get endpointServiceNotify => 'Notifications'; + + @override + String get endpointServiceOther => 'Other'; + @override String get feedConnecting => 'Menghubungkan…'; @@ -2045,12 +2233,33 @@ class AppLocalizationsId extends AppLocalizations { @override String get moreAnnouncements => 'Pengumuman'; + @override + String get moreTagline => 'Platform Integrasi Informasi Bencana'; + @override String get moreVersionStable => 'Versi resmi'; @override String get moreVersionNotes => 'Versi saat ini'; + @override + String get releaseHighlightsTitle => 'Yang berubah'; + + @override + String get releaseHighlightsTabNormal => 'Untuk pengguna'; + + @override + String get releaseHighlightsTabAdvanced => 'Mendalam'; + + @override + String get releaseHighlightsEmpty => 'Belum ada konten.'; + + @override + String get releaseHighlightsSeeNotes => 'Catatan rilis lengkap'; + + @override + String get highlightCardTechnical => 'Teknis'; + @override String get moreVersionNotesEmpty => 'Tidak ada changelog untuk build ini'; @@ -2944,4 +3153,29 @@ class AppLocalizationsId extends AppLocalizations { String meshtasticTraceHops(int n) { return '$n lompatan'; } + + @override + String get moreDumpDiagnostics => 'Unggah info debug dan log'; + + @override + String get moreDumpDiagnosticsHint => + 'Mengunggah lalu menyalin tautan untuk dilampirkan ke laporan'; + + @override + String get dumpUploaded => 'Terunggah'; + + @override + String get dumpLinkCopied => 'Tautan disalin ke papan klip'; + + @override + String get dumpCopyAgain => 'Salin lagi'; + + @override + String get dumpUploadFailed => 'Gagal mengunggah'; + + @override + String get statusLegendUnprobed => 'Belum diperiksa'; + + @override + String get statusLegendUnsupported => 'Tidak tersedia'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 3e21688e3..7bf2a8ccc 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -922,6 +922,28 @@ class AppLocalizationsJa extends AppLocalizations { @override String get moreSectionApp => 'アプリを入手'; + @override + String get moreSectionBeta => 'テスト版'; + + @override + String get moreAndroidBeta => 'Android テスト版'; + + @override + String get moreTestFlight => 'iOS テスト版(TestFlight)'; + + @override + String get moreSectionPartners => 'パートナー'; + + @override + String get morePartnersNote => + '提携順に表示しています。防災への貢献で DPIP を支えてくださった個人・企業の皆様に感謝します。'; + + @override + String get morePartnerGeoscience => 'Geoscience'; + + @override + String get morePartnerTwds => 'TWDS'; + @override String get reportFilterIntensityInfoLegacyBody => '震度は 0–7 のみ。5弱/5強/6弱/6強の区分はありません。'; @@ -1405,6 +1427,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get changelogBodyEmpty => 'このリリースの説明はありません。'; + @override + String get changelogOpenOnGitHub => 'GitHub で見る'; + @override String get radarGlobalOutline => '国境線'; @@ -1932,6 +1957,166 @@ class AppLocalizationsJa extends AppLocalizations { @override String get appLogs => 'アプリログ'; + @override + String get serverStatusLocal => 'デバイスの状態'; + + @override + String get serverStatusLocalBody => + 'サーバー指標はダッシュボードからのものです。以下は本機のマルチアクティブエンドポイント(LB / Core 各リージョン)への実際の接続判断です:本機が実際に送受信したトラフィックだけを受動的に記録するため、まだ触れていないエンドポイントは「未探知」と表示されます。'; + + @override + String get serverStatusAllUp => 'すべて正常'; + + @override + String get serverStatusDegraded => 'パフォーマンス低下'; + + @override + String get serverStatusDown => 'サービス異常'; + + @override + String get serverStatusErrorRate => '5xx エラー率'; + + @override + String get serverStatusLatency => '平均遅延'; + + @override + String get serverStatusUpdated => '更新'; + + @override + String get serverStatusWeb => 'サーバー状態'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @override + String get serverStatusExpTech => 'ExpTech ステータス'; + + @override + String get serverStatusCloudflare => 'Cloudflare ステータス'; + + @override + String get serverStatusCloudflareAllOperational => '全リージョン正常'; + + @override + String get serverStatusCloudflareOutage => 'Cloudflare の一部リージョンで異常'; + + @override + String get serverStatusCloudflareNone => '表示できるリージョンがありません。'; + + @override + String get serverStatusCloudflareOperational => '正常'; + + @override + String get serverStatusCloudflareDegraded => '性能低下'; + + @override + String get serverStatusCloudflarePartial => '部分停止'; + + @override + String get serverStatusCloudflareMajor => '大規模停止'; + + @override + String get serverStatusCloudflareUnknown => '不明'; + + @override + String get endpointTierLbApi => 'LB API'; + + @override + String get endpointTierLbStatic => 'LB Static'; + + @override + String get endpointTierCoreApi => 'Core API'; + + @override + String get endpointTierCoreStatic => 'Core Static'; + + @override + String get endpointTierCoreExclusiveApi => 'Core 専用 API(レーダー / 気象 / 風)'; + + @override + String get endpointTierCoreStaticExclusive => 'Core 専用静的リソース'; + + @override + String get endpointTierLegacyApi => 'レガシー API(api-1)'; + + @override + String get endpointHealthOk => '接続正常'; + + @override + String get endpointHealthDegraded => '不安定なエンドポイントあり'; + + @override + String get endpointHealthDown => '接続異常'; + + @override + String get endpointHealthUnknown => '観測データなし'; + + @override + String get endpointStateOk => '正常'; + + @override + String get endpointStateDegraded => '不安定'; + + @override + String get endpointStateDown => '異常'; + + @override + String get endpointStateUnknown => '不明'; + + @override + String get endpointLastSuccessNever => '未成功'; + + @override + String get endpointServiceEew => 'EEW'; + + @override + String get endpointServiceRts => 'RTS'; + + @override + String get endpointServiceRadar => 'Radar'; + + @override + String get endpointServiceSatellite => 'Satellite'; + + @override + String get endpointServiceQpesums => 'QPE'; + + @override + String get endpointServiceWind => 'Wind'; + + @override + String get endpointServiceDpm => 'Disaster points'; + + @override + String get endpointServiceWeather => 'Weather'; + + @override + String get endpointServiceRain => 'Rain'; + + @override + String get endpointServiceLightning => 'Lightning'; + + @override + String get endpointServiceTyphoon => 'Typhoon'; + + @override + String get endpointServiceReport => 'EQ reports'; + + @override + String get endpointServiceTremStation => 'Tremor station'; + + @override + String get endpointServiceEvent => 'Events'; + + @override + String get endpointServiceLocation => 'Location'; + + @override + String get endpointServiceNotify => 'Notifications'; + + @override + String get endpointServiceOther => 'Other'; + @override String get feedConnecting => '接続中…'; @@ -2011,12 +2196,33 @@ class AppLocalizationsJa extends AppLocalizations { @override String get moreAnnouncements => 'お知らせ'; + @override + String get moreTagline => '防災情報統合プラットフォーム'; + @override String get moreVersionStable => '正式版'; @override String get moreVersionNotes => '現在のバージョン'; + @override + String get releaseHighlightsTitle => '今回の更新'; + + @override + String get releaseHighlightsTabNormal => '変更点'; + + @override + String get releaseHighlightsTabAdvanced => '技術詳細'; + + @override + String get releaseHighlightsEmpty => 'まだコンテンツがありません。'; + + @override + String get releaseHighlightsSeeNotes => '完全なリリースノート'; + + @override + String get highlightCardTechnical => '技術詳細'; + @override String get moreVersionNotesEmpty => 'このビルドの更新履歴が見つかりません'; @@ -2894,4 +3100,28 @@ class AppLocalizationsJa extends AppLocalizations { String meshtasticTraceHops(int n) { return '$n ホップ'; } + + @override + String get moreDumpDiagnostics => 'デバッグ情報とログを送信'; + + @override + String get moreDumpDiagnosticsHint => 'アップロードしてリンクをコピーします'; + + @override + String get dumpUploaded => 'アップロードしました'; + + @override + String get dumpLinkCopied => 'リンクをクリップボードにコピーしました'; + + @override + String get dumpCopyAgain => 'もう一度コピー'; + + @override + String get dumpUploadFailed => 'アップロードに失敗しました'; + + @override + String get statusLegendUnprobed => '未探知'; + + @override + String get statusLegendUnsupported => '非対応'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 6098f2948..566795396 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -924,6 +924,28 @@ class AppLocalizationsKo extends AppLocalizations { @override String get moreSectionApp => '앱 다운로드'; + @override + String get moreSectionBeta => '테스트 버전'; + + @override + String get moreAndroidBeta => 'Android 테스트 버전'; + + @override + String get moreTestFlight => 'iOS 테스트 버전 (TestFlight)'; + + @override + String get moreSectionPartners => '파트너'; + + @override + String get morePartnersNote => + '파트너십 순서대로 표시됩니다. 재난 예방에 기여한 개인과 기업에 감사드립니다. 그들의 기여 더봉에 DPIP가 가능했습니다.'; + + @override + String get morePartnerGeoscience => 'Geoscience'; + + @override + String get morePartnerTwds => 'TWDS'; + @override String get reportFilterIntensityInfoLegacyBody => '진도는 0–7만 있으며 5약/5강/6약/6강 구분이 없습니다.'; @@ -1409,6 +1431,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get changelogBodyEmpty => '이 릴리스에 대한 설명이 없습니다.'; + @override + String get changelogOpenOnGitHub => 'GitHub에서 보기'; + @override String get radarGlobalOutline => '국경'; @@ -1939,6 +1964,166 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appLogs => '앱 로그'; + @override + String get serverStatusLocal => '기기 상태'; + + @override + String get serverStatusLocalBody => + '서버 지표는 대시보드에서 가져옵니다. 아래는 이 기기의 멀티 액티브 엔드포인트(LB / Core 각 지역)에 대한 실제 연결 판단입니다. 기기가 실제로 주고받은 트래픽만 수동적으로 기록하므로, 아직 접촉하지 않은 엔드포인트는 \'탐지 안 됨\'으로 표시됩니다.'; + + @override + String get serverStatusAllUp => '모든 서비스 정상'; + + @override + String get serverStatusDegraded => '성능 저하'; + + @override + String get serverStatusDown => '서비스 이상'; + + @override + String get serverStatusErrorRate => '5xx 오류율'; + + @override + String get serverStatusLatency => '평균 지연'; + + @override + String get serverStatusUpdated => '업데이트'; + + @override + String get serverStatusWeb => '서버 상태'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @override + String get serverStatusExpTech => 'ExpTech 상태'; + + @override + String get serverStatusCloudflare => 'Cloudflare 상태'; + + @override + String get serverStatusCloudflareAllOperational => '모든 리전 정상'; + + @override + String get serverStatusCloudflareOutage => 'Cloudflare 일부 리전 이상'; + + @override + String get serverStatusCloudflareNone => '표시할 리전이 없습니다.'; + + @override + String get serverStatusCloudflareOperational => '정상'; + + @override + String get serverStatusCloudflareDegraded => '성능 저하'; + + @override + String get serverStatusCloudflarePartial => '부분 중단'; + + @override + String get serverStatusCloudflareMajor => '대규모 중단'; + + @override + String get serverStatusCloudflareUnknown => '알 수 없음'; + + @override + String get endpointTierLbApi => 'LB API'; + + @override + String get endpointTierLbStatic => 'LB Static'; + + @override + String get endpointTierCoreApi => 'Core API'; + + @override + String get endpointTierCoreStatic => 'Core Static'; + + @override + String get endpointTierCoreExclusiveApi => 'Core 전용 API (레이다 / 기상 / 바람)'; + + @override + String get endpointTierCoreStaticExclusive => 'Core 전용 정적 리소스'; + + @override + String get endpointTierLegacyApi => '레거시 API (api-1)'; + + @override + String get endpointHealthOk => '연결 정상'; + + @override + String get endpointHealthDegraded => '불안정한 엔드포인트 있음'; + + @override + String get endpointHealthDown => '연결 이상'; + + @override + String get endpointHealthUnknown => '관측 데이터 없음'; + + @override + String get endpointStateOk => '정상'; + + @override + String get endpointStateDegraded => '불안정'; + + @override + String get endpointStateDown => '이상'; + + @override + String get endpointStateUnknown => '알 수 없음'; + + @override + String get endpointLastSuccessNever => '미성공'; + + @override + String get endpointServiceEew => 'EEW'; + + @override + String get endpointServiceRts => 'RTS'; + + @override + String get endpointServiceRadar => 'Radar'; + + @override + String get endpointServiceSatellite => 'Satellite'; + + @override + String get endpointServiceQpesums => 'QPE'; + + @override + String get endpointServiceWind => 'Wind'; + + @override + String get endpointServiceDpm => 'Disaster points'; + + @override + String get endpointServiceWeather => 'Weather'; + + @override + String get endpointServiceRain => 'Rain'; + + @override + String get endpointServiceLightning => 'Lightning'; + + @override + String get endpointServiceTyphoon => 'Typhoon'; + + @override + String get endpointServiceReport => 'EQ reports'; + + @override + String get endpointServiceTremStation => 'Tremor station'; + + @override + String get endpointServiceEvent => 'Events'; + + @override + String get endpointServiceLocation => 'Location'; + + @override + String get endpointServiceNotify => 'Notifications'; + + @override + String get endpointServiceOther => 'Other'; + @override String get feedConnecting => '연결 중…'; @@ -2018,12 +2203,33 @@ class AppLocalizationsKo extends AppLocalizations { @override String get moreAnnouncements => '공지사항'; + @override + String get moreTagline => '재해 정보 통합 플랫폼'; + @override String get moreVersionStable => '정식 버전'; @override String get moreVersionNotes => '현재 버전'; + @override + String get releaseHighlightsTitle => '이번 업데이트'; + + @override + String get releaseHighlightsTabNormal => '변경된 점'; + + @override + String get releaseHighlightsTabAdvanced => '기술 세부'; + + @override + String get releaseHighlightsEmpty => '아직 내용이 없습니다.'; + + @override + String get releaseHighlightsSeeNotes => '전체 릴리스 노트'; + + @override + String get highlightCardTechnical => '기술 세부'; + @override String get moreVersionNotesEmpty => '이 빌드의 업데이트 내역을 찾을 수 없습니다'; @@ -2903,4 +3109,28 @@ class AppLocalizationsKo extends AppLocalizations { String meshtasticTraceHops(int n) { return '$n 홉'; } + + @override + String get moreDumpDiagnostics => '디버그 정보 및 로그 업로드'; + + @override + String get moreDumpDiagnosticsHint => '업로드한 뒤 링크를 복사합니다'; + + @override + String get dumpUploaded => '업로드됨'; + + @override + String get dumpLinkCopied => '링크를 클립보드에 복사했습니다'; + + @override + String get dumpCopyAgain => '다시 복사'; + + @override + String get dumpUploadFailed => '업로드하지 못했습니다'; + + @override + String get statusLegendUnprobed => '탐지 안 됨'; + + @override + String get statusLegendUnsupported => '미지원'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 53be9c03a..46fa73faa 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -934,6 +934,28 @@ class AppLocalizationsTh extends AppLocalizations { @override String get moreSectionApp => 'ดาวน์โหลดแอป'; + @override + String get moreSectionBeta => 'เวอร์ชันทดสอบ'; + + @override + String get moreAndroidBeta => 'เวอร์ชันทดอบ Android'; + + @override + String get moreTestFlight => 'เวอร์ชันทดอบ iOS (TestFlight)'; + + @override + String get moreSectionPartners => 'พันธมิตร'; + + @override + String get morePartnersNote => + 'เรียงตามลำดับคู่ความร่วมมือ ขอบคุณบุคคลและบริษัทที่มีส่วนร่วมในการป้องกันภัยพิบัติ การสนับสนุนของพวกเขาทำให้ DPIP เกิดขึ้นได้'; + + @override + String get morePartnerGeoscience => 'Geoscience'; + + @override + String get morePartnerTwds => 'TWDS'; + @override String get reportFilterIntensityInfoLegacyBody => 'มีระดับ 0–7 เท่านั้น ไม่แยก 5−/5+/6−/6+'; @@ -1421,6 +1443,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get changelogBodyEmpty => 'ไม่มีคำอธิบายสำหรับรุ่นนี้'; + @override + String get changelogOpenOnGitHub => 'ดูบน GitHub'; + @override String get radarGlobalOutline => 'เส้นแบ่งเขตประเทศ'; @@ -1959,6 +1984,167 @@ class AppLocalizationsTh extends AppLocalizations { @override String get appLogs => 'บันทึกแอป'; + @override + String get serverStatusLocal => 'สถานะอุปกรณ์'; + + @override + String get serverStatusLocalBody => + 'ตัวชี้วัดเซิร์ฟเวอร์มาจากแดชบอร์ด ด้านล่างคือการตัดสินการเชื่อมต่อจริงของเครื่องนี้ต่อเอนด์พอยต์แบบ multi-active (LB / Core แต่ละภูมิภาค): แอปบันทึกเฉพาะทราฟฟิกที่เครื่องนี้รับส่งจริงโดยไม่รบกวน ถ้ายังไม่เคยแตะเอนด์พอยต์นั้นจะแสดง \'ยังไม่ตรวจ\''; + + @override + String get serverStatusAllUp => 'บริการทั้งหมดปกติ'; + + @override + String get serverStatusDegraded => 'ประสิทธิภาพลดลง'; + + @override + String get serverStatusDown => 'บริการผิดปกติ'; + + @override + String get serverStatusErrorRate => 'อัตราข้อผิดพลาด 5xx'; + + @override + String get serverStatusLatency => 'ความหน่วงเฉลี่ย'; + + @override + String get serverStatusUpdated => 'อัปเดต'; + + @override + String get serverStatusWeb => 'สถานะเซิร์ฟเวอร์'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @override + String get serverStatusExpTech => 'สถานะ ExpTech'; + + @override + String get serverStatusCloudflare => 'สถานะ Cloudflare'; + + @override + String get serverStatusCloudflareAllOperational => 'ทุกภูมิภาคปกติ'; + + @override + String get serverStatusCloudflareOutage => 'Cloudflare บางภูมิภาคผิดปกติ'; + + @override + String get serverStatusCloudflareNone => 'ไม่มีภูมิภาคให้แสดง'; + + @override + String get serverStatusCloudflareOperational => 'ปกติ'; + + @override + String get serverStatusCloudflareDegraded => 'ประสิทธิภาพลดลง'; + + @override + String get serverStatusCloudflarePartial => 'หยุดบางส่วน'; + + @override + String get serverStatusCloudflareMajor => 'หยุดบริการขนาดใหญ่'; + + @override + String get serverStatusCloudflareUnknown => 'ไม่ทราบ'; + + @override + String get endpointTierLbApi => 'LB API'; + + @override + String get endpointTierLbStatic => 'LB Static'; + + @override + String get endpointTierCoreApi => 'Core API'; + + @override + String get endpointTierCoreStatic => 'Core Static'; + + @override + String get endpointTierCoreExclusiveApi => + 'Core เฉพาะ API (เรดาร์ / อากาศ / ลม)'; + + @override + String get endpointTierCoreStaticExclusive => 'Core เฉพาะทรัพยากรคงที่'; + + @override + String get endpointTierLegacyApi => 'API เดิม (api-1)'; + + @override + String get endpointHealthOk => 'การเชื่อมต่อปกติ'; + + @override + String get endpointHealthDegraded => 'มีจุดเชื่อมต่อไม่เสถียร'; + + @override + String get endpointHealthDown => 'การเชื่อมต่อผิดปกติ'; + + @override + String get endpointHealthUnknown => 'ยังไม่มีข้อมูล'; + + @override + String get endpointStateOk => 'ปกติ'; + + @override + String get endpointStateDegraded => 'ไม่เสถียร'; + + @override + String get endpointStateDown => 'ผิดปกติ'; + + @override + String get endpointStateUnknown => 'ไม่ทราบ'; + + @override + String get endpointLastSuccessNever => 'ยังไม่สำเร็จ'; + + @override + String get endpointServiceEew => 'EEW'; + + @override + String get endpointServiceRts => 'RTS'; + + @override + String get endpointServiceRadar => 'Radar'; + + @override + String get endpointServiceSatellite => 'Satellite'; + + @override + String get endpointServiceQpesums => 'QPE'; + + @override + String get endpointServiceWind => 'Wind'; + + @override + String get endpointServiceDpm => 'Disaster points'; + + @override + String get endpointServiceWeather => 'Weather'; + + @override + String get endpointServiceRain => 'Rain'; + + @override + String get endpointServiceLightning => 'Lightning'; + + @override + String get endpointServiceTyphoon => 'Typhoon'; + + @override + String get endpointServiceReport => 'EQ reports'; + + @override + String get endpointServiceTremStation => 'Tremor station'; + + @override + String get endpointServiceEvent => 'Events'; + + @override + String get endpointServiceLocation => 'Location'; + + @override + String get endpointServiceNotify => 'Notifications'; + + @override + String get endpointServiceOther => 'Other'; + @override String get feedConnecting => 'กำลังเชื่อมต่อ…'; @@ -2039,12 +2225,33 @@ class AppLocalizationsTh extends AppLocalizations { @override String get moreAnnouncements => 'ประกาศ'; + @override + String get moreTagline => 'แพลตฟอร์มรวมข้อมูลป้องกันภัยพิบัติ'; + @override String get moreVersionStable => 'เวอร์ชันเต็ม'; @override String get moreVersionNotes => 'เวอร์ชันปัจจุบัน'; + @override + String get releaseHighlightsTitle => 'สิ่งที่เปลี่ยนแปลง'; + + @override + String get releaseHighlightsTabNormal => 'สำหรับผู้ใช้'; + + @override + String get releaseHighlightsTabAdvanced => 'เจาะลึก'; + + @override + String get releaseHighlightsEmpty => 'ยังไม่มีเนื้อหา'; + + @override + String get releaseHighlightsSeeNotes => 'ดูบันทึกทั้งหมด'; + + @override + String get highlightCardTechnical => 'เทคนิค'; + @override String get moreVersionNotesEmpty => 'ไม่พบประวัติการอัปเดตสำหรับบิลด์นี้'; @@ -2933,4 +3140,29 @@ class AppLocalizationsTh extends AppLocalizations { String meshtasticTraceHops(int n) { return '$n ฮอป'; } + + @override + String get moreDumpDiagnostics => 'อัปโหลดข้อมูลดีบักและบันทึก'; + + @override + String get moreDumpDiagnosticsHint => + 'อัปโหลดแล้วคัดลอกลิงก์เพื่อแนบในรายงาน'; + + @override + String get dumpUploaded => 'อัปโหลดแล้ว'; + + @override + String get dumpLinkCopied => 'คัดลอกลิงก์ไปยังคลิปบอร์ดแล้ว'; + + @override + String get dumpCopyAgain => 'คัดลอกอีกครั้ง'; + + @override + String get dumpUploadFailed => 'อัปโหลดไม่สำเร็จ'; + + @override + String get statusLegendUnprobed => 'ยังไม่ตรวจ'; + + @override + String get statusLegendUnsupported => 'ไม่รองรับ'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 09a4dc518..340c01676 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -937,6 +937,28 @@ class AppLocalizationsVi extends AppLocalizations { @override String get moreSectionApp => 'Tải ứng dụng'; + @override + String get moreSectionBeta => 'Bản thử nghiệm'; + + @override + String get moreAndroidBeta => 'Bản thử nghiệm Android'; + + @override + String get moreTestFlight => 'Bản thử nghiệm iOS (TestFlight)'; + + @override + String get moreSectionPartners => 'Đối tác'; + + @override + String get morePartnersNote => + 'Theo thứ tự hợp tác. Xin cảm ơn các cá nhân và công ty đã đóng góp cho công tác phòng chống thiên tai, nhờ đó DPIP mới có thể ra đời.'; + + @override + String get morePartnerGeoscience => 'Geoscience'; + + @override + String get morePartnerTwds => 'TWDS'; + @override String get reportFilterIntensityInfoLegacyBody => 'Chỉ có mức 0–7, không tách 5−/5+/6−/6+.'; @@ -1425,6 +1447,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get changelogBodyEmpty => 'Không có ghi chú cho bản phát hành này.'; + @override + String get changelogOpenOnGitHub => 'Xem trên GitHub'; + @override String get radarGlobalOutline => 'Biên giới quốc gia'; @@ -1964,6 +1989,167 @@ class AppLocalizationsVi extends AppLocalizations { @override String get appLogs => 'Nhật ký ứng dụng'; + @override + String get serverStatusLocal => 'Trạng thái thiết bị'; + + @override + String get serverStatusLocalBody => + 'Chỉ số máy chủ đến từ bảng điều khiển. Dưới đây là đánh giá kết nối thực tế của máy này với các endpoint đa hoạt động (LB / Core từng khu vực): ứng dụng chỉ ghi nhận thụ động lưu lượng thực tế, nếu endpoint chưa từng được máy này truy cập sẽ hiển thị \'Chưa dò\'.'; + + @override + String get serverStatusAllUp => 'Tất cả dịch vụ hoạt động'; + + @override + String get serverStatusDegraded => 'Hiệu suất giảm'; + + @override + String get serverStatusDown => 'Dịch vụ lỗi'; + + @override + String get serverStatusErrorRate => 'Tỷ lệ lỗi 5xx'; + + @override + String get serverStatusLatency => 'Độ trễ trung bình'; + + @override + String get serverStatusUpdated => 'Cập nhật'; + + @override + String get serverStatusWeb => 'Trạng thái máy chủ'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @override + String get serverStatusExpTech => 'Trạng thái ExpTech'; + + @override + String get serverStatusCloudflare => 'Trạng thái Cloudflare'; + + @override + String get serverStatusCloudflareAllOperational => 'Tất cả khu vực hoạt động'; + + @override + String get serverStatusCloudflareOutage => 'Cloudflare có khu vực bất thường'; + + @override + String get serverStatusCloudflareNone => 'Không có khu vực nào để hiển thị.'; + + @override + String get serverStatusCloudflareOperational => 'Hoạt động'; + + @override + String get serverStatusCloudflareDegraded => 'Hiệu suất giảm'; + + @override + String get serverStatusCloudflarePartial => 'Gián đoạn một phần'; + + @override + String get serverStatusCloudflareMajor => 'Gián đoạn lớn'; + + @override + String get serverStatusCloudflareUnknown => 'Không rõ'; + + @override + String get endpointTierLbApi => 'LB API'; + + @override + String get endpointTierLbStatic => 'LB Static'; + + @override + String get endpointTierCoreApi => 'Core API'; + + @override + String get endpointTierCoreStatic => 'Core Static'; + + @override + String get endpointTierCoreExclusiveApi => + 'Core độc quyền API (radar / thời tiết / gió)'; + + @override + String get endpointTierCoreStaticExclusive => 'Core độc quyền tĩnh'; + + @override + String get endpointTierLegacyApi => 'API kế thừa (api-1)'; + + @override + String get endpointHealthOk => 'Kết nối bình thường'; + + @override + String get endpointHealthDegraded => 'Có máy chủ không ổn định'; + + @override + String get endpointHealthDown => 'Kết nối bất thường'; + + @override + String get endpointHealthUnknown => 'Chưa có dữ liệu'; + + @override + String get endpointStateOk => 'Bình thường'; + + @override + String get endpointStateDegraded => 'Không ổn định'; + + @override + String get endpointStateDown => 'Bất thường'; + + @override + String get endpointStateUnknown => 'Không rõ'; + + @override + String get endpointLastSuccessNever => 'chưa thành công'; + + @override + String get endpointServiceEew => 'EEW'; + + @override + String get endpointServiceRts => 'RTS'; + + @override + String get endpointServiceRadar => 'Radar'; + + @override + String get endpointServiceSatellite => 'Satellite'; + + @override + String get endpointServiceQpesums => 'QPE'; + + @override + String get endpointServiceWind => 'Wind'; + + @override + String get endpointServiceDpm => 'Disaster points'; + + @override + String get endpointServiceWeather => 'Weather'; + + @override + String get endpointServiceRain => 'Rain'; + + @override + String get endpointServiceLightning => 'Lightning'; + + @override + String get endpointServiceTyphoon => 'Typhoon'; + + @override + String get endpointServiceReport => 'EQ reports'; + + @override + String get endpointServiceTremStation => 'Tremor station'; + + @override + String get endpointServiceEvent => 'Events'; + + @override + String get endpointServiceLocation => 'Location'; + + @override + String get endpointServiceNotify => 'Notifications'; + + @override + String get endpointServiceOther => 'Other'; + @override String get feedConnecting => 'Đang kết nối…'; @@ -2044,12 +2230,33 @@ class AppLocalizationsVi extends AppLocalizations { @override String get moreAnnouncements => 'Thông báo'; + @override + String get moreTagline => 'Nền tảng tích hợp thông tin phòng chống thiên tai'; + @override String get moreVersionStable => 'Bản chính thức'; @override String get moreVersionNotes => 'Phiên bản hiện tại'; + @override + String get releaseHighlightsTitle => 'Thay đổi trong bản này'; + + @override + String get releaseHighlightsTabNormal => 'Cho người dùng'; + + @override + String get releaseHighlightsTabAdvanced => 'Đi sâu'; + + @override + String get releaseHighlightsEmpty => 'Chưa có nội dung.'; + + @override + String get releaseHighlightsSeeNotes => 'Xem ghi chú đầy đủ'; + + @override + String get highlightCardTechnical => 'Kỹ thuật'; + @override String get moreVersionNotesEmpty => 'Không tìm thấy nhật ký cập nhật cho bản này'; @@ -2941,4 +3148,29 @@ class AppLocalizationsVi extends AppLocalizations { String meshtasticTraceHops(int n) { return '$n chặng'; } + + @override + String get moreDumpDiagnostics => 'Tải lên thông tin gỡ lỗi và nhật ký'; + + @override + String get moreDumpDiagnosticsHint => + 'Tải lên rồi sao chép liên kết để đính kèm vào báo cáo'; + + @override + String get dumpUploaded => 'Đã tải lên'; + + @override + String get dumpLinkCopied => 'Đã sao chép liên kết vào bảng nhớ tạm'; + + @override + String get dumpCopyAgain => 'Sao chép lại'; + + @override + String get dumpUploadFailed => 'Tải lên thất bại'; + + @override + String get statusLegendUnprobed => 'Chưa dò'; + + @override + String get statusLegendUnsupported => 'Không có'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index eda79edff..9495c5d8a 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -264,7 +264,7 @@ class AppLocalizationsZh extends AppLocalizations { String get typhoonLegendCircle25 => '十級風暴風圈'; @override - String get sponsorTitle => '支持 DPIP'; + String get sponsorTitle => '支援 DPIP'; @override String get mapNavSatellite => '衛星'; @@ -590,7 +590,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get sponsorIntro => - 'DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支持能幫助我們維持伺服器運作並持續開發。'; + 'DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支援能幫助我們維持伺服器運作並持續開發。'; @override String get shelterAddressLabel => '地址'; @@ -918,6 +918,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get moreSectionApp => '取得 App'; + @override + String get moreSectionBeta => '測試版'; + + @override + String get moreAndroidBeta => 'Android 測試版'; + + @override + String get moreTestFlight => 'iOS 測試版(TestFlight)'; + + @override + String get moreSectionPartners => '合作夥伴'; + + @override + String get morePartnersNote => '依合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。'; + + @override + String get morePartnerGeoscience => '巨科資訊有限公司'; + + @override + String get morePartnerTwds => '台灣數位串流有限公司'; + @override String get reportFilterIntensityInfoLegacyBody => '震度僅 0–7,沒有 5弱/5強/6弱/6強。'; @@ -1397,6 +1418,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get changelogBodyEmpty => '此版本沒有說明。'; + @override + String get changelogOpenOnGitHub => '在 GitHub 查看'; + @override String get radarGlobalOutline => '國界'; @@ -1561,7 +1585,7 @@ class AppLocalizationsZh extends AppLocalizations { String get onboardingPermLocation => '定位'; @override - String get sponsorCalloutBody => '沒有廣告,你的支持讓伺服器持續運作。'; + String get sponsorCalloutBody => '沒有廣告,你的支援讓伺服器持續運作。'; @override String get moreDiscordCalloutBody => '加入社群,直接和開發團隊交流。'; @@ -1922,6 +1946,166 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appLogs => 'App 日誌'; + @override + String get serverStatusLocal => '本機狀態'; + + @override + String get serverStatusLocalBody => + '伺服器指標來自控制台。下方是本機對多活端點(LB / Core 各區)的實際連線判斷:APP 只被動記錄本機實際播送的流量,若該端點從未被本機觸發,就會顯示未探測。'; + + @override + String get serverStatusAllUp => '所有服務正常'; + + @override + String get serverStatusDegraded => '服務效能下降'; + + @override + String get serverStatusDown => '服務異常'; + + @override + String get serverStatusErrorRate => '5xx 錯誤率'; + + @override + String get serverStatusLatency => '平均延遲'; + + @override + String get serverStatusUpdated => '更新於'; + + @override + String get serverStatusWeb => '伺服器狀態'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @override + String get serverStatusExpTech => 'ExpTech 状态'; + + @override + String get serverStatusCloudflare => 'Cloudflare 状态'; + + @override + String get serverStatusCloudflareAllOperational => '所有区域正常'; + + @override + String get serverStatusCloudflareOutage => 'Cloudflare 部分区域异常'; + + @override + String get serverStatusCloudflareNone => '目前没有可显示的区域。'; + + @override + String get serverStatusCloudflareOperational => '正常'; + + @override + String get serverStatusCloudflareDegraded => '性能下降'; + + @override + String get serverStatusCloudflarePartial => '部分中断'; + + @override + String get serverStatusCloudflareMajor => '大规模中断'; + + @override + String get serverStatusCloudflareUnknown => '未知'; + + @override + String get endpointTierLbApi => 'LB API'; + + @override + String get endpointTierLbStatic => 'LB Static'; + + @override + String get endpointTierCoreApi => 'Core API'; + + @override + String get endpointTierCoreStatic => 'Core Static'; + + @override + String get endpointTierCoreExclusiveApi => 'Core 專屬 API(雷達 / 氣象 / 風場)'; + + @override + String get endpointTierCoreStaticExclusive => 'Core 專屬靜態資源'; + + @override + String get endpointTierLegacyApi => '舊版 API(api-1)'; + + @override + String get endpointHealthOk => '本機連線正常'; + + @override + String get endpointHealthDegraded => '有端點連線不穩'; + + @override + String get endpointHealthDown => '本機連線異常'; + + @override + String get endpointHealthUnknown => '尚無觀測資料'; + + @override + String get endpointStateOk => '正常'; + + @override + String get endpointStateDegraded => '不穩'; + + @override + String get endpointStateDown => '異常'; + + @override + String get endpointStateUnknown => '未知'; + + @override + String get endpointLastSuccessNever => '尚未成功'; + + @override + String get endpointServiceEew => '地震速報'; + + @override + String get endpointServiceRts => '強震即時警報'; + + @override + String get endpointServiceRadar => '雷達'; + + @override + String get endpointServiceSatellite => '衛星'; + + @override + String get endpointServiceQpesums => '定量降水'; + + @override + String get endpointServiceWind => '風場'; + + @override + String get endpointServiceDpm => '災害點位'; + + @override + String get endpointServiceWeather => '天氣'; + + @override + String get endpointServiceRain => '降雨'; + + @override + String get endpointServiceLightning => '閃電'; + + @override + String get endpointServiceTyphoon => '颱風'; + + @override + String get endpointServiceReport => '地震報告'; + + @override + String get endpointServiceTremStation => '震度站'; + + @override + String get endpointServiceEvent => '事件'; + + @override + String get endpointServiceLocation => '定位'; + + @override + String get endpointServiceNotify => '通知'; + + @override + String get endpointServiceOther => '其他'; + @override String get feedConnecting => '連線中…'; @@ -2000,12 +2184,33 @@ class AppLocalizationsZh extends AppLocalizations { @override String get moreAnnouncements => '公告'; + @override + String get moreTagline => '防災資訊整合平台'; + @override String get moreVersionStable => '正式版'; @override String get moreVersionNotes => '目前版本'; + @override + String get releaseHighlightsTitle => '本次更新'; + + @override + String get releaseHighlightsTabNormal => '做了哪些改變'; + + @override + String get releaseHighlightsTabAdvanced => '深入技術'; + + @override + String get releaseHighlightsEmpty => '目前沒有內容。'; + + @override + String get releaseHighlightsSeeNotes => '查看完整更新日誌'; + + @override + String get highlightCardTechnical => '技術細節'; + @override String get moreVersionNotesEmpty => '找不到目前版本的更新日誌'; @@ -2883,6 +3088,30 @@ class AppLocalizationsZh extends AppLocalizations { String meshtasticTraceHops(int n) { return '$n 跳'; } + + @override + String get moreDumpDiagnostics => '傾印除錯資訊及日誌'; + + @override + String get moreDumpDiagnosticsHint => '上傳後複製連結'; + + @override + String get dumpUploaded => '已上傳'; + + @override + String get dumpLinkCopied => '連結已複製到剪貼簿'; + + @override + String get dumpCopyAgain => '再複製一次'; + + @override + String get dumpUploadFailed => '上傳失敗,請稍後再試'; + + @override + String get statusLegendUnprobed => '未探測'; + + @override + String get statusLegendUnsupported => '不支援'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -3798,6 +4027,27 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get moreSectionApp => '获取 App'; + @override + String get moreSectionBeta => '测试版'; + + @override + String get moreAndroidBeta => 'Android 测试版'; + + @override + String get moreTestFlight => 'iOS 测试版(TestFlight)'; + + @override + String get moreSectionPartners => '合作伙伴'; + + @override + String get morePartnersNote => '按合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。'; + + @override + String get morePartnerGeoscience => '巨科资讯有限公司'; + + @override + String get morePartnerTwds => '台湾数位串流有限公司'; + @override String get reportFilterIntensityInfoLegacyBody => '震度仅 0–7,没有 5弱/5强/6弱/6强。'; @@ -4277,6 +4527,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get changelogBodyEmpty => '此版本没有说明。'; + @override + String get changelogOpenOnGitHub => '在 GitHub 查看'; + @override String get radarGlobalOutline => '国界'; @@ -4803,198 +5056,379 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get appLogs => '应用日志'; @override - String get feedConnecting => '连接中…'; + String get serverStatusLocal => '本机状态'; @override - String get notifyBannerDisabled => '通知已关闭,将收不到灾害警报。'; + String get serverStatusLocalBody => + '服务器指标来自控制台。下方是本机对多活端点(LB / Core 各区)的实际连接判断:APP 只被动记录本机实际播送的流量,若该端点从未被本机触发,就会显示未探测。'; @override - String get weatherHumidity => '湿度'; + String get serverStatusAllUp => '所有服务正常'; @override - String typhoonValueMs(String n) { - return '每秒 $n 公尺'; - } + String get serverStatusDegraded => '服务性能下降'; @override - String homeForecastHumidity(String value) { - return '湿度 $value%'; - } + String get serverStatusDown => '服务异常'; @override - String get meshtasticBusyBody => - '请先在另一个 Meshtastic App 中断线。两个 App 同时连同一台设备会互相抢走讯息,导致部分讯息遗失。'; + String get serverStatusErrorRate => '5xx 错误率'; @override - String get meshtasticChannelNoSlot => '没有可用的频道空位 — 请先在设备上空出一个'; + String get serverStatusLatency => '平均延迟'; @override - String get restroomCategoryTransport => '交通'; + String get serverStatusUpdated => '更新于'; @override - String get reportFilterLocationHint => '例如:花莲、东部海域'; + String get serverStatusWeb => '服务器状态'; @override - String get moonSubtitle => '月相與亮度 — 完全本地計算'; + String get serverStatusWebUrl => 'status.exptech.dev'; @override - String get meshtasticBattery => '电量'; + String get serverStatusExpTech => 'ExpTech 状态'; @override - String get meshtasticDistance => '距离'; + String get serverStatusCloudflare => 'Cloudflare 状态'; @override - String get meshtasticSnrTrend => '信号趋势 (SNR)'; + String get serverStatusCloudflareAllOperational => '所有区域正常'; @override - String get meshtasticBatteryTrend => '电量趋势'; + String get serverStatusCloudflareOutage => 'Cloudflare 部分区域异常'; @override - String get typhoonOverlayMenuTooltip => '台风图层选项'; + String get serverStatusCloudflareNone => '目前没有可显示的区域。'; @override - String get mapLayerSatelliteBtdOzone => 'ひまわり 对流层顶'; + String get serverStatusCloudflareOperational => '正常'; @override - String meshtasticRegionMismatch(String region) { - return '设备地区为 $region — DPIP 需要 TW'; - } + String get serverStatusCloudflareDegraded => '性能下降'; @override - String get notifySectionEarthquake => '地震'; + String get serverStatusCloudflarePartial => '部分中断'; @override - String get mapLayerDisasterMap => '防灾地图'; + String get serverStatusCloudflareMajor => '大规模中断'; @override - String get weatherModeFog => '大雾'; + String get serverStatusCloudflareUnknown => '未知'; @override - String typhoonPickerNamed(String no, String name) { - return '$name TY $no'; - } + String get endpointTierLbApi => 'LB API'; @override - String get mapLayerStyleGrayTooltip => '气象厅灰度惯例:温度越低越白'; + String get endpointTierLbStatic => 'LB Static'; @override - String get moreAnnouncements => '公告'; + String get endpointTierCoreApi => 'Core API'; @override - String get moreVersionStable => '正式版'; + String get endpointTierCoreStatic => 'Core Static'; @override - String get moreVersionNotes => '当前版本'; + String get endpointTierCoreExclusiveApi => 'Core 专属 API(雷达 / 气象 / 风场)'; @override - String get moreVersionNotesEmpty => '找不到当前版本的更新日志'; + String get endpointTierCoreStaticExclusive => 'Core 专属静态资源'; @override - String get moreVersionSnapshot => '測試版'; + String get endpointTierLegacyApi => '旧版 API(api-1)'; @override - String get mapLayerSatelliteTransparentNoData => '无资料(陆地) = 透明'; + String get endpointHealthOk => '本机连接正常'; @override - String get restroomCategoryGovernment => '民众洽公场所'; + String get endpointHealthDegraded => '有端点连接不稳'; @override - String get typhoonLegendCurrent => '目前中心'; + String get endpointHealthDown => '本机连接异常'; @override - String get aedAddress => '地址'; + String get endpointHealthUnknown => '暂无观测数据'; @override - String get mapLayerAed => 'AED'; + String get endpointStateOk => '正常'; @override - String get changelogTypePrerelease => '测试版'; + String get endpointStateDegraded => '不稳'; @override - String get reportFilterIntensityInfoModernBody => - '震度为 0–4、5弱、5强、6弱、6强、7。筛选滑杆依新制;列表中较早的地震会以旧制标示显示。'; + String get endpointStateDown => '异常'; @override - String get typhoonOverlayWeatherNone => '无'; + String get endpointStateUnknown => '未知'; @override - String get mapLayerStyleGray => '灰度(JMA)'; + String get endpointLastSuccessNever => '尚未成功'; @override - String get weatherModeAuto => '自动'; + String get endpointServiceEew => '地震速報'; @override - String get typhoonLabelProbCircle => '70%概率圆'; + String get endpointServiceRts => '強震即時警報'; @override - String get notifyOptAll => '接收全部'; + String get endpointServiceRadar => '雷達'; @override - String get displayTheme => '主题'; + String get endpointServiceSatellite => '衛星'; @override - String get mapLayerSatelliteB07 => 'ひまわり 短波红外(B07)'; + String get endpointServiceQpesums => '定量降水'; @override - String get typhoonLabelDirection => '过去移动方向'; + String get endpointServiceWind => '風場'; @override - String get regionManageTitle => '常用地区'; + String get endpointServiceDpm => '災害點位'; @override - String get regionSaveNote => - '通知是基于 GPS 所在位置发送的,设置常用地区不会改变或影响通知发送,常用地区仅用于在首页快速查看不同区域状态,所以请务必授予 GPS 定位权限,否则通知无法运作'; + String get endpointServiceWeather => '天氣'; @override - String get typhoonLegendCone => '预测圆锥'; + String get endpointServiceRain => '降雨'; @override - String get moreCwaEew => '中央气象署地震预警'; + String get endpointServiceLightning => '閃電'; @override - String get onboardingPermsTitle => '权限授权'; + String get endpointServiceTyphoon => '颱風'; @override - String get mapLayerStyleJma => '云顶强调(JMA)'; + String get endpointServiceReport => '地震報告'; @override - String get rainInterval10m => '10 分'; + String get endpointServiceTremStation => '震度站'; @override - String weatherRankingAnalysisLow(String value) { - return '最低 $value'; - } + String get endpointServiceEvent => '事件'; @override - String get meshtasticConnectAnyway => '仍要连线'; + String get endpointServiceLocation => '定位'; @override - String reportListDayCount(int count) { - return '$count'; - } + String get endpointServiceNotify => '通知'; @override - String get mapLayerSatelliteB06 => 'ひまわり 近红外(B06)'; + String get endpointServiceOther => '其他'; @override - String get mapLayerSatelliteTransparentReflectance => '低反射率/夜间 = 透明,显示底图'; + String get feedConnecting => '连接中…'; @override - String chartHourLabel(int hour) { - return '$hour时'; - } + String get notifyBannerDisabled => '通知已关闭,将收不到灾害警报。'; @override - String get mapLayerShelter => '避难收容场所'; + String get weatherHumidity => '湿度'; @override - String get typhoonOverlayProbabilityTooltip => '显示侵袭概率(隐藏预测圆锥)'; + String typhoonValueMs(String n) { + return '每秒 $n 公尺'; + } @override - String get mapLayerSatelliteNdwi => 'ひまわり 水体指数'; + String homeForecastHumidity(String value) { + return '湿度 $value%'; + } @override - String get disasterMapOverlayShelterTooltip => '显示避难收容场所'; + String get meshtasticBusyBody => + '请先在另一个 Meshtastic App 中断线。两个 App 同时连同一台设备会互相抢走讯息,导致部分讯息遗失。'; + + @override + String get meshtasticChannelNoSlot => '没有可用的频道空位 — 请先在设备上空出一个'; + + @override + String get restroomCategoryTransport => '交通'; + + @override + String get reportFilterLocationHint => '例如:花莲、东部海域'; + + @override + String get moonSubtitle => '月相與亮度 — 完全本地計算'; + + @override + String get meshtasticBattery => '电量'; + + @override + String get meshtasticDistance => '距离'; + + @override + String get meshtasticSnrTrend => '信号趋势 (SNR)'; + + @override + String get meshtasticBatteryTrend => '电量趋势'; + + @override + String get typhoonOverlayMenuTooltip => '台风图层选项'; + + @override + String get mapLayerSatelliteBtdOzone => 'ひまわり 对流层顶'; + + @override + String meshtasticRegionMismatch(String region) { + return '设备地区为 $region — DPIP 需要 TW'; + } + + @override + String get notifySectionEarthquake => '地震'; + + @override + String get mapLayerDisasterMap => '防灾地图'; + + @override + String get weatherModeFog => '大雾'; + + @override + String typhoonPickerNamed(String no, String name) { + return '$name TY $no'; + } + + @override + String get mapLayerStyleGrayTooltip => '气象厅灰度惯例:温度越低越白'; + + @override + String get moreAnnouncements => '公告'; + + @override + String get moreTagline => '防灾信息整合平台'; + + @override + String get moreVersionStable => '正式版'; + + @override + String get moreVersionNotes => '当前版本'; + + @override + String get releaseHighlightsTitle => '本次更新'; + + @override + String get releaseHighlightsTabNormal => '做了哪些改变'; + + @override + String get releaseHighlightsTabAdvanced => '深入技术'; + + @override + String get releaseHighlightsEmpty => '目前没有内容。'; + + @override + String get releaseHighlightsSeeNotes => '查看完整更新日志'; + + @override + String get highlightCardTechnical => '技术细节'; + + @override + String get moreVersionNotesEmpty => '找不到当前版本的更新日志'; + + @override + String get moreVersionSnapshot => '測試版'; + + @override + String get mapLayerSatelliteTransparentNoData => '无资料(陆地) = 透明'; + + @override + String get restroomCategoryGovernment => '民众洽公场所'; + + @override + String get typhoonLegendCurrent => '目前中心'; + + @override + String get aedAddress => '地址'; + + @override + String get mapLayerAed => 'AED'; + + @override + String get changelogTypePrerelease => '测试版'; + + @override + String get reportFilterIntensityInfoModernBody => + '震度为 0–4、5弱、5强、6弱、6强、7。筛选滑杆依新制;列表中较早的地震会以旧制标示显示。'; + + @override + String get typhoonOverlayWeatherNone => '无'; + + @override + String get mapLayerStyleGray => '灰度(JMA)'; + + @override + String get weatherModeAuto => '自动'; + + @override + String get typhoonLabelProbCircle => '70%概率圆'; + + @override + String get notifyOptAll => '接收全部'; + + @override + String get displayTheme => '主题'; + + @override + String get mapLayerSatelliteB07 => 'ひまわり 短波红外(B07)'; + + @override + String get typhoonLabelDirection => '过去移动方向'; + + @override + String get regionManageTitle => '常用地区'; + + @override + String get regionSaveNote => + '通知是基于 GPS 所在位置发送的,设置常用地区不会改变或影响通知发送,常用地区仅用于在首页快速查看不同区域状态,所以请务必授予 GPS 定位权限,否则通知无法运作'; + + @override + String get typhoonLegendCone => '预测圆锥'; + + @override + String get moreCwaEew => '中央气象署地震预警'; + + @override + String get onboardingPermsTitle => '权限授权'; + + @override + String get mapLayerStyleJma => '云顶强调(JMA)'; + + @override + String get rainInterval10m => '10 分'; + + @override + String weatherRankingAnalysisLow(String value) { + return '最低 $value'; + } + + @override + String get meshtasticConnectAnyway => '仍要连线'; + + @override + String reportListDayCount(int count) { + return '$count'; + } + + @override + String get mapLayerSatelliteB06 => 'ひまわり 近红外(B06)'; + + @override + String get mapLayerSatelliteTransparentReflectance => '低反射率/夜间 = 透明,显示底图'; + + @override + String chartHourLabel(int hour) { + return '$hour时'; + } + + @override + String get mapLayerShelter => '避难收容场所'; + + @override + String get typhoonOverlayProbabilityTooltip => '显示侵袭概率(隐藏预测圆锥)'; + + @override + String get mapLayerSatelliteNdwi => 'ひまわり 水体指数'; + + @override + String get disasterMapOverlayShelterTooltip => '显示避难收容场所'; @override String get mapNavHumidity => '湿度'; @@ -5763,6 +6197,30 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String meshtasticTraceHops(int n) { return '$n 跳'; } + + @override + String get moreDumpDiagnostics => '转储调试信息及日志'; + + @override + String get moreDumpDiagnosticsHint => '上传后复制链接,附在反馈里就不用贴一整页'; + + @override + String get dumpUploaded => '已上传'; + + @override + String get dumpLinkCopied => '链接已复制到剪贴板'; + + @override + String get dumpCopyAgain => '再复制一次'; + + @override + String get dumpUploadFailed => '上传失败,请稍后再试'; + + @override + String get statusLegendUnprobed => '未探测'; + + @override + String get statusLegendUnsupported => '不支持'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -6024,7 +6482,7 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get typhoonLegendCircle25 => '十級風暴風圈'; @override - String get sponsorTitle => '支持 DPIP'; + String get sponsorTitle => '支援 DPIP'; @override String get mapNavSatellite => '衛星'; @@ -6350,7 +6808,7 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get sponsorIntro => - 'DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支持能幫助我們維持伺服器運作並持續開發。'; + 'DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支援能幫助我們維持伺服器運作並持續開發。'; @override String get shelterAddressLabel => '地址'; @@ -6678,6 +7136,27 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get moreSectionApp => '取得 App'; + @override + String get moreSectionBeta => '測試版'; + + @override + String get moreAndroidBeta => 'Android 測試版'; + + @override + String get moreTestFlight => 'iOS 測試版(TestFlight)'; + + @override + String get moreSectionPartners => '合作夥伴'; + + @override + String get morePartnersNote => '依合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。'; + + @override + String get morePartnerGeoscience => '巨科資訊有限公司'; + + @override + String get morePartnerTwds => '台灣數位串流有限公司'; + @override String get reportFilterIntensityInfoLegacyBody => '震度僅 0–7,沒有 5弱/5強/6弱/6強。'; @@ -7157,6 +7636,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get changelogBodyEmpty => '此版本沒有說明。'; + @override + String get changelogOpenOnGitHub => '在 GitHub 查看'; + @override String get radarGlobalOutline => '國界'; @@ -7321,7 +7803,7 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get onboardingPermLocation => '定位'; @override - String get sponsorCalloutBody => '沒有廣告,你的支持讓伺服器持續運作。'; + String get sponsorCalloutBody => '沒有廣告,你的支援讓伺服器持續運作。'; @override String get moreDiscordCalloutBody => '加入社群,直接和開發團隊交流。'; @@ -7682,6 +8164,166 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get appLogs => 'App 日誌'; + @override + String get serverStatusLocal => '本機狀態'; + + @override + String get serverStatusLocalBody => + '伺服器指標來自控制台。下方是本機對多活端點(LB / Core 各區)的實際連線判斷:APP 只被動記錄本機實際播送的流量,若該端點從未被本機觸發,就會顯示未探測。'; + + @override + String get serverStatusAllUp => '所有服務正常'; + + @override + String get serverStatusDegraded => '服務效能下降'; + + @override + String get serverStatusDown => '服務異常'; + + @override + String get serverStatusErrorRate => '5xx 錯誤率'; + + @override + String get serverStatusLatency => '平均延遲'; + + @override + String get serverStatusUpdated => '更新於'; + + @override + String get serverStatusWeb => '伺服器狀態'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @override + String get serverStatusExpTech => 'ExpTech 狀態'; + + @override + String get serverStatusCloudflare => 'Cloudflare 狀態'; + + @override + String get serverStatusCloudflareAllOperational => '所有區域正常'; + + @override + String get serverStatusCloudflareOutage => 'Cloudflare 部分區域異常'; + + @override + String get serverStatusCloudflareNone => '目前沒有可顯示的區域。'; + + @override + String get serverStatusCloudflareOperational => '正常'; + + @override + String get serverStatusCloudflareDegraded => '效能下降'; + + @override + String get serverStatusCloudflarePartial => '部分中斷'; + + @override + String get serverStatusCloudflareMajor => '大規模中斷'; + + @override + String get serverStatusCloudflareUnknown => '未知'; + + @override + String get endpointTierLbApi => 'LB API'; + + @override + String get endpointTierLbStatic => 'LB Static'; + + @override + String get endpointTierCoreApi => 'Core API'; + + @override + String get endpointTierCoreStatic => 'Core Static'; + + @override + String get endpointTierCoreExclusiveApi => 'Core 專屬 API(雷達 / 氣象 / 風場)'; + + @override + String get endpointTierCoreStaticExclusive => 'Core 專屬靜態資源'; + + @override + String get endpointTierLegacyApi => '舊版 API(api-1)'; + + @override + String get endpointHealthOk => '本機連線正常'; + + @override + String get endpointHealthDegraded => '有端點連線不穩'; + + @override + String get endpointHealthDown => '本機連線異常'; + + @override + String get endpointHealthUnknown => '尚無觀測資料'; + + @override + String get endpointStateOk => '正常'; + + @override + String get endpointStateDegraded => '不穩'; + + @override + String get endpointStateDown => '異常'; + + @override + String get endpointStateUnknown => '未知'; + + @override + String get endpointLastSuccessNever => '尚未成功'; + + @override + String get endpointServiceEew => '地震速報'; + + @override + String get endpointServiceRts => '強震即時警報'; + + @override + String get endpointServiceRadar => '雷達'; + + @override + String get endpointServiceSatellite => '衛星'; + + @override + String get endpointServiceQpesums => '定量降水'; + + @override + String get endpointServiceWind => '風場'; + + @override + String get endpointServiceDpm => '災害點位'; + + @override + String get endpointServiceWeather => '天氣'; + + @override + String get endpointServiceRain => '降雨'; + + @override + String get endpointServiceLightning => '閃電'; + + @override + String get endpointServiceTyphoon => '颱風'; + + @override + String get endpointServiceReport => '地震報告'; + + @override + String get endpointServiceTremStation => '震度站'; + + @override + String get endpointServiceEvent => '事件'; + + @override + String get endpointServiceLocation => '定位'; + + @override + String get endpointServiceNotify => '通知'; + + @override + String get endpointServiceOther => '其他'; + @override String get feedConnecting => '連接中…'; @@ -7760,12 +8402,33 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get moreAnnouncements => '公告'; + @override + String get moreTagline => '防災資訊整合平台'; + @override String get moreVersionStable => '正式版'; @override String get moreVersionNotes => '目前版本'; + @override + String get releaseHighlightsTitle => '本次更新'; + + @override + String get releaseHighlightsTabNormal => '做了哪些改變'; + + @override + String get releaseHighlightsTabAdvanced => '深入技術'; + + @override + String get releaseHighlightsEmpty => '目前沒有內容。'; + + @override + String get releaseHighlightsSeeNotes => '查看完整更新日誌'; + + @override + String get highlightCardTechnical => '技術細節'; + @override String get moreVersionNotesEmpty => '找不到目前版本的更新日誌'; @@ -8643,6 +9306,30 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String meshtasticTraceHops(int n) { return '$n 跳'; } + + @override + String get moreDumpDiagnostics => '傾印除錯資訊及日誌'; + + @override + String get moreDumpDiagnosticsHint => '上載後複製連結'; + + @override + String get dumpUploaded => '已上載'; + + @override + String get dumpLinkCopied => '連結已複製到剪貼簿'; + + @override + String get dumpCopyAgain => '再複製一次'; + + @override + String get dumpUploadFailed => '上載失敗,請稍後再試'; + + @override + String get statusLegendUnprobed => '未探測'; + + @override + String get statusLegendUnsupported => '不支援'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -8904,7 +9591,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get typhoonLegendCircle25 => '十級風暴風圈'; @override - String get sponsorTitle => '支持 DPIP'; + String get sponsorTitle => '支援 DPIP'; @override String get mapNavSatellite => '衛星'; @@ -9230,7 +9917,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get sponsorIntro => - 'DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支持能幫助我們維持伺服器運作並持續開發。'; + 'DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支援能幫助我們維持伺服器運作並持續開發。'; @override String get shelterAddressLabel => '地址'; @@ -9558,6 +10245,27 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get moreSectionApp => '取得 App'; + @override + String get moreSectionBeta => '測試版'; + + @override + String get moreAndroidBeta => 'Android 測試版'; + + @override + String get moreTestFlight => 'iOS 測試版(TestFlight)'; + + @override + String get moreSectionPartners => '合作夥伴'; + + @override + String get morePartnersNote => '依合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。'; + + @override + String get morePartnerGeoscience => '巨科資訊有限公司'; + + @override + String get morePartnerTwds => '台灣數位串流有限公司'; + @override String get reportFilterIntensityInfoLegacyBody => '震度僅 0–7,沒有 5弱/5強/6弱/6強。'; @@ -10037,6 +10745,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get changelogBodyEmpty => '此版本沒有說明。'; + @override + String get changelogOpenOnGitHub => '在 GitHub 查看'; + @override String get radarGlobalOutline => '國界'; @@ -10201,7 +10912,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get onboardingPermLocation => '定位'; @override - String get sponsorCalloutBody => '沒有廣告,你的支持讓伺服器持續運作。'; + String get sponsorCalloutBody => '沒有廣告,你的支援讓伺服器持續運作。'; @override String get moreDiscordCalloutBody => '加入社群,直接和開發團隊交流。'; @@ -10562,6 +11273,166 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get appLogs => 'App 日誌'; + @override + String get serverStatusLocal => '本機狀態'; + + @override + String get serverStatusLocalBody => + '伺服器指標來自控制台。下方是本機對多活端點(LB / Core 各區)的實際連線判斷:APP 只被動記錄本機實際播送的流量,若該端點從未被本機觸發,就會顯示未探測。'; + + @override + String get serverStatusAllUp => '所有服務正常'; + + @override + String get serverStatusDegraded => '服務效能下降'; + + @override + String get serverStatusDown => '服務異常'; + + @override + String get serverStatusErrorRate => '5xx 錯誤率'; + + @override + String get serverStatusLatency => '平均延遲'; + + @override + String get serverStatusUpdated => '更新於'; + + @override + String get serverStatusWeb => '伺服器狀態'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @override + String get serverStatusExpTech => 'ExpTech 狀態'; + + @override + String get serverStatusCloudflare => 'Cloudflare 狀態'; + + @override + String get serverStatusCloudflareAllOperational => '所有區域正常'; + + @override + String get serverStatusCloudflareOutage => 'Cloudflare 部分區域異常'; + + @override + String get serverStatusCloudflareNone => '目前沒有可顯示的區域。'; + + @override + String get serverStatusCloudflareOperational => '正常'; + + @override + String get serverStatusCloudflareDegraded => '效能下降'; + + @override + String get serverStatusCloudflarePartial => '部分中斷'; + + @override + String get serverStatusCloudflareMajor => '大規模中斷'; + + @override + String get serverStatusCloudflareUnknown => '未知'; + + @override + String get endpointTierLbApi => 'LB API'; + + @override + String get endpointTierLbStatic => 'LB Static'; + + @override + String get endpointTierCoreApi => 'Core API'; + + @override + String get endpointTierCoreStatic => 'Core Static'; + + @override + String get endpointTierCoreExclusiveApi => 'Core 專屬 API(雷達 / 氣象 / 風場)'; + + @override + String get endpointTierCoreStaticExclusive => 'Core 專屬靜態資源'; + + @override + String get endpointTierLegacyApi => '舊版 API(api-1)'; + + @override + String get endpointHealthOk => '本機連線正常'; + + @override + String get endpointHealthDegraded => '有端點連線不穩'; + + @override + String get endpointHealthDown => '本機連線異常'; + + @override + String get endpointHealthUnknown => '尚無觀測資料'; + + @override + String get endpointStateOk => '正常'; + + @override + String get endpointStateDegraded => '不穩'; + + @override + String get endpointStateDown => '異常'; + + @override + String get endpointStateUnknown => '未知'; + + @override + String get endpointLastSuccessNever => '尚未成功'; + + @override + String get endpointServiceEew => '地震速報'; + + @override + String get endpointServiceRts => '強震即時警報'; + + @override + String get endpointServiceRadar => '雷達'; + + @override + String get endpointServiceSatellite => '衛星'; + + @override + String get endpointServiceQpesums => '定量降水'; + + @override + String get endpointServiceWind => '風場'; + + @override + String get endpointServiceDpm => '災害點位'; + + @override + String get endpointServiceWeather => '天氣'; + + @override + String get endpointServiceRain => '降雨'; + + @override + String get endpointServiceLightning => '閃電'; + + @override + String get endpointServiceTyphoon => '颱風'; + + @override + String get endpointServiceReport => '地震報告'; + + @override + String get endpointServiceTremStation => '震度站'; + + @override + String get endpointServiceEvent => '事件'; + + @override + String get endpointServiceLocation => '定位'; + + @override + String get endpointServiceNotify => '通知'; + + @override + String get endpointServiceOther => '其他'; + @override String get feedConnecting => '連線中…'; @@ -10640,12 +11511,33 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get moreAnnouncements => '公告'; + @override + String get moreTagline => '防災資訊整合平台'; + @override String get moreVersionStable => '正式版'; @override String get moreVersionNotes => '目前版本'; + @override + String get releaseHighlightsTitle => '本次更新'; + + @override + String get releaseHighlightsTabNormal => '做了哪些改變'; + + @override + String get releaseHighlightsTabAdvanced => '深入技術'; + + @override + String get releaseHighlightsEmpty => '目前沒有內容。'; + + @override + String get releaseHighlightsSeeNotes => '查看完整更新日誌'; + + @override + String get highlightCardTechnical => '技術細節'; + @override String get moreVersionNotesEmpty => '找不到目前版本的更新日誌'; @@ -11523,4 +12415,28 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String meshtasticTraceHops(int n) { return '$n 跳'; } + + @override + String get moreDumpDiagnostics => '傾印除錯資訊及日誌'; + + @override + String get moreDumpDiagnosticsHint => '上傳後複製連結'; + + @override + String get dumpUploaded => '已上傳'; + + @override + String get dumpLinkCopied => '連結已複製到剪貼簿'; + + @override + String get dumpCopyAgain => '再複製一次'; + + @override + String get dumpUploadFailed => '上傳失敗,請稍後再試'; + + @override + String get statusLegendUnprobed => '未探測'; + + @override + String get statusLegendUnsupported => '不支援'; } diff --git a/lib/shared/diagnostics/dump_action.dart b/lib/shared/diagnostics/dump_action.dart new file mode 100644 index 000000000..0fb0eaee1 --- /dev/null +++ b/lib/shared/diagnostics/dump_action.dart @@ -0,0 +1,82 @@ +/// The one-tap diagnostics dump, shared by every screen that offers it. +library; + +import 'package:dpip/core/diagnostics/debug_dump.dart'; +import 'package:dpip/core/diagnostics/diagnostics_report.dart'; +import 'package:dpip/core/diagnostics/dump_uploader.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/network/etag_cache_store.dart'; +import 'package:dpip/core/network/network_usage_store.dart'; +import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:dpip/core/platform/background_location.dart'; +import 'package:dpip/core/storage/app_database.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/dump_link_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:talker_flutter/talker_flutter.dart'; + +/// Collects the diagnostics and the tail of the log, uploads them, copies the +/// link, and shows it. +/// +/// The two halves answer different questions and a report needs both: the +/// diagnostics say what this build and this device are, the log says what they +/// just did. Pasting 4000 characters into a chat buries the conversation they +/// are part of, so they go to a paste and only the link comes back. +/// +/// Returns true when a link was produced. Failures are reported to the user +/// here and logged; the caller only has to stop showing its spinner. +Future runDiagnosticsDump(BuildContext context) async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final uploader = context.read(); + final collector = DiagnosticsCollector( + notifications: context.read(), + database: context.read(), + backgroundLocation: context.read(), + etagCache: context.read(), + networkUsage: context.read(), + ); + + void fail() => messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.dumpUploadFailed))); + + try { + final report = await collector.collect(); + // Newest first, which is the order the budget spends from. + final lines = [ + for (final entry in Log.talker.history.reversed) + logLine( + tag: entry.title ?? 'log', + time: entry.time, + message: entry.displayMessage, + ), + ]; + final url = await uploader.upload( + buildDump( + diagnostics: diagnosticsText( + report.sections, + redacted: diagnosticsRedactedLabels, + ), + logLines: lines, + ), + ); + if (url == null) { + fail(); + return false; + } + // Copied before it is shown. The dialog can be dismissed by a tap past it, + // and the link is the whole point of having pressed the button — losing it + // to a stray tap would mean uploading again. + await Clipboard.setData(ClipboardData(text: url)); + if (!context.mounted) return true; + await showDumpLinkDialog(context, url); + return true; + } on Object catch (error, stackTrace) { + Log.handle(error, stackTrace, 'diagnostics dump'); + fail(); + return false; + } +} diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index 90a5109f7..b05eb2050 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -97,8 +97,17 @@ class _MapTimelineState extends State { void _cacheLabels() { final format = widget.timeFormat ?? _time; - _times = [for (final frame in widget.frames) format.format(frame.time)]; - _dates = [for (final frame in widget.frames) _date.format(frame.time)]; + // A frame's [DateTime] may be minted in UTC (server timestamps, moon + // instants) or in local time — either way it expresses the same instant, + // and the ruler must read in the caller's local time, not in the UTC + // representation a `isUtc: true` value would print verbatim. toLocal is + // the identity for a local DateTime and the conversion for a UTC one. + _times = [ + for (final frame in widget.frames) format.format(frame.time.toLocal()), + ]; + _dates = [ + for (final frame in widget.frames) _date.format(frame.time.toLocal()), + ]; } /// The big time label: the selected instant, or — when the layer's frames @@ -108,7 +117,9 @@ class _MapTimelineState extends State { final start = _times[_liveIndex]; final period = widget.framePeriod; if (period == null) return start; - final end = _time.format(widget.frames[_liveIndex].time.add(period)); + final end = _time.format( + widget.frames[_liveIndex].time.toLocal().add(period), + ); return '$start – $end'; } diff --git a/lib/shared/map/town_label_points.g.dart b/lib/shared/map/town_label_points.g.dart index 43170d25e..dc49875a6 100644 --- a/lib/shared/map/town_label_points.g.dart +++ b/lib/shared/map/town_label_points.g.dart @@ -1,4 +1,4 @@ -// GENERATED by tool/build_town_label_points.dart — do not edit. +// GENERATED by tool/gen/town_label_points.dart — do not edit. // // Where each township's name is drawn on the map: the point // inside it furthest from any edge. See the tool for why this is diff --git a/lib/shared/navigation/app_routes.dart b/lib/shared/navigation/app_routes.dart index 7583eda14..6fb5c6f28 100644 --- a/lib/shared/navigation/app_routes.dart +++ b/lib/shared/navigation/app_routes.dart @@ -117,6 +117,11 @@ abstract final class AppRoutes { static const String versionNotes = 'versionNotes'; static const String versionNotesPath = '/version-notes'; + /// This release's highlights — the version card's second detail page + /// (一般用戶 / 深入技術). + static const String releaseHighlights = 'releaseHighlights'; + static const String releaseHighlightsPath = '/release-highlights'; + // Saved-region management: the manage page (view/remove saved townships) // opens the picker to add. Saved townships feed the Home region bar. static const String regionManage = 'regionManage'; @@ -142,4 +147,8 @@ abstract final class AppRoutes { // Support / in-app-purchase page. static const String sponsor = 'sponsor'; static const String sponsorPath = '/sponsor'; + + /// ExpTech server status dashboard — pushed from the More hero cards. + static const String serverStatus = 'serverStatus'; + static const String serverStatusPath = '/server-status'; } diff --git a/lib/shared/widgets/dump_link_dialog.dart b/lib/shared/widgets/dump_link_dialog.dart new file mode 100644 index 000000000..200b8a594 --- /dev/null +++ b/lib/shared/widgets/dump_link_dialog.dart @@ -0,0 +1,68 @@ +/// The dialog shown after a diagnostics dump has been uploaded. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Shows [url], which is already on the clipboard. +/// +/// A dialog rather than a snackbar: a snackbar times out, and this is a URL +/// somebody has to carry into another app. It should wait for them. +Future showDumpLinkDialog(BuildContext context, String url) { + return showDialog( + context: context, + builder: (context) => DumpLinkDialog(url: url), + ); +} + +/// Confirms the upload and shows the link it produced. +class DumpLinkDialog extends StatelessWidget { + const DumpLinkDialog({required this.url, super.key}); + + /// The address the dump can be read at. + final String url; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context); + return AlertDialog( + icon: const Icon(Icons.cloud_done_outlined), + title: Text(l10n.dumpUploaded), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.dumpLinkCopied), + const SizedBox(height: AppSpacing.md), + // Selectable and shown in full: the clipboard is not the only way + // this travels — a screenshot of this dialog has to be readable too, + // and somebody pasting it into a public channel should be able to + // see what they are about to send. + SelectableText( + url, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + color: theme.colorScheme.primary, + ), + ), + ], + ), + actions: [ + TextButton( + // Does not close. The only reason to press it is that the first copy + // was lost, and a dialog that leaves on the press cannot be pressed + // twice. + onPressed: () => Clipboard.setData(ClipboardData(text: url)), + child: Text(l10n.dumpCopyAgain), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.commonClose), + ), + ], + ); + } +} diff --git a/macos/.gitignore b/macos/.gitignore deleted file mode 100644 index 746adbb6b..000000000 --- a/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index 4b81f9b2d..000000000 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index 5caa9d157..000000000 --- a/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 4b117adcb..000000000 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import awesome_notifications -import firebase_core -import firebase_messaging -import flutter_blue_plus_darwin -import geolocator_apple -import in_app_purchase_storekit -import package_info_plus -import share_plus -import sqflite_darwin -import url_launcher_macos - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - AwesomeNotificationsPlugin.register(with: registry.registrar(forPlugin: "AwesomeNotificationsPlugin")) - FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) - FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) - FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) - GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) - InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) - SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) -} diff --git a/macos/Podfile b/macos/Podfile deleted file mode 100644 index 167132a2f..000000000 --- a/macos/Podfile +++ /dev/null @@ -1,42 +0,0 @@ -platform :osx, '12.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -target 'Runner' do - use_frameworks! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/macos/Podfile.lock b/macos/Podfile.lock deleted file mode 100644 index 795135b5c..000000000 --- a/macos/Podfile.lock +++ /dev/null @@ -1,22 +0,0 @@ -PODS: - - awesome_notifications (0.12.0): - - FlutterMacOS - - FlutterMacOS (1.0.0) - -DEPENDENCIES: - - awesome_notifications (from `Flutter/ephemeral/.symlinks/plugins/awesome_notifications/macos`) - - FlutterMacOS (from `Flutter/ephemeral`) - -EXTERNAL SOURCES: - awesome_notifications: - :path: Flutter/ephemeral/.symlinks/plugins/awesome_notifications/macos - FlutterMacOS: - :path: Flutter/ephemeral - -SPEC CHECKSUMS: - awesome_notifications: 4e05708c3d44949fca858ace458b3c2ee823da8f - FlutterMacOS: c232990155153907050900a2e175c7773903ba4e - -PODFILE CHECKSUM: 1e95c36afbfd1cb6423ceca4de7a8e1b256fb6ac - -COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index db692292e..000000000 --- a/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,825 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; - 7D8FA1934B33054489FFC783 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7942483BDD4BE4115BB9143E /* Pods_Runner.framework */; }; - D331474A99BC54782397B40B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B8DC769F1EF2D111DD46756 /* Pods_RunnerTests.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC10EC2044A3C60003C045; - remoteInfo = Runner; - }; - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 05422854A0BE1053194A7501 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 0A3DCBFBFF5CCA0B94E86D22 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 1B8DC769F1EF2D111DD46756 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* dpip.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = dpip.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 48E8F5178FBFA0F8C69A4173 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 681AD74598C459F7F7256371 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - 7942483BDD4BE4115BB9143E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - DB139019BC2395D1508DA44A /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - DE5216EABA163CDB8F22F7A0 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 331C80D2294CF70F00263BE5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - D331474A99BC54782397B40B /* Pods_RunnerTests.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - 7D8FA1934B33054489FFC783 /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C80D6294CF71000263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C80D7294CF71000263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 331C80D6294CF71000263BE5 /* RunnerTests */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - A08E85351B2627510C9F8F12 /* Pods */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* dpip.app */, - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - A08E85351B2627510C9F8F12 /* Pods */ = { - isa = PBXGroup; - children = ( - DB139019BC2395D1508DA44A /* Pods-Runner.debug.xcconfig */, - 681AD74598C459F7F7256371 /* Pods-Runner.release.xcconfig */, - 48E8F5178FBFA0F8C69A4173 /* Pods-Runner.profile.xcconfig */, - 0A3DCBFBFF5CCA0B94E86D22 /* Pods-RunnerTests.debug.xcconfig */, - 05422854A0BE1053194A7501 /* Pods-RunnerTests.release.xcconfig */, - DE5216EABA163CDB8F22F7A0 /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 7942483BDD4BE4115BB9143E /* Pods_Runner.framework */, - 1B8DC769F1EF2D111DD46756 /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C80D4294CF70F00263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 4DCF2CD99520323860480D5E /* [CP] Check Pods Manifest.lock */, - 331C80D1294CF70F00263BE5 /* Sources */, - 331C80D2294CF70F00263BE5 /* Frameworks */, - 331C80D3294CF70F00263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C80DA294CF71000263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 19F2836D47C185D821D65F11 /* [CP] Check Pods Manifest.lock */, - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - 0040772DB111EC287515B8B4 /* [CP] Embed Pods Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* dpip.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C80D4294CF70F00263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 33CC10EC2044A3C60003C045; - }; - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, - ); - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 331C80D4294CF70F00263BE5 /* RunnerTests */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C80D3294CF70F00263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 0040772DB111EC287515B8B4 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 19F2836D47C185D821D65F11 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; - 4DCF2CD99520323860480D5E /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C80D1294CF70F00263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC10EC2044A3C60003C045 /* Runner */; - targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; - }; - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 331C80DB294CF71000263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 0A3DCBFBFF5CCA0B94E86D22 /* Pods-RunnerTests.debug.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/dpip.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/dpip"; - }; - name = Debug; - }; - 331C80DC294CF71000263BE5 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 05422854A0BE1053194A7501 /* Pods-RunnerTests.release.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/dpip.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/dpip"; - }; - name = Release; - }; - 331C80DD294CF71000263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = DE5216EABA163CDB8F22F7A0 /* Pods-RunnerTests.profile.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/dpip.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/dpip"; - }; - name = Profile; - }; - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C80DB294CF71000263BE5 /* Debug */, - 331C80DC294CF71000263BE5 /* Release */, - 331C80DD294CF71000263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003..000000000 --- a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 851094125..000000000 --- a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,122 +0,0 @@ -{ - "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", - "version" : "11.3.1" - } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270", - "version" : "12.15.0" - } - }, - { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", - "version" : "3.6.1" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b", - "version" : "12.15.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", - "version" : "8.1.2" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", - "version" : "1.69.1" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", - "version" : "5.3.1" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", - "version" : "2.30910.1" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", - "version" : "2.4.1" - } - } - ], - "version" : 2 -} diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index e672226b5..000000000 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 21a3cc14c..000000000 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003..000000000 --- a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 851094125..000000000 --- a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,122 +0,0 @@ -{ - "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", - "version" : "11.3.1" - } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270", - "version" : "12.15.0" - } - }, - { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", - "version" : "3.6.1" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b", - "version" : "12.15.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", - "version" : "8.1.2" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", - "version" : "1.69.1" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", - "version" : "5.3.1" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", - "version" : "2.30910.1" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", - "version" : "2.4.1" - } - } - ], - "version" : 2 -} diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift deleted file mode 100644 index b3c176141..000000000 --- a/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Cocoa -import FlutterMacOS - -@main -class AppDelegate: FlutterAppDelegate { - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true - } -} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a2ec33f19..000000000 --- a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 82b6f9d9a..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index 13b35eba5..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 0a3f5fa40..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index bdb57226d..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index f083318e0..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index 326c0e72c..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index 2f1632cfd..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index 80e867a4e..000000000 --- a/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index 6b07b4f8d..000000000 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = dpip - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 com.exptech.dpip. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd946..000000000 --- a/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f4956..000000000 --- a/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf478..000000000 --- a/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index 3ba6c1266..000000000 --- a/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.client - - com.apple.security.network.server - - - diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist deleted file mode 100644 index 4789daa6a..000000000 --- a/macos/Runner/Info.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 3cc05eb23..000000000 --- a/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Cocoa -import FlutterMacOS - -class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements deleted file mode 100644 index ee95ab7e5..000000000 --- a/macos/Runner/Release.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.network.client - - - diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift deleted file mode 100644 index 61f3bd1fc..000000000 --- a/macos/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Cocoa -import FlutterMacOS -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/pre-release-example.md b/pre-release-example.md deleted file mode 100644 index 614a6e41f..000000000 --- a/pre-release-example.md +++ /dev/null @@ -1,29 +0,0 @@ -_快照,取自 main 的 `45365b0`。未經審查,可能有問題。_ - -### 🌟 新功能 - -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) 「更多」頁面會顯示這個版本的名稱與送審版號 — @whes1015 - -### 🐞 錯誤修正 - -- ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) 修正 iOS 上拖曳雷達時間軸時畫面會跟不上手指 — @whes1015 - - - -
-English - -### 🌟 New features - -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) the More page shows this build's own version and its store train — @whes1015 - -### 🐞 Bug fixes - -- ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) fix the radar frame lagging behind your finger while scrubbing — @whes1015 - -
- - - - - diff --git a/pubspec.lock b/pubspec.lock index badae2cf5..9d6337db4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -241,6 +241,13 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + dpip_release_highlights: + dependency: "direct main" + description: + path: release_highlights + relative: true + source: path + version: "0.0.0" equatable: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a4178d645..16010ef0d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,12 @@ name: dpip description: "Disaster Prevention Information Platform" publish_to: 'none' -# A placeholder, not the source of truth. `tool/version.sh` decides what a -# build is called and how it sorts, and CI passes both in with `--build-name` -# / `--build-number`, which override this on every platform. It stays here, -# and stays legal semver, only because `flutter pub get` parses it: `26.1` -# fails with "must have three numeric components" and `26w33a` does not parse -# at all. +# A placeholder, not the source of truth. `tool/release/version.sh` decides +# what a build is called and how it sorts, and CI passes both in with +# `--build-name` / `--build-number`, which override this on every platform. It +# stays here, and stays legal semver, only because `flutter pub get` parses it: +# `26.1` fails with "must have three numeric components" and `26w33a` does not +# parse at all. version: 26.1.0+1 environment: @@ -71,6 +71,11 @@ dependencies: # text/JSON payloads decode as UTF-8 (fromCharCodes garbles CJK). meshtastic_flutter: path: third_party/meshtastic_flutter + # Version-highlight card content: each version's cards live here as Dart + # source (`lib/26.1/normal.dart`…). The app imports only the current version; + # older versions stay in the package as the archive and are never compiled. + dpip_release_highlights: + path: release_highlights # Direct dep: MeshtasticClientImpl's device map holds flutter_blue_plus's # BluetoothDevice (meshtastic_flutter doesn't re-export it). flutter_blue_plus: ^1.36.8 @@ -149,7 +154,7 @@ flutter: # Weather glyphs Flutter's bundled MaterialIcons font simply does not have — # there is no rain icon in it at all. A 6.7 KB subset of Material Symbols - # Outlined (Apache-2.0), built by `tool/build_weather_icons.py`, which also + # Outlined (Apache-2.0), built by `tool/gen/weather_icons.py`, which also # generates the codepoint constants so they can never drift from the font. fonts: - family: DpipWeatherIcons diff --git a/release-example.md b/release-example.md deleted file mode 100644 index 02f635d5c..000000000 --- a/release-example.md +++ /dev/null @@ -1,47 +0,0 @@ -_自 v26.0 以來的全部變更。_ - -### 🌟 新功能 - -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) 更新日誌的平台標記改用本機圖示,離線也看得到 — @whes1015 · `26w33a` -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) 「更多」頁面會顯示這個版本的名稱與送審版號 — @whes1015 - -### 🔌 最佳化 - -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) 更新日誌改成捲到底再載入下一頁,開啟快很多 — @whes1015 · `26w33b` - -### 🐞 錯誤修正 - -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) 修正更新 app 之後背景定位不會自動重新啟動 — @whes1015 · `26w33a` -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) 更新日誌不再中英文一起顯示 — @whes1015 · `26w33b` -- ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) 修正 iOS 上拖曳雷達時間軸時畫面會跟不上手指 — @whes1015 - - - -
-English - -### 🌟 New features - -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) the changelog draws its platform tags locally and survives offline — @whes1015 · `26w33a` -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) the More page shows this build's own version and its store train — @whes1015 - -### 🔌 Improvements - -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) the changelog loads a page at a time and opens much faster — @whes1015 · `26w33b` - -### 🐞 Bug fixes - -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) fix background location not re-arming itself after an app update — @whes1015 · `26w33a` -- ![Android](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/android.svg) ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) the changelog no longer shows both languages at once — @whes1015 · `26w33b` -- ![iOS](https://raw.githubusercontent.com/ExpTechTW/DPIP/main/.github/assets/ios.svg) fix the radar frame lagging behind your finger while scrubbing — @whes1015 - -
- - - - ---- - -**完整差異 / Full changelog**: https://github.com/ExpTechTW/DPIP/compare/v26.0...v26.1 - - diff --git a/release_highlights/.gitignore b/release_highlights/.gitignore new file mode 100644 index 000000000..db8373b31 --- /dev/null +++ b/release_highlights/.gitignore @@ -0,0 +1,2 @@ +.dart_tool/ +pubspec.lock \ No newline at end of file diff --git a/release_highlights/assets/26.1/advanced/cards.json b/release_highlights/assets/26.1/advanced/cards.json new file mode 100644 index 000000000..e75bd58cc --- /dev/null +++ b/release_highlights/assets/26.1/advanced/cards.json @@ -0,0 +1,1074 @@ +{ + "version": "26.1", + "kind": "advanced", + "title": { + "zh_Hant": "深入瞭解更多 — 底層真的怎麼運作", + "zh_Hans": "深入了解详情 — 底层真的怎么运作", + "en": "Go deeper — how the internals actually work", + "ja": "さらに詳しく — 内部は実際どう動いているか", + "ko": "더 자세히 — 내부는 실제로 어떻게 동작하나", + "th": "เจาะลึก — ระบบภายในทำงานอย่างไรจริง ๆ", + "vi": "Đi sâu hơn — các phần bên trong thực sự hoạt động ra sao", + "id": "Lebih dalam — bagaimana sistem internal benar-benar bekerja", + "fil": "Mas malalim — paano talaga gumagana ang mga internal" + }, + "subtitle": { + "zh_Hant": "給進階使用者與開發者的技術筆記。每一項都來自真實程式碼,附檔案與行號。", + "zh_Hans": "给进阶使用者与开发者的技术笔记。每一项都来自真实程式码,附档案与行号。", + "en": "Technical notes for advanced users and developers. Every item traces to real code, with file and line references.", + "ja": "上級ユーザーと開発者のための技術ノート。すべて実コードに由来し、ファイルと行番号を添えています。", + "ko": "고급 사용자와 개발자를 위한 기술 노트. 각 항목은 실제 코드에서 나온 것이며, 파일과 줄번호를 포함합니다.", + "th": "บันทึกเทคนิคสำหรับผู้ใช้ขั้นสูงและนักพัฒนา ทุกรายการมาจากโค้ดจริง พร้อมไฟล์และบรรทัดอ้างอิง", + "vi": "Ghi chú kỹ thuật cho người dùng nâng cao và lập trình viên. Mỗi mục đều bắt nguồn từ code thật, kèm file và dòng tham chiếu.", + "id": "Catatan teknis untuk pengguna mahir dan developer. Setiap item berasal dari kode nyata, dengan referensi file dan baris.", + "fil": "Teknikal na tala para sa mga advanced user at developer. Bawat item ay mula sa tunay na code, may file at line reference." + }, + "cards": [ + { + "id": "etag_core", + "icon": "verified", + "title": { + "zh_Hant": "ETag SQLite 快取 — 本版最重要的單一改進", + "zh_Hans": "ETag SQLite 快取 — 本版最重要的单一改进", + "en": "ETag SQLite cache — the single most important change", + "ja": "ETag SQLite キャッシュ — 本バージョン最重要の変更", + "ko": "ETag SQLite 캐시 — 이번 버전의 가장 중요한 변화", + "th": "แคช ETag SQLite — การเปลี่ยนแปลงที่สำคัญที่สุดในเวอร์ชันนี้", + "vi": "Cache ETag SQLite — thay đổi quan trọng nhất của bản này", + "id": "Cache ETag SQLite — perubahan terpenting versi ini", + "fil": "ETag SQLite cache — ang pinakamahalagang pagbabago" + }, + "body": { + "zh_Hant": "全新網址為鍵、ETag 驗證、LRU 位元組預算的 SQLite HTTP 快取。檔案:`core/network/etag_interceptor.dart` + `etag_cache_store.dart`。", + "zh_Hans": "全新网址为键、ETag 验证、LRU 位元组预算的 SQLite HTTP 快取。档案:core/network/etag_interceptor.dart + etag_cache_store.dart。", + "en": "A URL-keyed, ETag-validated, LRU byte-budgeted SQLite HTTP cache. Files: core/network/etag_interceptor.dart + etag_cache_store.dart.", + "ja": "URL キー、ETag 検証、LRU バイト予算の SQLite キャッシュ。ファイル: core/network/etag_interceptor.dart + etag_cache_store.dart。", + "ko": "URL 키, ETag 검증, LRU 바이트 예산 SQLite HTTP 캐시. 파일: core/network/etag_interceptor.dart + etag_cache_store.dart.", + "th": "แคช SQLite แบบใช้ URL เป็นคีย์ ตรวจสอบ ETag และจัดงบประมาณแบบ LRU ไฟล์: core/network/etag_interceptor.dart + etag_cache_store.dart", + "vi": "Cache HTTP SQLite theo khóa URL, xác thực ETag, ngân sách byte LRU. File: core/network/etag_interceptor.dart + etag_cache_store.dart.", + "id": "Cache HTTP SQLite berbasis kunci URL, validasi ETag, anggaran byte LRU. File: core/network/etag_interceptor.dart + etag_cache_store.dart.", + "fil": "URL-keyed, ETag-validated, LRU byte-budget SQLite HTTP cache. Files: core/network/etag_interceptor.dart + etag_cache_store.dart." + }, + "details": [ + { + "key": { + "zh_Hant": "驗證方式", + "zh_Hans": "验证方式", + "en": "Validation", + "ja": "検証方式", + "ko": "검증 방식", + "th": "การตรวจสอบ", + "vi": "Xác thực", + "id": "Validasi", + "fil": "Validation" + }, + "value": { + "zh_Hant": "ETag 是唯一 validator;Cache-Control / no-store 被忽略;200 無 ETag 不寫入", + "zh_Hans": "ETag 是唯一 validator;Cache-Control / no-store 被忽略;200 无 ETag 不写入", + "en": "ETag is the sole validator; Cache-Control/no-store ignored; 200s without an ETag are not cached", + "ja": "ETag が唯一のバリデータ。Cache-Control/no-store は無視。ETag なしの 200 はキャッシュしない", + "ko": "ETag이 유일한 검증자. Cache-Control/no-store 무시. ETag 없는 200은 캐시 안 함", + "th": "ETag เป็นตัวตรวจสอบเดียว; ไม่สนใจ Cache-Control/no-store; 200 ที่ไม่มี ETag จะไม่ถูกแคช", + "vi": "ETag là validator duy nhất; Cache-Control/no-store bị bỏ qua; 200 không có ETag không được lưu", + "id": "ETag satu-satunya validator; Cache-Control/no-store diabaikan; 200 tanpa ETag tidak dicache", + "fil": "ETag lang ang validator; hindi pinapansin ang Cache-Control/no-store; hindi naka-cache ang 200 na walang ETag" + } + }, + { + "key": { + "zh_Hant": "過期模型", + "zh_Hans": "过期模型", + "en": "Expiry model", + "ja": "有効期限モデル", + "ko": "만료 모델", + "th": "โมเดลการหมดอายุ", + "vi": "Mô hình hết hạn", + "id": "Model kedaluwarsa", + "fil": "Expiry model" + }, + "value": { + "zh_Hant": "不按時間;只有位元組預算(350 MiB)。30 天前仍被 hit 的 tile 保留位置", + "zh_Hans": "不按时间;只有位元组预算(350 MiB)。30 天前仍被 hit 的 tile 保留位置", + "en": "Not time-based; only a byte budget (350 MiB). A tile still being hit after 30 days keeps its place", + "ja": "時間ベースではない。バイト予算のみ (350 MiB)。30 日後もヒット中のタイルは場所を維持", + "ko": "시간 기반이 아님. 오직 바이트 예산(350MiB). 30일 지나도 계속 hit되는 타일은 유지", + "th": "ไม่อิงเวลา มีแค่งบประมาณไบต์ (350 MiB) ไทล์ที่ยังถูกเรียกใช้หลัง 30 วันยังคงอยู่", + "vi": "Không theo thời gian; chỉ có ngân sách byte (350 MiB). Tile vẫn được gọi sau 30 ngày giữ nguyên vị trí", + "id": "Tidak berbasis waktu; hanya anggaran byte (350 MiB). Tile yang masih diakses setelah 30 hari tetap bertahan", + "fil": "Hindi time-based; byte budget lang (350 MiB). Nananatili ang tile na ginagamit pa rin kahit 30 araw" + } + }, + { + "key": { + "zh_Hant": "高變動資料", + "zh_Hans": "高变动资料", + "en": "Volatile data", + "ja": "高変動データ", + "ko": "고변동 데이터", + "th": "ข้อมูลที่เปลี่ยนแปลงบ่อย", + "vi": "Dữ liệu biến động cao", + "id": "Data berubah cepat", + "fil": "Madaling magbago na data" + }, + "value": { + "zh_Hant": "EEW、RTS、location、notify 永不快取——token 不落磁碟", + "zh_Hans": "EEW、RTS、location、notify 永不快取——token 不落磁盘", + "en": "EEW, RTS, location and notify are never cached — tokens never touch disk", + "ja": "EEW・RTS・location・notify は絶対にキャッシュしない。トークンはディスクに落ちない", + "ko": "EEW, RTS, location, notify는 절대 캐시 안 함 — 토큰은 디스크에 안 닿음", + "th": "EEW, RTS, location, notify ไม่ถูกแคชเด็ดขาด — token ไม่ถูกเขียนลงดิสก์", + "vi": "EEW, RTS, location, notify không bao giờ bị cache — token không chạm đĩa", + "id": "EEW, RTS, location, notify tidak pernah dicache — token tidak menyentuh disk", + "fil": "EEW, RTS, location, notify — hindi kailanman naka-cache; hindi nahahawakan ng disk ang token" + } + }, + { + "key": { + "zh_Hant": "不可變資產", + "zh_Hans": "不可变资产", + "en": "Immutable assets", + "ja": "不変アセット", + "ko": "불변 에셋", + "th": "ทรัพยากรที่ไม่เปลี่ยนแปลง", + "vi": "Tài sản bất biến", + "id": "Aset tidak berubah", + "fil": "Immutable assets" + }, + "value": { + "zh_Hant": "URL 就 pin 住內容:本地 hit 直接 serve,永不發 If-None-Match", + "zh_Hans": "URL 就 pin 住内容:本地 hit 直接 serve,永不发 If-None-Match", + "en": "The URL pins the content: local hits serve directly, If-None-Match is never sent", + "ja": "URL が内容を固定する。ローカルヒットは直接サーブし、If-None-Match は送らない", + "ko": "URL이 내용을 고정. 로컬 히트는 바로 서브, If-None-Match는 절대 안 보냄", + "th": "URL ยึดเนื้อหาไว้: ฮิตในเครื่องเซิร์ฟโดยตรง ไม่ส่ง If-None-Match เลย", + "vi": "URL cố định nội dung: hit cục bộ phục vụ trực tiếp, không bao giờ gửi If-None-Match", + "id": "URL mengunci konten: hit lokal langsung disajikan, If-None-Match tidak pernah dikirim", + "fil": "Ang URL ang nagla-lock ng content: direktang sineserve ang local hit, hindi na ipinapadala ang If-None-Match" + } + }, + { + "key": { + "zh_Hant": "304 處理", + "zh_Hans": "304 处理", + "en": "304 handling", + "ja": "304 の扱い", + "ko": "304 처리", + "th": "การจัดการ 304", + "vi": "Xử lý 304", + "id": "Penanganan 304", + "fil": "Paghawak ng 304" + }, + "value": { + "zh_Hant": "空 304 被改寫回帶 cached body 的 200,呼叫者看不到 304", + "zh_Hans": "空 304 被改写回带 cached body 的 200,呼叫者看不到 304", + "en": "An empty 304 is rewritten as a 200 with the cached body — callers never see 304", + "ja": "空の 304 はキャッシュボディ付き 200 に書き換えられ、呼び出し元は 304 を見ない", + "ko": "빈 304는 캐시된 body가 있는 200으로 다시 쓰여져, 호출자는 304를 못 봄", + "th": "304 เปล่าแปลงกลับเป็น 200 พร้อม body แคช ผู้เรียกไม่เห็น 304", + "vi": "304 rỗng được viết lại thành 200 kèm body đã cache — caller không bao giờ thấy 304", + "id": "304 kosong ditulis ulang sebagai 200 dengan body cache — pemanggil tidak pernah melihat 304", + "fil": "Ang walang laman na 304 ay ginagawang 200 na may cached body — hindi nakikita ng caller ang 304" + } + }, + { + "key": { + "zh_Hant": "儲存工程", + "zh_Hans": "储存工程", + "en": "Storage engineering", + "ja": "ストレージ設計", + "ko": "저장 엔지니어링", + "th": "วิศวกรรมพื้นที่จัดเก็บ", + "vi": "Kỹ thuật lưu trữ", + "id": "Teknik penyimpanan", + "fil": "Storage engineering" + }, + "value": { + "zh_Hant": "WAL + PRAGMA cache_size -25600 (25 MiB page cache) + mmap 64 MiB;批次讀寫;JSON gzip-1(壓不縮就存 raw)", + "zh_Hans": "WAL + PRAGMA cache_size -25600 (25 MiB page cache) + mmap 64 MiB;批次读写;JSON gzip-1(压不缩就存 raw)", + "en": "WAL + PRAGMA cache_size -25600 (25 MiB page cache) + mmap 64 MiB; batched reads/writes; JSON gzip-1 (stored raw when it does not compress)", + "ja": "WAL + PRAGMA cache_size -25600 (25 MiB ページキャッシュ) + mmap 64 MiB。バッチ読み書き。JSON は gzip-1(圧縮効果がなければ raw で保存)", + "ko": "WAL + PRAGMA cache_size -25600 (25MiB 페이지 캐시) + mmap 64MiB. 배치 읽기/쓰기. JSON gzip-1 (압축 안 되면 raw로 저장)", + "th": "WAL + PRAGMA cache_size -25600 (page cache 25 MiB) + mmap 64 MiB; อ่านเขียนแบบแบตช์; JSON gzip-1 (เก็บแบบ raw ถ้าบีบไม่ลง)", + "vi": "WAL + PRAGMA cache_size -25600 (25 MiB page cache) + mmap 64 MiB; đọc/ghi theo lô; JSON gzip-1 (giữ raw nếu không nén được)", + "id": "WAL + PRAGMA cache_size -25600 (cache halaman 25 MiB) + mmap 64 MiB; baca/tulis batch; JSON gzip-1 (disimpan raw jika tidak terkompresi)", + "fil": "WAL + PRAGMA cache_size -25600 (25 MiB page cache) + mmap 64 MiB; batched read/write; JSON gzip-1 (raw ang itatago kung hindi mag-compress)" + } + } + ] + }, + { + "id": "region_failover", + "icon": "route", + "title": { + "zh_Hant": "區域選路與多活自動故障轉移", + "zh_Hans": "区域选路与多活自动故障转移", + "en": "Region routing and multi-active failover", + "ja": "リージョン選定とマルチアクティブ・フェイルオーバー", + "ko": "리전 라우팅과 다중 활성 장애 조치", + "th": "การเลือกภูมิภาคและการสลับเซิร์ฟเวอร์อัตโนมัติ", + "vi": "Định tuyến vùng và chuyển đổi dự phòng đa chủ động", + "id": "Routing wilayah dan failover multi-aktif", + "fil": "Region routing at multi-active failover" + }, + "body": { + "zh_Hant": "決定權從 DNS 平衡移到 app 內(app 自己 pin 住 region host),失敗時依序換下一台。檔案:`core/network/api_region.dart` + `region_selection.dart`。", + "zh_Hans": "决定权从 DNS 平衡移到 app 内(app 自己 pin 住 region host),失败时依序换下一台。档案:core/network/api_region.dart + region_selection.dart。", + "en": "Selection authority moved from DNS load balancing into the app (which pins region hosts itself), failing over to the next host in order. Files: core/network/api_region.dart + region_selection.dart.", + "ja": "選定の主導権が DNS ロードバランサーからアプリ内(自らリージョンホストを固定)へ。失敗時は次のホストへ順に切り替え。ファイル: core/network/api_region.dart + region_selection.dart。", + "ko": "선택 권한이 DNS 로드밸런싱에서 앱 내부(리전 호스트를 직접 고정)로 이동. 실패 시 다음 호스트로 순서대로 전환. 파일: core/network/api_region.dart + region_selection.dart.", + "th": "อำนาจการเลือกย้ายจาก DNS load balancing มาในแอป (ซึ่งปัก host ภูมิภาคเอง) และสลับไปยัง host ถัดไปเมื่อล้มเหลว ไฟล์: core/network/api_region.dart + region_selection.dart", + "vi": "Quyền chọn lựa chuyển từ cân bằng tải DNS vào trong app (tự ghim host vùng), chuyển sang host kế tiếp khi lỗi. File: core/network/api_region.dart + region_selection.dart.", + "id": "Kewenangan pemilihan pindah dari load balancing DNS ke dalam app (yang men-pin host region sendiri), gagal lalu beralih ke host berikutnya. File: core/network/api_region.dart + region_selection.dart.", + "fil": "Ang awtoridad sa pagpili ay lumipat mula sa DNS load balancing papasok sa app (na siya mismo ang nagpi-pin ng region host), nag-failover sa susunod na host. Files: core/network/api_region.dart + region_selection.dart." + }, + "details": [ + { + "key": { + "zh_Hant": "7 個 tier", + "zh_Hans": "7 个 tier", + "en": "7 tiers", + "ja": "7 つのティア", + "ko": "7개 티어", + "th": "7 ระดับ", + "vi": "7 tầng", + "id": "7 tier", + "fil": "7 tier" + }, + "value": { + "zh_Hant": "4 個 region 的 concrete host;從不請求 DNS 平衡的裸 host;選中的 region 被 persisted", + "zh_Hans": "4 个 region 的 concrete host;从不请求 DNS 平衡的裸 host;选中的 region 被 persisted", + "en": "Concrete hosts across 4 regions; never hits a bare DNS-balanced host; the chosen region is persisted", + "ja": "4 リージョンの concrete host。DNS 分散の素のホストには決して接続しない。選択リージョンは永続化", + "ko": "4개 리전의 구체적 호스트. DNS 밸런싱된 호스트는 절대 안 씀. 선택한 리전은 영구 저장", + "th": "โฮสต์จริงใน 4 ภูมิภาค ไม่เคยเรียกใช้ host ที่ใช้ DNS balancing; ภูมิภาคที่เลือกถูกบันทึกถาวร", + "vi": "Host cụ thể trên 4 vùng; không bao giờ gọi host DNS cân bằng; vùng đã chọn được lưu bền vững", + "id": "Host konkret di 4 region; tidak pernah memanggil host balancing DNS; region terpilih dipersist", + "fil": "Mga konkretong host sa 4 na region; hindi kailanman gumagamit ng bare DNS-balance host; naka-persist ang napiling region" + } + }, + { + "key": { + "zh_Hant": "Failover 條件", + "zh_Hans": "Failover 条件", + "en": "Failover rules", + "ja": "フェイルオーバー条件", + "ko": "장애 조치 조건", + "th": "เงื่อนไขการสลับ", + "vi": "Điều kiện failover", + "id": "Aturan failover", + "fil": "Failover rules" + }, + "value": { + "zh_Hant": "只有 connection / timeout / 5xx 換台;4xx 和 user cancel 直接 throw", + "zh_Hans": "只有 connection / timeout / 5xx 换台;4xx 和 user cancel 直接 throw", + "en": "Only connection/timeout/5xx trigger a switch; 4xx and user cancellation throw directly", + "ja": "接続・タイムアウト・5xx のみ切り替える。4xx とユーザーキャンセルは直接 throw", + "ko": "연결/타임아웃/5xx만 전환. 4xx와 사용자 취소는 그냥 throw", + "th": "เฉพาะ connection/timeout/5xx ที่สลับ; 4xx และการยกเลิกโดยผู้ใช้ throw โดยตรง", + "vi": "Chỉ connection/timeout/5xx mới chuyển; 4xx và huỷ do người dùng throw thẳng", + "id": "Hanya connection/timeout/5xx yang memicu pindah; 4xx dan cancel user langsung throw", + "fil": "Connection/timeout/5xx lang ang nagpapalit; ang 4xx at cancellation ay nag-throw na lang" + } + }, + { + "key": { + "zh_Hant": "Timeout 策略", + "zh_Hans": "Timeout 策略", + "en": "Timeout strategy", + "ja": "タイムアウト戦略", + "ko": "타임아웃 전략", + "th": "กลยุทธ์ timeout", + "vi": "Chiến lược timeout", + "id": "Strategi timeout", + "fil": "Timeout strategy" + }, + "value": { + "zh_Hant": "connectTimeout 8s / receiveTimeout 10s:快點死、快點 failover", + "zh_Hans": "connectTimeout 8s / receiveTimeout 10s:快点死、快点 failover", + "en": "connectTimeout 8s / receiveTimeout 10s — fail fast, fail over fast", + "ja": "connectTimeout 8s / receiveTimeout 10s — 早く死に、早くフェイルオーバー", + "ko": "connectTimeout 8s / receiveTimeout 10s — 빨리 죽고 빨리 전환", + "th": "connectTimeout 8s / receiveTimeout 10s — ตายเร็ว สลับเร็ว", + "vi": "connectTimeout 8s / receiveTimeout 10s — chết nhanh, failover nhanh", + "id": "connectTimeout 8s / receiveTimeout 10s — cepat gagal, cepat failover", + "fil": "connectTimeout 8s / receiveTimeout 10s — mamatay nang mabilis, lumipat nang mabilis" + } + }, + { + "key": { + "zh_Hant": "健康觀測", + "zh_Hans": "健康观测", + "en": "Health monitoring", + "ja": "健全性モニタリング", + "ko": "건강 모니터링", + "th": "การเฝ้าระวังสุขภาพระบบ", + "vi": "Giám sát sức khỏe", + "id": "Pemantauan kesehatan", + "fil": "Health monitoring" + }, + "value": { + "zh_Hant": "service × tier × host 分桶;連續失敗 ≥2 次才判 down;4xx/cancel/cert 不計入", + "zh_Hans": "service × tier × host 分桶;连续失败 ≥2 次才判 down;4xx/cancel/cert 不计入", + "en": "Bucketed by service × tier × host; ≥2 consecutive failures mark down; 4xx/cancel/cert errors don't count", + "ja": "service × tier × host でバケット化。2 回以上連続失敗で down。4xx・キャンセル・証明書エラーは数えない", + "ko": "service × tier × host 버킷. 2회 이상 연속 실패 시 down. 4xx/취소/인증서 오류는 미집계", + "th": "แบ่งกลุ่มตาม service × tier × host; ล้มเหลวติดต่อกัน ≥2 ครั้งถึงจะลงว่า down; 4xx/cancel/cert ไม่นับ", + "vi": "Phân nhóm theo service × tier × host; ≥2 lần lỗi liên tiếp mới là down; 4xx/cancel/cert không tính", + "id": "Dikelompokkan per service × tier × host; gagal berturut-turut ≥2 kali baru down; 4xx/cancel/cert tidak dihitung", + "fil": "Naka-bucket sa service × tier × host; ≥2 sunod-sunod na palya ang sasabihing down; hindi binibilang ang 4xx/cancel/cert" + } + } + ] + }, + { + "id": "sse_stream", + "icon": "stream", + "title": { + "zh_Hant": "SSE 串流取代每秒輪詢", + "zh_Hans": "SSE 串流取代每秒轮询", + "en": "SSE streaming replaces second-by-second polling", + "ja": "SSE ストリームが毎秒ポーリングに取って代わる", + "ko": "SSE 스트리밍이 초당 폴링을 대체", + "th": "SSE streaming แทนที่การดึงข้อมูลทุกวินาที", + "vi": "SSE streaming thay thế kéo dữ liệu mỗi giây", + "id": "SSE streaming menggantikan polling per detik", + "fil": "Pinapalitan ng SSE streaming ang polling bawat segundo" + }, + "body": { + "zh_Hant": "核心即時資料傳輸改以 Server-Sent Events 串流。檔案:`core/network/sse_client.dart` + `core/realtime/sse_realtime_source.dart`。", + "zh_Hans": "核心即时资料传输改以 Server-Sent Events 串流。档案:core/network/sse_client.dart + core/realtime/sse_realtime_source.dart。", + "en": "Core real-time data moves over Server-Sent Events streaming. Files: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart.", + "ja": "中核のリアルタイムデータ転送が Server-Sent Events ストリーミングに移行。ファイル: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart。", + "ko": "핵심 실시간 데이터 전송이 SSE 스트리밍으로 전환. 파일: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart.", + "th": "ข้อมูลเรียลไทม์หลักเปลี่ยนเป็นการส่งแบบ Server-Sent Events ไฟล์: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart", + "vi": "Dữ liệu thời gian thực cốt lõi chuyển sang streaming Server-Sent Events. File: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart.", + "id": "Transfer data real-time inti beralih ke streaming Server-Sent Events. File: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart.", + "fil": "Ang core real-time data ay nasa Server-Sent Events streaming na. Files: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart." + }, + "details": [ + { + "key": { + "zh_Hant": "壓縮事件", + "zh_Hans": "压缩事件", + "en": "Compressed events", + "ja": "圧縮イベント", + "ko": "압축 이벤트", + "th": "เหตุการณ์บีบอัด", + "vi": "Sự kiện nén", + "id": "Event terkompresi", + "fil": "Compressed events" + }, + "value": { + "zh_Hant": "event: g 事件,data: 是 base64 gzip;資料格式與純 GET 相同,模型不變", + "zh_Hans": "event: g 事件,data: 是 base64 gzip;资料格式与纯 GET 相同,模型不变", + "en": "event: g events carry base64 gzip data; the payload is identical to the plain GET, so the model never changes", + "ja": "event: g イベントの data は base64 gzip。ペイロードは素の GET と同一で、モデルは変わらない", + "ko": "event: g 이벤트, data는 base64 gzip. 페이로드는 일반 GET과 동일해서 모델은 그대로", + "th": "เหตุการณ์ event: g, data เป็น base64 gzip; รูปแบบข้อมูลเหมือน GET ทั่วไป รุ่นไม่เปลี่ยน", + "vi": "Sự kiện event: g, data là base64 gzip; payload giống hệt GET thuần, model không đổi", + "id": "Event event: g, data berbentuk base64 gzip; payload sama persis dengan GET biasa, model tak berubah", + "fil": "Ang event: g, data ay base64 gzip; kapareho ng plain GET ang payload, hindi nagbabago ang model" + } + }, + { + "key": { + "zh_Hant": "Backoff", + "zh_Hans": "Backoff", + "en": "Backoff", + "ja": "バックオフ", + "ko": "백오프", + "th": "การถอยหลัง", + "vi": "Backoff", + "id": "Backoff", + "fil": "Backoff" + }, + "value": { + "zh_Hant": "1s → 2s → cap 在 server retry hint(預設 3s);事件一到立刻歸零", + "zh_Hans": "1s → 2s → cap 在 server retry hint(预设 3s);事件一到立刻归零", + "en": "1s → 2s, capped at the server retry hint (3s default); zeroed instantly on any event", + "ja": "1s → 2s → server の retry ヒント(デフォルト 3s)で上限。イベント到着で即リセット", + "ko": "1s → 2s → 서버 retry 힌트(기본 3s)에서 제한. 이벤트 오면 즉시 초기화", + "th": "1s → 2s → จำกัดที่ค่า retry จากเซิร์ฟเวอร์ (ค่าเริ่มต้น 3s); รีเซ็ตทันทีเมื่อมีเหตุการณ์", + "vi": "1s → 2s, chặn ở hint retry của server (mặc định 3s); về 0 ngay khi có sự kiện", + "id": "1s → 2s, di-cap pada hint retry server (default 3s); seketika nol saat ada event", + "fil": "1s → 2s, may cap sa server retry hint (default 3s); babalik sa zero pagdating ng event" + } + }, + { + "key": { + "zh_Hant": "兩種 liveness", + "zh_Hans": "两种 liveness", + "en": "Two liveness modes", + "ja": "2 種類の liveness", + "ko": "두 가지 라이브니스", + "th": "โหมด liveness สองแบบ", + "vi": "Hai chế độ liveness", + "id": "Dua mode liveness", + "fil": "Dalawang liveness mode" + }, + "value": { + "zh_Hant": "EEW 用 connectionOpen(地震之間無聲);RTS 用 eventRecency 3s 窗(連續 feed)", + "zh_Hans": "EEW 用 connectionOpen(地震之间无声);RTS 用 eventRecency 3s 窗(连续 feed)", + "en": "EEW uses connectionOpen (silent between quakes); RTS uses a 3s event-recency window (continuous feed)", + "ja": "EEW は connectionOpen(地震間は無音)、RTS は 3 秒の eventRecency 窓(連続フィード)", + "ko": "EEW는 connectionOpen(지진 사이엔 침묵), RTS는 3초 eventRecency 윈도(연속 피드)", + "th": "EEW ใช้ connectionOpen (เงียบระหว่างแผ่นดินไหว); RTS ใช้ eventRecency 3 วินาที (feed ต่อเนื่อง)", + "vi": "EEW dùng connectionOpen (im lặng giữa các trận động đất); RTS dùng cửa sổ eventRecency 3s (feed liên tục)", + "id": "EEW pakai connectionOpen (hening di antara gempa); RTS pakai jendela eventRecency 3 detik (feed kontinu)", + "fil": "EEW gumagamit ng connectionOpen (tahimik sa pagitan ng lindol); RTS gumagamit ng 3s eventRecency window (continuous feed)" + } + }, + { + "key": { + "zh_Hant": "Push → poll 橋", + "zh_Hans": "Push → poll 桥", + "en": "Push → poll bridge", + "ja": "Push → poll ブリッジ", + "ko": "Push → poll 브리지", + "th": "สะพาน Push → poll", + "vi": "Cầu nối Push → poll", + "id": "Jembatan Push → poll", + "fil": "Push → poll bridge" + }, + "value": { + "zh_Hant": "fetch() 回傳緩衝的最新 snapshot 當成當前狀態;整個 live→stale→offline 骨架重用", + "zh_Hans": "fetch() 回传缓冲的最新 snapshot 当成当前状态;整个 live→stale→offline 骨架重用", + "en": "fetch() returns the latest buffered snapshot as the current state; the whole live→stale→offline skeleton is reused", + "ja": "fetch() はバッファ済み最新スナップショットを現在状態として返す。live→stale→offline の骨格はすべて再利用", + "ko": "fetch()는 버퍼링된 최신 스냅샷을 현재 상태로 반환. live→stale→offline 골격 전체 재사용", + "th": "fetch() คืนสแนปช็อตล่าสุดที่บัฟเฟอร์เป็นสถานะปัจจุบัน; โครง live→stale→offline ทั้งหมดถูกใช้ซ้ำ", + "vi": "fetch() trả về snapshot mới nhất đã đệm như trạng thái hiện tại; toàn bộ khung live→stale→offline được tái sử dụng", + "id": "fetch() mengembalikan snapshot terbaru yang di-buffer sebagai status saat ini; seluruh kerangka live→stale→offline dipakai ulang", + "fil": "Ibinabalik ng fetch() ang pinakabagong buffered snapshot bilang kasalukuyang estado; ginamit muli ang buong live→stale→offline skeleton" + } + } + ] + }, + { + "id": "dual_db", + "icon": "storage", + "title": { + "zh_Hant": "雙 SQLite:按耐久性切分", + "zh_Hans": "双 SQLite:按耐久性切分", + "en": "Two SQLite files: split by durability", + "ja": "二つの SQLite:耐久性で分割", + "ko": "SQLite 2개: 내구성 기준 분리", + "th": "SQLite สองไฟล์: แบ่งตามความคงทน", + "vi": "Hai SQLite: chia theo độ bền", + "id": "Dua SQLite: dipisah berdasarkan ketahanan", + "fil": "Dalawang SQLite: nahati ayon sa tibay" + }, + "body": { + "zh_Hant": "不再一個檔裝全部。按「資料能不能重抓」決定放哪個資料庫。檔案:`core/storage/app_database.dart` + `core/network/etag_cache_store.dart`。", + "zh_Hans": "不再一个档装全部。按「资料能不能重抓」决定放哪个资料库。档案:core/storage/app_database.dart + core/network/etag_cache_store.dart。", + "en": "No more one file for everything. Where data lives is decided by 'can it be re-fetched?'. Files: core/storage/app_database.dart + core/network/etag_cache_store.dart.", + "ja": "1 ファイルですべてを済ませるのをやめ、データが「再取得できるか」で置き場所を決める。ファイル: core/storage/app_database.dart + core/network/etag_cache_store.dart。", + "ko": "한 파일에 다 넣던 방식을 버리고, '다시 받을 수 있는지'로 저장 위치를 결정. 파일: core/storage/app_database.dart + core/network/etag_cache_store.dart.", + "th": "ไม่ใช้ไฟล์เดียวเก็บทุกอย่างอีกต่อไป พิจารณาว่า \"ข้อมูลโหลดใหม่ได้ไหม\" เพื่อเลือกฐานข้อมูล ไฟล์: core/storage/app_database.dart + core/network/etag_cache_store.dart", + "vi": "Không còn một file chứa mọi thứ. Vị trí dữ liệu do \"có thể tải lại không\" quyết định. File: core/storage/app_database.dart + core/network/etag_cache_store.dart.", + "id": "Tidak lagi satu file untuk semuanya. Lokasi data ditentukan oleh 'bisa diunduh ulang?'. File: core/storage/app_database.dart + core/network/etag_cache_store.dart.", + "fil": "Hindi na isang file para sa lahat. Kung saan titira ang data ay pinagpapasyahan ng 'maaari bang kunin muli?'. Files: core/storage/app_database.dart + core/network/etag_cache_store.dart." + }, + "details": [ + { + "key": { + "zh_Hant": "dpip.db", + "zh_Hans": "dpip.db", + "en": "dpip.db", + "ja": "dpip.db", + "ko": "dpip.db", + "th": "dpip.db", + "vi": "dpip.db", + "id": "dpip.db", + "fil": "dpip.db" + }, + "value": { + "zh_Hant": "application-support(OS 不可清):settings、logs(24h)、tle、mesh;synchronous = FULL", + "zh_Hans": "application-support(OS 不可清):settings、logs(24h)、tle、mesh;synchronous = FULL", + "en": "application-support (OS-uncullable): settings, logs (24h), TLE, mesh; synchronous = FULL", + "ja": "application-support(OS に消されない): settings、logs (24h)、tle、mesh。synchronous = FULL", + "ko": "application-support(OS가 못 지움): settings, logs(24h), tle, mesh. synchronous = FULL", + "th": "application-support (ระบบไม่ลบ): settings, logs (24 ชม.), tle, mesh; synchronous = FULL", + "vi": "application-support (OS không xoá được): settings, logs (24h), tle, mesh; synchronous = FULL", + "id": "application-support (tak bisa dihapus OS): settings, logs (24 jam), tle, mesh; synchronous = FULL", + "fil": "application-support (hindi pwedeng burahin ng OS): settings, logs (24h), tle, mesh; synchronous = FULL" + } + }, + { + "key": { + "zh_Hant": "http_etag_cache.db", + "zh_Hans": "http_etag_cache.db", + "en": "http_etag_cache.db", + "ja": "http_etag_cache.db", + "ko": "http_etag_cache.db", + "th": "http_etag_cache.db", + "vi": "http_etag_cache.db", + "id": "http_etag_cache.db", + "fil": "http_etag_cache.db" + }, + "value": { + "zh_Hant": "cache(OS 可清):http_cache、net_bucket;synchronous = NORMAL(WAL commit 零 fsync)", + "zh_Hans": "cache(OS 可清):http_cache、net_bucket;synchronous = NORMAL(WAL commit 零 fsync)", + "en": "cache (OS-cullable): http_cache, net_bucket; synchronous = NORMAL (zero-fsync WAL commits)", + "ja": "cache(OS に消され得る): http_cache、net_bucket。synchronous = NORMAL(WAL commit は fsync なし)", + "ko": "cache(OS가 지울 수 있음): http_cache, net_bucket. synchronous = NORMAL (WAL commit 무 fsync)", + "th": "cache (ระบบลบได้): http_cache, net_bucket; synchronous = NORMAL (WAL commit ไม่ fsync)", + "vi": "cache (OS xoá được): http_cache, net_bucket; synchronous = NORMAL (commit WAL không fsync)", + "id": "cache (bisa dihapus OS): http_cache, net_bucket; synchronous = NORMAL (commit WAL tanpa fsync)", + "fil": "cache (pwedeng burahin ng OS): http_cache, net_bucket; synchronous = NORMAL (walang fsync ang WAL commit)" + } + }, + { + "key": { + "zh_Hant": "WAL 的意義", + "zh_Hans": "WAL 的意义", + "en": "Why WAL matters", + "ja": "WAL の意義", + "ko": "WAL의 의미", + "th": "ความสำคัญของ WAL", + "vi": "Ý nghĩa của WAL", + "id": "Mengapa WAL penting", + "fil": "Bakit mahalaga ang WAL" + }, + "value": { + "zh_Hant": "每個小 transaction(log flush、LRU touch、tile batch)不再建 journal + fsync 數次;只 append 到長命 -wal 檔", + "zh_Hans": "每个小 transaction(log flush、LRU touch、tile batch)不再建 journal + fsync 数次;只 append 到长命 -wal 档", + "en": "Small transactions (log flush, LRU touch, tile batch) no longer create a journal + several fsyncs; they just append to a long-lived -wal file", + "ja": "小さなトランザクション(log flush、LRU touch、tile batch)はジャーナル作成+複数 fsync をしなくなり、長命の -wal ファイルに追記するだけになる", + "ko": "작은 트랜잭션(log flush, LRU touch, tile batch)은 더 이상 저널 + fsync 여러 번을 하지 않고, 오래 사는 -wal 파일에 append만 함", + "th": "ธุรกรรมเล็ก (log flush, LRU touch, tile batch) ไม่สร้าง journal + fsync หลายครั้งอีกแล้ว แค่ append ไปยังไฟล์ -wal ที่มีอายุยาว", + "vi": "Transaction nhỏ (log flush, LRU touch, tile batch) không còn tạo journal + nhiều lần fsync; chỉ append vào file -wal sống lâu", + "id": "Transaksi kecil (log flush, LRU touch, tile batch) tidak lagi membuat jurnal + beberapa fsync; cukup append ke file -wal panjang umur", + "fil": "Ang maliliit na transaksyon (log flush, LRU touch, tile batch) ay hindi na gumagawa ng journal + ilang fsync; nag-a-append lang sa mahabang -wal file" + } + }, + { + "key": { + "zh_Hant": "保留策略", + "zh_Hans": "保留策略", + "en": "Retention", + "ja": "保持期間", + "ko": "보존 정책", + "th": "นโยบายการเก็บรักษา", + "vi": "Chính sách giữ lại", + "id": "Kebijakan retensi", + "fil": "Retention" + }, + "value": { + "zh_Hant": "集中 hourly sweep(啟動後 1 分首 sweep):mesh 30 天、radio/neighbour 24h、app log 24h、net_bucket 7 天", + "zh_Hans": "集中 hourly sweep(启动后 1 分首 sweep):mesh 30 天、radio/neighbour 24h、app log 24h、net_bucket 7 天", + "en": "Centralized hourly sweep (first sweep 1 min after start): mesh 30 days, radio/neighbour 24h, app log 24h, net_bucket 7 days", + "ja": "毎時スイープを一元化(起動 1 分後に最初の実行)。mesh 30 日、radio/neighbour 24h、アプリログ 24h、net_bucket 7 日", + "ko": "매시간 스윕을 중앙화(시작 1분 후 첫 실행). mesh 30일, radio/neighbour 24h, 앱 로그 24h, net_bucket 7일", + "th": "รวมการกวาดล้างรายชั่วโมง (ครั้งแรกหลังเริ่ม 1 นาที): mesh 30 วัน, radio/neighbour 24 ชม., app log 24 ชม., net_bucket 7 วัน", + "vi": "Quét hàng giờ tập trung (lần đầu 1 phút sau khởi động): mesh 30 ngày, radio/neighbour 24h, app log 24h, net_bucket 7 ngày", + "id": "Sweep tiap jam terpusat (pertama 1 menit setelah start): mesh 30 hari, radio/neighbour 24 jam, app log 24 jam, net_bucket 7 hari", + "fil": "Sentrong hourly sweep (una 1 minuto pagkatapos magsimula): mesh 30 araw, radio/neighbour 24h, app log 24h, net_bucket 7 araw" + } + }, + { + "key": { + "zh_Hant": "流量記帳", + "zh_Hans": "流量记帐", + "en": "Traffic accounting", + "ja": "トラフィック記帳", + "ko": "트래픽 회계", + "th": "การบันทึกปริมาณการใช้", + "vi": "Hạch toán lưu lượng", + "id": "Akuntansi lalu lintas", + "fil": "Pagbibilang ng traffic" + }, + "value": { + "zh_Hant": "net_bucket 每小時記 down/saved/hits/misses;trailing window(24h/7d),可自證省流量", + "zh_Hans": "net_bucket 每小时记 down/saved/hits/misses;trailing window(24h/7d),可自证省流量", + "en": "net_bucket records down/saved/hits/misses hourly; trailing window (24h/7d) proves your data savings", + "ja": "net_bucket が down/saved/hits/misses を毎時記録。trailing window(24h/7d)で通信量の節約を実証できる", + "ko": "net_bucket이 down/saved/hits/misses를 매시간 기록. trailing window(24h/7d)로 데이터 절약을 스스로 증명", + "th": "net_bucket บันทึก down/saved/hits/misses รายชั่วโมง; trailing window (24 ชม./7 วัน) พิสูจน์การประหยัดดาต้าได้", + "vi": "net_bucket ghi down/saved/hits/misses mỗi giờ; cửa sổ trượt (24h/7 ngày) tự chứng minh tiết kiệm dữ liệu", + "id": "net_bucket mencatat down/saved/hits/misses per jam; trailing window (24 jam/7 hari) membuktikan penghematan data", + "fil": "Nagre-record ang net_bucket ng down/saved/hits/misses kada oras; trailing window (24h/7 araw) nagpapatunay ng tipid sa data" + } + } + ] + }, + { + "id": "map_engine", + "icon": "layers", + "title": { + "zh_Hant": "地圖引擎重寫:三層 tile cache 與順滑 scrub", + "zh_Hans": "地图引擎重写:三层 tile cache 与顺滑 scrub", + "en": "Map engine rewrite: three-tier tile cache & silky scrubbing", + "ja": "地図エンジン刷新:3 層タイルキャッシュとなめらかなスクラブ", + "ko": "지도 엔진 재작성: 3계층 타일 캐시와 부드러운 스크럽", + "th": "เขียนเอนจินแผนที่ใหม่: แคชไทล์ 3 ชั้นและการสครับที่ลื่นไหล", + "vi": "Viết lại engine bản đồ: cache tile ba tầng & lướt mượt", + "id": "Tulis ulang engine peta: cache tile tiga tingkat & scrub mulus", + "fil": "Muling isinulat ang map engine: three-tier tile cache & makinis na scrub" + }, + "body": { + "zh_Hant": "MapLibre Native 6.27.0 + ExpTech fork,vector tile、terrain-RGB、WebP raster。關鍵在於三層分工快取與雷達 scrub 的七種機制。檔案:`shared/map/*` + `features/map/*`。", + "zh_Hans": "MapLibre Native 6.27.0 + ExpTech fork,vector tile、terrain-RGB、WebP raster。关键在于三层分工快取与雷达 scrub 的七种机制。档案:shared/map/* + features/map/*。", + "en": "MapLibre Native 6.27.0 + ExpTech fork — vector tiles, terrain-RGB, WebP rasters. The key is the three-tier cache division and seven mechanisms behind silky radar scrubbing. Files: shared/map/* + features/map/*.", + "ja": "MapLibre Native 6.27.0 + ExpTech フォーク。ベクタータイル、terrain-RGB、WebP ラスター。鍵は 3 層キャッシュの分担と、レーダーのなめらかなスクラブを支える 7 つの仕組み。ファイル: shared/map/* + features/map/*。", + "ko": "MapLibre Native 6.27.0 + ExpTech 포크. 벡터 타일, terrain-RGB, WebP 래스터. 핵심은 3계층 캐시 분담과 레이더 스크럽의 7가지 메커니즘. 파일: shared/map/* + features/map/*.", + "th": "MapLibre Native 6.27.0 + ExpTech fork — vector tile, terrain-RGB, WebP raster ระบบสำคัญคือการแบ่งแคช 3 ชั้นและกลไก 7 อย่างในการสครับเรดาร์ ไฟล์: shared/map/* + features/map/*", + "vi": "MapLibre Native 6.27.0 + bản fork ExpTech — vector tile, terrain-RGB, raster WebP. Mấu chốt là phân chia cache ba tầng và bảy cơ chế lướt radar mượt. File: shared/map/* + features/map/*.", + "id": "MapLibre Native 6.27.0 + fork ExpTech — vector tile, terrain-RGB, raster WebP. Kuncinya pembagian cache tiga tingkat dan tujuh mekanisme scrub radar mulus. File: shared/map/* + features/map/*.", + "fil": "MapLibre Native 6.27.0 + ExpTech fork — vector tiles, terrain-RGB, WebP rasters. Ang susi ay ang three-tier cache at pitong mekanismo sa likod ng makinis na radar scrub. Files: shared/map/* + features/map/*." + }, + "details": [ + { + "key": { + "zh_Hant": "層 1:原生 ambient", + "zh_Hans": "层 1:原生 ambient", + "en": "Layer 1: native ambient", + "ja": "層 1: ネイティブ ambient", + "ko": "1단계: 네이티브 ambient", + "th": "ชั้น 1: ambient ดั้งเดิม", + "vi": "Tầng 1: ambient gốc", + "id": "Lapisan 1: ambient native", + "fil": "Layer 1: native ambient" + }, + "value": { + "zh_Hant": "最終關掉(設 0):否則與自家 SQLite 重複 ≈214 MB,且只有一份看得見流量", + "zh_Hans": "最终关掉(设 0):否则与自家 SQLite 重复 ≈214 MB,且只有一份看得见流量", + "en": "Disabled in the end (set to 0): otherwise it duplicated the SQLite store by ≈214 MB, with only one copy visible to accounting", + "ja": "最終的に無効化(0 に設定)。有効だと SQLite と約 214 MB 重複し、流量会計に見えるのは片方だけになるため", + "ko": "결국 비활성화(0으로 설정). 켜두면 SQLite와 약 214MB 중복되고, 회계에는 하나만 보임", + "th": "ปิดในที่สุด (ตั้งเป็น 0): ไม่เช่นนั้นซ้ำกับ SQLite ประมาณ 214 MB และมีเพียงชุดเดียวที่เห็นในบัญชี流量", + "vi": "Tắt hẳn (đặt 0): nếu không sẽ trùng với SQLite ≈214 MB, và chỉ một bản được hạch toán", + "id": "Akhirnya dimatikan (set 0): kalau tidak, duplikat dengan SQLite ≈214 MB dan hanya satu yang terhitung", + "fil": "Naka-disable sa huli (set to 0): kung hindi, magdadoble ito sa SQLite nang ≈214 MB, at isa lang ang nakikita ng accounting" + } + }, + { + "key": { + "zh_Hant": "層 2:權威 SQLite", + "zh_Hans": "层 2:权威 SQLite", + "en": "Layer 2: authoritative SQLite", + "ja": "層 2: 権威 SQLite", + "ko": "2단계: 권위 SQLite", + "th": "ชั้น 2: SQLite หลัก", + "vi": "Tầng 2: SQLite chủ đạo", + "id": "Lapisan 2: SQLite otoritatif", + "fil": "Layer 2: authoritative SQLite" + }, + "value": { + "zh_Hant": "EtagCacheStore(350 MB、LRU、URL-keyed);bridge 雙向批次,一個 IN query 取整批 tile", + "zh_Hans": "EtagCacheStore(350 MB、LRU、URL-keyed);bridge 双向批次,一个 IN query 取整批 tile", + "en": "EtagCacheStore (350 MB, LRU, URL-keyed); batched bridge both ways — one IN query fetches a whole tile batch", + "ja": "EtagCacheStore(350 MB、LRU、URL キー)。ブリッジは双方向バッチ。1 つの IN クエリでタイル一式を取得", + "ko": "EtagCacheStore(350MB, LRU, URL 키). 브리지는 양방향 배치. 하나의 IN 쿼리로 타일 묶음 전체 조회", + "th": "EtagCacheStore (350 MB, LRU, URL-keyed); bridge แบบแบตช์ 2 ทาง — หนึ่ง IN query เรียกไทล์ทั้งชุด", + "vi": "EtagCacheStore (350 MB, LRU, URL-keyed); bridge theo lô hai chiều — một câu IN query lấy cả lô tile", + "id": "EtagCacheStore (350 MB, LRU, URL-keyed); bridge batch dua arah — satu query IN mengambil seluruh batch tile", + "fil": "EtagCacheStore (350 MB, LRU, URL-keyed); batched bridge both ways — isang IN query ang kumukuha ng buong tile batch" + } + }, + { + "key": { + "zh_Hant": "層 3:記憶體 mirror", + "zh_Hans": "层 3:记忆体 mirror", + "en": "Layer 3: in-memory mirror", + "ja": "層 3: メモリミラー", + "ko": "3단계: 메모리 미러", + "th": "ชั้น 3: มิเรอร์ในหน่วยความจำ", + "vi": "Tầng 3: mirror trong bộ nhớ", + "id": "Lapisan 3: mirror di memori", + "fil": "Layer 3: in-memory mirror" + }, + "value": { + "zh_Hant": "MapTileCache(native in-process,48 MB);warm 把 SQLite bytes 直接注入,frame 揭露時 zero IPC/SQL/network", + "zh_Hans": "MapTileCache(native in-process,48 MB);warm 把 SQLite bytes 直接注入,frame 揭露时 zero IPC/SQL/network", + "en": "MapTileCache (native in-process, 48 MB); warm() injects SQLite bytes directly, so revealing a frame costs zero IPC/SQL/network", + "ja": "MapTileCache(ネイティブ in-process、48 MB)。warm() が SQLite の bytes を直接注入し、フレーム公開時は IPC・SQL・ネットワークすべてゼロ", + "ko": "MapTileCache(네이티브 in-process, 48MB). warm()이 SQLite bytes를 직접 주입해 프레임 공개 시 IPC/SQL/네트워크 0", + "th": "MapTileCache (native in-process, 48 MB); warm() ฉีดไบต์จาก SQLite โดยตรง การแสดงเฟรมใช้ IPC/SQL/network เป็นศูนย์", + "vi": "MapTileCache (native in-process, 48 MB); warm() nạp trực tiếp bytes từ SQLite, lộ khung tốn 0 IPC/SQL/network", + "id": "MapTileCache (native in-process, 48 MB); warm() menyuntikkan bytes SQLite langsung, membuka frame nol IPC/SQL/network", + "fil": "MapTileCache (native in-process, 48 MB); ang warm() ay direktang nag-iinject ng SQLite bytes, kaya ang pagpapakita ng frame ay zero IPC/SQL/network" + } + }, + { + "key": { + "zh_Hant": "Scrub 七機制", + "zh_Hans": "Scrub 七机制", + "en": "Seven scrub mechanisms", + "ja": "スクラブの 7 つの仕組み", + "ko": "스크럽 7가지 메커니즘", + "th": "กลไกสครับ 7 อย่าง", + "vi": "Bảy cơ chế scrub", + "id": "Tujuh mekanisme scrub", + "fil": "Pitong mekanismo ng scrub" + }, + "value": { + "zh_Hant": "opacity flip、0ms cross-fade、skipNulls、hide/show 併發、latest-wins pump、fling 阻尼、固定 itemExtent 清單", + "zh_Hans": "opacity flip、0ms cross-fade、skipNulls、hide/show 并发、latest-wins pump、fling 阻尼、固定 itemExtent 清单", + "en": "Opacity flip, 0ms cross-fade, skipNulls, concurrent hide/show, latest-wins pump, fling damping, fixed-itemExtent list", + "ja": "opacity flip、0ms cross-fade、skipNulls、hide/show 並列、latest-wins pump、フリング減衰、固定 itemExtent リスト", + "ko": "opacity flip, 0ms cross-fade, skipNulls, hide/show 동시, latest-wins pump, 플링 감쇠, 고정 itemExtent 목록", + "th": "opacity flip, 0ms cross-fade, skipNulls, hide/show พร้อมกัน, latest-wins pump, หน่วง fling, รายการ itemExtent คงที่", + "vi": "opacity flip, cross-fade 0ms, skipNulls, hide/show song song, latest-wins pump, giảm chấn fling, danh sách itemExtent cố định", + "id": "opacity flip, cross-fade 0ms, skipNulls, hide/show konkuren, latest-wins pump, peredaman fling, daftar itemExtent tetap", + "fil": "Opacity flip, 0ms cross-fade, skipNulls, sabay na hide/show, latest-wins pump, fling damping, fixed-itemExtent list" + } + } + ] + }, + { + "id": "app_time", + "icon": "query_stats", + "title": { + "zh_Hant": "校時與單調時鐘", + "zh_Hans": "校时与单调时钟", + "en": "Time sync & the monotonic clock", + "ja": "時刻同期とモノトニッククロック", + "ko": "시간 동기화와 모노토닉 클록", + "th": "การซิงค์เวลาและนาฬิกาโมโนโทนิก", + "vi": "Đồng bộ giờ & đồng hồ đơn điệu", + "id": "Sinkronisasi waktu & jam monotonik", + "fil": "Time sync at ang monotonic clock" + }, + "body": { + "zh_Hant": "真 SNTP(UDP/123,主/備)校正,錨定在 monotonic clock;所有伺服器蓋時間戳的事物都用 AppTime,永不 DateTime.now()。檔案:`core/time/*`。", + "zh_Hans": "真 SNTP(UDP/123,主/备)校正,锚定在 monotonic clock;所有伺服器盖时间戳的事物都用 AppTime,永不 DateTime.now()。档案:core/time/*。", + "en": "Real SNTP (UDP/123, primary/backup) synced and anchored to the monotonic clock; everything the server timestamps goes through AppTime, never DateTime.now(). Files: core/time/*.", + "ja": "本物の SNTP(UDP/123、主/副)で同期し、モノトニッククロックに固定。サーバーがタイムスタンプを付けるものはすべて AppTime 経由で、DateTime.now() は使わない。ファイル: core/time/*。", + "ko": "진짜 SNTP(UDP/123, 주/백업)로 동기화하고 모노토닉 클록에 앵커. 서버가 타임스탬프를 찍는 모든 것은 AppTime 사용, DateTime.now()는 금지. 파일: core/time/*.", + "th": "ใช้ SNTP จริง (UDP/123, หลัก/สำรอง) ซิงค์และยึดกับนาฬิกาโมโนโทนิก ทุกอย่างที่เซิร์ฟเวอร์ประทับเวลาใช้ AppTime ไม่ใช้ DateTime.now() ไฟล์: core/time/*", + "vi": "SNTP thật (UDP/123, chính/dự phòng) đồng bộ và neo vào đồng hồ đơn điệu; mọi thứ máy chủ đóng dấu thời gian đều qua AppTime, không bao giờ DateTime.now(). File: core/time/*.", + "id": "SNTP asli (UDP/123, utama/cadangan) sinkron dan berjangkar pada jam monotonik; semua yang distempel waktu server lewat AppTime, tidak pernah DateTime.now(). File: core/time/*.", + "fil": "Tunay na SNTP (UDP/123, primary/backup) na naka-angkla sa monotonic clock; lahat ng may timestamp ng server ay dumadaan sa AppTime, hindi kailanman DateTime.now(). Files: core/time/*." + }, + "details": [ + { + "key": { + "zh_Hant": "Legacy 對比", + "zh_Hans": "Legacy 对比", + "en": "vs. Legacy", + "ja": "旧版との比較", + "ko": "구버전과 비교", + "th": "เทียบกับเดิม", + "vi": "So với bản cũ", + "id": "vs Legacy", + "fil": "vs Legacy" + }, + "value": { + "zh_Hant": "Legacy:自家 HTTP 端點每分鐘校正,currentTime = now + offset;新版:標準協定 + monotonic 錨定,改鐘不移動時間", + "zh_Hans": "Legacy:自家 HTTP 端点每分钟校正,currentTime = now + offset;新版:标准协定 + monotonic 锚定,改钟不移动时间", + "en": "Legacy: custom HTTP endpoint corrected every minute (now + offset); new: standard protocol + monotonic anchoring, clock edits don't move time", + "ja": "旧版: 独自 HTTP エンドポイントで毎分補正 (now + offset)。新版: 標準プロトコル + モノトニック固定。時計操作は時間を動かさない", + "ko": "구버전: 자체 HTTP 엔드포인트로 매분 보정(now + offset). 신버전: 표준 프로토콜 + 모노토닉 앵커. 시계 조작이 시간을 움직이지 않음", + "th": "เดิม: endpoint HTTP เอง ปรับทุกนาที (now + offset); ใหม่: โปรโตคอลมาตรฐาน + ผูกกับ monotonic การแก้นาฬิกาไม่ขยับเวลา", + "vi": "Cũ: endpoint HTTP tự chế chỉnh mỗi phút (now + offset); mới: giao thức chuẩn + neo monotonic, sửa đồng hồ không làm lệch thời gian", + "id": "Legacy: endpoint HTTP sendiri dikoreksi setiap menit (now + offset); baru: protokol standar + jangkar monotonik, ubah jam tak menggeser waktu", + "fil": "Legacy: sariling HTTP endpoint, minuto-minutong inaayos (now + offset); bago: standard na protocol + monotonic anchoring, hindi gumagalaw ang oras kahit baguhin ang clock" + } + } + ] + }, + { + "id": "error_model", + "icon": "rule", + "title": { + "zh_Hant": "錯誤模型:Result 取代散落 throw", + "zh_Hans": "错误模型:Result 取代散落 throw", + "en": "Error model: Result replaces scattered throws", + "ja": "エラーモデル: Result が散らばった throw を置き換え", + "ko": "오류 모델: Result가 흩어진 throw를 대체", + "th": "โมเดลข้อผิดพลาด: Result แทนที่ throw ที่กระจาย", + "vi": "Mô hình lỗi: Result thay thế throw lộn xộn", + "id": "Model error: Result menggantikan throw tersebar", + "fil": "Error model: Result ang pumalit sa nagkalat na throw" + }, + "body": { + "zh_Hant": "資料層方法回 Result 而非 throw:「被吞掉的 exception 絕不能變成靜默的 all-clear」——對防災 app 這是最糟的失敗模式。檔案:`core/error/*`。", + "zh_Hans": "资料层方法回 Result 而非 throw:「被吞掉的 exception 绝不能被变成静默的 all-clear」——对防灾 app 这是最糟的失败模式。档案:core/error/*。", + "en": "Data-layer methods return Result instead of throwing: 'a swallowed exception must never become a silent all-clear' — the worst failure mode for a disaster app. Files: core/error/*.", + "ja": "データ層のメソッドは throw ではなく Result を返す。「握りつぶされた例外が静かな全クリアになってはならない」—防災アプリにとって最悪の失敗モード。ファイル: core/error/*。", + "ko": "데이터 계층 메서드는 throw 대신 Result 반환: \"삼켜진 예외가 조용한 올클리어가 되어선 안 된다\" — 방재 앱에 최악의 실패 모드. 파일: core/error/*.", + "th": "เมธอดเลเยอร์ข้อมูลคืน Result แทนการ throw: \"exception ที่ถูกกลืนต้องไม่กลายเป็นการเคลียร์แบบเงียบ\" — โหมดความล้มเหลวที่แย่ที่สุดสำหรับแอปภัยพิบัติ ไฟล์: core/error/*", + "vi": "Method tầng dữ liệu trả Result thay vì throw: 'exception bị nuốt không bao giờ được trở thành tất-cả-rõ-ràng thầm lặng' — chế độ lỗi tệ nhất cho app thảm hoạ. File: core/error/*.", + "id": "Metode lapisan data mengembalikan Result, bukan throw: 'exception yang ditelan tidak boleh menjadi all-clear senyap' — mode gagal terburuk untuk aplikasi bencana. File: core/error/*.", + "fil": "Ang data-layer methods ay nagbabalik ng Result imbes na mag-throw: 'ang nalamon na exception ay hindi dapat maging tahimik na all-clear' — pinakamasamang failure mode para sa disaster app. Files: core/error/*." + }, + "details": [ + { + "key": { + "zh_Hant": "Failure 階層", + "zh_Hans": "Failure 阶层", + "en": "Failure hierarchy", + "ja": "Failure 階層", + "ko": "Failure 계층", + "th": "ลำดับชั้น Failure", + "vi": "Hệ tầng Failure", + "id": "Hierarki Failure", + "fil": "Failure hierarchy" + }, + "value": { + "zh_Hant": "Network / Timeout / Decode / NoData(空 list ≠ 錯誤)/ Unexpected / MeshConflict", + "zh_Hans": "Network / Timeout / Decode / NoData(空 list ≠ 错误)/ Unexpected / MeshConflict", + "en": "Network / Timeout / Decode / NoData (empty list ≠ error) / Unexpected / MeshConflict", + "ja": "Network / Timeout / Decode / NoData(空リスト ≠ エラー)/ Unexpected / MeshConflict", + "ko": "Network / Timeout / Decode / NoData(빈 목록 ≠ 오류) / Unexpected / MeshConflict", + "th": "Network / Timeout / Decode / NoData (รายการว่าง ≠ ข้อผิดพลาด) / Unexpected / MeshConflict", + "vi": "Network / Timeout / Decode / NoData (danh sách trống ≠ lỗi) / Unexpected / MeshConflict", + "id": "Network / Timeout / Decode / NoData (list kosong ≠ error) / Unexpected / MeshConflict", + "fil": "Network / Timeout / Decode / NoData (walang laman na list ≠ error) / Unexpected / MeshConflict" + } + }, + { + "key": { + "zh_Hant": "Timeout 獨立化", + "zh_Hans": "Timeout 独立化", + "en": "Timeout is its own type", + "ja": "Timeout の独立", + "ko": "Timeout 독립 타입", + "th": "Timeout เป็นประเภทของตัวเอง", + "vi": "Timeout là kiểu riêng", + "id": "Timeout berdiri sendiri", + "fil": "Independent ang Timeout" + }, + "value": { + "zh_Hant": "獨立出來,讓 realtime UI 顯示 STALE(而非泛用錯誤)而不是卡住", + "zh_Hans": "独立出来,让 realtime UI 显示 STALE(而非泛用错误)而不是卡住", + "en": "Separated so the realtime UI shows STALE (not a generic error) instead of hanging", + "ja": "独立させ、リアルタイム UI が汎用エラーではなく STALE を表示できるように", + "ko": "분리하여 실시간 UI가 일반 오류가 아닌 STALE을 표시하게", + "th": "แยกออกมาเพื่อให้ UI เรียลไทม์แสดง STALE (ไม่ใช่ข้อผิดพลาดทั่วไป) แทนการค้าง", + "vi": "Tách riêng để UI realtime hiển thị STALE (không phải lỗi chung) thay vì treo", + "id": "Dipisah agar UI realtime menampilkan STALE (bukan error umum) alih-alih menggantung", + "fil": "Inihiwalay para ang realtime UI ay magpakita ng STALE (hindi generic error) imbes na mag-hang" + } + }, + { + "key": { + "zh_Hant": "guardResult", + "zh_Hans": "guardResult", + "en": "guardResult", + "ja": "guardResult", + "ko": "guardResult", + "th": "guardResult", + "vi": "guardResult", + "id": "guardResult", + "fil": "guardResult" + }, + "value": { + "zh_Hant": "DB 層唯一把 throw→Result 的地方;每個 repository 方法一行,沒人會忘了 try", + "zh_Hans": "DB 层唯一把 throw→Result 的地方;每个 repository 方法一行,没人会忘了 try", + "en": "The only place DB-layer turns throw→Result; one line per repository method, so no one forgets the try", + "ja": "DB 層で throw→Result に変換する唯一の場所。リポジトリメソッドあたり 1 行なので、try を忘れない", + "ko": "DB 계층에서 throw→Result로 바꾸는 유일한 곳. 저장소 메서드당 한 줄이라 try를 잊지 않음", + "th": "จุดเดียวที่เลเยอร์ DB เปลี่ยน throw→Result; หนึ่งบรรทัดต่อ method repository ไม่มีทางลืม try", + "vi": "Nơi duy nhất tầng DB chuyển throw→Result; một dòng cho mỗi method repository, không ai quên try", + "id": "Satu-satunya tempat lapisan DB mengubah throw→Result; satu baris per method repository, tidak ada yang lupa try", + "fil": "Ang tanging lugar na ginagawang throw→Result ng DB-layer; isang linya bawat repository method, walang nakakalimot sa try" + } + } + ] + }, + { + "id": "logging", + "icon": "receipt_long", + "title": { + "zh_Hant": "持久化日誌:crash 前那一支可回放", + "zh_Hans": "持久化日志:crash 前那一支可回放", + "en": "Persistent log: the last 24h before a crash can be replayed", + "ja": "永続ログ:クラッシュ前 24 時間を再生できる", + "ko": "영구 로그: 크래시 24시간 전을 재생 가능", + "th": "ล็อกถาวร: ย้อนดู 24 ชั่วโมงก่อน crash ได้", + "vi": "Nhật ký bền vững: 24h trước crash có thể xem lại", + "id": "Log persisten: 24 jam sebelum crash bisa diputar ulang", + "fil": "Persistent log: maaaring i-replay ang 24h bago mag-crash" + }, + "body": { + "zh_Hant": "Log 常駐 SQLite(24h 保留)。重複錯誤抑制:同 signature 5s 窗內重複超過 8 次開始 drop。App 內「日誌」頁可回放崩潰前最後一支。檔案:`core/logging/*`。", + "zh_Hans": "Log 常驻 SQLite(24h 保留)。重复错误抑制:同 signature 5s 窗内重复超过 8 次开始 drop。App 内「日志」页可回放崩溃前最后一支。档案:core/logging/*。", + "en": "Logs persist in SQLite (24h retention). Repeat-error suppression: more than 8 of the same signature in a 5s window starts dropping. The in-app Log page replays the final moments before a crash. Files: core/logging/*.", + "ja": "ログは SQLite に常駐(24 時間保持)。重複エラー抑制:同一シグネチャが 5 秒窓で 8 回超えると削り始める。アプリ内「ログ」ページでクラッシュ直前を再生できる。ファイル: core/logging/*。", + "ko": "로그는 SQLite에 상주(24시간 보존). 반복 오류 억제: 같은 시그니처가 5초 창에서 8회 넘으면 드랍 시작. 앱 내 '로그' 페이지에서 크래시 직전을 재생 가능. 파일: core/logging/*.", + "th": "ล็อกอยู่ใน SQLite (เก็บ 24 ชม.) ลดการซ้ำของข้อผิดพลาด: ซ้ำกันเกิน 8 ครั้งในหน้าต่าง 5 วินาทีจะเริ่มตัดทิ้ง หน้า 'Log' ในแอปสามารถย้อนดูช่วงก่อน crash ได้ ไฟล์: core/logging/*", + "vi": "Log thường trú trong SQLite (giữ 24h). Chèn lặp lỗi: hơn 8 lần cùng signature trong cửa sổ 5s bắt đầu loại bỏ. Trang 'Nhật ký' trong app xem lại khoảnh khắc cuối trước crash. File: core/logging/*.", + "id": "Log menetap di SQLite (retensi 24 jam). Penekanan error berulang: lebih dari 8 kali signature sama dalam jendela 5 detik mulai dibuang. Halaman Log di app memutar ulang momen terakhir sebelum crash. File: core/logging/*.", + "fil": "Nananatili ang log sa SQLite (24h retention). Repeat-error suppression: higit sa 8 na parehong signature sa 5s window ay magsisimulang i-drop. Ang in-app Log page ay nag-re-replay ng mga huling sandali bago mag-crash. Files: core/logging/*." + }, + "details": [ + { + "key": { + "zh_Hant": "Legacy 對比", + "zh_Hans": "Legacy 对比", + "en": "vs. Legacy", + "ja": "旧版との比較", + "ko": "구버전과 비교", + "th": "เทียบกับเดิม", + "vi": "So với bản cũ", + "id": "vs Legacy", + "fil": "vs Legacy" + }, + "value": { + "zh_Hant": "Legacy: talker_flutter 純記憶體,重開機就查不到昨天為何崩潰;新版:SQLite 24h 可回放", + "zh_Hans": "Legacy: talker_flutter 纯记忆体,重开机就查不到昨天为何崩溃;新版:SQLite 24h 可回放", + "en": "Legacy: talker_flutter in memory — reboot loses the reason behind yesterday's crash; new: SQLite 24h, replayable", + "ja": "旧版: talker_flutter はメモリのみ、再起動で昨日のクラッシュ理由は消える。新版: SQLite 24 時間再生可能", + "ko": "구버전: talker_flutter 메모리 전용, 재부팅되면 어제 크래시 이유 조회 불가. 신버전: SQLite 24시간 재생 가능", + "th": "เดิม: talker_flutter หน่วยความจำล้วน รีสตาร์ทแล้วหาสาเหตุ crash เมื่อวานไม่ได้; ใหม่: SQLite 24 ชม. ดูย้อนหลังได้", + "vi": "Cũ: talker_flutter chỉ trong bộ nhớ, khởi động lại mất lý do crash hôm qua; mới: SQLite 24h, xem lại được", + "id": "Legacy: talker_flutter murni memori, restart hilang sebab crash kemarin; baru: SQLite 24 jam, bisa diputar ulang", + "fil": "Legacy: talker_flutter memory lang, mawawala ang dahilan ng crash kahapon pag-restart; bago: SQLite 24h, replayable" + } + } + ] + }, + { + "id": "perf_numbers", + "icon": "straighten", + "title": { + "zh_Hant": "能量化的數字", + "zh_Hans": "能量化的数字", + "en": "The measurable numbers", + "ja": "定量化できる数字", + "ko": "계량 가능한 숫자", + "th": "ตัวเลขที่วัดได้", + "vi": "Những con số đo lường được", + "id": "Angka yang terukur", + "fil": "Ang mga nasusukat na numero" + }, + "body": { + "zh_Hant": "所有數字都有出處。repo 沒有全域 benchmark,所以只列測得到的。Source:`REWRITE-vs-LEGACY.md`。", + "zh_Hans": "所有数字都有出处。repo 没有全域 benchmark,所以只列测得到的。Source:REWRITE-vs-LEGACY.md。", + "en": "Every number has a source. The repo has no global benchmark, so these are the ones actually measured. Source: REWRITE-vs-LEGACY.md.", + "ja": "すべての数字に出典があります。リポジトリにグローバルなベンチマークはないため、実際に計測できたものだけを載せています。出典: REWRITE-vs-LEGACY.md。", + "ko": "모든 숫자에 출처가 있습니다. 저장소에 전역 벤치마크가 없어 실제 측정된 것만 실었습니다. 출처: REWRITE-vs-LEGACY.md.", + "th": "ทุกตัวเลขมีที่มา ไม่มี benchmark แบบครอบคลุมทั้ง repo จึงแสดงเฉพาะที่วัดได้จริง ที่มา: REWRITE-vs-LEGACY.md", + "vi": "Mọi con số đều có nguồn. Repo không có benchmark toàn cục, nên chỉ liệt kê thứ đo được. Nguồn: REWRITE-vs-LEGACY.md.", + "id": "Setiap angka punya sumber. Repo tak punya benchmark global, jadi hanya yang benar-benar terukur yang dicantumkan. Sumber: REWRITE-vs-LEGACY.md.", + "fil": "Ang bawat numero ay may pinagmulan. Walang global benchmark ang repo, kaya ito lang ang talagang nasukat. Source: REWRITE-vs-LEGACY.md." + }, + "stats": [ + { + "value": { + "zh_Hant": "−72%", + "zh_Hans": "−72%", + "en": "−72%", + "ja": "−72%", + "ko": "−72%", + "th": "−72%", + "vi": "−72%", + "id": "−72%", + "fil": "−72%" + }, + "label": { + "zh_Hant": "鄉鎮邊界單一資產的安裝檔縮減", + "zh_Hans": "乡镇边界单一资产的安装档缩减", + "en": "install-size cut for the town-boundary asset alone", + "ja": "町境界アセット単体のインストール縮小", + "ko": "읍면 경계 단일 에셋의 설치 축소", + "th": "การลดขนาดแอปสำหรับ asset ขอบเขตเมืองเพียงอย่างเดียว", + "vi": "giảm kích thước cài đặt riêng asset ranh giới", + "id": "pemangkasan ukuran instal untuk aset batas kota saja", + "fil": "bawas sa laki ng install para sa town-boundary asset lamang" + } + }, + { + "value": { + "zh_Hant": "1–2 秒", + "zh_Hans": "1–2 秒", + "en": "1–2 s", + "ja": "1〜2 秒", + "ko": "1~2초", + "th": "1–2 วิ", + "vi": "1–2 s", + "id": "1–2 dtk", + "fil": "1–2 s" + }, + "label": { + "zh_Hant": "Android 部分裝置的冷啟動省時(再 +500ms)", + "zh_Hans": "Android 部分装置的冷启动省时(再 +500ms)", + "en": "cold-start saving on some Android devices (+500 ms more)", + "ja": "一部 Android 端末のコールドスタート短縮(さらに +500 ms)", + "ko": "일부 Android 기기의 콜드 스타트 단축(+500ms 추가)", + "th": "ประหยัดเวลาตอนเริ่มบน Android บางรุ่น (บวกอีก 500ms)", + "vi": "khởi động lạnh nhanh hơn trên một số Android (+500 ms nữa)", + "id": "penghematan cold start pada sebagian Android (+500ms lagi)", + "fil": "tipid sa cold start sa ilang Android (+500ms pa)" + } + }, + { + "value": { + "zh_Hant": "350 MB", + "zh_Hans": "350 MB", + "en": "350 MB", + "ja": "350 MB", + "ko": "350MB", + "th": "350 MB", + "vi": "350 MB", + "id": "350 MB", + "fil": "350 MB" + }, + "label": { + "zh_Hant": "ETag 快取的 LRU byte 預算", + "zh_Hans": "ETag 快取的 LRU byte 预算", + "en": "ETag cache's LRU byte budget", + "ja": "ETag キャッシュの LRU バイト予算", + "ko": "ETag 캐시의 LRU 바이트 예산", + "th": "งบประมาณไบต์ LRU ของแคช ETag", + "vi": "ngân sách byte LRU của cache ETag", + "id": "anggaran byte LRU cache ETag", + "fil": "LRU byte budget ng ETag cache" + } + }, + { + "value": { + "zh_Hant": "1–3", + "zh_Hans": "1–3", + "en": "1–3", + "ja": "1〜3", + "ko": "1~3", + "th": "1–3", + "vi": "1–3", + "id": "1–3", + "fil": "1–3" + }, + "label": { + "zh_Hant": "GEO 查詢實際測試的多邊形數(舊:367)", + "zh_Hans": "GEO 查询实际测试的多边形数(旧:367)", + "en": "polygons actually tested per GEO query (old: 367)", + "ja": "GEO クエリの実際にテストされるポリゴン数(旧: 367)", + "ko": "GEO 쿼리당 실제 테스트 폴리곤 수(이전: 367)", + "th": "จำนวนรูปหลายเหลี่ยมที่ทดสอบจริงต่อการค้นหา GEO (เดิม: 367)", + "vi": "số đa giác thực sự được kiểm tra mỗi truy vấn GEO (cũ: 367)", + "id": "poligon yang benar-benar diuji per kueri GEO (lama: 367)", + "fil": "polygon na talagang tinitingnan bawat GEO query (dati: 367)" + } + } + ] + }, + { + "id": "crash_fixes", + "icon": "bug_report", + "title": { + "zh_Hant": "直接修掉的 crash", + "zh_Hans": "直接修掉的 crash", + "en": "Crashes fixed along the way", + "ja": "直したクラッシュ", + "ko": "고친 크래시", + "th": "crash ที่แก้แล้ว", + "vi": "Các crash đã sửa", + "id": "Crash yang diperbaiki", + "fil": "Mga crash na naayos" + }, + "body": { + "zh_Hant": "重寫過程中也修掉幾個潛在 crash:相機框景在 Dart 端算(原生對 degenerate box 會丟未捕捉 exception 直接 SIGABRT)、MapLibre fork 加 Android gzip 修正、閃電 PNG 明確 dispose(修掉每 register 一次洩兩個 native handle)。", + "zh_Hans": "重写过程中也修掉几个潜在 crash:相机框景在 Dart 端算(原生对 degenerate box 会丢未捕捉 exception 直接 SIGABRT)、MapLibre fork 加 Android gzip 修正、闪电 PNG 明确 dispose(修掉每 register 一次泄两个 native handle)。", + "en": "The rewrite also fixed lurking crashes: the camera frame is computed in Dart (native throws an uncaught exception on degenerate boxes → SIGABRT), the MapLibre fork adds an Android gzip fix, and lightning PNGs are explicitly disposed (two native handle leaks per register).", + "ja": "リライトで潜在クラッシュも修正。カメラフレームは Dart 側で計算(ネイティブは退化ボックスで未捕捉例外→SIGABRT)。MapLibre フォークで Android の gzip 問題を修正。雷 PNG を明示的 dispose(register ごとにネイティブハンドル 2 つのリークを修正)。", + "ko": "재작성 과정에서 잠재 크래시도 수정됐습니다. 카메라 프레임을 Dart에서 계산(네이티브는 degenerate box에서 미처리 예외 → SIGABRT). MapLibre 포크로 Android gzip 수정. 번개 PNG 명시적 dispose(register마다 네이티브 핸들 2개 누수 수정).", + "th": "การเขียนใหม่ยังแก้ crash ที่ซ่อนอยู่ด้วย: คำนวณเฟรมกล้องใน Dart (เนทีฟยิง exception ที่ไม่ catch ใน degenerate box → SIGABRT), MapLibre fork เพิ่มการแก้ gzip Android, และ dispose PNG สายฟ้าอย่างชัดเจน (แก้หน่วยความจำรั่ว 2 handle ต่อการ register)", + "vi": "Việc viết lại cũng sửa các crash tiềm ẩn: khung camera tính trong Dart (native ném exception chưa bắt trên box suy biến → SIGABRT), fork MapLibre thêm bản vá gzip Android, PNG sét được dispose tường minh (sửa rò rỉ 2 native handle mỗi lần register).", + "id": "Penulisan ulang juga memperbaiki crash tersembunyi: bingkai kamera dihitung di Dart (native melempar exception tak tertangkap pada kotak degenerate → SIGABRT), fork MapLibre menambah perbaikan gzip Android, PNG petir di-dispose eksplisit (memperbaiki kebocoran 2 native handle per register).", + "fil": "Ang rewrite ay nag-ayos din ng mga nakatagong crash: ang camera frame ay kinukwenta sa Dart (nag-throw ng uncaught exception ang native sa degenerate box → SIGABRT), ang MapLibre fork ay may Android gzip fix, at ang lightning PNG ay eksplisit na dine-dispose (dalawang native handle leak bawat register)." + } + } + ] +} \ No newline at end of file diff --git a/release_highlights/assets/26.1/normal/cards.json b/release_highlights/assets/26.1/normal/cards.json new file mode 100644 index 000000000..bbf24d30b --- /dev/null +++ b/release_highlights/assets/26.1/normal/cards.json @@ -0,0 +1,670 @@ +{ + "version": "26.1", + "kind": "normal", + "title": { + "zh_Hant": "DPIP 26.1 第 4 次大更新 做了哪些改變", + "zh_Hans": "DPIP 26.1 第 4 次大更新 做了哪些改变", + "en": "DPIP 26.1 — What the 4th major update changed", + "ja": "DPIP 26.1 第4回大型アップデートで変わったこと", + "ko": "DPIP 26.1 제4차 대규모 업데이트에서 바뀐 점", + "th": "DPIP 26.1 การอัปเดตครั้งใหญ่ครั้งที่ 4 เปลี่ยนอะไรบ้าง", + "vi": "DPIP 26.1 — Bản cập nhật lớn lần thứ 4 có gì mới", + "id": "DPIP 26.1 — Yang berubah di pembaruan besar ke-4", + "fil": "DPIP 26.1 — Ano ang binago ng ika-4 na malaking update" + }, + "subtitle": { + "zh_Hant": "這次不是換皮——通訊、地圖、省電、啟動全部重做,讓警報更快到、更省流量地到。", + "zh_Hans": "这次不是换皮——通讯、地图、省电、启动全部重做,让警报更快到、更省流量地到。", + "en": "Not a reskin — networking, maps, battery and startup were all rebuilt, so alerts get to you faster and use far less data.", + "ja": "見た目だけの更新ではありません。通信・地図・省電力・起動を全て作り直し、警報がより速く、より少ない通信量で届くようになりました。", + "ko": "겉모습만 바뀐 게 아닙니다. 통신·지도·배터리·시작이 모두 재구축되어, 알림이 더 빠르고 더 적은 데이터로 도착합니다.", + "th": "ไม่ใช่แค่เปลี่ยนโฉม — ระบบสื่อสาร แผนที่ ประหยัดพลังงาน และการเริ่มต้นระบบถูกสร้างใหม่ทั้งหมด แจ้งเตือนถึงเร็วขึ้นและใช้ดาต้าน้อยลง", + "vi": "Không phải chỉ đổi giao diện — mạng, bản đồ, pin và khởi động đều được làm lại, để cảnh báo đến nhanh hơn và tốn ít dữ liệu hơn.", + "id": "Bukan sekadar ganti tampilan — jaringan, peta, baterai, dan startup semuanya dibangun ulang, agar peringatan lebih cepat sampai dan hemat data.", + "fil": "Hindi lang bagong itsura — muling ginawa ang network, mapa, baterya, at startup, para mas mabilis at mas tipid sa data ang alerto." + }, + "cards": [ + { + "id": "speed", + "icon": "bolt", + "title": { + "zh_Hant": "警報更快到", + "zh_Hans": "警报更快到", + "en": "Alerts arrive faster", + "ja": "警報がより速く届く", + "ko": "알림이 더 빨리 도착", + "th": "การแจ้งเตือนถึงเร็วขึ้น", + "vi": "Cảnh báo đến nhanh hơn", + "id": "Peringatan lebih cepat sampai", + "fil": "Mas mabilis dumating ang alerto" + }, + "headline": { + "zh_Hant": "即時資料從每秒輪詢,改成伺服器主動推送", + "zh_Hans": "即时资料从每秒轮询,改成服务器主动推送", + "en": "Real-time data switched from 1-second polling to server push", + "ja": "リアルタイムデータが毎秒ポーリングからサーバー配信に", + "ko": "실시간 데이터가 초당 폴링에서 서버 푸시로", + "th": "ข้อมูลเรียลไทม์เปลี่ยนจากการดึงทุกวินาที เป็นการส่งจากเซิร์ฟเวอร์", + "vi": "Dữ liệu thời gian thực chuyển từ kéo mỗi giây sang máy chủ đẩy", + "id": "Data real-time berubah dari polling tiap detik menjadi push server", + "fil": "Real-time data: mula sa bawat segundong pagkuha, naging push mula sa server" + }, + "body": { + "zh_Hant": "以前 App 每秒問一次伺服器「有沒有新資料?」;現在伺服器有資料才送來。地震速報 (EEW) 與強震監視 (RTS) 都改用串流傳輸——同樣的警報內容,更即時、更省流量。", + "zh_Hans": "以前 App 每秒问一次服务器「有没有新数据?」;现在服务器有数据才送来。地震速报 (EEW) 与强震监视 (RTS) 都改用串流传输——同样的警报内容,更即时、更省流量。", + "en": "The app used to ask the server 'anything new?' every second; now the server pushes only when there is something to say. Both the earthquake early warning (EEW) and strong-motion monitoring (RTS) feeds stream changes instead — the same alerts, delivered live on less data.", + "ja": "以前はアプリが毎秒サーバーに「新しいデータは?」と問い合わせていました。今はサーバーが更新があるときだけ送信します。緊急地震速報 (EEW) と強震モニタ (RTS) はどちらもストリーム配信に。同じ警報内容で、よりリアルタイム、より節約に。", + "ko": "예전엔 앱이 매초 서버에 \"새로운 데이터 있나요?\"라고 물었습니다. 이제는 서버가 새 데이터가 있을 때만 보냅니다. 지진조기경보(EEW)와 강진 모니터(RTS) 모두 스트리밍 전송으로 바뀌었습니다. 같은 알림 내용, 더 실시간, 더 적은 데이터.", + "th": "แต่ก่อนแอปถามเซิร์ฟเวอร์ทุกวินาทีว่า \"มีข้อมูลใหม่ไหม?\" ตอนนี้เซิร์ฟเวอร์ส่งเฉพาะเมื่อมีข้อมูลใหม่ การแจ้งเตือนแผ่นดินไหว (EEW) และการเฝ้าระวังแรงสั่นสะเทือน (RTS) เปลี่ยนเป็นสตรีมมิ่ง — เนื้อหาเดียวกัน แต่เร็วและประหยัดกว่า", + "vi": "Trước đây app hỏi máy chủ mỗi giây \"có gì mới không?\"; giờ máy chủ chỉ gửi khi có dữ liệu. Cảnh báo động đất (EEW) và giám sát rung lắc (RTS) đều chuyển sang truyền luồng — cùng nội dung cảnh báo, nhanh hơn và tốn ít dữ liệu hơn.", + "id": "Dulu aplikasi bertanya ke server setiap detik 'ada data baru?'; kini server hanya mengirim saat ada data baru. Peringatan dini gempa (EEW) dan pemantauan getaran (RTS) keduanya kini streaming — konten sama, lebih real-time, lebih hemat data.", + "fil": "Dati, nagtatanong ang app sa server bawat segundo kung 'may bago na ba?'; ngayon, nagpapadala lamang ang server kapag may pagbabago. Ang EEW at RTS ay streaming na — parehong alerto, mas real-time, mas tipid sa data." + }, + "stat": { + "zh_Hant": "1 秒", + "zh_Hans": "1 秒", + "en": "1 s", + "ja": "1 秒", + "ko": "1초", + "th": "1 วิ", + "vi": "1 s", + "id": "1 dtk", + "fil": "1 s" + }, + "statLabel": { + "zh_Hant": "以前的輪詢間隔,現在變為零輪詢——有變動才傳", + "zh_Hans": "以前的轮询间隔,现在变为零轮询——有变动才传", + "en": "the old polling interval — now zero polling, changes are pushed", + "ja": "従来のポーリング間隔。今はポーリングなし、変化時のみ送信", + "ko": "이전 폴링 간격. 이제는 폴링 없음, 변화만 전송", + "th": "ช่วงเวลาการดึงข้อมูลเดิม — ตอนนี้ไม่มีการดึงอีก ส่งเมื่อมีการเปลี่ยนแปลง", + "vi": "khoảng cách kéo dữ liệu trước đây — giờ không còn kéo nữa, chỉ đẩy khi thay đổi", + "id": "interval polling lama — kini tanpa polling, hanya kirim saat ada perubahan", + "fil": "dati nating interval sa pagkuha — wala nang polling, push na lang kapag may pagbabago" + }, + "highlights": [ + { + "zh_Hant": "EEW 與 RTS 皆改為 SSE 串流", + "zh_Hans": "EEW 与 RTS 皆改为 SSE 串流", + "en": "Both EEW and RTS now stream over SSE", + "ja": "EEW と RTS は両方 SSE ストリームに", + "ko": "EEW와 RTS 모두 SSE 스트리밍으로", + "th": "EEW และ RTS เปลี่ยนเป็นสตรีมมิ่ง SSE", + "vi": "Cả EEW và RTS đều chuyển sang SSE", + "id": "EEW dan RTS kini streaming SSE", + "fil": "Ang EEW at RTS ay streaming na sa SSE" + }, + { + "zh_Hant": "斷線自動重連,恢復秒回", + "zh_Hans": "断线自动重连,恢复秒回", + "en": "Auto-reconnect with instant recovery", + "ja": "切断は自動再接続、復旧は即座に", + "ko": "끊기면 자동 재연결, 즉시 복구", + "th": "ตัดการเชื่อมต่ออัตโนมัติ กลับมาทันที", + "vi": "Tự động kết nối lại khi mất, khôi phục tức thì", + "id": "Putus otomatis tersambung lagi, pulih seketika", + "fil": "Awtomatikong kumokonekta, mabilis gumaling" + } + ] + }, + { + "id": "data", + "icon": "data_saver", + "title": { + "zh_Hant": "省流量看得見", + "zh_Hans": "省流量看得见", + "en": "Data savings you can see", + "ja": "通信量の節約が見える", + "ko": "데이터 절약이 보인다", + "th": "ประหยัดดาต้าที่มองเห็นได้", + "vi": "Tiết kiệm dữ liệu thấy được", + "id": "Hemat data yang terlihat", + "fil": "Makikitang tipid sa data" + }, + "headline": { + "zh_Hant": "磁碟智慧快取,看過的地圖不再重抓", + "zh_Hans": "磁盘智慧快取,看过的地图不再重抓", + "en": "Smart on-disk cache — maps you have seen are never re-fetched", + "ja": "ディスクの賢いキャッシュ。見た地図は再取得されない", + "ko": "디스크 스마트 캐시. 본 지도는 다시 받지 않습니다", + "th": "แคชอัจฉริยะบนดิสก์ — แผนที่ที่เคยดูไม่ต้องดาวน์โหลดซ้ำ", + "vi": "Bộ nhớ đệm thông minh — bản đồ đã xem không tải lại", + "id": "Cache cerdas di disk — peta yang pernah dilihat tidak diunduh ulang", + "fil": "Matalinong cache — hindi na muling kinukuhá ang mapang nakita na" + }, + "body": { + "zh_Hant": "App 在磁碟上架起一個容納 350 MB 的快取,用 ETag 驗證機制決定什麼要重抓、什麼直接沿用。雷達、衛星、等高線圖層都受益——天天看地圖的人,長期能省下大量流量。", + "zh_Hans": "App 在磁盘上架起一个容纳 350 MB 的快取,用 ETag 验证机制决定什么要重抓、什么直接沿用。雷达、卫星、等高线图层都受益——天天看地图的人,长期能省下大量流量。", + "en": "The app keeps a 350 MB on-disk cache with ETag-based validation, deciding exactly what needs re-fetching. Radar, satellite and terrain layers all benefit — if you check the map every day, the data you save adds up.", + "ja": "アプリは 350 MB のディスクキャッシュを構え、ETag 検証で何を再取得するか、何をそのまま使うかを判断します。レーダー・衛星・地形レイヤーすべてが恩恵を受け、毎日地図を見る人ほど通信量の節約が積み上がります。", + "ko": "앱은 350MB 디스크 캐시를 두고, ETag 검증으로 무엇을 다시 받을지, 무엇을 그대로 쓸지 판단합니다. 레이더·위성·지형 레이어 모두 혜택을 받아, 매일 지도를 보는 사람일수록 절약이 쌓입니다.", + "th": "แอปมีแคชบนดิสก์ 350 MB พร้อมการตรวจสอบ ETag ว่าอะไรต้องโหลดใหม่ อะไรใช้ของเดิมได้ ระบบเรดาร์ ดาวเทียม และชั้นภูมิประเทศได้ประโยชน์ทั้งหมด — ยิ่งดูแผนที่บ่อย ยิ่งประหยัดดาต้ามาก", + "vi": "App lưu bộ nhớ đệm 350 MB trên đĩa, dùng cơ chế xác thực ETag để quyết định thứ gì cần tải lại. Radar, vệ tinh và lớp địa hình đều được hưởng lợi — càng xem bản đồ thường xuyên, càng tiết kiệm.", + "id": "App menyimpan cache 350 MB di disk, memakai validasi ETag untuk memutuskan apa yang perlu diambil ulang. Radar, satelit, dan lapisan medan semuanya diuntungkan — makin sering buka peta, makin banyak data yang dihemat.", + "fil": "May 350 MB na cache ang app sa disk, gamit ang ETag validation para malaman kung ano ang kailangang kunin ulit. Ang radar, satellite, at terrain layers lahat nakikinabang — mas madalas tingnan ang mapa, mas malaki ang matitipid." + }, + "stat": { + "zh_Hant": "350 MB", + "zh_Hans": "350 MB", + "en": "350 MB", + "ja": "350 MB", + "ko": "350MB", + "th": "350 MB", + "vi": "350 MB", + "id": "350 MB", + "fil": "350 MB" + }, + "statLabel": { + "zh_Hant": "智慧快取預算,全由 LRU 自動管理", + "zh_Hans": "智慧快取预算,全由 LRU 自动管理", + "en": "smart cache budget, fully LRU-managed", + "ja": "スマートキャッシュ予算、完全 LRU 管理", + "ko": "스마트 캐시 예산, LRU로 자동 관리", + "th": "งบแคชอัจฉริยะ จัดการ LRU อัตโนมัติ", + "vi": "hạn mức cache thông minh, tự quản lý bằng LRU", + "id": "budget cache cerdas, dikelola LRU otomatis", + "fil": "badget ng smart cache, LRU ang nagmamay-ari" + }, + "highlights": [ + { + "zh_Hant": "ETag 驗證,只有真的更新才重抓", + "zh_Hans": "ETag 验证,只有真的更新才重抓", + "en": "ETag validation — only truly changed content is re-fetched", + "ja": "ETag 検証、本当に更新されたものだけ再取得", + "ko": "ETag 검증, 진짜 바뀐 것만 다시 받음", + "th": "ตรวจสอบ ETag — โหลดใหม่เฉพาะเมื่อมีเปลี่ยนจริง", + "vi": "Xác thực ETag — chỉ tải lại nội dung thực sự thay đổi", + "id": "Validasi ETag — hanya konten yang benar-benar berubah yang diambil ulang", + "fil": "ETag validation — ang tunay na nagbago lang ang kinukuha ulit" + }, + { + "zh_Hant": "看過的雷達、衛星圖框秒開", + "zh_Hans": "看过的雷达、卫星图框秒开", + "en": "Frames you have seen open instantly", + "ja": "見たことのあるレーダー・衛星フレームは即表示", + "ko": "본 적 있는 레이더·위성 프레임 즉시 표시", + "th": "เฟรมเรดาร์และดาวเทียมที่เคยดูเปิดได้ทันที", + "vi": "Khung radar, vệ tinh đã xem mở tức thì", + "id": "Frame radar & satelit yang pernah dilihat langsung terbuka", + "fil": "Agad nabubuksan ang mga frame na nakita na" + } + ] + }, + { + "id": "battery", + "icon": "battery_saver", + "title": { + "zh_Hant": "更省電、更懂你的位置", + "zh_Hans": "更省电、更懂你的位置", + "en": "Battery-friendly location tracking", + "ja": "省電力で賢い位置情報", + "ko": "배터리 친화적 위치 추적", + "th": "ติดตามตำแหน่งแบบประหยัดแบต", + "vi": "Theo dõi vị trí tiết kiệm pin", + "id": "Pelacakan lokasi yang hemat baterai", + "fil": "Pagsubaybay ng lokasyon na hindi pumapatay ng baterya" + }, + "headline": { + "zh_Hant": "背景定位不再是耗電大戶", + "zh_Hans": "背景定位不再是耗电大户", + "en": "Background location is no longer a battery hog", + "ja": "バックグラウンド位置情報が電池食いではなくなった", + "ko": "백그라운드 위치 추적이 더 이상 배터리 대장이 아님", + "th": "การระบุตำแหน่งเบื้องหลังไม่กินแบตอีกต่อไป", + "vi": "Định vị nền không còn là kẻ ngốn pin", + "id": "Lokasi latar bukan lagi penghabis baterai", + "fil": "Hindi na baterya-drain ang background location" + }, + "body": { + "zh_Hant": "新版把每 10 分鐘固定定位改成自適應間隔:移動越快定得越頻繁(最快 5 分鐘),靜止時自動退到 60 分鐘。不需要前台服務,對 Android 的省電模式也更友善。", + "zh_Hans": "新版把每 10 分钟固定定位改成自适应间隔:移动越快定得越频繁(最快 5 分钟),静止时自动退到 60 分钟。不需要前台服务,对 Android 的省电模式也更友善。", + "en": "Updates track movement instead of a fixed 10-minute timer: the faster you move the more often it locates (down to 5 minutes), and it eases back to 60 minutes when you stay put. No foreground service needed, and friendlier to Android's power-saving modes.", + "ja": "新版では固定 10 分ごとを適応型間隔に変更。動きが速いほど頻繁に(最速 5 分)、静止時は自動で 60 分に。フォアグラウンドサービス不要で、Android の省電力モードにも優しくなりました。", + "ko": "새 버전은 고정 10분 대신 적응형 간격으로 바뀌었습니다. 움직일수록 더 자주(최소 5분), 가만히 있으면 자동으로 60분으로. 포그라운드 서비스가 필요 없어졌고 Android 절전 모드에도 더 친절합니다.", + "th": "เวอร์ชันใหม่เปลี่ยนจากการระบุตำแหน่งทุก 10 นาที เป็นระยะห่างแบบปรับอัตโนมัติ ยิ่งเคลื่อนไหวเร็วยิ่งถี่ (ขั้นต่ำ 5 นาที) นิ่งอยู่ก็ขยายไป 60 นาที ไม่ต้องใช้ foreground service และเป็นมิตรกับโหมดประหยัดพลังงาน Android", + "vi": "Phiên bản mới đổi từ định vị mỗi 10 phút cố định sang khoảng cách thích ứng: di chuyển càng nhanh định vị càng thường xuyên (tối thiểu 5 phút), đứng yên tự giãn ra 60 phút. Không cần foreground service, thân thiện hơn với chế độ tiết kiệm pin Android.", + "id": "Versi baru mengganti lokasi tiap 10 menit tetap dengan interval adaptif: makin cepat bergerak makin sering (minimal 5 menit), diam otomatis mundur ke 60 menit. Tidak perlu foreground service, lebih ramah mode hemat baterai Android.", + "fil": "Pinalitan ng bagong bersyon ang fixed 10-minutong lokasyon ng adaptive interval: mas mabilis kumilos, mas madalas (hanggang 5 minuto); tahimik, awtomatikong 60 minuto. Wala nang foreground service, mas maayos sa power-saving ng Android." + }, + "stat": { + "zh_Hant": "5–60 分", + "zh_Hans": "5–60 分", + "en": "5–60 min", + "ja": "5〜60 分", + "ko": "5~60분", + "th": "5–60 นาที", + "vi": "5–60 phút", + "id": "5–60 mnt", + "fil": "5–60 min" + }, + "statLabel": { + "zh_Hant": "自適應定位間隔,移動越快定得越頻繁", + "zh_Hans": "自适应定位间隔,移动越快定得越频繁", + "en": "adaptive location interval — faster movement, more frequent fixes", + "ja": "適応型の位置情報間隔。速く動くほど頻繁に", + "ko": "적응형 위치 간격 — 빠르게 움직일수록 더 자주", + "th": "ระยะห่างการระบุตำแหน่งแบบปรับได้ — ขยับไว ระบุถี่ขึ้น", + "vi": "khoảng định vị thích ứng — càng di chuyển nhanh càng thường xuyên", + "id": "interval lokasi adaptif — makin cepat bergerak makin sering", + "fil": "adaptive na interval — mas mabilis gumalaw, mas madalas ang fix" + }, + "highlights": [ + { + "zh_Hant": "靜止時自動退到 60 分鐘才定位一次", + "zh_Hans": "静止时自动退到 60 分钟才定位一次", + "en": "Eases back to once per 60 minutes when still", + "ja": "静止時は自動で 60 分に 1 回へ", + "ko": "가만히 있으면 자동으로 60분에 한 번으로", + "th": "อยู่นิ่ง ๆ จะขยับไปถี่ขึ้นเป็น 60 นาทีครั้ง", + "vi": "Đứng yên tự giãn ra 60 phút một lần", + "id": "Diam otomatis melambat ke sekali per 60 menit", + "fil": "Kapag tahimik, awtomatikong 60 minutong agwat" + }, + { + "zh_Hant": "對 Android Doze 省電模式友善", + "zh_Hans": "对 Android Doze 省电模式友善", + "en": "Friendly to Android Doze power-saving", + "ja": "Android Doze 省電力モードに優しい", + "ko": "Android Doze 절전 모드에 친화적", + "th": "เป็นมิตรกับโหมดประหยัดพลังงาน Doze ของ Android", + "vi": "Thân thiện với chế độ tiết kiệm pin Doze của Android", + "id": "Ramah terhadap mode hemat daya Doze Android", + "fil": "Kaibigan ng Android Doze power-saving" + } + ] + }, + { + "id": "startup", + "icon": "rocket_launch", + "title": { + "zh_Hant": "開得更快", + "zh_Hans": "开得更快", + "en": "Faster startup", + "ja": "起動が速い", + "ko": "더 빠른 시작", + "th": "เริ่มต้นเร็วขึ้น", + "vi": "Khởi động nhanh hơn", + "id": "Startup lebih cepat", + "fil": "Mas mabilis magsimula" + }, + "headline": { + "zh_Hant": "冷啟動省下 1–2 秒,開 App 不再等", + "zh_Hans": "冷启动省下 1–2 秒,开 App 不再等", + "en": "Cold start saves 1–2 seconds — no more waiting", + "ja": "コールドスタートが 1〜2 秒短縮、待たされない", + "ko": "콜드 스타트 1~2초 단축, 기다리지 않아도 됩니다", + "th": "การเริ่มต้นครั้งแรกเร็วขึ้น 1–2 วินาที ไม่ต้องรออีกต่อไป", + "vi": "Khởi động lạnh nhanh hơn 1–2 giây, không còn chờ đợi", + "id": "Cold start hemat 1–2 detik, tak perlu menunggu", + "fil": "Makatipid sa cold start ng 1–2 segundo, hindi na maghihintay" + }, + "body": { + "zh_Hant": "把推播初始化與裝置資訊讀取移到背景執行,多項資源同時載入。部分 Android 裝置實測省下 1–2 秒,之後又再省了 500ms 以上。", + "zh_Hans": "把推播初始化与装置资讯读取移到背景执行,多项资源同时载入。部分 Android 装置实测省下 1–2 秒,之后又再省了 500ms 以上。", + "en": "Push initialization and device-info reads moved off the critical path, and boot resources load in parallel. Measured on some Android devices at 1–2 seconds saved, then another 500 ms+ after the follow-up.", + "ja": "プッシュ初期化と端末情報の読み込みをバックグラウンド化、起動リソースを並列読み込み。一部の Android 端末で 1〜2 秒、その後の改善でさらに 500 ms 以上短縮されました。", + "ko": "푸시 초기화와 기기 정보 읽기를 백그라운드로 옮기고, 부팅 리소스를 병렬로 불러옵니다. 일부 Android 기기에서 1~2초, 이후 개선으로 500ms 이상 더 단축됐습니다.", + "th": "ย้ายการเริ่มต้นระบบแจ้งเตือนและการอ่านข้อมูลอุปกรณ์ไปทำงานเบื้องหลัง โหลดทรัพยากรพร้อมกัน บางรุ่น Android วัดผลได้เร็วขึ้น 1–2 วินาที และปรับปรุงอีก 500 มิลลิวินาทีขึ้นไป", + "vi": "Chuyển khởi tạo push và đọc thông tin thiết bị xuống nền, tải tài nguyên song song. Đo trên một số thiết bị Android tiết kiệm 1–2 giây, sau đó giảm thêm hơn 500ms.", + "id": "Inisialisasi push dan pembacaan info perangkat dipindah ke latar, sumber daya dimuat paralel. Terukur di sebagian perangkat Android hemat 1–2 detik, kemudian hemat lagi 500ms+.", + "fil": "Ang push init at pagbabasa ng device info ay nasa background na, at sabay-sabay na naglo-load ang boot resources. Sa ilang Android device, 1–2 segundong tipid, saka dagdag 500ms pa." + }, + "stat": { + "zh_Hant": "1–2 秒", + "zh_Hans": "1–2 秒", + "en": "1–2 s", + "ja": "1〜2 秒", + "ko": "1~2초", + "th": "1–2 วิ", + "vi": "1–2 s", + "id": "1–2 dtk", + "fil": "1–2 s" + }, + "statLabel": { + "zh_Hant": "部分 Android 裝置的冷啟動省時", + "zh_Hans": "部分 Android 装置的冷启动省时", + "en": "cold-start saving on some Android devices", + "ja": "一部 Android 端末のコールドスタート短縮時間", + "ko": "일부 Android 기기의 콜드 스타트 단축 시간", + "th": "เวลาที่ประหยัดได้ตอนเริ่มต้นบน Android บางรุ่น", + "vi": "thời gian khởi động lạnh tiết kiệm trên một số thiết bị Android", + "id": "penghematan cold start pada sebagian perangkat Android", + "fil": "tipid sa cold start sa ilang Android device" + }, + "highlights": [ + { + "zh_Hant": "多項啟動資源平行載入", + "zh_Hans": "多项启动资源平行载入", + "en": "Boot resources load in parallel", + "ja": "起動リソースを並列読み込み", + "ko": "부팅 리소스를 병렬 로드", + "th": "โหลดทรัพยากรเริ่มต้นแบบขนาน", + "vi": "Tải tài nguyên khởi động song song", + "id": "Sumber daya boot dimuat paralel", + "fil": "Sabay-sabay na load ng boot resources" + }, + { + "zh_Hant": "推播初始化不再擋住首幀", + "zh_Hans": "推播初始化不再挡住首帧", + "en": "Push init no longer blocks the first frame", + "ja": "プッシュ初期化が初回表示を妨げない", + "ko": "푸시 초기화가 첫 프레임을 막지 않음", + "th": "การเริ่มต้น push ไม่บล็อกเฟรมแรกอีกต่อไป", + "vi": "Khởi tạo push không còn chặn khung hình đầu", + "id": "Inisialisasi push tak lagi memblokir frame pertama", + "fil": "Hindi na hinaharangan ng push init ang unang frame" + } + ] + }, + { + "id": "map", + "icon": "map", + "title": { + "zh_Hant": "地圖順滑拖曳 + 更小的安裝檔", + "zh_Hans": "地图顺滑拖曳 + 更小的安装档", + "en": "Silky map scrubbing, smaller install", + "ja": "なめらかな地図操作とインストール軽量化", + "ko": "부드러운 지도 조작 + 가벼운 설치", + "th": "ลากแผนที่ลื่นไหล + แอปเล็กลง", + "vi": "Lướt bản đồ mượt + cài đặt nhẹ hơn", + "id": "Geser peta mulus + ukuran instal lebih kecil", + "fil": "Makinis na pag-scroll ng mapa + mas maliit na app" + }, + "headline": { + "zh_Hant": "拖曳時間軸看雷達,不再卡頓", + "zh_Hans": "拖曳时间轴看雷达,不再卡顿", + "en": "Scrubbing the radar timeline no longer stutters", + "ja": "レーダーのタイムラインをドラッグしてもカクつかない", + "ko": "레이더 타임라인을 드래그해도 버벅이지 않음", + "th": "ลากไทม์ไลน์เรดาร์แล้วไม่กระตุก", + "vi": "Kéo trục thời gian radar không còn giật", + "id": "Menggerus garis waktu radar tak lagi patah-patah", + "fil": "Hindi na nag-hu-hitch ang pag-scroll ng radar timeline" + }, + "body": { + "zh_Hant": "地圖引擎全面換新。切換雷達幀變成兩次透明的屬性切換——零重新下載、零延遲。所有圖層共用同一個三層快取,暖機過的幀直接顯示。安裝檔也因為鄉鎮邊界重新編碼而縮小近 1 MB。", + "zh_Hans": "地图引擎全面换新。切换雷达帧变成两次透明的属性切换——零重新下载、零延迟。所有图层共用同一个三层快取,暖机过的帧直接显示。安装档也因为乡镇边界重新编码而缩小近 1 MB。", + "en": "The map engine was fully replaced. Switching radar frames is now two invisible property flips — zero re-downloads, zero latency. All layers share one three-tier cache, and warmed frames show instantly. The install also shrank by nearly 1 MB after town boundaries were re-encoded.", + "ja": "地図エンジンを全面的に刷新。レーダーのフレーム切り替えは透過的な 2 回のプロパティ切替になり、再ダウンロードゼロ・遅延ゼロ。全レイヤーが同じ 3 層キャッシュを共有し、ウォーム済みフレームは即表示。町境界の再エンコードでインストールも約 1 MB 縮小しました。", + "ko": "지도 엔진이 전면 교체됐습니다. 레이더 프레임 전환은 이제 투명한 속성 전환 두 번 — 재다운로드 0건, 지연 0. 모든 레이어가 같은 3단계 캐시를 공유하고, 예열된 프레임은 즉시 표시됩니다. 읍면 경계를 다시 인코딩해 설치 용량도 약 1MB 줄었습니다.", + "th": "เปลี่ยนเอนจินแผนที่ใหม่ทั้งระบบ การสลับเฟรมเรดาร์เหลือเพียงการเปลี่ยนคุณสมบัติแบบโปร่งใส 2 ครั้ง — ไม่ดาวน์โหลดซ้ำ ไม่ดีเลย์ ทุกเลเยอร์ใช้แคช 3 ชั้นร่วมกัน เฟรมที่วอร์มแล้วเปิดได้ทันที ไซส์แอปเล็กลงเกือบ 1 MB หลังเข้ารหัสขอบเขตเมืองใหม่", + "vi": "Engine bản đồ đã được thay mới hoàn toàn. Chuyển khung radar giờ chỉ là hai lần đổi thuộc tính trong suốt — không tải lại, không trễ. Mọi lớp dùng chung một cache ba tầng, khung đã làm nóng mở tức thì. Bản cài cũng nhẹ đi gần 1 MB sau khi tái mã hoá ranh giới thị trấn.", + "id": "Engine peta diganti total. Ganti frame radar kini hanya dua kali flip properti transparan — nol unduhan ulang, nol latensi. Semua layer berbagi satu cache tiga tingkat, frame yang sudah hangat langsung tampil. Ukuran instal juga menyusut hampir 1 MB setelah batas kota di-encode ulang.", + "fil": "Pinalitan nang buo ang map engine. Ang paglipat ng radar frame ay dalawang invisible na property flip na lang — walang re-download, walang delay. Lahat ng layer ay may isang three-tier cache, at agad lumalabas ang mga warmed frame. Lumiit din ng halos 1 MB ang app pagkatapos i-re-encode ang town boundaries." + }, + "stat": { + "zh_Hant": "−1 MB", + "zh_Hans": "−1 MB", + "en": "−1 MB", + "ja": "−1 MB", + "ko": "−1MB", + "th": "−1 MB", + "vi": "−1 MB", + "id": "−1 MB", + "fil": "−1 MB" + }, + "statLabel": { + "zh_Hant": "安裝檔因鄉鎮邊界重新編碼縮小", + "zh_Hans": "安装档因乡镇边界重新编码缩小", + "en": "install shrank after town boundaries were re-encoded", + "ja": "町境界の再エンコードでインストール縮小", + "ko": "읍면 경계 재인코딩으로 설치 축소", + "th": "แอปเล็กลงหลังเข้ารหัสขอบเขตเมืองใหม่", + "vi": "bản cài nhẹ hơn sau khi tái mã hoá ranh giới", + "id": "ukuran instal menyusut setelah batas kota di-encode ulang", + "fil": "lumiit ang app matapos i-re-encode ang boundary" + }, + "highlights": [ + { + "zh_Hant": "零成本切幀,跟得上手指", + "zh_Hans": "零成本切帧,跟得上手指", + "en": "Zero-cost frame switching that follows your finger", + "ja": "ゼロコストでフレーム切替、指に追従", + "ko": "제로 코스트 프레임 전환, 손가락을 따라감", + "th": "สลับเฟรมแบบไร้ต้นทุน ตามนิ้วได้ทัน", + "vi": "Chuyển khung không tốn chi phí, theo kịp ngón tay", + "id": "Beralih frame tanpa biaya, mengikuti jari", + "fil": "Walang-bisang paglipat ng frame, sumasabay sa daliri" + }, + { + "zh_Hant": "三層快取,暖機過的幀直接顯示", + "zh_Hans": "三层快取,暖机过的帧直接显示", + "en": "Three-tier cache shows warmed frames instantly", + "ja": "3 層キャッシュでウォーム済みフレームは即表示", + "ko": "3단계 캐시로 예열된 프레임 즉시 표시", + "th": "แคช 3 ชั้น แสดงเฟรมที่วอร์มแล้วทันที", + "vi": "Cache ba tầng hiển thị khung đã nóng tức thì", + "id": "Cache tiga tingkat langsung tampilkan frame hangat", + "fil": "Three-tier cache — agad lalabas ang warmed frame" + } + ] + }, + { + "id": "accuracy", + "icon": "my_location", + "title": { + "zh_Hant": "位置判斷更準", + "zh_Hans": "位置判断更准", + "en": "More accurate location", + "ja": "位置判定がより正確に", + "ko": "더 정확한 위치 판단", + "th": "ระบุตำแหน่งแม่นยำขึ้น", + "vi": "Xác định vị trí chính xác hơn", + "id": "Lokasi lebih akurat", + "fil": "Mas tumpak na lokasyon" + }, + "headline": { + "zh_Hant": "鄉鎮判定從中心點猜測,變成真正的幾何測試", + "zh_Hans": "乡镇判定从中心点猜测,变成真正的几何测试", + "en": "Township detection went from centroid guessing to real geometry", + "ja": "町判定が中心点の推測から、本当の幾何テストに", + "ko": "읍면 판정이 중심점 추측에서 진짜 기하 테스트로", + "th": "การระบุตำบลเปลี่ยนจากการเดาจากศูนย์กลาง เป็นการทดสอบเรขาคณิตจริง", + "vi": "Xác định thị trấn chuyển từ đoán tâm điểm sang phép thử hình học thật", + "id": "Penentuan kecamatan berubah dari tebakan titik pusat menjadi uji geometri sejati", + "fil": "Ang pagtukoy ng bayan ay hindi na hula sa gitna, kundi tunay na geometry" + }, + "body": { + "zh_Hant": "在鄉鎮交界或邊緣時會判錯位置的舊演算法,已被精確的「點在多邊形內」幾何演算法取代。位置測量涵蓋正確的鄉鎮——在地警報更準確。", + "zh_Hans": "在乡镇交界或边缘时会判错位置的旧演算法,已被精确的「点在多边形内」几何演算法取代。位置测量涵盖正确的乡镇——在地警报更准确。", + "en": "The old algorithm that misjudged you at town borders has been replaced with an exact point-in-polygon geometry test. Your measured position now resolves to the correct township — so location-based alerts are more accurate.", + "ja": "町の境界や端で位置を誤判定した旧アルゴリズムは、正確な「ポリゴン内点判定」に置き換わりました。測位が正しい町に結びつき、ローカル警報がより正確になります。", + "ko": "읍면 경계에서 위치를 잘못 판정하던 기존 알고리즘은 정확한 \"다각형 내부 점 판정\"으로 바뀌었습니다. 측정 위치가 올바른 읍면에 연결되어, 지역 알림이 더 정확해졌습니다.", + "th": "อัลกอริทึมเดิมที่ระบุตำแหน่งผิดบริเวณชายแดนของตำบล ถูกแทนที่ด้วยการทดสอบจุดในรูปหลายเหลี่ยมที่แม่นยำ ตำแหน่งที่วัดได้ครอบคลุมตำบลที่ถูกต้อง — การแจ้งเตือนตามพื้นที่แม่นยำขึ้น", + "vi": "Thuật toán cũ hay xác định sai vị trí ở ranh giới thị trấn đã được thay thế bằng phép thử điểm-trong-đa-giác chính xác. Vị trí đo được gắn đúng thị trấn — cảnh báo theo khu vực chính xác hơn.", + "id": "Algoritma lama yang salah menilai posisi di perbatasan kota telah digantikan uji titik-di-dalam-poligon yang presisi. Posisi terukur kini masuk kecamatan yang tepat — peringatan berbasis lokasi lebih akurat.", + "fil": "Ang lumang algorithm na nagkakamali sa mga hangganan ng bayan ay pinalitan na ng eksaktong point-in-polygon geometry. Ang sukat na posisyon ay nasa tamang bayan — mas tumpak ang lokasyong alerto." + }, + "stat": { + "zh_Hant": "1–3 個", + "zh_Hans": "1–3 个", + "en": "1–3", + "ja": "1〜3 個", + "ko": "1~3개", + "th": "1–3 จุด", + "vi": "1–3", + "id": "1–3", + "fil": "1–3" + }, + "statLabel": { + "zh_Hant": "一次查詢只需測試的鄉鎮數(舊演算法要測 367 個)", + "zh_Hans": "一次查询只需测试的乡镇数(旧演算法要测 367 个)", + "en": "townships tested per query (the old algorithm checked all 367)", + "ja": "1 回の照会でテストする町の数(旧式は 367 個をチェック)", + "ko": "조회마다 테스트하는 읍면 수 (이전엔 367개 모두)", + "th": "จำนวนตำบลที่ทดสอบต่อการค้นหา (เดิมต้องทดสอบ 367 ตำบล)", + "vi": "số thị trấn cần kiểm tra mỗi truy vấn (cũ phải kiểm tra cả 367)", + "id": "jumlah kecamatan yang diuji per pencarian (lama harus uji 367)", + "fil": "bilang ng bayang tinitest kada query (dati 367 lahat)" + }, + "highlights": [ + { + "zh_Hant": "邊界不再誤判,在地警報更準", + "zh_Hans": "边界不再误判,在地警报更准", + "en": "No more border misjudgment — alerts hit the right town", + "ja": "境界の誤判定がなくなり、ローカル警報が正確に", + "ko": "경계 오판이 사라져 지역 알림이 정확해짐", + "th": "ไม่ผิดที่ชายแดนอีกต่อไป การแจ้งเตือนตามพื้นที่แม่นยำขึ้น", + "vi": "Hết sai ranh giới, cảnh báo theo vùng chính xác hơn", + "id": "Tidak ada lagi salah batas, peringatan lokal lebih akurat", + "fil": "Wala nang maling hangganan, mas tumpak ang lokal na alerto" + } + ] + }, + { + "id": "time", + "icon": "access_time", + "title": { + "zh_Hant": "時間不再被手機時鐘騙", + "zh_Hans": "时间不再被手机时钟骗", + "en": "Time is no longer fooled by your phone clock", + "ja": "スマホ時計に騙されない時間", + "ko": "휴대폰 시계에 속지 않는 시간", + "th": "เวลาไม่ถูกหลอกด้วยนาฬิกามือถืออีกต่อไป", + "vi": "Thời gian không còn bị lừa bởi đồng hồ điện thoại", + "id": "Waktu tak lagi tertipu jam ponsel", + "fil": "Hindi na nadadaya ng orasan ng phone ang oras" + }, + "headline": { + "zh_Hant": "真正的網路校時 + 硬體鐘錨定", + "zh_Hans": "真正的网络校时 + 硬件钟锚定", + "en": "True network time sync, anchored to the hardware clock", + "ja": "本物のネットワーク時刻同期とハードウェア時計への固定", + "ko": "진짜 네트워크 시간 동기화 + 하드웨어 시계 앵커링", + "th": "ซิงค์เวลาจากเครือข่ายจริง + ยึดกับนาฬิกาฮาร์ดแวร์", + "vi": "Đồng bộ thời gian mạng thực sự + neo vào đồng hồ phần cứng", + "id": "Sinkronisasi waktu jaringan sejati + jangkar jam perangkat keras", + "fil": "Tunay na network time sync + naka-angkla sa hardware clock" + }, + "body": { + "zh_Hant": "改用標準 SNTP 協定校時,並把時間錨定在硬體時鐘上。手動改時間、跨時區飛行都不會讓「距離搖晃還有幾秒」的倒數算錯。", + "zh_Hans": "改用标准 SNTP 协议校时,并把时间锚定在硬件时钟上。手动改时间、跨时区飞行都不会让「距离摇晃还有几秒」的倒数算错。", + "en": "Uses the standard SNTP protocol for time sync, anchored to the hardware clock. Manually changing the time or flying across time zones no longer breaks the countdown to shaking.", + "ja": "標準 SNTP プロトコルで時刻同期し、ハードウェア時計に固定。手動で時刻を変えたり、タイムゾーンをまたいだりしても、「揺れまであと何秒」のカウントダウンが狂いません。", + "ko": "표준 SNTP 프로토콜로 시간을 동기화하고 하드웨어 시계에 고정합니다. 수동으로 시간을 바꾸거나 다른 시간대로 비행해도 \"흔들림까지 몇 초\" 카운트다운이 틀리지 않습니다.", + "th": "ใช้โปรโตคอล SNTP มาตรฐานซิงค์เวลา และยึดกับนาฬิกาฮาร์ดแวร์ การเปลี่ยนเวลามือถือหรือข้ามโซนเวลา จะไม่ทำให้การนับถอยหลัง \"อีกกี่วินาทีจะสั่น\" คลาดเคลื่อน", + "vi": "Dùng giao thức SNTP chuẩn để đồng bộ giờ, neo vào đồng hồ phần cứng. Chỉnh giờ tay hay bay qua múi giờ không còn làm sai đếm ngược \"còn mấy giây nữa rung lắc\".", + "id": "Memakai protokol SNTP standar untuk sinkronisasi, berjangkar pada jam perangkat keras. Mengubah jam manual atau terbang lintas zona waktu tidak lagi membuat hitung mundur \"beberapa detik lagi bergetar\" salah.", + "fil": "Gumagamit ng standard na SNTP para sa time sync, naka-angkla sa hardware clock. Ang manu-manong pagbabago ng oras o paglipad sa ibang time zone ay hindi na nakasisira ng countdown sa pagyanig." + }, + "stat": { + "zh_Hant": "SNTP", + "zh_Hans": "SNTP", + "en": "SNTP", + "ja": "SNTP", + "ko": "SNTP", + "th": "SNTP", + "vi": "SNTP", + "id": "SNTP", + "fil": "SNTP" + }, + "statLabel": { + "zh_Hant": "標準網路校時協定(UDP/123)", + "zh_Hans": "标准网络校时协议(UDP/123)", + "en": "standard network time protocol (UDP/123)", + "ja": "標準ネットワーク時刻同期プロトコル (UDP/123)", + "ko": "표준 네트워크 시간 프로토콜 (UDP/123)", + "th": "โปรโตคอลเวลามาตรฐาน (UDP/123)", + "vi": "giao thức thời gian mạng chuẩn (UDP/123)", + "id": "protokol waktu jaringan standar (UDP/123)", + "fil": "standard network time protocol (UDP/123)" + }, + "highlights": [ + { + "zh_Hant": "手動改鐘、跨時區都不影響倒數", + "zh_Hans": "手动改钟、跨时区都不影响倒数", + "en": "Manual clock changes or time-zone travel won't skew the countdown", + "ja": "手動の時計変更や時差移動でもカウントダウンは狂わない", + "ko": "수동 시계 변경, 시간대 이동에도 카운트다운 정상", + "th": "เปลี่ยนเวลามือถือหรือข้ามเขตเวลาก็ไม่กระทบการนับถอยหลัง", + "vi": "Đổi giờ tay hay đi qua múi giờ không làm lệch đếm ngược", + "id": "Ubah jam manual atau lintas zona waktu tidak memengaruhi hitung mundur", + "fil": "Hindi nagbabago ang countdown kahit palitan ang oras o mag-iba ng time zone" + } + ] + }, + { + "id": "privacy", + "icon": "shield", + "title": { + "zh_Hant": "隱私與資料安全", + "zh_Hans": "隐私与资料安全", + "en": "Privacy and data safety", + "ja": "プライバシーとデータの安全性", + "ko": "개인정보와 데이터 안전", + "th": "ความเป็นส่วนตัวและความปลอดภัยของข้อมูล", + "vi": "Quyền riêng tư và an toàn dữ liệu", + "id": "Privasi dan keamanan data", + "fil": "Privacy at kaligtasan ng data" + }, + "headline": { + "zh_Hant": "「清快取」真的只清快取", + "zh_Hans": "「清快取」真的只清快取", + "en": "'Clear cache' actually clears only the cache", + "ja": "「キャッシュを消す」は本当にキャッシュだけ消す", + "ko": "'캐시 지우기'는 정말 캐시만 지웁니다", + "th": "'ล้างแคช' ล้างเฉพาะแคชจริง ๆ", + "vi": "'Xoá bộ nhớ đệm' thực sự chỉ xoá bộ nhớ đệm", + "id": "'Bersihkan cache' benar-benar hanya membersihkan cache", + "fil": "'Clear cache' ay talagang cache lang ang nililinis" + }, + "body": { + "zh_Hant": "你的設定、記錄與離線訊息存放在另一個「耐久」資料庫,清快取時物理上碰不到它。多餘資料同步壓縮,ETag token 永不落磁碟。", + "zh_Hans": "你的设定、记录与离线讯息存放在另一个「耐久」资料库,清快取时物理上碰不到它。多余资料同步压缩,ETag token 永不落磁盘。", + "en": "Your settings, history and offline messages live in a separate durable database that the cache cleaner cannot physically touch. Redundant data is compressed on write, and auth tokens never hit the disk cache.", + "ja": "設定・履歴・オフラインメッセージは別の「耐久」データベースに入っており、キャッシュ削除は物理的に触れません。冗長データは書き込み時に圧縮され、トークンがキャッシュディスクに保存されることはありません。", + "ko": "설정·기록·오프라인 메시지는 별도의 \"영구\" 데이터베이스에 있어, 캐시 정리가 물리적으로 건드릴 수 없습니다. 중복 데이터는 압축 저장되고, 토큰은 디스크 캐시에 닿지 않습니다.", + "th": "การตั้งค่า ประวัติ และข้อความออฟไลน์อยู่ในฐานข้อมูล \"ถาวร\" แยกต่างหาก ที่การล้างแคชไม่สามารถแตะถึงได้ ข้อมูลซ้ำซ้อนถูกบีบอัดตอนจัดเก็บ และ token ไม่ถูกเก็บลงดิสก์แคช", + "vi": "Cài đặt, lịch sử và tin nhắn ngoại tuyến nằm trong cơ sở dữ liệu \"bền vững\" riêng mà việc xoá cache không thể đụng tới. Dữ liệu dư thừa được nén khi ghi, token không bao giờ chạm vào ổ đĩa cache.", + "id": "Pengaturan, riwayat, dan pesan offline ada di database 'tahan lama' terpisah yang tak dapat disentuh pembersih cache. Data redundan dikompres saat ditulis, token tidak pernah menyentuh disk cache.", + "fil": "Ang settings, history, at offline messages ay nasa hiwalay na 'durable' database na hindi maaaring maapektuhan ng paglilinis ng cache. Ni-compress ang redundan na data, at hindi kailanman nahahawakan ng disk cache ang token." + }, + "stat": { + "zh_Hant": "2 個", + "zh_Hans": "2 个", + "en": "2", + "ja": "2 つ", + "ko": "2개", + "th": "2 ฐาน", + "vi": "2", + "id": "2", + "fil": "2" + }, + "statLabel": { + "zh_Hant": "資料庫:耐久(設定/記錄)與快取(可重抓)分開存放", + "zh_Hans": "数据库:耐久(设定/记录)与快取(可重抓)分开存放", + "en": "databases — durable (settings/logs) kept apart from cache (re-fetchable)", + "ja": "データベース:耐久(設定/ログ)とキャッシュ(再取得可)を分離", + "ko": "개 데이터베이스 — 영구(설정/기록)와 캐시(재다운로드 가능) 분리", + "th": "ฐานข้อมูล — ข้อมูลถาวร (ตั้งค่า/บันทึก) แยกจากแคช (โหลดใหม่ได้)", + "vi": "cơ sở dữ liệu — bền vững (cài đặt/nhật ký) tách khỏi cache (tải lại được)", + "id": "database — tangguh (pengaturan/log) dipisah dari cache (bisa diunduh ulang)", + "fil": "database — durable (settings/logs) hiwalay sa cache (pwedeng kunin ulit)" + }, + "highlights": [ + { + "zh_Hant": "清快取是結構保證,不是程式紀律", + "zh_Hans": "清快取是结构保证,不是程式纪律", + "en": "Cache-clearing safety is a structural guarantee, not code discipline", + "ja": "キャッシュ削除の安全性は構造的な保証、規律ではありません", + "ko": "캐시 정리 안전성은 구조적 보장, 습관이 아님", + "th": "ความปลอดภัยในการล้างแคชเป็นการรับประกันเชิงโครงสร้าง ไม่ใช่แค่ระเบียบโค้ด", + "vi": "An toàn khi xoá cache là cam kết cấu trúc, không phải kỷ luật code", + "id": "Keamanan bersihkan cache adalah jaminan struktural, bukan disiplin kode", + "fil": "Ang kaligtasan sa pag-clear ng cache ay structural guarantee, hindi disiplina sa code" + }, + { + "zh_Hant": "token 永不落磁碟快取", + "zh_Hans": "token 永不落磁盘快取", + "en": "Auth tokens never touch the disk cache", + "ja": "トークンがキャッシュディスクに落ちない", + "ko": "토큰이 디스크 캐시에 저장되지 않음", + "th": "token ไม่ถูกเขียนลงแคชดิสก์", + "vi": "token không bao giờ chạm vào ổ đĩa cache", + "id": "token tidak pernah menyentuh disk cache", + "fil": "Hindi nahahawakan ng disk cache ang token" + } + ] + } + ] +} \ No newline at end of file diff --git a/release_highlights/lib/26.1/advanced.dart b/release_highlights/lib/26.1/advanced.dart new file mode 100644 index 000000000..84a4c92a3 --- /dev/null +++ b/release_highlights/lib/26.1/advanced.dart @@ -0,0 +1,1143 @@ +// Version-highlight card content for DPIP 26.1 (advanced). +// +// GENERATED from `release_highlights/assets/26.1/advanced/cards.json` by `tool/json_to_dart_highlights.py` — edit the +// JSON, not this file. Rendering lives in `lib/features/release_highlights`; +// this package carries only data. +library; + +const title = { + "zh_Hant": "深入瞭解更多 — 底層真的怎麼運作", + "zh_Hans": "深入了解详情 — 底层真的怎么运作", + "en": "Go deeper — how the internals actually work", + "ja": "さらに詳しく — 内部は実際どう動いているか", + "ko": "더 자세히 — 내부는 실제로 어떻게 동작하나", + "th": "เจาะลึก — ระบบภายในทำงานอย่างไรจริง ๆ", + "vi": "Đi sâu hơn — các phần bên trong thực sự hoạt động ra sao", + "id": "Lebih dalam — bagaimana sistem internal benar-benar bekerja", + "fil": "Mas malalim — paano talaga gumagana ang mga internal", +}; +const subtitle = { + "zh_Hant": "給進階使用者與開發者的技術筆記。每一項都來自真實程式碼,附檔案與行號。", + "zh_Hans": "给进阶使用者与开发者的技术笔记。每一项都来自真实程式码,附档案与行号。", + "en": "Technical notes for advanced users and developers. Every item traces to real code, with file and line references.", + "ja": "上級ユーザーと開発者のための技術ノート。すべて実コードに由来し、ファイルと行番号を添えています。", + "ko": "고급 사용자와 개발자를 위한 기술 노트. 각 항목은 실제 코드에서 나온 것이며, 파일과 줄번호를 포함합니다.", + "th": "บันทึกเทคนิคสำหรับผู้ใช้ขั้นสูงและนักพัฒนา ทุกรายการมาจากโค้ดจริง พร้อมไฟล์และบรรทัดอ้างอิง", + "vi": "Ghi chú kỹ thuật cho người dùng nâng cao và lập trình viên. Mỗi mục đều bắt nguồn từ code thật, kèm file và dòng tham chiếu.", + "id": "Catatan teknis untuk pengguna mahir dan developer. Setiap item berasal dari kode nyata, dengan referensi file dan baris.", + "fil": "Teknikal na tala para sa mga advanced user at developer. Bawat item ay mula sa tunay na code, may file at line reference.", +}; +const cards = >[ + { + 'id': "etag_core", + 'icon': "verified", + 'title': { + "zh_Hant": "ETag SQLite 快取 — 本版最重要的單一改進", + "zh_Hans": "ETag SQLite 快取 — 本版最重要的单一改进", + "en": "ETag SQLite cache — the single most important change", + "ja": "ETag SQLite キャッシュ — 本バージョン最重要の変更", + "ko": "ETag SQLite 캐시 — 이번 버전의 가장 중요한 변화", + "th": "แคช ETag SQLite — การเปลี่ยนแปลงที่สำคัญที่สุดในเวอร์ชันนี้", + "vi": "Cache ETag SQLite — thay đổi quan trọng nhất của bản này", + "id": "Cache ETag SQLite — perubahan terpenting versi ini", + "fil": "ETag SQLite cache — ang pinakamahalagang pagbabago", + }, + 'headline': null, + 'body': { + "zh_Hant": "全新網址為鍵、ETag 驗證、LRU 位元組預算的 SQLite HTTP 快取。檔案:`core/network/etag_interceptor.dart` + `etag_cache_store.dart`。", + "zh_Hans": "全新网址为键、ETag 验证、LRU 位元组预算的 SQLite HTTP 快取。档案:core/network/etag_interceptor.dart + etag_cache_store.dart。", + "en": "A URL-keyed, ETag-validated, LRU byte-budgeted SQLite HTTP cache. Files: core/network/etag_interceptor.dart + etag_cache_store.dart.", + "ja": "URL キー、ETag 検証、LRU バイト予算の SQLite キャッシュ。ファイル: core/network/etag_interceptor.dart + etag_cache_store.dart。", + "ko": "URL 키, ETag 검증, LRU 바이트 예산 SQLite HTTP 캐시. 파일: core/network/etag_interceptor.dart + etag_cache_store.dart.", + "th": "แคช SQLite แบบใช้ URL เป็นคีย์ ตรวจสอบ ETag และจัดงบประมาณแบบ LRU ไฟล์: core/network/etag_interceptor.dart + etag_cache_store.dart", + "vi": "Cache HTTP SQLite theo khóa URL, xác thực ETag, ngân sách byte LRU. File: core/network/etag_interceptor.dart + etag_cache_store.dart.", + "id": "Cache HTTP SQLite berbasis kunci URL, validasi ETag, anggaran byte LRU. File: core/network/etag_interceptor.dart + etag_cache_store.dart.", + "fil": "URL-keyed, ETag-validated, LRU byte-budget SQLite HTTP cache. Files: core/network/etag_interceptor.dart + etag_cache_store.dart.", + }, + 'stat': null, + 'statLabel': null, + 'highlights': >[], + 'details': >[ + { + 'key': { + "zh_Hant": "驗證方式", + "zh_Hans": "验证方式", + "en": "Validation", + "ja": "検証方式", + "ko": "검증 방식", + "th": "การตรวจสอบ", + "vi": "Xác thực", + "id": "Validasi", + "fil": "Validation", + }, + 'value': { + "zh_Hant": + "ETag 是唯一 validator;Cache-Control / no-store 被忽略;200 無 ETag 不寫入", + "zh_Hans": + "ETag 是唯一 validator;Cache-Control / no-store 被忽略;200 无 ETag 不写入", + "en": "ETag is the sole validator; Cache-Control/no-store ignored; 200s without an ETag are not cached", + "ja": "ETag が唯一のバリデータ。Cache-Control/no-store は無視。ETag なしの 200 はキャッシュしない", + "ko": "ETag이 유일한 검증자. Cache-Control/no-store 무시. ETag 없는 200은 캐시 안 함", + "th": "ETag เป็นตัวตรวจสอบเดียว; ไม่สนใจ Cache-Control/no-store; 200 ที่ไม่มี ETag จะไม่ถูกแคช", + "vi": "ETag là validator duy nhất; Cache-Control/no-store bị bỏ qua; 200 không có ETag không được lưu", + "id": "ETag satu-satunya validator; Cache-Control/no-store diabaikan; 200 tanpa ETag tidak dicache", + "fil": "ETag lang ang validator; hindi pinapansin ang Cache-Control/no-store; hindi naka-cache ang 200 na walang ETag", + }, + }, + { + 'key': { + "zh_Hant": "過期模型", + "zh_Hans": "过期模型", + "en": "Expiry model", + "ja": "有効期限モデル", + "ko": "만료 모델", + "th": "โมเดลการหมดอายุ", + "vi": "Mô hình hết hạn", + "id": "Model kedaluwarsa", + "fil": "Expiry model", + }, + 'value': { + "zh_Hant": "不按時間;只有位元組預算(350 MiB)。30 天前仍被 hit 的 tile 保留位置", + "zh_Hans": "不按时间;只有位元组预算(350 MiB)。30 天前仍被 hit 的 tile 保留位置", + "en": "Not time-based; only a byte budget (350 MiB). A tile still being hit after 30 days keeps its place", + "ja": "時間ベースではない。バイト予算のみ (350 MiB)。30 日後もヒット中のタイルは場所を維持", + "ko": "시간 기반이 아님. 오직 바이트 예산(350MiB). 30일 지나도 계속 hit되는 타일은 유지", + "th": "ไม่อิงเวลา มีแค่งบประมาณไบต์ (350 MiB) ไทล์ที่ยังถูกเรียกใช้หลัง 30 วันยังคงอยู่", + "vi": "Không theo thời gian; chỉ có ngân sách byte (350 MiB). Tile vẫn được gọi sau 30 ngày giữ nguyên vị trí", + "id": "Tidak berbasis waktu; hanya anggaran byte (350 MiB). Tile yang masih diakses setelah 30 hari tetap bertahan", + "fil": "Hindi time-based; byte budget lang (350 MiB). Nananatili ang tile na ginagamit pa rin kahit 30 araw", + }, + }, + { + 'key': { + "zh_Hant": "高變動資料", + "zh_Hans": "高变动资料", + "en": "Volatile data", + "ja": "高変動データ", + "ko": "고변동 데이터", + "th": "ข้อมูลที่เปลี่ยนแปลงบ่อย", + "vi": "Dữ liệu biến động cao", + "id": "Data berubah cepat", + "fil": "Madaling magbago na data", + }, + 'value': { + "zh_Hant": "EEW、RTS、location、notify 永不快取——token 不落磁碟", + "zh_Hans": "EEW、RTS、location、notify 永不快取——token 不落磁盘", + "en": "EEW, RTS, location and notify are never cached — tokens never touch disk", + "ja": "EEW・RTS・location・notify は絶対にキャッシュしない。トークンはディスクに落ちない", + "ko": "EEW, RTS, location, notify는 절대 캐시 안 함 — 토큰은 디스크에 안 닿음", + "th": "EEW, RTS, location, notify ไม่ถูกแคชเด็ดขาด — token ไม่ถูกเขียนลงดิสก์", + "vi": "EEW, RTS, location, notify không bao giờ bị cache — token không chạm đĩa", + "id": "EEW, RTS, location, notify tidak pernah dicache — token tidak menyentuh disk", + "fil": "EEW, RTS, location, notify — hindi kailanman naka-cache; hindi nahahawakan ng disk ang token", + }, + }, + { + 'key': { + "zh_Hant": "不可變資產", + "zh_Hans": "不可变资产", + "en": "Immutable assets", + "ja": "不変アセット", + "ko": "불변 에셋", + "th": "ทรัพยากรที่ไม่เปลี่ยนแปลง", + "vi": "Tài sản bất biến", + "id": "Aset tidak berubah", + "fil": "Immutable assets", + }, + 'value': { + "zh_Hant": "URL 就 pin 住內容:本地 hit 直接 serve,永不發 If-None-Match", + "zh_Hans": "URL 就 pin 住内容:本地 hit 直接 serve,永不发 If-None-Match", + "en": "The URL pins the content: local hits serve directly, If-None-Match is never sent", + "ja": "URL が内容を固定する。ローカルヒットは直接サーブし、If-None-Match は送らない", + "ko": "URL이 내용을 고정. 로컬 히트는 바로 서브, If-None-Match는 절대 안 보냄", + "th": "URL ยึดเนื้อหาไว้: ฮิตในเครื่องเซิร์ฟโดยตรง ไม่ส่ง If-None-Match เลย", + "vi": "URL cố định nội dung: hit cục bộ phục vụ trực tiếp, không bao giờ gửi If-None-Match", + "id": "URL mengunci konten: hit lokal langsung disajikan, If-None-Match tidak pernah dikirim", + "fil": "Ang URL ang nagla-lock ng content: direktang sineserve ang local hit, hindi na ipinapadala ang If-None-Match", + }, + }, + { + 'key': { + "zh_Hant": "304 處理", + "zh_Hans": "304 处理", + "en": "304 handling", + "ja": "304 の扱い", + "ko": "304 처리", + "th": "การจัดการ 304", + "vi": "Xử lý 304", + "id": "Penanganan 304", + "fil": "Paghawak ng 304", + }, + 'value': { + "zh_Hant": "空 304 被改寫回帶 cached body 的 200,呼叫者看不到 304", + "zh_Hans": "空 304 被改写回带 cached body 的 200,呼叫者看不到 304", + "en": "An empty 304 is rewritten as a 200 with the cached body — callers never see 304", + "ja": "空の 304 はキャッシュボディ付き 200 に書き換えられ、呼び出し元は 304 を見ない", + "ko": "빈 304는 캐시된 body가 있는 200으로 다시 쓰여져, 호출자는 304를 못 봄", + "th": "304 เปล่าแปลงกลับเป็น 200 พร้อม body แคช ผู้เรียกไม่เห็น 304", + "vi": "304 rỗng được viết lại thành 200 kèm body đã cache — caller không bao giờ thấy 304", + "id": "304 kosong ditulis ulang sebagai 200 dengan body cache — pemanggil tidak pernah melihat 304", + "fil": "Ang walang laman na 304 ay ginagawang 200 na may cached body — hindi nakikita ng caller ang 304", + }, + }, + { + 'key': { + "zh_Hant": "儲存工程", + "zh_Hans": "储存工程", + "en": "Storage engineering", + "ja": "ストレージ設計", + "ko": "저장 엔지니어링", + "th": "วิศวกรรมพื้นที่จัดเก็บ", + "vi": "Kỹ thuật lưu trữ", + "id": "Teknik penyimpanan", + "fil": "Storage engineering", + }, + 'value': { + "zh_Hant": "WAL + PRAGMA cache_size -25600 (25 MiB page cache) + mmap 64 MiB;批次讀寫;JSON gzip-1(壓不縮就存 raw)", + "zh_Hans": "WAL + PRAGMA cache_size -25600 (25 MiB page cache) + mmap 64 MiB;批次读写;JSON gzip-1(压不缩就存 raw)", + "en": "WAL + PRAGMA cache_size -25600 (25 MiB page cache) + mmap 64 MiB; batched reads/writes; JSON gzip-1 (stored raw when it does not compress)", + "ja": "WAL + PRAGMA cache_size -25600 (25 MiB ページキャッシュ) + mmap 64 MiB。バッチ読み書き。JSON は gzip-1(圧縮効果がなければ raw で保存)", + "ko": "WAL + PRAGMA cache_size -25600 (25MiB 페이지 캐시) + mmap 64MiB. 배치 읽기/쓰기. JSON gzip-1 (압축 안 되면 raw로 저장)", + "th": "WAL + PRAGMA cache_size -25600 (page cache 25 MiB) + mmap 64 MiB; อ่านเขียนแบบแบตช์; JSON gzip-1 (เก็บแบบ raw ถ้าบีบไม่ลง)", + "vi": "WAL + PRAGMA cache_size -25600 (25 MiB page cache) + mmap 64 MiB; đọc/ghi theo lô; JSON gzip-1 (giữ raw nếu không nén được)", + "id": "WAL + PRAGMA cache_size -25600 (cache halaman 25 MiB) + mmap 64 MiB; baca/tulis batch; JSON gzip-1 (disimpan raw jika tidak terkompresi)", + "fil": "WAL + PRAGMA cache_size -25600 (25 MiB page cache) + mmap 64 MiB; batched read/write; JSON gzip-1 (raw ang itatago kung hindi mag-compress)", + }, + }, + ], + 'stats': >[], + }, + { + 'id': "region_failover", + 'icon': "route", + 'title': { + "zh_Hant": "區域選路與多活自動故障轉移", + "zh_Hans": "区域选路与多活自动故障转移", + "en": "Region routing and multi-active failover", + "ja": "リージョン選定とマルチアクティブ・フェイルオーバー", + "ko": "리전 라우팅과 다중 활성 장애 조치", + "th": "การเลือกภูมิภาคและการสลับเซิร์ฟเวอร์อัตโนมัติ", + "vi": "Định tuyến vùng và chuyển đổi dự phòng đa chủ động", + "id": "Routing wilayah dan failover multi-aktif", + "fil": "Region routing at multi-active failover", + }, + 'headline': null, + 'body': { + "zh_Hant": "決定權從 DNS 平衡移到 app 內(app 自己 pin 住 region host),失敗時依序換下一台。檔案:`core/network/api_region.dart` + `region_selection.dart`。", + "zh_Hans": "决定权从 DNS 平衡移到 app 内(app 自己 pin 住 region host),失败时依序换下一台。档案:core/network/api_region.dart + region_selection.dart。", + "en": "Selection authority moved from DNS load balancing into the app (which pins region hosts itself), failing over to the next host in order. Files: core/network/api_region.dart + region_selection.dart.", + "ja": "選定の主導権が DNS ロードバランサーからアプリ内(自らリージョンホストを固定)へ。失敗時は次のホストへ順に切り替え。ファイル: core/network/api_region.dart + region_selection.dart。", + "ko": "선택 권한이 DNS 로드밸런싱에서 앱 내부(리전 호스트를 직접 고정)로 이동. 실패 시 다음 호스트로 순서대로 전환. 파일: core/network/api_region.dart + region_selection.dart.", + "th": "อำนาจการเลือกย้ายจาก DNS load balancing มาในแอป (ซึ่งปัก host ภูมิภาคเอง) และสลับไปยัง host ถัดไปเมื่อล้มเหลว ไฟล์: core/network/api_region.dart + region_selection.dart", + "vi": "Quyền chọn lựa chuyển từ cân bằng tải DNS vào trong app (tự ghim host vùng), chuyển sang host kế tiếp khi lỗi. File: core/network/api_region.dart + region_selection.dart.", + "id": "Kewenangan pemilihan pindah dari load balancing DNS ke dalam app (yang men-pin host region sendiri), gagal lalu beralih ke host berikutnya. File: core/network/api_region.dart + region_selection.dart.", + "fil": "Ang awtoridad sa pagpili ay lumipat mula sa DNS load balancing papasok sa app (na siya mismo ang nagpi-pin ng region host), nag-failover sa susunod na host. Files: core/network/api_region.dart + region_selection.dart.", + }, + 'stat': null, + 'statLabel': null, + 'highlights': >[], + 'details': >[ + { + 'key': { + "zh_Hant": "7 個 tier", + "zh_Hans": "7 个 tier", + "en": "7 tiers", + "ja": "7 つのティア", + "ko": "7개 티어", + "th": "7 ระดับ", + "vi": "7 tầng", + "id": "7 tier", + "fil": "7 tier", + }, + 'value': { + "zh_Hant": "4 個 region 的 concrete host;從不請求 DNS 平衡的裸 host;選中的 region 被 persisted", + "zh_Hans": "4 个 region 的 concrete host;从不请求 DNS 平衡的裸 host;选中的 region 被 persisted", + "en": "Concrete hosts across 4 regions; never hits a bare DNS-balanced host; the chosen region is persisted", + "ja": "4 リージョンの concrete host。DNS 分散の素のホストには決して接続しない。選択リージョンは永続化", + "ko": "4개 리전의 구체적 호스트. DNS 밸런싱된 호스트는 절대 안 씀. 선택한 리전은 영구 저장", + "th": "โฮสต์จริงใน 4 ภูมิภาค ไม่เคยเรียกใช้ host ที่ใช้ DNS balancing; ภูมิภาคที่เลือกถูกบันทึกถาวร", + "vi": "Host cụ thể trên 4 vùng; không bao giờ gọi host DNS cân bằng; vùng đã chọn được lưu bền vững", + "id": "Host konkret di 4 region; tidak pernah memanggil host balancing DNS; region terpilih dipersist", + "fil": "Mga konkretong host sa 4 na region; hindi kailanman gumagamit ng bare DNS-balance host; naka-persist ang napiling region", + }, + }, + { + 'key': { + "zh_Hant": "Failover 條件", + "zh_Hans": "Failover 条件", + "en": "Failover rules", + "ja": "フェイルオーバー条件", + "ko": "장애 조치 조건", + "th": "เงื่อนไขการสลับ", + "vi": "Điều kiện failover", + "id": "Aturan failover", + "fil": "Failover rules", + }, + 'value': { + "zh_Hant": + "只有 connection / timeout / 5xx 換台;4xx 和 user cancel 直接 throw", + "zh_Hans": + "只有 connection / timeout / 5xx 换台;4xx 和 user cancel 直接 throw", + "en": "Only connection/timeout/5xx trigger a switch; 4xx and user cancellation throw directly", + "ja": "接続・タイムアウト・5xx のみ切り替える。4xx とユーザーキャンセルは直接 throw", + "ko": "연결/타임아웃/5xx만 전환. 4xx와 사용자 취소는 그냥 throw", + "th": "เฉพาะ connection/timeout/5xx ที่สลับ; 4xx และการยกเลิกโดยผู้ใช้ throw โดยตรง", + "vi": "Chỉ connection/timeout/5xx mới chuyển; 4xx và huỷ do người dùng throw thẳng", + "id": "Hanya connection/timeout/5xx yang memicu pindah; 4xx dan cancel user langsung throw", + "fil": "Connection/timeout/5xx lang ang nagpapalit; ang 4xx at cancellation ay nag-throw na lang", + }, + }, + { + 'key': { + "zh_Hant": "Timeout 策略", + "zh_Hans": "Timeout 策略", + "en": "Timeout strategy", + "ja": "タイムアウト戦略", + "ko": "타임아웃 전략", + "th": "กลยุทธ์ timeout", + "vi": "Chiến lược timeout", + "id": "Strategi timeout", + "fil": "Timeout strategy", + }, + 'value': { + "zh_Hant": "connectTimeout 8s / receiveTimeout 10s:快點死、快點 failover", + "zh_Hans": "connectTimeout 8s / receiveTimeout 10s:快点死、快点 failover", + "en": "connectTimeout 8s / receiveTimeout 10s — fail fast, fail over fast", + "ja": "connectTimeout 8s / receiveTimeout 10s — 早く死に、早くフェイルオーバー", + "ko": "connectTimeout 8s / receiveTimeout 10s — 빨리 죽고 빨리 전환", + "th": "connectTimeout 8s / receiveTimeout 10s — ตายเร็ว สลับเร็ว", + "vi": "connectTimeout 8s / receiveTimeout 10s — chết nhanh, failover nhanh", + "id": "connectTimeout 8s / receiveTimeout 10s — cepat gagal, cepat failover", + "fil": "connectTimeout 8s / receiveTimeout 10s — mamatay nang mabilis, lumipat nang mabilis", + }, + }, + { + 'key': { + "zh_Hant": "健康觀測", + "zh_Hans": "健康观测", + "en": "Health monitoring", + "ja": "健全性モニタリング", + "ko": "건강 모니터링", + "th": "การเฝ้าระวังสุขภาพระบบ", + "vi": "Giám sát sức khỏe", + "id": "Pemantauan kesehatan", + "fil": "Health monitoring", + }, + 'value': { + "zh_Hant": + "service × tier × host 分桶;連續失敗 ≥2 次才判 down;4xx/cancel/cert 不計入", + "zh_Hans": + "service × tier × host 分桶;连续失败 ≥2 次才判 down;4xx/cancel/cert 不计入", + "en": "Bucketed by service × tier × host; ≥2 consecutive failures mark down; 4xx/cancel/cert errors don't count", + "ja": "service × tier × host でバケット化。2 回以上連続失敗で down。4xx・キャンセル・証明書エラーは数えない", + "ko": "service × tier × host 버킷. 2회 이상 연속 실패 시 down. 4xx/취소/인증서 오류는 미집계", + "th": "แบ่งกลุ่มตาม service × tier × host; ล้มเหลวติดต่อกัน ≥2 ครั้งถึงจะลงว่า down; 4xx/cancel/cert ไม่นับ", + "vi": "Phân nhóm theo service × tier × host; ≥2 lần lỗi liên tiếp mới là down; 4xx/cancel/cert không tính", + "id": "Dikelompokkan per service × tier × host; gagal berturut-turut ≥2 kali baru down; 4xx/cancel/cert tidak dihitung", + "fil": "Naka-bucket sa service × tier × host; ≥2 sunod-sunod na palya ang sasabihing down; hindi binibilang ang 4xx/cancel/cert", + }, + }, + ], + 'stats': >[], + }, + { + 'id': "sse_stream", + 'icon': "stream", + 'title': { + "zh_Hant": "SSE 串流取代每秒輪詢", + "zh_Hans": "SSE 串流取代每秒轮询", + "en": "SSE streaming replaces second-by-second polling", + "ja": "SSE ストリームが毎秒ポーリングに取って代わる", + "ko": "SSE 스트리밍이 초당 폴링을 대체", + "th": "SSE streaming แทนที่การดึงข้อมูลทุกวินาที", + "vi": "SSE streaming thay thế kéo dữ liệu mỗi giây", + "id": "SSE streaming menggantikan polling per detik", + "fil": "Pinapalitan ng SSE streaming ang polling bawat segundo", + }, + 'headline': null, + 'body': { + "zh_Hant": "核心即時資料傳輸改以 Server-Sent Events 串流。檔案:`core/network/sse_client.dart` + `core/realtime/sse_realtime_source.dart`。", + "zh_Hans": "核心即时资料传输改以 Server-Sent Events 串流。档案:core/network/sse_client.dart + core/realtime/sse_realtime_source.dart。", + "en": "Core real-time data moves over Server-Sent Events streaming. Files: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart.", + "ja": "中核のリアルタイムデータ転送が Server-Sent Events ストリーミングに移行。ファイル: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart。", + "ko": "핵심 실시간 데이터 전송이 SSE 스트리밍으로 전환. 파일: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart.", + "th": "ข้อมูลเรียลไทม์หลักเปลี่ยนเป็นการส่งแบบ Server-Sent Events ไฟล์: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart", + "vi": "Dữ liệu thời gian thực cốt lõi chuyển sang streaming Server-Sent Events. File: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart.", + "id": "Transfer data real-time inti beralih ke streaming Server-Sent Events. File: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart.", + "fil": "Ang core real-time data ay nasa Server-Sent Events streaming na. Files: core/network/sse_client.dart + core/realtime/sse_realtime_source.dart.", + }, + 'stat': null, + 'statLabel': null, + 'highlights': >[], + 'details': >[ + { + 'key': { + "zh_Hant": "壓縮事件", + "zh_Hans": "压缩事件", + "en": "Compressed events", + "ja": "圧縮イベント", + "ko": "압축 이벤트", + "th": "เหตุการณ์บีบอัด", + "vi": "Sự kiện nén", + "id": "Event terkompresi", + "fil": "Compressed events", + }, + 'value': { + "zh_Hant": "event: g 事件,data: 是 base64 gzip;資料格式與純 GET 相同,模型不變", + "zh_Hans": "event: g 事件,data: 是 base64 gzip;资料格式与纯 GET 相同,模型不变", + "en": "event: g events carry base64 gzip data; the payload is identical to the plain GET, so the model never changes", + "ja": "event: g イベントの data は base64 gzip。ペイロードは素の GET と同一で、モデルは変わらない", + "ko": "event: g 이벤트, data는 base64 gzip. 페이로드는 일반 GET과 동일해서 모델은 그대로", + "th": "เหตุการณ์ event: g, data เป็น base64 gzip; รูปแบบข้อมูลเหมือน GET ทั่วไป รุ่นไม่เปลี่ยน", + "vi": "Sự kiện event: g, data là base64 gzip; payload giống hệt GET thuần, model không đổi", + "id": "Event event: g, data berbentuk base64 gzip; payload sama persis dengan GET biasa, model tak berubah", + "fil": "Ang event: g, data ay base64 gzip; kapareho ng plain GET ang payload, hindi nagbabago ang model", + }, + }, + { + 'key': { + "zh_Hant": "Backoff", + "zh_Hans": "Backoff", + "en": "Backoff", + "ja": "バックオフ", + "ko": "백오프", + "th": "การถอยหลัง", + "vi": "Backoff", + "id": "Backoff", + "fil": "Backoff", + }, + 'value': { + "zh_Hant": "1s → 2s → cap 在 server retry hint(預設 3s);事件一到立刻歸零", + "zh_Hans": "1s → 2s → cap 在 server retry hint(预设 3s);事件一到立刻归零", + "en": "1s → 2s, capped at the server retry hint (3s default); zeroed instantly on any event", + "ja": "1s → 2s → server の retry ヒント(デフォルト 3s)で上限。イベント到着で即リセット", + "ko": "1s → 2s → 서버 retry 힌트(기본 3s)에서 제한. 이벤트 오면 즉시 초기화", + "th": "1s → 2s → จำกัดที่ค่า retry จากเซิร์ฟเวอร์ (ค่าเริ่มต้น 3s); รีเซ็ตทันทีเมื่อมีเหตุการณ์", + "vi": "1s → 2s, chặn ở hint retry của server (mặc định 3s); về 0 ngay khi có sự kiện", + "id": "1s → 2s, di-cap pada hint retry server (default 3s); seketika nol saat ada event", + "fil": "1s → 2s, may cap sa server retry hint (default 3s); babalik sa zero pagdating ng event", + }, + }, + { + 'key': { + "zh_Hant": "兩種 liveness", + "zh_Hans": "两种 liveness", + "en": "Two liveness modes", + "ja": "2 種類の liveness", + "ko": "두 가지 라이브니스", + "th": "โหมด liveness สองแบบ", + "vi": "Hai chế độ liveness", + "id": "Dua mode liveness", + "fil": "Dalawang liveness mode", + }, + 'value': { + "zh_Hant": + "EEW 用 connectionOpen(地震之間無聲);RTS 用 eventRecency 3s 窗(連續 feed)", + "zh_Hans": + "EEW 用 connectionOpen(地震之间无声);RTS 用 eventRecency 3s 窗(连续 feed)", + "en": "EEW uses connectionOpen (silent between quakes); RTS uses a 3s event-recency window (continuous feed)", + "ja": + "EEW は connectionOpen(地震間は無音)、RTS は 3 秒の eventRecency 窓(連続フィード)", + "ko": + "EEW는 connectionOpen(지진 사이엔 침묵), RTS는 3초 eventRecency 윈도(연속 피드)", + "th": "EEW ใช้ connectionOpen (เงียบระหว่างแผ่นดินไหว); RTS ใช้ eventRecency 3 วินาที (feed ต่อเนื่อง)", + "vi": "EEW dùng connectionOpen (im lặng giữa các trận động đất); RTS dùng cửa sổ eventRecency 3s (feed liên tục)", + "id": "EEW pakai connectionOpen (hening di antara gempa); RTS pakai jendela eventRecency 3 detik (feed kontinu)", + "fil": "EEW gumagamit ng connectionOpen (tahimik sa pagitan ng lindol); RTS gumagamit ng 3s eventRecency window (continuous feed)", + }, + }, + { + 'key': { + "zh_Hant": "Push → poll 橋", + "zh_Hans": "Push → poll 桥", + "en": "Push → poll bridge", + "ja": "Push → poll ブリッジ", + "ko": "Push → poll 브리지", + "th": "สะพาน Push → poll", + "vi": "Cầu nối Push → poll", + "id": "Jembatan Push → poll", + "fil": "Push → poll bridge", + }, + 'value': { + "zh_Hant": + "fetch() 回傳緩衝的最新 snapshot 當成當前狀態;整個 live→stale→offline 骨架重用", + "zh_Hans": + "fetch() 回传缓冲的最新 snapshot 当成当前状态;整个 live→stale→offline 骨架重用", + "en": "fetch() returns the latest buffered snapshot as the current state; the whole live→stale→offline skeleton is reused", + "ja": "fetch() はバッファ済み最新スナップショットを現在状態として返す。live→stale→offline の骨格はすべて再利用", + "ko": "fetch()는 버퍼링된 최신 스냅샷을 현재 상태로 반환. live→stale→offline 골격 전체 재사용", + "th": "fetch() คืนสแนปช็อตล่าสุดที่บัฟเฟอร์เป็นสถานะปัจจุบัน; โครง live→stale→offline ทั้งหมดถูกใช้ซ้ำ", + "vi": "fetch() trả về snapshot mới nhất đã đệm như trạng thái hiện tại; toàn bộ khung live→stale→offline được tái sử dụng", + "id": "fetch() mengembalikan snapshot terbaru yang di-buffer sebagai status saat ini; seluruh kerangka live→stale→offline dipakai ulang", + "fil": "Ibinabalik ng fetch() ang pinakabagong buffered snapshot bilang kasalukuyang estado; ginamit muli ang buong live→stale→offline skeleton", + }, + }, + ], + 'stats': >[], + }, + { + 'id': "dual_db", + 'icon': "storage", + 'title': { + "zh_Hant": "雙 SQLite:按耐久性切分", + "zh_Hans": "双 SQLite:按耐久性切分", + "en": "Two SQLite files: split by durability", + "ja": "二つの SQLite:耐久性で分割", + "ko": "SQLite 2개: 내구성 기준 분리", + "th": "SQLite สองไฟล์: แบ่งตามความคงทน", + "vi": "Hai SQLite: chia theo độ bền", + "id": "Dua SQLite: dipisah berdasarkan ketahanan", + "fil": "Dalawang SQLite: nahati ayon sa tibay", + }, + 'headline': null, + 'body': { + "zh_Hant": "不再一個檔裝全部。按「資料能不能重抓」決定放哪個資料庫。檔案:`core/storage/app_database.dart` + `core/network/etag_cache_store.dart`。", + "zh_Hans": "不再一个档装全部。按「资料能不能重抓」决定放哪个资料库。档案:core/storage/app_database.dart + core/network/etag_cache_store.dart。", + "en": "No more one file for everything. Where data lives is decided by 'can it be re-fetched?'. Files: core/storage/app_database.dart + core/network/etag_cache_store.dart.", + "ja": "1 ファイルですべてを済ませるのをやめ、データが「再取得できるか」で置き場所を決める。ファイル: core/storage/app_database.dart + core/network/etag_cache_store.dart。", + "ko": "한 파일에 다 넣던 방식을 버리고, '다시 받을 수 있는지'로 저장 위치를 결정. 파일: core/storage/app_database.dart + core/network/etag_cache_store.dart.", + "th": "ไม่ใช้ไฟล์เดียวเก็บทุกอย่างอีกต่อไป พิจารณาว่า \"ข้อมูลโหลดใหม่ได้ไหม\" เพื่อเลือกฐานข้อมูล ไฟล์: core/storage/app_database.dart + core/network/etag_cache_store.dart", + "vi": "Không còn một file chứa mọi thứ. Vị trí dữ liệu do \"có thể tải lại không\" quyết định. File: core/storage/app_database.dart + core/network/etag_cache_store.dart.", + "id": "Tidak lagi satu file untuk semuanya. Lokasi data ditentukan oleh 'bisa diunduh ulang?'. File: core/storage/app_database.dart + core/network/etag_cache_store.dart.", + "fil": "Hindi na isang file para sa lahat. Kung saan titira ang data ay pinagpapasyahan ng 'maaari bang kunin muli?'. Files: core/storage/app_database.dart + core/network/etag_cache_store.dart.", + }, + 'stat': null, + 'statLabel': null, + 'highlights': >[], + 'details': >[ + { + 'key': { + "zh_Hant": "dpip.db", + "zh_Hans": "dpip.db", + "en": "dpip.db", + "ja": "dpip.db", + "ko": "dpip.db", + "th": "dpip.db", + "vi": "dpip.db", + "id": "dpip.db", + "fil": "dpip.db", + }, + 'value': { + "zh_Hant": "application-support(OS 不可清):settings、logs(24h)、tle、mesh;synchronous = FULL", + "zh_Hans": "application-support(OS 不可清):settings、logs(24h)、tle、mesh;synchronous = FULL", + "en": "application-support (OS-uncullable): settings, logs (24h), TLE, mesh; synchronous = FULL", + "ja": "application-support(OS に消されない): settings、logs (24h)、tle、mesh。synchronous = FULL", + "ko": "application-support(OS가 못 지움): settings, logs(24h), tle, mesh. synchronous = FULL", + "th": "application-support (ระบบไม่ลบ): settings, logs (24 ชม.), tle, mesh; synchronous = FULL", + "vi": "application-support (OS không xoá được): settings, logs (24h), tle, mesh; synchronous = FULL", + "id": "application-support (tak bisa dihapus OS): settings, logs (24 jam), tle, mesh; synchronous = FULL", + "fil": "application-support (hindi pwedeng burahin ng OS): settings, logs (24h), tle, mesh; synchronous = FULL", + }, + }, + { + 'key': { + "zh_Hant": "http_etag_cache.db", + "zh_Hans": "http_etag_cache.db", + "en": "http_etag_cache.db", + "ja": "http_etag_cache.db", + "ko": "http_etag_cache.db", + "th": "http_etag_cache.db", + "vi": "http_etag_cache.db", + "id": "http_etag_cache.db", + "fil": "http_etag_cache.db", + }, + 'value': { + "zh_Hant": "cache(OS 可清):http_cache、net_bucket;synchronous = NORMAL(WAL commit 零 fsync)", + "zh_Hans": "cache(OS 可清):http_cache、net_bucket;synchronous = NORMAL(WAL commit 零 fsync)", + "en": "cache (OS-cullable): http_cache, net_bucket; synchronous = NORMAL (zero-fsync WAL commits)", + "ja": "cache(OS に消され得る): http_cache、net_bucket。synchronous = NORMAL(WAL commit は fsync なし)", + "ko": "cache(OS가 지울 수 있음): http_cache, net_bucket. synchronous = NORMAL (WAL commit 무 fsync)", + "th": "cache (ระบบลบได้): http_cache, net_bucket; synchronous = NORMAL (WAL commit ไม่ fsync)", + "vi": "cache (OS xoá được): http_cache, net_bucket; synchronous = NORMAL (commit WAL không fsync)", + "id": "cache (bisa dihapus OS): http_cache, net_bucket; synchronous = NORMAL (commit WAL tanpa fsync)", + "fil": "cache (pwedeng burahin ng OS): http_cache, net_bucket; synchronous = NORMAL (walang fsync ang WAL commit)", + }, + }, + { + 'key': { + "zh_Hant": "WAL 的意義", + "zh_Hans": "WAL 的意义", + "en": "Why WAL matters", + "ja": "WAL の意義", + "ko": "WAL의 의미", + "th": "ความสำคัญของ WAL", + "vi": "Ý nghĩa của WAL", + "id": "Mengapa WAL penting", + "fil": "Bakit mahalaga ang WAL", + }, + 'value': { + "zh_Hant": "每個小 transaction(log flush、LRU touch、tile batch)不再建 journal + fsync 數次;只 append 到長命 -wal 檔", + "zh_Hans": "每个小 transaction(log flush、LRU touch、tile batch)不再建 journal + fsync 数次;只 append 到长命 -wal 档", + "en": "Small transactions (log flush, LRU touch, tile batch) no longer create a journal + several fsyncs; they just append to a long-lived -wal file", + "ja": "小さなトランザクション(log flush、LRU touch、tile batch)はジャーナル作成+複数 fsync をしなくなり、長命の -wal ファイルに追記するだけになる", + "ko": "작은 트랜잭션(log flush, LRU touch, tile batch)은 더 이상 저널 + fsync 여러 번을 하지 않고, 오래 사는 -wal 파일에 append만 함", + "th": "ธุรกรรมเล็ก (log flush, LRU touch, tile batch) ไม่สร้าง journal + fsync หลายครั้งอีกแล้ว แค่ append ไปยังไฟล์ -wal ที่มีอายุยาว", + "vi": "Transaction nhỏ (log flush, LRU touch, tile batch) không còn tạo journal + nhiều lần fsync; chỉ append vào file -wal sống lâu", + "id": "Transaksi kecil (log flush, LRU touch, tile batch) tidak lagi membuat jurnal + beberapa fsync; cukup append ke file -wal panjang umur", + "fil": "Ang maliliit na transaksyon (log flush, LRU touch, tile batch) ay hindi na gumagawa ng journal + ilang fsync; nag-a-append lang sa mahabang -wal file", + }, + }, + { + 'key': { + "zh_Hant": "保留策略", + "zh_Hans": "保留策略", + "en": "Retention", + "ja": "保持期間", + "ko": "보존 정책", + "th": "นโยบายการเก็บรักษา", + "vi": "Chính sách giữ lại", + "id": "Kebijakan retensi", + "fil": "Retention", + }, + 'value': { + "zh_Hant": "集中 hourly sweep(啟動後 1 分首 sweep):mesh 30 天、radio/neighbour 24h、app log 24h、net_bucket 7 天", + "zh_Hans": "集中 hourly sweep(启动后 1 分首 sweep):mesh 30 天、radio/neighbour 24h、app log 24h、net_bucket 7 天", + "en": "Centralized hourly sweep (first sweep 1 min after start): mesh 30 days, radio/neighbour 24h, app log 24h, net_bucket 7 days", + "ja": "毎時スイープを一元化(起動 1 分後に最初の実行)。mesh 30 日、radio/neighbour 24h、アプリログ 24h、net_bucket 7 日", + "ko": "매시간 스윕을 중앙화(시작 1분 후 첫 실행). mesh 30일, radio/neighbour 24h, 앱 로그 24h, net_bucket 7일", + "th": "รวมการกวาดล้างรายชั่วโมง (ครั้งแรกหลังเริ่ม 1 นาที): mesh 30 วัน, radio/neighbour 24 ชม., app log 24 ชม., net_bucket 7 วัน", + "vi": "Quét hàng giờ tập trung (lần đầu 1 phút sau khởi động): mesh 30 ngày, radio/neighbour 24h, app log 24h, net_bucket 7 ngày", + "id": "Sweep tiap jam terpusat (pertama 1 menit setelah start): mesh 30 hari, radio/neighbour 24 jam, app log 24 jam, net_bucket 7 hari", + "fil": "Sentrong hourly sweep (una 1 minuto pagkatapos magsimula): mesh 30 araw, radio/neighbour 24h, app log 24h, net_bucket 7 araw", + }, + }, + { + 'key': { + "zh_Hant": "流量記帳", + "zh_Hans": "流量记帐", + "en": "Traffic accounting", + "ja": "トラフィック記帳", + "ko": "트래픽 회계", + "th": "การบันทึกปริมาณการใช้", + "vi": "Hạch toán lưu lượng", + "id": "Akuntansi lalu lintas", + "fil": "Pagbibilang ng traffic", + }, + 'value': { + "zh_Hant": "net_bucket 每小時記 down/saved/hits/misses;trailing window(24h/7d),可自證省流量", + "zh_Hans": "net_bucket 每小时记 down/saved/hits/misses;trailing window(24h/7d),可自证省流量", + "en": "net_bucket records down/saved/hits/misses hourly; trailing window (24h/7d) proves your data savings", + "ja": "net_bucket が down/saved/hits/misses を毎時記録。trailing window(24h/7d)で通信量の節約を実証できる", + "ko": "net_bucket이 down/saved/hits/misses를 매시간 기록. trailing window(24h/7d)로 데이터 절약을 스스로 증명", + "th": "net_bucket บันทึก down/saved/hits/misses รายชั่วโมง; trailing window (24 ชม./7 วัน) พิสูจน์การประหยัดดาต้าได้", + "vi": "net_bucket ghi down/saved/hits/misses mỗi giờ; cửa sổ trượt (24h/7 ngày) tự chứng minh tiết kiệm dữ liệu", + "id": "net_bucket mencatat down/saved/hits/misses per jam; trailing window (24 jam/7 hari) membuktikan penghematan data", + "fil": "Nagre-record ang net_bucket ng down/saved/hits/misses kada oras; trailing window (24h/7 araw) nagpapatunay ng tipid sa data", + }, + }, + ], + 'stats': >[], + }, + { + 'id': "map_engine", + 'icon': "layers", + 'title': { + "zh_Hant": "地圖引擎重寫:三層 tile cache 與順滑 scrub", + "zh_Hans": "地图引擎重写:三层 tile cache 与顺滑 scrub", + "en": "Map engine rewrite: three-tier tile cache & silky scrubbing", + "ja": "地図エンジン刷新:3 層タイルキャッシュとなめらかなスクラブ", + "ko": "지도 엔진 재작성: 3계층 타일 캐시와 부드러운 스크럽", + "th": "เขียนเอนจินแผนที่ใหม่: แคชไทล์ 3 ชั้นและการสครับที่ลื่นไหล", + "vi": "Viết lại engine bản đồ: cache tile ba tầng & lướt mượt", + "id": "Tulis ulang engine peta: cache tile tiga tingkat & scrub mulus", + "fil": "Muling isinulat ang map engine: three-tier tile cache & makinis na scrub", + }, + 'headline': null, + 'body': { + "zh_Hant": "MapLibre Native 6.27.0 + ExpTech fork,vector tile、terrain-RGB、WebP raster。關鍵在於三層分工快取與雷達 scrub 的七種機制。檔案:`shared/map/*` + `features/map/*`。", + "zh_Hans": "MapLibre Native 6.27.0 + ExpTech fork,vector tile、terrain-RGB、WebP raster。关键在于三层分工快取与雷达 scrub 的七种机制。档案:shared/map/* + features/map/*。", + "en": "MapLibre Native 6.27.0 + ExpTech fork — vector tiles, terrain-RGB, WebP rasters. The key is the three-tier cache division and seven mechanisms behind silky radar scrubbing. Files: shared/map/* + features/map/*.", + "ja": "MapLibre Native 6.27.0 + ExpTech フォーク。ベクタータイル、terrain-RGB、WebP ラスター。鍵は 3 層キャッシュの分担と、レーダーのなめらかなスクラブを支える 7 つの仕組み。ファイル: shared/map/* + features/map/*。", + "ko": "MapLibre Native 6.27.0 + ExpTech 포크. 벡터 타일, terrain-RGB, WebP 래스터. 핵심은 3계층 캐시 분담과 레이더 스크럽의 7가지 메커니즘. 파일: shared/map/* + features/map/*.", + "th": "MapLibre Native 6.27.0 + ExpTech fork — vector tile, terrain-RGB, WebP raster ระบบสำคัญคือการแบ่งแคช 3 ชั้นและกลไก 7 อย่างในการสครับเรดาร์ ไฟล์: shared/map/* + features/map/*", + "vi": "MapLibre Native 6.27.0 + bản fork ExpTech — vector tile, terrain-RGB, raster WebP. Mấu chốt là phân chia cache ba tầng và bảy cơ chế lướt radar mượt. File: shared/map/* + features/map/*.", + "id": "MapLibre Native 6.27.0 + fork ExpTech — vector tile, terrain-RGB, raster WebP. Kuncinya pembagian cache tiga tingkat dan tujuh mekanisme scrub radar mulus. File: shared/map/* + features/map/*.", + "fil": "MapLibre Native 6.27.0 + ExpTech fork — vector tiles, terrain-RGB, WebP rasters. Ang susi ay ang three-tier cache at pitong mekanismo sa likod ng makinis na radar scrub. Files: shared/map/* + features/map/*.", + }, + 'stat': null, + 'statLabel': null, + 'highlights': >[], + 'details': >[ + { + 'key': { + "zh_Hant": "層 1:原生 ambient", + "zh_Hans": "层 1:原生 ambient", + "en": "Layer 1: native ambient", + "ja": "層 1: ネイティブ ambient", + "ko": "1단계: 네이티브 ambient", + "th": "ชั้น 1: ambient ดั้งเดิม", + "vi": "Tầng 1: ambient gốc", + "id": "Lapisan 1: ambient native", + "fil": "Layer 1: native ambient", + }, + 'value': { + "zh_Hant": "最終關掉(設 0):否則與自家 SQLite 重複 ≈214 MB,且只有一份看得見流量", + "zh_Hans": "最终关掉(设 0):否则与自家 SQLite 重复 ≈214 MB,且只有一份看得见流量", + "en": "Disabled in the end (set to 0): otherwise it duplicated the SQLite store by ≈214 MB, with only one copy visible to accounting", + "ja": "最終的に無効化(0 に設定)。有効だと SQLite と約 214 MB 重複し、流量会計に見えるのは片方だけになるため", + "ko": "결국 비활성화(0으로 설정). 켜두면 SQLite와 약 214MB 중복되고, 회계에는 하나만 보임", + "th": "ปิดในที่สุด (ตั้งเป็น 0): ไม่เช่นนั้นซ้ำกับ SQLite ประมาณ 214 MB และมีเพียงชุดเดียวที่เห็นในบัญชี流量", + "vi": "Tắt hẳn (đặt 0): nếu không sẽ trùng với SQLite ≈214 MB, và chỉ một bản được hạch toán", + "id": "Akhirnya dimatikan (set 0): kalau tidak, duplikat dengan SQLite ≈214 MB dan hanya satu yang terhitung", + "fil": "Naka-disable sa huli (set to 0): kung hindi, magdadoble ito sa SQLite nang ≈214 MB, at isa lang ang nakikita ng accounting", + }, + }, + { + 'key': { + "zh_Hant": "層 2:權威 SQLite", + "zh_Hans": "层 2:权威 SQLite", + "en": "Layer 2: authoritative SQLite", + "ja": "層 2: 権威 SQLite", + "ko": "2단계: 권위 SQLite", + "th": "ชั้น 2: SQLite หลัก", + "vi": "Tầng 2: SQLite chủ đạo", + "id": "Lapisan 2: SQLite otoritatif", + "fil": "Layer 2: authoritative SQLite", + }, + 'value': { + "zh_Hant": "EtagCacheStore(350 MB、LRU、URL-keyed);bridge 雙向批次,一個 IN query 取整批 tile", + "zh_Hans": "EtagCacheStore(350 MB、LRU、URL-keyed);bridge 双向批次,一个 IN query 取整批 tile", + "en": "EtagCacheStore (350 MB, LRU, URL-keyed); batched bridge both ways — one IN query fetches a whole tile batch", + "ja": "EtagCacheStore(350 MB、LRU、URL キー)。ブリッジは双方向バッチ。1 つの IN クエリでタイル一式を取得", + "ko": "EtagCacheStore(350MB, LRU, URL 키). 브리지는 양방향 배치. 하나의 IN 쿼리로 타일 묶음 전체 조회", + "th": "EtagCacheStore (350 MB, LRU, URL-keyed); bridge แบบแบตช์ 2 ทาง — หนึ่ง IN query เรียกไทล์ทั้งชุด", + "vi": "EtagCacheStore (350 MB, LRU, URL-keyed); bridge theo lô hai chiều — một câu IN query lấy cả lô tile", + "id": "EtagCacheStore (350 MB, LRU, URL-keyed); bridge batch dua arah — satu query IN mengambil seluruh batch tile", + "fil": "EtagCacheStore (350 MB, LRU, URL-keyed); batched bridge both ways — isang IN query ang kumukuha ng buong tile batch", + }, + }, + { + 'key': { + "zh_Hant": "層 3:記憶體 mirror", + "zh_Hans": "层 3:记忆体 mirror", + "en": "Layer 3: in-memory mirror", + "ja": "層 3: メモリミラー", + "ko": "3단계: 메모리 미러", + "th": "ชั้น 3: มิเรอร์ในหน่วยความจำ", + "vi": "Tầng 3: mirror trong bộ nhớ", + "id": "Lapisan 3: mirror di memori", + "fil": "Layer 3: in-memory mirror", + }, + 'value': { + "zh_Hant": "MapTileCache(native in-process,48 MB);warm 把 SQLite bytes 直接注入,frame 揭露時 zero IPC/SQL/network", + "zh_Hans": "MapTileCache(native in-process,48 MB);warm 把 SQLite bytes 直接注入,frame 揭露时 zero IPC/SQL/network", + "en": "MapTileCache (native in-process, 48 MB); warm() injects SQLite bytes directly, so revealing a frame costs zero IPC/SQL/network", + "ja": "MapTileCache(ネイティブ in-process、48 MB)。warm() が SQLite の bytes を直接注入し、フレーム公開時は IPC・SQL・ネットワークすべてゼロ", + "ko": "MapTileCache(네이티브 in-process, 48MB). warm()이 SQLite bytes를 직접 주입해 프레임 공개 시 IPC/SQL/네트워크 0", + "th": "MapTileCache (native in-process, 48 MB); warm() ฉีดไบต์จาก SQLite โดยตรง การแสดงเฟรมใช้ IPC/SQL/network เป็นศูนย์", + "vi": "MapTileCache (native in-process, 48 MB); warm() nạp trực tiếp bytes từ SQLite, lộ khung tốn 0 IPC/SQL/network", + "id": "MapTileCache (native in-process, 48 MB); warm() menyuntikkan bytes SQLite langsung, membuka frame nol IPC/SQL/network", + "fil": "MapTileCache (native in-process, 48 MB); ang warm() ay direktang nag-iinject ng SQLite bytes, kaya ang pagpapakita ng frame ay zero IPC/SQL/network", + }, + }, + { + 'key': { + "zh_Hant": "Scrub 七機制", + "zh_Hans": "Scrub 七机制", + "en": "Seven scrub mechanisms", + "ja": "スクラブの 7 つの仕組み", + "ko": "스크럽 7가지 메커니즘", + "th": "กลไกสครับ 7 อย่าง", + "vi": "Bảy cơ chế scrub", + "id": "Tujuh mekanisme scrub", + "fil": "Pitong mekanismo ng scrub", + }, + 'value': { + "zh_Hant": "opacity flip、0ms cross-fade、skipNulls、hide/show 併發、latest-wins pump、fling 阻尼、固定 itemExtent 清單", + "zh_Hans": "opacity flip、0ms cross-fade、skipNulls、hide/show 并发、latest-wins pump、fling 阻尼、固定 itemExtent 清单", + "en": "Opacity flip, 0ms cross-fade, skipNulls, concurrent hide/show, latest-wins pump, fling damping, fixed-itemExtent list", + "ja": "opacity flip、0ms cross-fade、skipNulls、hide/show 並列、latest-wins pump、フリング減衰、固定 itemExtent リスト", + "ko": "opacity flip, 0ms cross-fade, skipNulls, hide/show 동시, latest-wins pump, 플링 감쇠, 고정 itemExtent 목록", + "th": "opacity flip, 0ms cross-fade, skipNulls, hide/show พร้อมกัน, latest-wins pump, หน่วง fling, รายการ itemExtent คงที่", + "vi": "opacity flip, cross-fade 0ms, skipNulls, hide/show song song, latest-wins pump, giảm chấn fling, danh sách itemExtent cố định", + "id": "opacity flip, cross-fade 0ms, skipNulls, hide/show konkuren, latest-wins pump, peredaman fling, daftar itemExtent tetap", + "fil": "Opacity flip, 0ms cross-fade, skipNulls, sabay na hide/show, latest-wins pump, fling damping, fixed-itemExtent list", + }, + }, + ], + 'stats': >[], + }, + { + 'id': "app_time", + 'icon': "query_stats", + 'title': { + "zh_Hant": "校時與單調時鐘", + "zh_Hans": "校时与单调时钟", + "en": "Time sync & the monotonic clock", + "ja": "時刻同期とモノトニッククロック", + "ko": "시간 동기화와 모노토닉 클록", + "th": "การซิงค์เวลาและนาฬิกาโมโนโทนิก", + "vi": "Đồng bộ giờ & đồng hồ đơn điệu", + "id": "Sinkronisasi waktu & jam monotonik", + "fil": "Time sync at ang monotonic clock", + }, + 'headline': null, + 'body': { + "zh_Hant": "真 SNTP(UDP/123,主/備)校正,錨定在 monotonic clock;所有伺服器蓋時間戳的事物都用 AppTime,永不 DateTime.now()。檔案:`core/time/*`。", + "zh_Hans": "真 SNTP(UDP/123,主/备)校正,锚定在 monotonic clock;所有伺服器盖时间戳的事物都用 AppTime,永不 DateTime.now()。档案:core/time/*。", + "en": "Real SNTP (UDP/123, primary/backup) synced and anchored to the monotonic clock; everything the server timestamps goes through AppTime, never DateTime.now(). Files: core/time/*.", + "ja": "本物の SNTP(UDP/123、主/副)で同期し、モノトニッククロックに固定。サーバーがタイムスタンプを付けるものはすべて AppTime 経由で、DateTime.now() は使わない。ファイル: core/time/*。", + "ko": "진짜 SNTP(UDP/123, 주/백업)로 동기화하고 모노토닉 클록에 앵커. 서버가 타임스탬프를 찍는 모든 것은 AppTime 사용, DateTime.now()는 금지. 파일: core/time/*.", + "th": "ใช้ SNTP จริง (UDP/123, หลัก/สำรอง) ซิงค์และยึดกับนาฬิกาโมโนโทนิก ทุกอย่างที่เซิร์ฟเวอร์ประทับเวลาใช้ AppTime ไม่ใช้ DateTime.now() ไฟล์: core/time/*", + "vi": "SNTP thật (UDP/123, chính/dự phòng) đồng bộ và neo vào đồng hồ đơn điệu; mọi thứ máy chủ đóng dấu thời gian đều qua AppTime, không bao giờ DateTime.now(). File: core/time/*.", + "id": "SNTP asli (UDP/123, utama/cadangan) sinkron dan berjangkar pada jam monotonik; semua yang distempel waktu server lewat AppTime, tidak pernah DateTime.now(). File: core/time/*.", + "fil": "Tunay na SNTP (UDP/123, primary/backup) na naka-angkla sa monotonic clock; lahat ng may timestamp ng server ay dumadaan sa AppTime, hindi kailanman DateTime.now(). Files: core/time/*.", + }, + 'stat': null, + 'statLabel': null, + 'highlights': >[], + 'details': >[ + { + 'key': { + "zh_Hant": "Legacy 對比", + "zh_Hans": "Legacy 对比", + "en": "vs. Legacy", + "ja": "旧版との比較", + "ko": "구버전과 비교", + "th": "เทียบกับเดิม", + "vi": "So với bản cũ", + "id": "vs Legacy", + "fil": "vs Legacy", + }, + 'value': { + "zh_Hant": "Legacy:自家 HTTP 端點每分鐘校正,currentTime = now + offset;新版:標準協定 + monotonic 錨定,改鐘不移動時間", + "zh_Hans": "Legacy:自家 HTTP 端点每分钟校正,currentTime = now + offset;新版:标准协定 + monotonic 锚定,改钟不移动时间", + "en": "Legacy: custom HTTP endpoint corrected every minute (now + offset); new: standard protocol + monotonic anchoring, clock edits don't move time", + "ja": "旧版: 独自 HTTP エンドポイントで毎分補正 (now + offset)。新版: 標準プロトコル + モノトニック固定。時計操作は時間を動かさない", + "ko": "구버전: 자체 HTTP 엔드포인트로 매분 보정(now + offset). 신버전: 표준 프로토콜 + 모노토닉 앵커. 시계 조작이 시간을 움직이지 않음", + "th": "เดิม: endpoint HTTP เอง ปรับทุกนาที (now + offset); ใหม่: โปรโตคอลมาตรฐาน + ผูกกับ monotonic การแก้นาฬิกาไม่ขยับเวลา", + "vi": "Cũ: endpoint HTTP tự chế chỉnh mỗi phút (now + offset); mới: giao thức chuẩn + neo monotonic, sửa đồng hồ không làm lệch thời gian", + "id": "Legacy: endpoint HTTP sendiri dikoreksi setiap menit (now + offset); baru: protokol standar + jangkar monotonik, ubah jam tak menggeser waktu", + "fil": "Legacy: sariling HTTP endpoint, minuto-minutong inaayos (now + offset); bago: standard na protocol + monotonic anchoring, hindi gumagalaw ang oras kahit baguhin ang clock", + }, + }, + ], + 'stats': >[], + }, + { + 'id': "error_model", + 'icon': "rule", + 'title': { + "zh_Hant": "錯誤模型:Result 取代散落 throw", + "zh_Hans": "错误模型:Result 取代散落 throw", + "en": "Error model: Result replaces scattered throws", + "ja": "エラーモデル: Result が散らばった throw を置き換え", + "ko": "오류 모델: Result가 흩어진 throw를 대체", + "th": "โมเดลข้อผิดพลาด: Result แทนที่ throw ที่กระจาย", + "vi": "Mô hình lỗi: Result thay thế throw lộn xộn", + "id": "Model error: Result menggantikan throw tersebar", + "fil": "Error model: Result ang pumalit sa nagkalat na throw", + }, + 'headline': null, + 'body': { + "zh_Hant": "資料層方法回 Result 而非 throw:「被吞掉的 exception 絕不能變成靜默的 all-clear」——對防災 app 這是最糟的失敗模式。檔案:`core/error/*`。", + "zh_Hans": "资料层方法回 Result 而非 throw:「被吞掉的 exception 绝不能被变成静默的 all-clear」——对防灾 app 这是最糟的失败模式。档案:core/error/*。", + "en": "Data-layer methods return Result instead of throwing: 'a swallowed exception must never become a silent all-clear' — the worst failure mode for a disaster app. Files: core/error/*.", + "ja": "データ層のメソッドは throw ではなく Result を返す。「握りつぶされた例外が静かな全クリアになってはならない」—防災アプリにとって最悪の失敗モード。ファイル: core/error/*。", + "ko": "데이터 계층 메서드는 throw 대신 Result 반환: \"삼켜진 예외가 조용한 올클리어가 되어선 안 된다\" — 방재 앱에 최악의 실패 모드. 파일: core/error/*.", + "th": "เมธอดเลเยอร์ข้อมูลคืน Result แทนการ throw: \"exception ที่ถูกกลืนต้องไม่กลายเป็นการเคลียร์แบบเงียบ\" — โหมดความล้มเหลวที่แย่ที่สุดสำหรับแอปภัยพิบัติ ไฟล์: core/error/*", + "vi": "Method tầng dữ liệu trả Result thay vì throw: 'exception bị nuốt không bao giờ được trở thành tất-cả-rõ-ràng thầm lặng' — chế độ lỗi tệ nhất cho app thảm hoạ. File: core/error/*.", + "id": "Metode lapisan data mengembalikan Result, bukan throw: 'exception yang ditelan tidak boleh menjadi all-clear senyap' — mode gagal terburuk untuk aplikasi bencana. File: core/error/*.", + "fil": "Ang data-layer methods ay nagbabalik ng Result imbes na mag-throw: 'ang nalamon na exception ay hindi dapat maging tahimik na all-clear' — pinakamasamang failure mode para sa disaster app. Files: core/error/*.", + }, + 'stat': null, + 'statLabel': null, + 'highlights': >[], + 'details': >[ + { + 'key': { + "zh_Hant": "Failure 階層", + "zh_Hans": "Failure 阶层", + "en": "Failure hierarchy", + "ja": "Failure 階層", + "ko": "Failure 계층", + "th": "ลำดับชั้น Failure", + "vi": "Hệ tầng Failure", + "id": "Hierarki Failure", + "fil": "Failure hierarchy", + }, + 'value': { + "zh_Hant": "Network / Timeout / Decode / NoData(空 list ≠ 錯誤)/ Unexpected / MeshConflict", + "zh_Hans": "Network / Timeout / Decode / NoData(空 list ≠ 错误)/ Unexpected / MeshConflict", + "en": "Network / Timeout / Decode / NoData (empty list ≠ error) / Unexpected / MeshConflict", + "ja": "Network / Timeout / Decode / NoData(空リスト ≠ エラー)/ Unexpected / MeshConflict", + "ko": "Network / Timeout / Decode / NoData(빈 목록 ≠ 오류) / Unexpected / MeshConflict", + "th": "Network / Timeout / Decode / NoData (รายการว่าง ≠ ข้อผิดพลาด) / Unexpected / MeshConflict", + "vi": "Network / Timeout / Decode / NoData (danh sách trống ≠ lỗi) / Unexpected / MeshConflict", + "id": "Network / Timeout / Decode / NoData (list kosong ≠ error) / Unexpected / MeshConflict", + "fil": "Network / Timeout / Decode / NoData (walang laman na list ≠ error) / Unexpected / MeshConflict", + }, + }, + { + 'key': { + "zh_Hant": "Timeout 獨立化", + "zh_Hans": "Timeout 独立化", + "en": "Timeout is its own type", + "ja": "Timeout の独立", + "ko": "Timeout 독립 타입", + "th": "Timeout เป็นประเภทของตัวเอง", + "vi": "Timeout là kiểu riêng", + "id": "Timeout berdiri sendiri", + "fil": "Independent ang Timeout", + }, + 'value': { + "zh_Hant": "獨立出來,讓 realtime UI 顯示 STALE(而非泛用錯誤)而不是卡住", + "zh_Hans": "独立出来,让 realtime UI 显示 STALE(而非泛用错误)而不是卡住", + "en": "Separated so the realtime UI shows STALE (not a generic error) instead of hanging", + "ja": "独立させ、リアルタイム UI が汎用エラーではなく STALE を表示できるように", + "ko": "분리하여 실시간 UI가 일반 오류가 아닌 STALE을 표시하게", + "th": "แยกออกมาเพื่อให้ UI เรียลไทม์แสดง STALE (ไม่ใช่ข้อผิดพลาดทั่วไป) แทนการค้าง", + "vi": "Tách riêng để UI realtime hiển thị STALE (không phải lỗi chung) thay vì treo", + "id": "Dipisah agar UI realtime menampilkan STALE (bukan error umum) alih-alih menggantung", + "fil": "Inihiwalay para ang realtime UI ay magpakita ng STALE (hindi generic error) imbes na mag-hang", + }, + }, + { + 'key': { + "zh_Hant": "guardResult", + "zh_Hans": "guardResult", + "en": "guardResult", + "ja": "guardResult", + "ko": "guardResult", + "th": "guardResult", + "vi": "guardResult", + "id": "guardResult", + "fil": "guardResult", + }, + 'value': { + "zh_Hant": "DB 層唯一把 throw→Result 的地方;每個 repository 方法一行,沒人會忘了 try", + "zh_Hans": "DB 层唯一把 throw→Result 的地方;每个 repository 方法一行,没人会忘了 try", + "en": "The only place DB-layer turns throw→Result; one line per repository method, so no one forgets the try", + "ja": "DB 層で throw→Result に変換する唯一の場所。リポジトリメソッドあたり 1 行なので、try を忘れない", + "ko": "DB 계층에서 throw→Result로 바꾸는 유일한 곳. 저장소 메서드당 한 줄이라 try를 잊지 않음", + "th": "จุดเดียวที่เลเยอร์ DB เปลี่ยน throw→Result; หนึ่งบรรทัดต่อ method repository ไม่มีทางลืม try", + "vi": "Nơi duy nhất tầng DB chuyển throw→Result; một dòng cho mỗi method repository, không ai quên try", + "id": "Satu-satunya tempat lapisan DB mengubah throw→Result; satu baris per method repository, tidak ada yang lupa try", + "fil": "Ang tanging lugar na ginagawang throw→Result ng DB-layer; isang linya bawat repository method, walang nakakalimot sa try", + }, + }, + ], + 'stats': >[], + }, + { + 'id': "logging", + 'icon': "receipt_long", + 'title': { + "zh_Hant": "持久化日誌:crash 前那一支可回放", + "zh_Hans": "持久化日志:crash 前那一支可回放", + "en": "Persistent log: the last 24h before a crash can be replayed", + "ja": "永続ログ:クラッシュ前 24 時間を再生できる", + "ko": "영구 로그: 크래시 24시간 전을 재생 가능", + "th": "ล็อกถาวร: ย้อนดู 24 ชั่วโมงก่อน crash ได้", + "vi": "Nhật ký bền vững: 24h trước crash có thể xem lại", + "id": "Log persisten: 24 jam sebelum crash bisa diputar ulang", + "fil": "Persistent log: maaaring i-replay ang 24h bago mag-crash", + }, + 'headline': null, + 'body': { + "zh_Hant": "Log 常駐 SQLite(24h 保留)。重複錯誤抑制:同 signature 5s 窗內重複超過 8 次開始 drop。App 內「日誌」頁可回放崩潰前最後一支。檔案:`core/logging/*`。", + "zh_Hans": "Log 常驻 SQLite(24h 保留)。重复错误抑制:同 signature 5s 窗内重复超过 8 次开始 drop。App 内「日志」页可回放崩溃前最后一支。档案:core/logging/*。", + "en": "Logs persist in SQLite (24h retention). Repeat-error suppression: more than 8 of the same signature in a 5s window starts dropping. The in-app Log page replays the final moments before a crash. Files: core/logging/*.", + "ja": "ログは SQLite に常駐(24 時間保持)。重複エラー抑制:同一シグネチャが 5 秒窓で 8 回超えると削り始める。アプリ内「ログ」ページでクラッシュ直前を再生できる。ファイル: core/logging/*。", + "ko": "로그는 SQLite에 상주(24시간 보존). 반복 오류 억제: 같은 시그니처가 5초 창에서 8회 넘으면 드랍 시작. 앱 내 '로그' 페이지에서 크래시 직전을 재생 가능. 파일: core/logging/*.", + "th": "ล็อกอยู่ใน SQLite (เก็บ 24 ชม.) ลดการซ้ำของข้อผิดพลาด: ซ้ำกันเกิน 8 ครั้งในหน้าต่าง 5 วินาทีจะเริ่มตัดทิ้ง หน้า 'Log' ในแอปสามารถย้อนดูช่วงก่อน crash ได้ ไฟล์: core/logging/*", + "vi": "Log thường trú trong SQLite (giữ 24h). Chèn lặp lỗi: hơn 8 lần cùng signature trong cửa sổ 5s bắt đầu loại bỏ. Trang 'Nhật ký' trong app xem lại khoảnh khắc cuối trước crash. File: core/logging/*.", + "id": "Log menetap di SQLite (retensi 24 jam). Penekanan error berulang: lebih dari 8 kali signature sama dalam jendela 5 detik mulai dibuang. Halaman Log di app memutar ulang momen terakhir sebelum crash. File: core/logging/*.", + "fil": "Nananatili ang log sa SQLite (24h retention). Repeat-error suppression: higit sa 8 na parehong signature sa 5s window ay magsisimulang i-drop. Ang in-app Log page ay nag-re-replay ng mga huling sandali bago mag-crash. Files: core/logging/*.", + }, + 'stat': null, + 'statLabel': null, + 'highlights': >[], + 'details': >[ + { + 'key': { + "zh_Hant": "Legacy 對比", + "zh_Hans": "Legacy 对比", + "en": "vs. Legacy", + "ja": "旧版との比較", + "ko": "구버전과 비교", + "th": "เทียบกับเดิม", + "vi": "So với bản cũ", + "id": "vs Legacy", + "fil": "vs Legacy", + }, + 'value': { + "zh_Hant": + "Legacy: talker_flutter 純記憶體,重開機就查不到昨天為何崩潰;新版:SQLite 24h 可回放", + "zh_Hans": + "Legacy: talker_flutter 纯记忆体,重开机就查不到昨天为何崩溃;新版:SQLite 24h 可回放", + "en": "Legacy: talker_flutter in memory — reboot loses the reason behind yesterday's crash; new: SQLite 24h, replayable", + "ja": "旧版: talker_flutter はメモリのみ、再起動で昨日のクラッシュ理由は消える。新版: SQLite 24 時間再生可能", + "ko": "구버전: talker_flutter 메모리 전용, 재부팅되면 어제 크래시 이유 조회 불가. 신버전: SQLite 24시간 재생 가능", + "th": "เดิม: talker_flutter หน่วยความจำล้วน รีสตาร์ทแล้วหาสาเหตุ crash เมื่อวานไม่ได้; ใหม่: SQLite 24 ชม. ดูย้อนหลังได้", + "vi": "Cũ: talker_flutter chỉ trong bộ nhớ, khởi động lại mất lý do crash hôm qua; mới: SQLite 24h, xem lại được", + "id": "Legacy: talker_flutter murni memori, restart hilang sebab crash kemarin; baru: SQLite 24 jam, bisa diputar ulang", + "fil": "Legacy: talker_flutter memory lang, mawawala ang dahilan ng crash kahapon pag-restart; bago: SQLite 24h, replayable", + }, + }, + ], + 'stats': >[], + }, + { + 'id': "perf_numbers", + 'icon': "straighten", + 'title': { + "zh_Hant": "能量化的數字", + "zh_Hans": "能量化的数字", + "en": "The measurable numbers", + "ja": "定量化できる数字", + "ko": "계량 가능한 숫자", + "th": "ตัวเลขที่วัดได้", + "vi": "Những con số đo lường được", + "id": "Angka yang terukur", + "fil": "Ang mga nasusukat na numero", + }, + 'headline': null, + 'body': { + "zh_Hant": "所有數字都有出處。repo 沒有全域 benchmark,所以只列測得到的。Source:`REWRITE-vs-LEGACY.md`。", + "zh_Hans": + "所有数字都有出处。repo 没有全域 benchmark,所以只列测得到的。Source:REWRITE-vs-LEGACY.md。", + "en": "Every number has a source. The repo has no global benchmark, so these are the ones actually measured. Source: REWRITE-vs-LEGACY.md.", + "ja": "すべての数字に出典があります。リポジトリにグローバルなベンチマークはないため、実際に計測できたものだけを載せています。出典: REWRITE-vs-LEGACY.md。", + "ko": "모든 숫자에 출처가 있습니다. 저장소에 전역 벤치마크가 없어 실제 측정된 것만 실었습니다. 출처: REWRITE-vs-LEGACY.md.", + "th": "ทุกตัวเลขมีที่มา ไม่มี benchmark แบบครอบคลุมทั้ง repo จึงแสดงเฉพาะที่วัดได้จริง ที่มา: REWRITE-vs-LEGACY.md", + "vi": "Mọi con số đều có nguồn. Repo không có benchmark toàn cục, nên chỉ liệt kê thứ đo được. Nguồn: REWRITE-vs-LEGACY.md.", + "id": "Setiap angka punya sumber. Repo tak punya benchmark global, jadi hanya yang benar-benar terukur yang dicantumkan. Sumber: REWRITE-vs-LEGACY.md.", + "fil": "Ang bawat numero ay may pinagmulan. Walang global benchmark ang repo, kaya ito lang ang talagang nasukat. Source: REWRITE-vs-LEGACY.md.", + }, + 'stat': null, + 'statLabel': null, + 'highlights': >[], + 'details': >[], + 'stats': >[ + { + 'value': { + "zh_Hant": "−72%", + "zh_Hans": "−72%", + "en": "−72%", + "ja": "−72%", + "ko": "−72%", + "th": "−72%", + "vi": "−72%", + "id": "−72%", + "fil": "−72%", + }, + 'label': { + "zh_Hant": "鄉鎮邊界單一資產的安裝檔縮減", + "zh_Hans": "乡镇边界单一资产的安装档缩减", + "en": "install-size cut for the town-boundary asset alone", + "ja": "町境界アセット単体のインストール縮小", + "ko": "읍면 경계 단일 에셋의 설치 축소", + "th": "การลดขนาดแอปสำหรับ asset ขอบเขตเมืองเพียงอย่างเดียว", + "vi": "giảm kích thước cài đặt riêng asset ranh giới", + "id": "pemangkasan ukuran instal untuk aset batas kota saja", + "fil": "bawas sa laki ng install para sa town-boundary asset lamang", + }, + }, + { + 'value': { + "zh_Hant": "1–2 秒", + "zh_Hans": "1–2 秒", + "en": "1–2 s", + "ja": "1〜2 秒", + "ko": "1~2초", + "th": "1–2 วิ", + "vi": "1–2 s", + "id": "1–2 dtk", + "fil": "1–2 s", + }, + 'label': { + "zh_Hant": "Android 部分裝置的冷啟動省時(再 +500ms)", + "zh_Hans": "Android 部分装置的冷启动省时(再 +500ms)", + "en": "cold-start saving on some Android devices (+500 ms more)", + "ja": "一部 Android 端末のコールドスタート短縮(さらに +500 ms)", + "ko": "일부 Android 기기의 콜드 스타트 단축(+500ms 추가)", + "th": "ประหยัดเวลาตอนเริ่มบน Android บางรุ่น (บวกอีก 500ms)", + "vi": "khởi động lạnh nhanh hơn trên một số Android (+500 ms nữa)", + "id": "penghematan cold start pada sebagian Android (+500ms lagi)", + "fil": "tipid sa cold start sa ilang Android (+500ms pa)", + }, + }, + { + 'value': { + "zh_Hant": "350 MB", + "zh_Hans": "350 MB", + "en": "350 MB", + "ja": "350 MB", + "ko": "350MB", + "th": "350 MB", + "vi": "350 MB", + "id": "350 MB", + "fil": "350 MB", + }, + 'label': { + "zh_Hant": "ETag 快取的 LRU byte 預算", + "zh_Hans": "ETag 快取的 LRU byte 预算", + "en": "ETag cache's LRU byte budget", + "ja": "ETag キャッシュの LRU バイト予算", + "ko": "ETag 캐시의 LRU 바이트 예산", + "th": "งบประมาณไบต์ LRU ของแคช ETag", + "vi": "ngân sách byte LRU của cache ETag", + "id": "anggaran byte LRU cache ETag", + "fil": "LRU byte budget ng ETag cache", + }, + }, + { + 'value': { + "zh_Hant": "1–3", + "zh_Hans": "1–3", + "en": "1–3", + "ja": "1〜3", + "ko": "1~3", + "th": "1–3", + "vi": "1–3", + "id": "1–3", + "fil": "1–3", + }, + 'label': { + "zh_Hant": "GEO 查詢實際測試的多邊形數(舊:367)", + "zh_Hans": "GEO 查询实际测试的多边形数(旧:367)", + "en": "polygons actually tested per GEO query (old: 367)", + "ja": "GEO クエリの実際にテストされるポリゴン数(旧: 367)", + "ko": "GEO 쿼리당 실제 테스트 폴리곤 수(이전: 367)", + "th": "จำนวนรูปหลายเหลี่ยมที่ทดสอบจริงต่อการค้นหา GEO (เดิม: 367)", + "vi": "số đa giác thực sự được kiểm tra mỗi truy vấn GEO (cũ: 367)", + "id": "poligon yang benar-benar diuji per kueri GEO (lama: 367)", + "fil": "polygon na talagang tinitingnan bawat GEO query (dati: 367)", + }, + }, + ], + }, + { + 'id': "crash_fixes", + 'icon': "bug_report", + 'title': { + "zh_Hant": "直接修掉的 crash", + "zh_Hans": "直接修掉的 crash", + "en": "Crashes fixed along the way", + "ja": "直したクラッシュ", + "ko": "고친 크래시", + "th": "crash ที่แก้แล้ว", + "vi": "Các crash đã sửa", + "id": "Crash yang diperbaiki", + "fil": "Mga crash na naayos", + }, + 'headline': null, + 'body': { + "zh_Hant": "重寫過程中也修掉幾個潛在 crash:相機框景在 Dart 端算(原生對 degenerate box 會丟未捕捉 exception 直接 SIGABRT)、MapLibre fork 加 Android gzip 修正、閃電 PNG 明確 dispose(修掉每 register 一次洩兩個 native handle)。", + "zh_Hans": "重写过程中也修掉几个潜在 crash:相机框景在 Dart 端算(原生对 degenerate box 会丢未捕捉 exception 直接 SIGABRT)、MapLibre fork 加 Android gzip 修正、闪电 PNG 明确 dispose(修掉每 register 一次泄两个 native handle)。", + "en": "The rewrite also fixed lurking crashes: the camera frame is computed in Dart (native throws an uncaught exception on degenerate boxes → SIGABRT), the MapLibre fork adds an Android gzip fix, and lightning PNGs are explicitly disposed (two native handle leaks per register).", + "ja": "リライトで潜在クラッシュも修正。カメラフレームは Dart 側で計算(ネイティブは退化ボックスで未捕捉例外→SIGABRT)。MapLibre フォークで Android の gzip 問題を修正。雷 PNG を明示的 dispose(register ごとにネイティブハンドル 2 つのリークを修正)。", + "ko": "재작성 과정에서 잠재 크래시도 수정됐습니다. 카메라 프레임을 Dart에서 계산(네이티브는 degenerate box에서 미처리 예외 → SIGABRT). MapLibre 포크로 Android gzip 수정. 번개 PNG 명시적 dispose(register마다 네이티브 핸들 2개 누수 수정).", + "th": "การเขียนใหม่ยังแก้ crash ที่ซ่อนอยู่ด้วย: คำนวณเฟรมกล้องใน Dart (เนทีฟยิง exception ที่ไม่ catch ใน degenerate box → SIGABRT), MapLibre fork เพิ่มการแก้ gzip Android, และ dispose PNG สายฟ้าอย่างชัดเจน (แก้หน่วยความจำรั่ว 2 handle ต่อการ register)", + "vi": "Việc viết lại cũng sửa các crash tiềm ẩn: khung camera tính trong Dart (native ném exception chưa bắt trên box suy biến → SIGABRT), fork MapLibre thêm bản vá gzip Android, PNG sét được dispose tường minh (sửa rò rỉ 2 native handle mỗi lần register).", + "id": "Penulisan ulang juga memperbaiki crash tersembunyi: bingkai kamera dihitung di Dart (native melempar exception tak tertangkap pada kotak degenerate → SIGABRT), fork MapLibre menambah perbaikan gzip Android, PNG petir di-dispose eksplisit (memperbaiki kebocoran 2 native handle per register).", + "fil": "Ang rewrite ay nag-ayos din ng mga nakatagong crash: ang camera frame ay kinukwenta sa Dart (nag-throw ng uncaught exception ang native sa degenerate box → SIGABRT), ang MapLibre fork ay may Android gzip fix, at ang lightning PNG ay eksplisit na dine-dispose (dalawang native handle leak bawat register).", + }, + 'stat': null, + 'statLabel': null, + 'highlights': >[], + 'details': >[], + 'stats': >[], + }, +]; diff --git a/release_highlights/lib/26.1/normal.dart b/release_highlights/lib/26.1/normal.dart new file mode 100644 index 000000000..d1c1c073c --- /dev/null +++ b/release_highlights/lib/26.1/normal.dart @@ -0,0 +1,696 @@ +// Version-highlight card content for DPIP 26.1 (normal). +// +// GENERATED from `release_highlights/assets/26.1/normal/cards.json` by `tool/json_to_dart_highlights.py` — edit the +// JSON, not this file. Rendering lives in `lib/features/release_highlights`; +// this package carries only data. +library; + +const title = { + "zh_Hant": "DPIP 26.1 第 4 次大更新 做了哪些改變", + "zh_Hans": "DPIP 26.1 第 4 次大更新 做了哪些改变", + "en": "DPIP 26.1 — What the 4th major update changed", + "ja": "DPIP 26.1 第4回大型アップデートで変わったこと", + "ko": "DPIP 26.1 제4차 대규모 업데이트에서 바뀐 점", + "th": "DPIP 26.1 การอัปเดตครั้งใหญ่ครั้งที่ 4 เปลี่ยนอะไรบ้าง", + "vi": "DPIP 26.1 — Bản cập nhật lớn lần thứ 4 có gì mới", + "id": "DPIP 26.1 — Yang berubah di pembaruan besar ke-4", + "fil": "DPIP 26.1 — Ano ang binago ng ika-4 na malaking update", +}; +const subtitle = { + "zh_Hant": "這次不是換皮——通訊、地圖、省電、啟動全部重做,讓警報更快到、更省流量地到。", + "zh_Hans": "这次不是换皮——通讯、地图、省电、启动全部重做,让警报更快到、更省流量地到。", + "en": "Not a reskin — networking, maps, battery and startup were all rebuilt, so alerts get to you faster and use far less data.", + "ja": "見た目だけの更新ではありません。通信・地図・省電力・起動を全て作り直し、警報がより速く、より少ない通信量で届くようになりました。", + "ko": "겉모습만 바뀐 게 아닙니다. 통신·지도·배터리·시작이 모두 재구축되어, 알림이 더 빠르고 더 적은 데이터로 도착합니다.", + "th": "ไม่ใช่แค่เปลี่ยนโฉม — ระบบสื่อสาร แผนที่ ประหยัดพลังงาน และการเริ่มต้นระบบถูกสร้างใหม่ทั้งหมด แจ้งเตือนถึงเร็วขึ้นและใช้ดาต้าน้อยลง", + "vi": "Không phải chỉ đổi giao diện — mạng, bản đồ, pin và khởi động đều được làm lại, để cảnh báo đến nhanh hơn và tốn ít dữ liệu hơn.", + "id": "Bukan sekadar ganti tampilan — jaringan, peta, baterai, dan startup semuanya dibangun ulang, agar peringatan lebih cepat sampai dan hemat data.", + "fil": "Hindi lang bagong itsura — muling ginawa ang network, mapa, baterya, at startup, para mas mabilis at mas tipid sa data ang alerto.", +}; +const cards = >[ + { + 'id': "speed", + 'icon': "bolt", + 'title': { + "zh_Hant": "警報更快到", + "zh_Hans": "警报更快到", + "en": "Alerts arrive faster", + "ja": "警報がより速く届く", + "ko": "알림이 더 빨리 도착", + "th": "การแจ้งเตือนถึงเร็วขึ้น", + "vi": "Cảnh báo đến nhanh hơn", + "id": "Peringatan lebih cepat sampai", + "fil": "Mas mabilis dumating ang alerto", + }, + 'headline': { + "zh_Hant": "即時資料從每秒輪詢,改成伺服器主動推送", + "zh_Hans": "即时资料从每秒轮询,改成服务器主动推送", + "en": "Real-time data switched from 1-second polling to server push", + "ja": "リアルタイムデータが毎秒ポーリングからサーバー配信に", + "ko": "실시간 데이터가 초당 폴링에서 서버 푸시로", + "th": "ข้อมูลเรียลไทม์เปลี่ยนจากการดึงทุกวินาที เป็นการส่งจากเซิร์ฟเวอร์", + "vi": "Dữ liệu thời gian thực chuyển từ kéo mỗi giây sang máy chủ đẩy", + "id": + "Data real-time berubah dari polling tiap detik menjadi push server", + "fil": "Real-time data: mula sa bawat segundong pagkuha, naging push mula sa server", + }, + 'body': { + "zh_Hant": "以前 App 每秒問一次伺服器「有沒有新資料?」;現在伺服器有資料才送來。地震速報 (EEW) 與強震監視 (RTS) 都改用串流傳輸——同樣的警報內容,更即時、更省流量。", + "zh_Hans": "以前 App 每秒问一次服务器「有没有新数据?」;现在服务器有数据才送来。地震速报 (EEW) 与强震监视 (RTS) 都改用串流传输——同样的警报内容,更即时、更省流量。", + "en": "The app used to ask the server 'anything new?' every second; now the server pushes only when there is something to say. Both the earthquake early warning (EEW) and strong-motion monitoring (RTS) feeds stream changes instead — the same alerts, delivered live on less data.", + "ja": "以前はアプリが毎秒サーバーに「新しいデータは?」と問い合わせていました。今はサーバーが更新があるときだけ送信します。緊急地震速報 (EEW) と強震モニタ (RTS) はどちらもストリーム配信に。同じ警報内容で、よりリアルタイム、より節約に。", + "ko": "예전엔 앱이 매초 서버에 \"새로운 데이터 있나요?\"라고 물었습니다. 이제는 서버가 새 데이터가 있을 때만 보냅니다. 지진조기경보(EEW)와 강진 모니터(RTS) 모두 스트리밍 전송으로 바뀌었습니다. 같은 알림 내용, 더 실시간, 더 적은 데이터.", + "th": "แต่ก่อนแอปถามเซิร์ฟเวอร์ทุกวินาทีว่า \"มีข้อมูลใหม่ไหม?\" ตอนนี้เซิร์ฟเวอร์ส่งเฉพาะเมื่อมีข้อมูลใหม่ การแจ้งเตือนแผ่นดินไหว (EEW) และการเฝ้าระวังแรงสั่นสะเทือน (RTS) เปลี่ยนเป็นสตรีมมิ่ง — เนื้อหาเดียวกัน แต่เร็วและประหยัดกว่า", + "vi": "Trước đây app hỏi máy chủ mỗi giây \"có gì mới không?\"; giờ máy chủ chỉ gửi khi có dữ liệu. Cảnh báo động đất (EEW) và giám sát rung lắc (RTS) đều chuyển sang truyền luồng — cùng nội dung cảnh báo, nhanh hơn và tốn ít dữ liệu hơn.", + "id": "Dulu aplikasi bertanya ke server setiap detik 'ada data baru?'; kini server hanya mengirim saat ada data baru. Peringatan dini gempa (EEW) dan pemantauan getaran (RTS) keduanya kini streaming — konten sama, lebih real-time, lebih hemat data.", + "fil": "Dati, nagtatanong ang app sa server bawat segundo kung 'may bago na ba?'; ngayon, nagpapadala lamang ang server kapag may pagbabago. Ang EEW at RTS ay streaming na — parehong alerto, mas real-time, mas tipid sa data.", + }, + 'stat': { + "zh_Hant": "1 秒", + "zh_Hans": "1 秒", + "en": "1 s", + "ja": "1 秒", + "ko": "1초", + "th": "1 วิ", + "vi": "1 s", + "id": "1 dtk", + "fil": "1 s", + }, + 'statLabel': { + "zh_Hant": "以前的輪詢間隔,現在變為零輪詢——有變動才傳", + "zh_Hans": "以前的轮询间隔,现在变为零轮询——有变动才传", + "en": "the old polling interval — now zero polling, changes are pushed", + "ja": "従来のポーリング間隔。今はポーリングなし、変化時のみ送信", + "ko": "이전 폴링 간격. 이제는 폴링 없음, 변화만 전송", + "th": "ช่วงเวลาการดึงข้อมูลเดิม — ตอนนี้ไม่มีการดึงอีก ส่งเมื่อมีการเปลี่ยนแปลง", + "vi": "khoảng cách kéo dữ liệu trước đây — giờ không còn kéo nữa, chỉ đẩy khi thay đổi", + "id": "interval polling lama — kini tanpa polling, hanya kirim saat ada perubahan", + "fil": "dati nating interval sa pagkuha — wala nang polling, push na lang kapag may pagbabago", + }, + 'highlights': >[ + { + "zh_Hant": "EEW 與 RTS 皆改為 SSE 串流", + "zh_Hans": "EEW 与 RTS 皆改为 SSE 串流", + "en": "Both EEW and RTS now stream over SSE", + "ja": "EEW と RTS は両方 SSE ストリームに", + "ko": "EEW와 RTS 모두 SSE 스트리밍으로", + "th": "EEW และ RTS เปลี่ยนเป็นสตรีมมิ่ง SSE", + "vi": "Cả EEW và RTS đều chuyển sang SSE", + "id": "EEW dan RTS kini streaming SSE", + "fil": "Ang EEW at RTS ay streaming na sa SSE", + }, + { + "zh_Hant": "斷線自動重連,恢復秒回", + "zh_Hans": "断线自动重连,恢复秒回", + "en": "Auto-reconnect with instant recovery", + "ja": "切断は自動再接続、復旧は即座に", + "ko": "끊기면 자동 재연결, 즉시 복구", + "th": "ตัดการเชื่อมต่ออัตโนมัติ กลับมาทันที", + "vi": "Tự động kết nối lại khi mất, khôi phục tức thì", + "id": "Putus otomatis tersambung lagi, pulih seketika", + "fil": "Awtomatikong kumokonekta, mabilis gumaling", + }, + ], + 'details': >[], + 'stats': >[], + }, + { + 'id': "data", + 'icon': "data_saver", + 'title': { + "zh_Hant": "省流量看得見", + "zh_Hans": "省流量看得见", + "en": "Data savings you can see", + "ja": "通信量の節約が見える", + "ko": "데이터 절약이 보인다", + "th": "ประหยัดดาต้าที่มองเห็นได้", + "vi": "Tiết kiệm dữ liệu thấy được", + "id": "Hemat data yang terlihat", + "fil": "Makikitang tipid sa data", + }, + 'headline': { + "zh_Hant": "磁碟智慧快取,看過的地圖不再重抓", + "zh_Hans": "磁盘智慧快取,看过的地图不再重抓", + "en": "Smart on-disk cache — maps you have seen are never re-fetched", + "ja": "ディスクの賢いキャッシュ。見た地図は再取得されない", + "ko": "디스크 스마트 캐시. 본 지도는 다시 받지 않습니다", + "th": "แคชอัจฉริยะบนดิสก์ — แผนที่ที่เคยดูไม่ต้องดาวน์โหลดซ้ำ", + "vi": "Bộ nhớ đệm thông minh — bản đồ đã xem không tải lại", + "id": + "Cache cerdas di disk — peta yang pernah dilihat tidak diunduh ulang", + "fil": "Matalinong cache — hindi na muling kinukuhá ang mapang nakita na", + }, + 'body': { + "zh_Hant": "App 在磁碟上架起一個容納 350 MB 的快取,用 ETag 驗證機制決定什麼要重抓、什麼直接沿用。雷達、衛星、等高線圖層都受益——天天看地圖的人,長期能省下大量流量。", + "zh_Hans": "App 在磁盘上架起一个容纳 350 MB 的快取,用 ETag 验证机制决定什么要重抓、什么直接沿用。雷达、卫星、等高线图层都受益——天天看地图的人,长期能省下大量流量。", + "en": "The app keeps a 350 MB on-disk cache with ETag-based validation, deciding exactly what needs re-fetching. Radar, satellite and terrain layers all benefit — if you check the map every day, the data you save adds up.", + "ja": "アプリは 350 MB のディスクキャッシュを構え、ETag 検証で何を再取得するか、何をそのまま使うかを判断します。レーダー・衛星・地形レイヤーすべてが恩恵を受け、毎日地図を見る人ほど通信量の節約が積み上がります。", + "ko": "앱은 350MB 디스크 캐시를 두고, ETag 검증으로 무엇을 다시 받을지, 무엇을 그대로 쓸지 판단합니다. 레이더·위성·지형 레이어 모두 혜택을 받아, 매일 지도를 보는 사람일수록 절약이 쌓입니다.", + "th": "แอปมีแคชบนดิสก์ 350 MB พร้อมการตรวจสอบ ETag ว่าอะไรต้องโหลดใหม่ อะไรใช้ของเดิมได้ ระบบเรดาร์ ดาวเทียม และชั้นภูมิประเทศได้ประโยชน์ทั้งหมด — ยิ่งดูแผนที่บ่อย ยิ่งประหยัดดาต้ามาก", + "vi": "App lưu bộ nhớ đệm 350 MB trên đĩa, dùng cơ chế xác thực ETag để quyết định thứ gì cần tải lại. Radar, vệ tinh và lớp địa hình đều được hưởng lợi — càng xem bản đồ thường xuyên, càng tiết kiệm.", + "id": "App menyimpan cache 350 MB di disk, memakai validasi ETag untuk memutuskan apa yang perlu diambil ulang. Radar, satelit, dan lapisan medan semuanya diuntungkan — makin sering buka peta, makin banyak data yang dihemat.", + "fil": "May 350 MB na cache ang app sa disk, gamit ang ETag validation para malaman kung ano ang kailangang kunin ulit. Ang radar, satellite, at terrain layers lahat nakikinabang — mas madalas tingnan ang mapa, mas malaki ang matitipid.", + }, + 'stat': { + "zh_Hant": "350 MB", + "zh_Hans": "350 MB", + "en": "350 MB", + "ja": "350 MB", + "ko": "350MB", + "th": "350 MB", + "vi": "350 MB", + "id": "350 MB", + "fil": "350 MB", + }, + 'statLabel': { + "zh_Hant": "智慧快取預算,全由 LRU 自動管理", + "zh_Hans": "智慧快取预算,全由 LRU 自动管理", + "en": "smart cache budget, fully LRU-managed", + "ja": "スマートキャッシュ予算、完全 LRU 管理", + "ko": "스마트 캐시 예산, LRU로 자동 관리", + "th": "งบแคชอัจฉริยะ จัดการ LRU อัตโนมัติ", + "vi": "hạn mức cache thông minh, tự quản lý bằng LRU", + "id": "budget cache cerdas, dikelola LRU otomatis", + "fil": "badget ng smart cache, LRU ang nagmamay-ari", + }, + 'highlights': >[ + { + "zh_Hant": "ETag 驗證,只有真的更新才重抓", + "zh_Hans": "ETag 验证,只有真的更新才重抓", + "en": "ETag validation — only truly changed content is re-fetched", + "ja": "ETag 検証、本当に更新されたものだけ再取得", + "ko": "ETag 검증, 진짜 바뀐 것만 다시 받음", + "th": "ตรวจสอบ ETag — โหลดใหม่เฉพาะเมื่อมีเปลี่ยนจริง", + "vi": "Xác thực ETag — chỉ tải lại nội dung thực sự thay đổi", + "id": "Validasi ETag — hanya konten yang benar-benar berubah yang diambil ulang", + "fil": "ETag validation — ang tunay na nagbago lang ang kinukuha ulit", + }, + { + "zh_Hant": "看過的雷達、衛星圖框秒開", + "zh_Hans": "看过的雷达、卫星图框秒开", + "en": "Frames you have seen open instantly", + "ja": "見たことのあるレーダー・衛星フレームは即表示", + "ko": "본 적 있는 레이더·위성 프레임 즉시 표시", + "th": "เฟรมเรดาร์และดาวเทียมที่เคยดูเปิดได้ทันที", + "vi": "Khung radar, vệ tinh đã xem mở tức thì", + "id": "Frame radar & satelit yang pernah dilihat langsung terbuka", + "fil": "Agad nabubuksan ang mga frame na nakita na", + }, + ], + 'details': >[], + 'stats': >[], + }, + { + 'id': "battery", + 'icon': "battery_saver", + 'title': { + "zh_Hant": "更省電、更懂你的位置", + "zh_Hans": "更省电、更懂你的位置", + "en": "Battery-friendly location tracking", + "ja": "省電力で賢い位置情報", + "ko": "배터리 친화적 위치 추적", + "th": "ติดตามตำแหน่งแบบประหยัดแบต", + "vi": "Theo dõi vị trí tiết kiệm pin", + "id": "Pelacakan lokasi yang hemat baterai", + "fil": "Pagsubaybay ng lokasyon na hindi pumapatay ng baterya", + }, + 'headline': { + "zh_Hant": "背景定位不再是耗電大戶", + "zh_Hans": "背景定位不再是耗电大户", + "en": "Background location is no longer a battery hog", + "ja": "バックグラウンド位置情報が電池食いではなくなった", + "ko": "백그라운드 위치 추적이 더 이상 배터리 대장이 아님", + "th": "การระบุตำแหน่งเบื้องหลังไม่กินแบตอีกต่อไป", + "vi": "Định vị nền không còn là kẻ ngốn pin", + "id": "Lokasi latar bukan lagi penghabis baterai", + "fil": "Hindi na baterya-drain ang background location", + }, + 'body': { + "zh_Hant": "新版把每 10 分鐘固定定位改成自適應間隔:移動越快定得越頻繁(最快 5 分鐘),靜止時自動退到 60 分鐘。不需要前台服務,對 Android 的省電模式也更友善。", + "zh_Hans": "新版把每 10 分钟固定定位改成自适应间隔:移动越快定得越频繁(最快 5 分钟),静止时自动退到 60 分钟。不需要前台服务,对 Android 的省电模式也更友善。", + "en": "Updates track movement instead of a fixed 10-minute timer: the faster you move the more often it locates (down to 5 minutes), and it eases back to 60 minutes when you stay put. No foreground service needed, and friendlier to Android's power-saving modes.", + "ja": "新版では固定 10 分ごとを適応型間隔に変更。動きが速いほど頻繁に(最速 5 分)、静止時は自動で 60 分に。フォアグラウンドサービス不要で、Android の省電力モードにも優しくなりました。", + "ko": "새 버전은 고정 10분 대신 적응형 간격으로 바뀌었습니다. 움직일수록 더 자주(최소 5분), 가만히 있으면 자동으로 60분으로. 포그라운드 서비스가 필요 없어졌고 Android 절전 모드에도 더 친절합니다.", + "th": "เวอร์ชันใหม่เปลี่ยนจากการระบุตำแหน่งทุก 10 นาที เป็นระยะห่างแบบปรับอัตโนมัติ ยิ่งเคลื่อนไหวเร็วยิ่งถี่ (ขั้นต่ำ 5 นาที) นิ่งอยู่ก็ขยายไป 60 นาที ไม่ต้องใช้ foreground service และเป็นมิตรกับโหมดประหยัดพลังงาน Android", + "vi": "Phiên bản mới đổi từ định vị mỗi 10 phút cố định sang khoảng cách thích ứng: di chuyển càng nhanh định vị càng thường xuyên (tối thiểu 5 phút), đứng yên tự giãn ra 60 phút. Không cần foreground service, thân thiện hơn với chế độ tiết kiệm pin Android.", + "id": "Versi baru mengganti lokasi tiap 10 menit tetap dengan interval adaptif: makin cepat bergerak makin sering (minimal 5 menit), diam otomatis mundur ke 60 menit. Tidak perlu foreground service, lebih ramah mode hemat baterai Android.", + "fil": "Pinalitan ng bagong bersyon ang fixed 10-minutong lokasyon ng adaptive interval: mas mabilis kumilos, mas madalas (hanggang 5 minuto); tahimik, awtomatikong 60 minuto. Wala nang foreground service, mas maayos sa power-saving ng Android.", + }, + 'stat': { + "zh_Hant": "5–60 分", + "zh_Hans": "5–60 分", + "en": "5–60 min", + "ja": "5〜60 分", + "ko": "5~60분", + "th": "5–60 นาที", + "vi": "5–60 phút", + "id": "5–60 mnt", + "fil": "5–60 min", + }, + 'statLabel': { + "zh_Hant": "自適應定位間隔,移動越快定得越頻繁", + "zh_Hans": "自适应定位间隔,移动越快定得越频繁", + "en": "adaptive location interval — faster movement, more frequent fixes", + "ja": "適応型の位置情報間隔。速く動くほど頻繁に", + "ko": "적응형 위치 간격 — 빠르게 움직일수록 더 자주", + "th": "ระยะห่างการระบุตำแหน่งแบบปรับได้ — ขยับไว ระบุถี่ขึ้น", + "vi": "khoảng định vị thích ứng — càng di chuyển nhanh càng thường xuyên", + "id": "interval lokasi adaptif — makin cepat bergerak makin sering", + "fil": "adaptive na interval — mas mabilis gumalaw, mas madalas ang fix", + }, + 'highlights': >[ + { + "zh_Hant": "靜止時自動退到 60 分鐘才定位一次", + "zh_Hans": "静止时自动退到 60 分钟才定位一次", + "en": "Eases back to once per 60 minutes when still", + "ja": "静止時は自動で 60 分に 1 回へ", + "ko": "가만히 있으면 자동으로 60분에 한 번으로", + "th": "อยู่นิ่ง ๆ จะขยับไปถี่ขึ้นเป็น 60 นาทีครั้ง", + "vi": "Đứng yên tự giãn ra 60 phút một lần", + "id": "Diam otomatis melambat ke sekali per 60 menit", + "fil": "Kapag tahimik, awtomatikong 60 minutong agwat", + }, + { + "zh_Hant": "對 Android Doze 省電模式友善", + "zh_Hans": "对 Android Doze 省电模式友善", + "en": "Friendly to Android Doze power-saving", + "ja": "Android Doze 省電力モードに優しい", + "ko": "Android Doze 절전 모드에 친화적", + "th": "เป็นมิตรกับโหมดประหยัดพลังงาน Doze ของ Android", + "vi": "Thân thiện với chế độ tiết kiệm pin Doze của Android", + "id": "Ramah terhadap mode hemat daya Doze Android", + "fil": "Kaibigan ng Android Doze power-saving", + }, + ], + 'details': >[], + 'stats': >[], + }, + { + 'id': "startup", + 'icon': "rocket_launch", + 'title': { + "zh_Hant": "開得更快", + "zh_Hans": "开得更快", + "en": "Faster startup", + "ja": "起動が速い", + "ko": "더 빠른 시작", + "th": "เริ่มต้นเร็วขึ้น", + "vi": "Khởi động nhanh hơn", + "id": "Startup lebih cepat", + "fil": "Mas mabilis magsimula", + }, + 'headline': { + "zh_Hant": "冷啟動省下 1–2 秒,開 App 不再等", + "zh_Hans": "冷启动省下 1–2 秒,开 App 不再等", + "en": "Cold start saves 1–2 seconds — no more waiting", + "ja": "コールドスタートが 1〜2 秒短縮、待たされない", + "ko": "콜드 스타트 1~2초 단축, 기다리지 않아도 됩니다", + "th": "การเริ่มต้นครั้งแรกเร็วขึ้น 1–2 วินาที ไม่ต้องรออีกต่อไป", + "vi": "Khởi động lạnh nhanh hơn 1–2 giây, không còn chờ đợi", + "id": "Cold start hemat 1–2 detik, tak perlu menunggu", + "fil": "Makatipid sa cold start ng 1–2 segundo, hindi na maghihintay", + }, + 'body': { + "zh_Hant": "把推播初始化與裝置資訊讀取移到背景執行,多項資源同時載入。部分 Android 裝置實測省下 1–2 秒,之後又再省了 500ms 以上。", + "zh_Hans": "把推播初始化与装置资讯读取移到背景执行,多项资源同时载入。部分 Android 装置实测省下 1–2 秒,之后又再省了 500ms 以上。", + "en": "Push initialization and device-info reads moved off the critical path, and boot resources load in parallel. Measured on some Android devices at 1–2 seconds saved, then another 500 ms+ after the follow-up.", + "ja": "プッシュ初期化と端末情報の読み込みをバックグラウンド化、起動リソースを並列読み込み。一部の Android 端末で 1〜2 秒、その後の改善でさらに 500 ms 以上短縮されました。", + "ko": "푸시 초기화와 기기 정보 읽기를 백그라운드로 옮기고, 부팅 리소스를 병렬로 불러옵니다. 일부 Android 기기에서 1~2초, 이후 개선으로 500ms 이상 더 단축됐습니다.", + "th": "ย้ายการเริ่มต้นระบบแจ้งเตือนและการอ่านข้อมูลอุปกรณ์ไปทำงานเบื้องหลัง โหลดทรัพยากรพร้อมกัน บางรุ่น Android วัดผลได้เร็วขึ้น 1–2 วินาที และปรับปรุงอีก 500 มิลลิวินาทีขึ้นไป", + "vi": "Chuyển khởi tạo push và đọc thông tin thiết bị xuống nền, tải tài nguyên song song. Đo trên một số thiết bị Android tiết kiệm 1–2 giây, sau đó giảm thêm hơn 500ms.", + "id": "Inisialisasi push dan pembacaan info perangkat dipindah ke latar, sumber daya dimuat paralel. Terukur di sebagian perangkat Android hemat 1–2 detik, kemudian hemat lagi 500ms+.", + "fil": "Ang push init at pagbabasa ng device info ay nasa background na, at sabay-sabay na naglo-load ang boot resources. Sa ilang Android device, 1–2 segundong tipid, saka dagdag 500ms pa.", + }, + 'stat': { + "zh_Hant": "1–2 秒", + "zh_Hans": "1–2 秒", + "en": "1–2 s", + "ja": "1〜2 秒", + "ko": "1~2초", + "th": "1–2 วิ", + "vi": "1–2 s", + "id": "1–2 dtk", + "fil": "1–2 s", + }, + 'statLabel': { + "zh_Hant": "部分 Android 裝置的冷啟動省時", + "zh_Hans": "部分 Android 装置的冷启动省时", + "en": "cold-start saving on some Android devices", + "ja": "一部 Android 端末のコールドスタート短縮時間", + "ko": "일부 Android 기기의 콜드 스타트 단축 시간", + "th": "เวลาที่ประหยัดได้ตอนเริ่มต้นบน Android บางรุ่น", + "vi": "thời gian khởi động lạnh tiết kiệm trên một số thiết bị Android", + "id": "penghematan cold start pada sebagian perangkat Android", + "fil": "tipid sa cold start sa ilang Android device", + }, + 'highlights': >[ + { + "zh_Hant": "多項啟動資源平行載入", + "zh_Hans": "多项启动资源平行载入", + "en": "Boot resources load in parallel", + "ja": "起動リソースを並列読み込み", + "ko": "부팅 리소스를 병렬 로드", + "th": "โหลดทรัพยากรเริ่มต้นแบบขนาน", + "vi": "Tải tài nguyên khởi động song song", + "id": "Sumber daya boot dimuat paralel", + "fil": "Sabay-sabay na load ng boot resources", + }, + { + "zh_Hant": "推播初始化不再擋住首幀", + "zh_Hans": "推播初始化不再挡住首帧", + "en": "Push init no longer blocks the first frame", + "ja": "プッシュ初期化が初回表示を妨げない", + "ko": "푸시 초기화가 첫 프레임을 막지 않음", + "th": "การเริ่มต้น push ไม่บล็อกเฟรมแรกอีกต่อไป", + "vi": "Khởi tạo push không còn chặn khung hình đầu", + "id": "Inisialisasi push tak lagi memblokir frame pertama", + "fil": "Hindi na hinaharangan ng push init ang unang frame", + }, + ], + 'details': >[], + 'stats': >[], + }, + { + 'id': "map", + 'icon': "map", + 'title': { + "zh_Hant": "地圖順滑拖曳 + 更小的安裝檔", + "zh_Hans": "地图顺滑拖曳 + 更小的安装档", + "en": "Silky map scrubbing, smaller install", + "ja": "なめらかな地図操作とインストール軽量化", + "ko": "부드러운 지도 조작 + 가벼운 설치", + "th": "ลากแผนที่ลื่นไหล + แอปเล็กลง", + "vi": "Lướt bản đồ mượt + cài đặt nhẹ hơn", + "id": "Geser peta mulus + ukuran instal lebih kecil", + "fil": "Makinis na pag-scroll ng mapa + mas maliit na app", + }, + 'headline': { + "zh_Hant": "拖曳時間軸看雷達,不再卡頓", + "zh_Hans": "拖曳时间轴看雷达,不再卡顿", + "en": "Scrubbing the radar timeline no longer stutters", + "ja": "レーダーのタイムラインをドラッグしてもカクつかない", + "ko": "레이더 타임라인을 드래그해도 버벅이지 않음", + "th": "ลากไทม์ไลน์เรดาร์แล้วไม่กระตุก", + "vi": "Kéo trục thời gian radar không còn giật", + "id": "Menggerus garis waktu radar tak lagi patah-patah", + "fil": "Hindi na nag-hu-hitch ang pag-scroll ng radar timeline", + }, + 'body': { + "zh_Hant": "地圖引擎全面換新。切換雷達幀變成兩次透明的屬性切換——零重新下載、零延遲。所有圖層共用同一個三層快取,暖機過的幀直接顯示。安裝檔也因為鄉鎮邊界重新編碼而縮小近 1 MB。", + "zh_Hans": "地图引擎全面换新。切换雷达帧变成两次透明的属性切换——零重新下载、零延迟。所有图层共用同一个三层快取,暖机过的帧直接显示。安装档也因为乡镇边界重新编码而缩小近 1 MB。", + "en": "The map engine was fully replaced. Switching radar frames is now two invisible property flips — zero re-downloads, zero latency. All layers share one three-tier cache, and warmed frames show instantly. The install also shrank by nearly 1 MB after town boundaries were re-encoded.", + "ja": "地図エンジンを全面的に刷新。レーダーのフレーム切り替えは透過的な 2 回のプロパティ切替になり、再ダウンロードゼロ・遅延ゼロ。全レイヤーが同じ 3 層キャッシュを共有し、ウォーム済みフレームは即表示。町境界の再エンコードでインストールも約 1 MB 縮小しました。", + "ko": "지도 엔진이 전면 교체됐습니다. 레이더 프레임 전환은 이제 투명한 속성 전환 두 번 — 재다운로드 0건, 지연 0. 모든 레이어가 같은 3단계 캐시를 공유하고, 예열된 프레임은 즉시 표시됩니다. 읍면 경계를 다시 인코딩해 설치 용량도 약 1MB 줄었습니다.", + "th": "เปลี่ยนเอนจินแผนที่ใหม่ทั้งระบบ การสลับเฟรมเรดาร์เหลือเพียงการเปลี่ยนคุณสมบัติแบบโปร่งใส 2 ครั้ง — ไม่ดาวน์โหลดซ้ำ ไม่ดีเลย์ ทุกเลเยอร์ใช้แคช 3 ชั้นร่วมกัน เฟรมที่วอร์มแล้วเปิดได้ทันที ไซส์แอปเล็กลงเกือบ 1 MB หลังเข้ารหัสขอบเขตเมืองใหม่", + "vi": "Engine bản đồ đã được thay mới hoàn toàn. Chuyển khung radar giờ chỉ là hai lần đổi thuộc tính trong suốt — không tải lại, không trễ. Mọi lớp dùng chung một cache ba tầng, khung đã làm nóng mở tức thì. Bản cài cũng nhẹ đi gần 1 MB sau khi tái mã hoá ranh giới thị trấn.", + "id": "Engine peta diganti total. Ganti frame radar kini hanya dua kali flip properti transparan — nol unduhan ulang, nol latensi. Semua layer berbagi satu cache tiga tingkat, frame yang sudah hangat langsung tampil. Ukuran instal juga menyusut hampir 1 MB setelah batas kota di-encode ulang.", + "fil": "Pinalitan nang buo ang map engine. Ang paglipat ng radar frame ay dalawang invisible na property flip na lang — walang re-download, walang delay. Lahat ng layer ay may isang three-tier cache, at agad lumalabas ang mga warmed frame. Lumiit din ng halos 1 MB ang app pagkatapos i-re-encode ang town boundaries.", + }, + 'stat': { + "zh_Hant": "−1 MB", + "zh_Hans": "−1 MB", + "en": "−1 MB", + "ja": "−1 MB", + "ko": "−1MB", + "th": "−1 MB", + "vi": "−1 MB", + "id": "−1 MB", + "fil": "−1 MB", + }, + 'statLabel': { + "zh_Hant": "安裝檔因鄉鎮邊界重新編碼縮小", + "zh_Hans": "安装档因乡镇边界重新编码缩小", + "en": "install shrank after town boundaries were re-encoded", + "ja": "町境界の再エンコードでインストール縮小", + "ko": "읍면 경계 재인코딩으로 설치 축소", + "th": "แอปเล็กลงหลังเข้ารหัสขอบเขตเมืองใหม่", + "vi": "bản cài nhẹ hơn sau khi tái mã hoá ranh giới", + "id": "ukuran instal menyusut setelah batas kota di-encode ulang", + "fil": "lumiit ang app matapos i-re-encode ang boundary", + }, + 'highlights': >[ + { + "zh_Hant": "零成本切幀,跟得上手指", + "zh_Hans": "零成本切帧,跟得上手指", + "en": "Zero-cost frame switching that follows your finger", + "ja": "ゼロコストでフレーム切替、指に追従", + "ko": "제로 코스트 프레임 전환, 손가락을 따라감", + "th": "สลับเฟรมแบบไร้ต้นทุน ตามนิ้วได้ทัน", + "vi": "Chuyển khung không tốn chi phí, theo kịp ngón tay", + "id": "Beralih frame tanpa biaya, mengikuti jari", + "fil": "Walang-bisang paglipat ng frame, sumasabay sa daliri", + }, + { + "zh_Hant": "三層快取,暖機過的幀直接顯示", + "zh_Hans": "三层快取,暖机过的帧直接显示", + "en": "Three-tier cache shows warmed frames instantly", + "ja": "3 層キャッシュでウォーム済みフレームは即表示", + "ko": "3단계 캐시로 예열된 프레임 즉시 표시", + "th": "แคช 3 ชั้น แสดงเฟรมที่วอร์มแล้วทันที", + "vi": "Cache ba tầng hiển thị khung đã nóng tức thì", + "id": "Cache tiga tingkat langsung tampilkan frame hangat", + "fil": "Three-tier cache — agad lalabas ang warmed frame", + }, + ], + 'details': >[], + 'stats': >[], + }, + { + 'id': "accuracy", + 'icon': "my_location", + 'title': { + "zh_Hant": "位置判斷更準", + "zh_Hans": "位置判断更准", + "en": "More accurate location", + "ja": "位置判定がより正確に", + "ko": "더 정확한 위치 판단", + "th": "ระบุตำแหน่งแม่นยำขึ้น", + "vi": "Xác định vị trí chính xác hơn", + "id": "Lokasi lebih akurat", + "fil": "Mas tumpak na lokasyon", + }, + 'headline': { + "zh_Hant": "鄉鎮判定從中心點猜測,變成真正的幾何測試", + "zh_Hans": "乡镇判定从中心点猜测,变成真正的几何测试", + "en": "Township detection went from centroid guessing to real geometry", + "ja": "町判定が中心点の推測から、本当の幾何テストに", + "ko": "읍면 판정이 중심점 추측에서 진짜 기하 테스트로", + "th": "การระบุตำบลเปลี่ยนจากการเดาจากศูนย์กลาง เป็นการทดสอบเรขาคณิตจริง", + "vi": "Xác định thị trấn chuyển từ đoán tâm điểm sang phép thử hình học thật", + "id": "Penentuan kecamatan berubah dari tebakan titik pusat menjadi uji geometri sejati", + "fil": "Ang pagtukoy ng bayan ay hindi na hula sa gitna, kundi tunay na geometry", + }, + 'body': { + "zh_Hant": + "在鄉鎮交界或邊緣時會判錯位置的舊演算法,已被精確的「點在多邊形內」幾何演算法取代。位置測量涵蓋正確的鄉鎮——在地警報更準確。", + "zh_Hans": + "在乡镇交界或边缘时会判错位置的旧演算法,已被精确的「点在多边形内」几何演算法取代。位置测量涵盖正确的乡镇——在地警报更准确。", + "en": "The old algorithm that misjudged you at town borders has been replaced with an exact point-in-polygon geometry test. Your measured position now resolves to the correct township — so location-based alerts are more accurate.", + "ja": "町の境界や端で位置を誤判定した旧アルゴリズムは、正確な「ポリゴン内点判定」に置き換わりました。測位が正しい町に結びつき、ローカル警報がより正確になります。", + "ko": "읍면 경계에서 위치를 잘못 판정하던 기존 알고리즘은 정확한 \"다각형 내부 점 판정\"으로 바뀌었습니다. 측정 위치가 올바른 읍면에 연결되어, 지역 알림이 더 정확해졌습니다.", + "th": "อัลกอริทึมเดิมที่ระบุตำแหน่งผิดบริเวณชายแดนของตำบล ถูกแทนที่ด้วยการทดสอบจุดในรูปหลายเหลี่ยมที่แม่นยำ ตำแหน่งที่วัดได้ครอบคลุมตำบลที่ถูกต้อง — การแจ้งเตือนตามพื้นที่แม่นยำขึ้น", + "vi": "Thuật toán cũ hay xác định sai vị trí ở ranh giới thị trấn đã được thay thế bằng phép thử điểm-trong-đa-giác chính xác. Vị trí đo được gắn đúng thị trấn — cảnh báo theo khu vực chính xác hơn.", + "id": "Algoritma lama yang salah menilai posisi di perbatasan kota telah digantikan uji titik-di-dalam-poligon yang presisi. Posisi terukur kini masuk kecamatan yang tepat — peringatan berbasis lokasi lebih akurat.", + "fil": "Ang lumang algorithm na nagkakamali sa mga hangganan ng bayan ay pinalitan na ng eksaktong point-in-polygon geometry. Ang sukat na posisyon ay nasa tamang bayan — mas tumpak ang lokasyong alerto.", + }, + 'stat': { + "zh_Hant": "1–3 個", + "zh_Hans": "1–3 个", + "en": "1–3", + "ja": "1〜3 個", + "ko": "1~3개", + "th": "1–3 จุด", + "vi": "1–3", + "id": "1–3", + "fil": "1–3", + }, + 'statLabel': { + "zh_Hant": "一次查詢只需測試的鄉鎮數(舊演算法要測 367 個)", + "zh_Hans": "一次查询只需测试的乡镇数(旧演算法要测 367 个)", + "en": "townships tested per query (the old algorithm checked all 367)", + "ja": "1 回の照会でテストする町の数(旧式は 367 個をチェック)", + "ko": "조회마다 테스트하는 읍면 수 (이전엔 367개 모두)", + "th": "จำนวนตำบลที่ทดสอบต่อการค้นหา (เดิมต้องทดสอบ 367 ตำบล)", + "vi": "số thị trấn cần kiểm tra mỗi truy vấn (cũ phải kiểm tra cả 367)", + "id": "jumlah kecamatan yang diuji per pencarian (lama harus uji 367)", + "fil": "bilang ng bayang tinitest kada query (dati 367 lahat)", + }, + 'highlights': >[ + { + "zh_Hant": "邊界不再誤判,在地警報更準", + "zh_Hans": "边界不再误判,在地警报更准", + "en": "No more border misjudgment — alerts hit the right town", + "ja": "境界の誤判定がなくなり、ローカル警報が正確に", + "ko": "경계 오판이 사라져 지역 알림이 정확해짐", + "th": "ไม่ผิดที่ชายแดนอีกต่อไป การแจ้งเตือนตามพื้นที่แม่นยำขึ้น", + "vi": "Hết sai ranh giới, cảnh báo theo vùng chính xác hơn", + "id": "Tidak ada lagi salah batas, peringatan lokal lebih akurat", + "fil": "Wala nang maling hangganan, mas tumpak ang lokal na alerto", + }, + ], + 'details': >[], + 'stats': >[], + }, + { + 'id': "time", + 'icon': "access_time", + 'title': { + "zh_Hant": "時間不再被手機時鐘騙", + "zh_Hans": "时间不再被手机时钟骗", + "en": "Time is no longer fooled by your phone clock", + "ja": "スマホ時計に騙されない時間", + "ko": "휴대폰 시계에 속지 않는 시간", + "th": "เวลาไม่ถูกหลอกด้วยนาฬิกามือถืออีกต่อไป", + "vi": "Thời gian không còn bị lừa bởi đồng hồ điện thoại", + "id": "Waktu tak lagi tertipu jam ponsel", + "fil": "Hindi na nadadaya ng orasan ng phone ang oras", + }, + 'headline': { + "zh_Hant": "真正的網路校時 + 硬體鐘錨定", + "zh_Hans": "真正的网络校时 + 硬件钟锚定", + "en": "True network time sync, anchored to the hardware clock", + "ja": "本物のネットワーク時刻同期とハードウェア時計への固定", + "ko": "진짜 네트워크 시간 동기화 + 하드웨어 시계 앵커링", + "th": "ซิงค์เวลาจากเครือข่ายจริง + ยึดกับนาฬิกาฮาร์ดแวร์", + "vi": "Đồng bộ thời gian mạng thực sự + neo vào đồng hồ phần cứng", + "id": "Sinkronisasi waktu jaringan sejati + jangkar jam perangkat keras", + "fil": "Tunay na network time sync + naka-angkla sa hardware clock", + }, + 'body': { + "zh_Hant": "改用標準 SNTP 協定校時,並把時間錨定在硬體時鐘上。手動改時間、跨時區飛行都不會讓「距離搖晃還有幾秒」的倒數算錯。", + "zh_Hans": "改用标准 SNTP 协议校时,并把时间锚定在硬件时钟上。手动改时间、跨时区飞行都不会让「距离摇晃还有几秒」的倒数算错。", + "en": "Uses the standard SNTP protocol for time sync, anchored to the hardware clock. Manually changing the time or flying across time zones no longer breaks the countdown to shaking.", + "ja": "標準 SNTP プロトコルで時刻同期し、ハードウェア時計に固定。手動で時刻を変えたり、タイムゾーンをまたいだりしても、「揺れまであと何秒」のカウントダウンが狂いません。", + "ko": "표준 SNTP 프로토콜로 시간을 동기화하고 하드웨어 시계에 고정합니다. 수동으로 시간을 바꾸거나 다른 시간대로 비행해도 \"흔들림까지 몇 초\" 카운트다운이 틀리지 않습니다.", + "th": "ใช้โปรโตคอล SNTP มาตรฐานซิงค์เวลา และยึดกับนาฬิกาฮาร์ดแวร์ การเปลี่ยนเวลามือถือหรือข้ามโซนเวลา จะไม่ทำให้การนับถอยหลัง \"อีกกี่วินาทีจะสั่น\" คลาดเคลื่อน", + "vi": "Dùng giao thức SNTP chuẩn để đồng bộ giờ, neo vào đồng hồ phần cứng. Chỉnh giờ tay hay bay qua múi giờ không còn làm sai đếm ngược \"còn mấy giây nữa rung lắc\".", + "id": "Memakai protokol SNTP standar untuk sinkronisasi, berjangkar pada jam perangkat keras. Mengubah jam manual atau terbang lintas zona waktu tidak lagi membuat hitung mundur \"beberapa detik lagi bergetar\" salah.", + "fil": "Gumagamit ng standard na SNTP para sa time sync, naka-angkla sa hardware clock. Ang manu-manong pagbabago ng oras o paglipad sa ibang time zone ay hindi na nakasisira ng countdown sa pagyanig.", + }, + 'stat': { + "zh_Hant": "SNTP", + "zh_Hans": "SNTP", + "en": "SNTP", + "ja": "SNTP", + "ko": "SNTP", + "th": "SNTP", + "vi": "SNTP", + "id": "SNTP", + "fil": "SNTP", + }, + 'statLabel': { + "zh_Hant": "標準網路校時協定(UDP/123)", + "zh_Hans": "标准网络校时协议(UDP/123)", + "en": "standard network time protocol (UDP/123)", + "ja": "標準ネットワーク時刻同期プロトコル (UDP/123)", + "ko": "표준 네트워크 시간 프로토콜 (UDP/123)", + "th": "โปรโตคอลเวลามาตรฐาน (UDP/123)", + "vi": "giao thức thời gian mạng chuẩn (UDP/123)", + "id": "protokol waktu jaringan standar (UDP/123)", + "fil": "standard network time protocol (UDP/123)", + }, + 'highlights': >[ + { + "zh_Hant": "手動改鐘、跨時區都不影響倒數", + "zh_Hans": "手动改钟、跨时区都不影响倒数", + "en": + "Manual clock changes or time-zone travel won't skew the countdown", + "ja": "手動の時計変更や時差移動でもカウントダウンは狂わない", + "ko": "수동 시계 변경, 시간대 이동에도 카운트다운 정상", + "th": "เปลี่ยนเวลามือถือหรือข้ามเขตเวลาก็ไม่กระทบการนับถอยหลัง", + "vi": "Đổi giờ tay hay đi qua múi giờ không làm lệch đếm ngược", + "id": "Ubah jam manual atau lintas zona waktu tidak memengaruhi hitung mundur", + "fil": "Hindi nagbabago ang countdown kahit palitan ang oras o mag-iba ng time zone", + }, + ], + 'details': >[], + 'stats': >[], + }, + { + 'id': "privacy", + 'icon': "shield", + 'title': { + "zh_Hant": "隱私與資料安全", + "zh_Hans": "隐私与资料安全", + "en": "Privacy and data safety", + "ja": "プライバシーとデータの安全性", + "ko": "개인정보와 데이터 안전", + "th": "ความเป็นส่วนตัวและความปลอดภัยของข้อมูล", + "vi": "Quyền riêng tư và an toàn dữ liệu", + "id": "Privasi dan keamanan data", + "fil": "Privacy at kaligtasan ng data", + }, + 'headline': { + "zh_Hant": "「清快取」真的只清快取", + "zh_Hans": "「清快取」真的只清快取", + "en": "'Clear cache' actually clears only the cache", + "ja": "「キャッシュを消す」は本当にキャッシュだけ消す", + "ko": "'캐시 지우기'는 정말 캐시만 지웁니다", + "th": "'ล้างแคช' ล้างเฉพาะแคชจริง ๆ", + "vi": "'Xoá bộ nhớ đệm' thực sự chỉ xoá bộ nhớ đệm", + "id": "'Bersihkan cache' benar-benar hanya membersihkan cache", + "fil": "'Clear cache' ay talagang cache lang ang nililinis", + }, + 'body': { + "zh_Hant": + "你的設定、記錄與離線訊息存放在另一個「耐久」資料庫,清快取時物理上碰不到它。多餘資料同步壓縮,ETag token 永不落磁碟。", + "zh_Hans": + "你的设定、记录与离线讯息存放在另一个「耐久」资料库,清快取时物理上碰不到它。多余资料同步压缩,ETag token 永不落磁盘。", + "en": "Your settings, history and offline messages live in a separate durable database that the cache cleaner cannot physically touch. Redundant data is compressed on write, and auth tokens never hit the disk cache.", + "ja": "設定・履歴・オフラインメッセージは別の「耐久」データベースに入っており、キャッシュ削除は物理的に触れません。冗長データは書き込み時に圧縮され、トークンがキャッシュディスクに保存されることはありません。", + "ko": "설정·기록·오프라인 메시지는 별도의 \"영구\" 데이터베이스에 있어, 캐시 정리가 물리적으로 건드릴 수 없습니다. 중복 데이터는 압축 저장되고, 토큰은 디스크 캐시에 닿지 않습니다.", + "th": "การตั้งค่า ประวัติ และข้อความออฟไลน์อยู่ในฐานข้อมูล \"ถาวร\" แยกต่างหาก ที่การล้างแคชไม่สามารถแตะถึงได้ ข้อมูลซ้ำซ้อนถูกบีบอัดตอนจัดเก็บ และ token ไม่ถูกเก็บลงดิสก์แคช", + "vi": "Cài đặt, lịch sử và tin nhắn ngoại tuyến nằm trong cơ sở dữ liệu \"bền vững\" riêng mà việc xoá cache không thể đụng tới. Dữ liệu dư thừa được nén khi ghi, token không bao giờ chạm vào ổ đĩa cache.", + "id": "Pengaturan, riwayat, dan pesan offline ada di database 'tahan lama' terpisah yang tak dapat disentuh pembersih cache. Data redundan dikompres saat ditulis, token tidak pernah menyentuh disk cache.", + "fil": "Ang settings, history, at offline messages ay nasa hiwalay na 'durable' database na hindi maaaring maapektuhan ng paglilinis ng cache. Ni-compress ang redundan na data, at hindi kailanman nahahawakan ng disk cache ang token.", + }, + 'stat': { + "zh_Hant": "2 個", + "zh_Hans": "2 个", + "en": "2", + "ja": "2 つ", + "ko": "2개", + "th": "2 ฐาน", + "vi": "2", + "id": "2", + "fil": "2", + }, + 'statLabel': { + "zh_Hant": "資料庫:耐久(設定/記錄)與快取(可重抓)分開存放", + "zh_Hans": "数据库:耐久(设定/记录)与快取(可重抓)分开存放", + "en": "databases — durable (settings/logs) kept apart from cache (re-fetchable)", + "ja": "データベース:耐久(設定/ログ)とキャッシュ(再取得可)を分離", + "ko": "개 데이터베이스 — 영구(설정/기록)와 캐시(재다운로드 가능) 분리", + "th": "ฐานข้อมูล — ข้อมูลถาวร (ตั้งค่า/บันทึก) แยกจากแคช (โหลดใหม่ได้)", + "vi": "cơ sở dữ liệu — bền vững (cài đặt/nhật ký) tách khỏi cache (tải lại được)", + "id": "database — tangguh (pengaturan/log) dipisah dari cache (bisa diunduh ulang)", + "fil": "database — durable (settings/logs) hiwalay sa cache (pwedeng kunin ulit)", + }, + 'highlights': >[ + { + "zh_Hant": "清快取是結構保證,不是程式紀律", + "zh_Hans": "清快取是结构保证,不是程式纪律", + "en": "Cache-clearing safety is a structural guarantee, not code discipline", + "ja": "キャッシュ削除の安全性は構造的な保証、規律ではありません", + "ko": "캐시 정리 안전성은 구조적 보장, 습관이 아님", + "th": "ความปลอดภัยในการล้างแคชเป็นการรับประกันเชิงโครงสร้าง ไม่ใช่แค่ระเบียบโค้ด", + "vi": "An toàn khi xoá cache là cam kết cấu trúc, không phải kỷ luật code", + "id": "Keamanan bersihkan cache adalah jaminan struktural, bukan disiplin kode", + "fil": "Ang kaligtasan sa pag-clear ng cache ay structural guarantee, hindi disiplina sa code", + }, + { + "zh_Hant": "token 永不落磁碟快取", + "zh_Hans": "token 永不落磁盘快取", + "en": "Auth tokens never touch the disk cache", + "ja": "トークンがキャッシュディスクに落ちない", + "ko": "토큰이 디스크 캐시에 저장되지 않음", + "th": "token ไม่ถูกเขียนลงแคชดิสก์", + "vi": "token không bao giờ chạm vào ổ đĩa cache", + "id": "token tidak pernah menyentuh disk cache", + "fil": "Hindi nahahawakan ng disk cache ang token", + }, + ], + 'details': >[], + 'stats': >[], + }, +]; diff --git a/release_highlights/pubspec.yaml b/release_highlights/pubspec.yaml new file mode 100644 index 000000000..86de43c53 --- /dev/null +++ b/release_highlights/pubspec.yaml @@ -0,0 +1,12 @@ +name: dpip_release_highlights +description: > + Version-highlight cards for DPIP. Each version's card content lives here as + Dart source, per version and per kind (`lib/26.1/normal.dart`, + `lib/26.1/advanced.dart`). The app depends on this package via a path + dependency and imports only the *current* version — older versions stay here + as the archive and are never compiled into a build. +publish_to: 'none' +version: 0.0.0 + +environment: + sdk: ^3.13.0 \ No newline at end of file diff --git a/shaders/README.md b/shaders/README.md index 94da48919..3cc87c0cc 100644 --- a/shaders/README.md +++ b/shaders/README.md @@ -87,7 +87,7 @@ than animation frames. A procedural fbm field reads as marble and cannot reproduce them; that was the first attempt at this and it failed. DPIP generates its own sprites in the same two-part layout -(`tool/gen_cloud_sprites.py`, an offline volumetric raymarch), so the *shading* +(`tool/gen/cloud_sprites.py`, an offline volumetric raymarch), so the *shading* is a faithful port while the artwork is original: ``` @@ -121,9 +121,9 @@ which is why the clouds agree with the sky at every hour with no palette. ## Regenerating ``` -tool/gen_cloud_sprites.py # cloud sprites -> assets/weather/clouds/ -tool/gen_particle_sprites.py # rain/snow/drop -> assets/weather/particles/ -tool/gen_sky_textures.py # starmap, flare -> assets/weather/sky/ +tool/gen/cloud_sprites.py # cloud sprites -> assets/weather/clouds/ +tool/gen/particle_sprites.py # rain/snow/drop -> assets/weather/particles/ +tool/gen/sky_textures.py # starmap, flare -> assets/weather/sky/ ``` Preview renders: `flutter test --run-skipped --tags preview diff --git a/test/app/theme/app_gold_test.dart b/test/app/theme/app_gold_test.dart index 2b1df6bdd..1f137ab3e 100644 --- a/test/app/theme/app_gold_test.dart +++ b/test/app/theme/app_gold_test.dart @@ -32,19 +32,12 @@ void main() { ('dark', AppGold.dark), ]) { group(name, () { - test('ink reads on both ends of the gradient', () { - // Both ends: a gradient that only passes at one end is unreadable - // across half the card. - for (final (where, fill) in [ - ('start', gold.fillStart), - ('end', gold.fillEnd), - ]) { - expect( - _contrast(gold.ink, fill), - greaterThanOrEqualTo(4.5), - reason: '$name ink on the gradient $where', - ); - } + test('ink reads on the fill', () { + expect( + _contrast(gold.ink, gold.fill), + greaterThanOrEqualTo(4.5), + reason: '$name ink on the fill', + ); }); test('the badge mark reads on the badge', () { @@ -53,8 +46,8 @@ void main() { test('the badge separates from the card it sits on', () { // A filled badge is a non-text element: 3:1 is what makes it a shape - // rather than a smudge on the gradient behind it. - expect(_contrast(gold.badge, gold.fillEnd), greaterThanOrEqualTo(3)); + // rather than a smudge on the fill behind it. + expect(_contrast(gold.badge, gold.fill), greaterThanOrEqualTo(3)); }); }); } @@ -64,8 +57,8 @@ void main() { // dark mode with an opacity. A dark fill that is not actually dark makes // the card glare on a near-black page. expect( - _luminance(AppGold.light.fillEnd), - greaterThan(_luminance(AppGold.dark.fillEnd) * 4), + _luminance(AppGold.light.fill), + greaterThan(_luminance(AppGold.dark.fill) * 4), reason: 'the dark fill is not meaningfully darker', ); expect( diff --git a/test/core/diagnostics/debug_dump_test.dart b/test/core/diagnostics/debug_dump_test.dart new file mode 100644 index 000000000..7d2bad71b --- /dev/null +++ b/test/core/diagnostics/debug_dump_test.dart @@ -0,0 +1,81 @@ +/// What a diagnostics dump carries, and what it drops when it cannot carry it. +library; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dpip/core/diagnostics/debug_dump.dart'; + +void main() { + List lines(int n) => [ + for (var i = n - 1; i >= 0; i--) '[1:00:00][INFO] : line $i', + ]; + + test('diagnostics first, then the log, separated by a blank line', () { + final out = buildDump( + diagnostics: 'Version: 26w34b', + logLines: ['[1:00:00][INFO] : started'], + ); + expect( + out, + '=== 除錯資訊 ===\n' + 'Version: 26w34b\n' + '\n' + '=== 日誌紀錄 ===\n' + '[1:00:00][INFO] : started', + ); + }); + + test('the whole thing fits the limit', () { + final out = buildDump(diagnostics: 'x' * 500, logLines: lines(2000)); + expect(out.length, lessThanOrEqualTo(dumpLimit)); + }); + + test('the log is filled from the newest backwards', () { + // Sized so exactly one line fits, rather than padded until it does. + const head = '=== 除錯資訊 ===\nd\n\n=== 日誌紀錄 ===\n'; + const line = '[1:00:00][INFO] : newest'; + final out = buildDump( + diagnostics: 'd', + logLines: const [line, '[1:00:00][INFO] : oldest'], + limit: head.length + line.length + 1, + ); + expect(out, contains('newest')); + expect(out, isNot(contains('oldest'))); + }); + + test('but reads oldest first, like a log', () { + final out = buildDump( + diagnostics: 'x', + logLines: ['[1:00:02][INFO] : third', '[1:00:01][INFO] : second'], + ); + expect(out.indexOf('second'), lessThan(out.indexOf('third'))); + }); + + test('diagnostics are never cut to make room', () { + // A partial diagnostic reads as a complete one and is answered as if it + // were, which is worse than carrying no log at all. + final big = 'Version: 26w34b\n${'x' * 5000}'; + final out = buildDump(diagnostics: big, logLines: lines(50)); + expect(out, contains('Version: 26w34b')); + expect(out, contains('x' * 5000)); + expect(out, isNot(contains('line 0'))); + }); + + test('no log at all still produces a readable dump', () { + final out = buildDump(diagnostics: 'Version: 26w34b', logLines: const []); + expect(out, endsWith('=== 日誌紀錄 ===')); + }); + + test('a line is taken whole or not at all', () { + const head = '=== 除錯資訊 ===\nd\n\n=== 日誌紀錄 ===\n'; + final out = buildDump( + diagnostics: 'd', + logLines: const ['[1:00:00][INFO] : a line that will not fit'], + // One short of what the line needs. + limit: head.length + 10, + ); + // Half a line is a lie about what was logged. + expect(out, isNot(contains('a line'))); + expect(out.length, lessThanOrEqualTo(head.length + 10)); + }); +} diff --git a/test/core/diagnostics/diagnostics_text_test.dart b/test/core/diagnostics/diagnostics_text_test.dart new file mode 100644 index 000000000..76aec8f4b --- /dev/null +++ b/test/core/diagnostics/diagnostics_text_test.dart @@ -0,0 +1,57 @@ +/// The pasted half of a dump. Two things here are worth a test rather than a +/// reading: that the redaction list is actually applied (a push token in a +/// pasted dump lets anyone notify that device), and that a section whose every +/// field was redacted does not leave its heading behind — an empty `[Push]` +/// block reads as "this device has no token", which is a different bug report. +library; + +import 'package:dpip/core/diagnostics/diagnostics_report.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('redacted labels do not appear, by label or by value', () { + final text = diagnosticsText([ + ( + title: 'Device', + fields: [ + (label: 'Model', value: 'iPhone17,1'), + (label: 'Identifier', value: 'D4E7-PRIVATE'), + ], + ), + (title: 'Push', fields: [(label: 'APNs token', value: 'SECRET-TOKEN')]), + ], redacted: diagnosticsRedactedLabels); + + expect(text, contains('iPhone17,1')); + expect(text, isNot(contains('D4E7-PRIVATE'))); + expect(text, isNot(contains('SECRET-TOKEN'))); + expect(text, isNot(contains('Identifier'))); + }); + + test('a section left with nothing to say is dropped whole', () { + final text = diagnosticsText([ + (title: 'Push', fields: [(label: 'FCM token', value: 'SECRET')]), + ], redacted: diagnosticsRedactedLabels); + + expect(text, isNot(contains('[Push]'))); + }); + + test('a field the platform could not answer reads as a dash, not blank', () { + final text = diagnosticsText([ + (title: 'Platform', fields: [(label: 'OS version', value: null)]), + ]); + + // A blank after the colon looks like a formatting bug; the dash says the + // field was asked for and came back empty. + expect(text, contains('OS version: —')); + }); + + test('nothing redacted by default', () { + final text = diagnosticsText([ + (title: 'Device', fields: [(label: 'Identifier', value: 'VISIBLE')]), + ]); + + // The redaction is the caller's decision: the Developer page shows these + // rows on screen and only strips them on the way out. + expect(text, contains('VISIBLE')); + }); +} diff --git a/test/core/logging/log_benign_assert_test.dart b/test/core/logging/log_benign_assert_test.dart new file mode 100644 index 000000000..5f6a2478c --- /dev/null +++ b/test/core/logging/log_benign_assert_test.dart @@ -0,0 +1,79 @@ +/// Opening the log screen's own Actions sheet used to write one ERROR per row +/// into the log that sheet belongs to — a debug assert from talker_flutter, +/// which paints that sheet as a coloured box with bare `ListTile`s inside it. +/// Nothing this app can fix, and nothing a user is affected by, but it filled +/// the terminal, the log page and the 4000-character dump budget. +/// +/// The tests here pin the two halves of the compromise: it is said once, so it +/// is on the record, and it is said only once, so it cannot flood. A real error +/// arriving in between must still come through untouched. +library; + +import 'package:dpip/core/logging/log.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:talker_flutter/talker_flutter.dart'; + +/// The exact summary Flutter raises. Copied from +/// `packages/flutter/lib/src/material/list_tile.dart`. +const String _summary = + 'ListTile background color or ink splashes may be invisible.'; + +FlutterErrorDetails _details(String summary) => FlutterErrorDetails( + exception: FlutterError.fromParts([ErrorSummary(summary)]), + library: 'widgets library', +); + +void main() { + setUp(() { + Log.resetErrorRepeats(); + Log.talker.cleanHistory(); + }); + + test('the known assert is logged once and then dropped', () { + Log.installErrorHandlers(); + + // Six rows in the sheet — six reports, from one tap. + for (var i = 0; i < 6; i++) { + FlutterError.onError!(_details(_summary)); + } + + final said = Log.talker.history + .where((e) => e.displayMessage.contains('ink splashes')) + .toList(); + expect(said, hasLength(1)); + // Warning, not error: it is a real defect, just not one anybody here can + // act on — and the level is what the log page filters by. + expect(said.single.title, 'WARN'); + // The line has to carry the reason, or the next person spends the evening + // hunting a widget that is not in this repository. + expect(said.single.displayMessage, contains('talker_flutter')); + }); + + test('a second session says it again', () { + Log.installErrorHandlers(); + FlutterError.onError!(_details(_summary)); + Log.resetErrorRepeats(); + Log.talker.cleanHistory(); + + FlutterError.onError!(_details(_summary)); + + expect( + Log.talker.history.where( + (e) => e.displayMessage.contains('ink splashes'), + ), + hasLength(1), + ); + }); + + test('an unrelated error is not swallowed by the match', () { + Log.installErrorHandlers(); + + FlutterError.onError!(_details('A RenderFlex overflowed by 42 pixels.')); + + expect( + Log.talker.history.where((e) => e.displayMessage.contains('RenderFlex')), + isNotEmpty, + ); + }); +} diff --git a/test/core/logging/log_clean_test.dart b/test/core/logging/log_clean_test.dart new file mode 100644 index 000000000..80d0bfeda --- /dev/null +++ b/test/core/logging/log_clean_test.dart @@ -0,0 +1,78 @@ +/// The screen's clear button has to clear the table, not just the screen. +/// +/// `talker.cleanHistory()` empties the in-memory list; the stored log is what +/// the screen replays from on the next visit, so leaving it behind made the +/// button look broken — everything came straight back. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/logging/log_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + sqfliteFfiInit(); + + late Database db; + late LogStore store; + + setUp(() async { + db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + await LogStore.createSchema(db); + store = LogStore(db); + Log.store = store; + Log.talker.cleanHistory(); + }); + + tearDown(() async { + Log.store = null; + await db.close(); + }); + + test('clearing the screen empties the stored log too', () async { + store.add( + StoredLog( + time: DateTime.utc(2026, 8, 18), + level: 'info', + message: 'before', + ), + ); + await store.flush(); + expect((await store.recent()).length, 1); + + Log.talker.cleanHistory(); + // `clean` is synchronous and the delete is not; the write is started, not + // waited on. + await Future.delayed(Duration.zero); + await pumpEventQueue(); + + expect(await store.recent(), isEmpty); + expect(Log.talker.history, isEmpty); + }); + + test('clearing without a store does not throw', () async { + Log.store = null; + expect(Log.talker.cleanHistory, returnsNormally); + }); + + test( + 'lines logged before the database opened are written when it does', + () async { + // `Log.info('DPIP starting up')` runs at bootstrap.dart:111 and the store + // opens at :139, so the lines that explain a crash during launch were the + // ones never stored — and are the ones lost the moment the screen loads + // the table over the top of memory. + Log.store = null; + Log.talker.cleanHistory(); + Log.info('DPIP starting up'); + + Log.persistTo(store); + await store.flush(); + + final stored = await store.recent(); + expect(stored.map((e) => e.message), contains('DPIP starting up')); + }, + ); +} diff --git a/test/core/logging/log_console_test.dart b/test/core/logging/log_console_test.dart new file mode 100644 index 000000000..96a54ac83 --- /dev/null +++ b/test/core/logging/log_console_test.dart @@ -0,0 +1,53 @@ +/// What the console actually receives. +/// +/// `flutter run` prefixes every printed line with `flutter: `, and nothing on +/// that pipe interprets ANSI — so the default logger's box drew three prefixed +/// lines around one message and its colours arrived as the literal text +/// `^[[38;5;4m`. +library; + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dpip/core/logging/log.dart'; + +List _printed(void Function() body) { + final lines = []; + runZoned( + body, + zoneSpecification: ZoneSpecification( + print: (_, _, _, line) => lines.add(line), + ), + ); + return lines; +} + +void main() { + test('one entry prints one line', () { + final lines = _printed(() { + Log.info('a line'); + Log.warning('another'); + }); + expect(lines.length, 2); + }); + + test('nothing is drawn around it', () { + final line = _printed(() => Log.info('a line')).single; + expect(line, isNot(contains('\u2500')), reason: 'no rule'); + expect(line, isNot(contains('\u2502')), reason: 'no border'); + expect(line, isNot(contains('\u250c'))); + expect(line, isNot(contains('\u2514'))); + }); + + test('no escape sequence reaches a pipe that cannot read one', () { + final line = _printed(() => Log.error('a line')).single; + expect(line, isNot(contains(String.fromCharCode(27)))); + }); + + test('the level tag and the message both survive', () { + final line = _printed(() => Log.warning('poll failed')).single; + expect(line, contains('[WARN]')); + expect(line, contains('poll failed')); + }); +} diff --git a/test/core/logging/log_formatter_test.dart b/test/core/logging/log_formatter_test.dart new file mode 100644 index 000000000..a08af7250 --- /dev/null +++ b/test/core/logging/log_formatter_test.dart @@ -0,0 +1,77 @@ +/// The console formatter, on both sides of the colour switch. +/// +/// Whether an escape sequence renders is the terminal's business, not the +/// app's — see [Log.enableConsoleColor] for why this is a `--dart-define` +/// rather than a detection. +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:talker_flutter/talker_flutter.dart'; + +import 'package:dpip/core/logging/log.dart'; + +const _esc = 27; + +String format(String message, {required bool colour}) => + const TagFormatter().fmt( + LogDetails( + message: message, + level: LogLevel.warning, + pen: AnsiPen()..yellow(), + ), + TalkerLoggerSettings(enableColors: colour), + ); + +void main() { + // `ansicolor` disables itself when stdout is not a terminal, and a test host + // is not one. `TalkerLogger`'s constructor forces it back on regardless of + // stdout — which is why the escapes reached a pipe that could not read them + // in the first place — so the coloured path has to be opened here to be + // exercised at all. + setUp(() => ansiColorDisabled = false); + tearDown(() => ansiColorDisabled = true); + + test('colour off: the line is exactly the message', () { + final out = format('[WARN] | 12:00:00 5ms | hi', colour: false); + expect(out, '[12:00:00][WARN] : hi'); + expect(out.codeUnits, isNot(contains(_esc))); + }); + + test('colour on: only the tag is painted', () { + final out = format('[WARN] | 12:00:00 5ms | hi', colour: true); + expect(out.codeUnits, contains(_esc), reason: 'the tag is coloured'); + // A fully coloured line is harder to read than a plain one, and a leak + // into a window that cannot render it then costs one token, not the line. + final afterTag = out.substring(out.indexOf(': ')); + expect(afterTag.codeUnits, isNot(contains(_esc))); + expect(out, contains('hi')); + }); + + test('a line with no tag is left alone', () { + expect(format('no tag here', colour: true), 'no tag here'); + }); + + test('the default build ships no escapes at all', () { + // The common window — VS Code's Debug Console — prints them literally. + expect(Log.enableConsoleColor, isFalse); + }); + + test('iOS never emits them, flag or no flag', () { + // The platform's log path escapes the escape character, so even a + // terminal that supports ANSI receives a backslash and the sequence and + // prints it — flutter/flutter#20663. The flag cannot help there, so it + // does not apply there. + if (!Platform.isIOS) { + // The VM host is not iOS; assert the rule that produces the value + // rather than a value this platform cannot exercise. + expect( + Log.enableConsoleColor, + const bool.fromEnvironment('DPIP_LOG_COLOR'), + ); + return; + } + expect(Log.enableConsoleColor, isFalse); + }); +} diff --git a/test/core/logging/log_line_test.dart b/test/core/logging/log_line_test.dart new file mode 100644 index 000000000..37e6bf0fc --- /dev/null +++ b/test/core/logging/log_line_test.dart @@ -0,0 +1,83 @@ +/// The one shape a log line has, in the console and in an uploaded dump. +library; + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dpip/core/logging/log.dart'; + +List _printed(void Function() body) { + final lines = []; + runZoned( + body, + zoneSpecification: ZoneSpecification( + print: (_, _, _, line) => lines.add(line), + ), + ); + return lines; +} + +void main() { + test('a line reads [time][TAG] : message', () { + expect( + logLine( + tag: 'INFO', + time: DateTime(2026, 8, 18, 5, 32, 38), + message: 'Firebase initialized', + ), + '[5:32:38][INFO] : Firebase initialized', + ); + }); + + test('the colons line up whatever the tag', () { + final columns = {}; + for (final tag in [ + 'VERBOSE', + 'DEBUG', + 'INFO', + 'WARN', + 'ERROR', + 'CRITICAL', + ]) { + final line = logLine( + tag: tag, + time: DateTime(2026, 8, 18, 5, 32, 38), + message: 'x', + ); + columns.add(line.indexOf(': ')); + } + expect(columns.length, 1, reason: 'one column, or the messages step'); + }); + + test('minutes and seconds are padded, the hour is not', () { + expect( + logLine(tag: 'INFO', time: DateTime(2026, 8, 18, 5, 2, 3), message: 'x'), + startsWith('[5:02:03]'), + ); + }); + + test('the console prints that same shape', () { + // Talker hands the formatter a finished string, so this is a parse — if + // its layout ever changes, this fails instead of the terminal. + final line = _printed(() => Log.warning('poll failed')).single; + expect(line, endsWith(': poll failed')); + expect(line, contains('[WARN]')); + expect(RegExp(r'^\[\d{1,2}:\d{2}:\d{2}\]').hasMatch(line), isTrue); + // Padded to the same column the builder uses. + expect( + line.indexOf(': '), + logLine(tag: 'WARN', time: DateTime.now(), message: 'x').indexOf(': '), + ); + }); + + test('the console keeps one line per entry', () { + expect( + _printed(() { + Log.info('a'); + Log.error('b'); + }).length, + 2, + ); + }); +} diff --git a/test/core/logging/log_repeat_test.dart b/test/core/logging/log_repeat_test.dart new file mode 100644 index 000000000..f84a86194 --- /dev/null +++ b/test/core/logging/log_repeat_test.dart @@ -0,0 +1,98 @@ +/// The loop that made "tap the log, the app freezes" possible. +/// +/// Reporting an error is not consequence-free: it goes to Talker, whose stream +/// the log screen rebuilds on and the persister writes to disk from. A fault +/// raised *while rendering that screen* therefore re-enters through the +/// rebuild it just caused, and every turn adds a Crashlytics report and a +/// database write. +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dpip/core/logging/log.dart'; + +void main() { + // `flutter_test` installs its own `FlutterError.onError`, so the real wiring + // has to be put back to be exercised at all. + FlutterExceptionHandler? original; + setUp(() { + Log.resetErrorRepeats(); + original = FlutterError.onError; + Log.installErrorHandlers(); + }); + tearDown(() => FlutterError.onError = original); + + FlutterErrorDetails overflow() => FlutterErrorDetails( + exception: FlutterError('A RenderFlex overflowed by 42 pixels'), + library: 'rendering library', + context: ErrorDescription('during layout'), + ); + + test('the same fault is reported, then stops being reported', () { + final before = Log.talker.history.length; + // What a layout fault does: once per frame, forever. + for (var i = 0; i < 200; i++) { + FlutterError.onError!(overflow()); + } + final logged = Log.talker.history.length - before; + expect( + logged, + lessThan(200), + reason: 'an unbounded loop is what freezes the screen', + ); + expect(logged, greaterThan(0), reason: 'the first one is real'); + }); + + test('the console dump is suppressed too, not only the record', () { + // The flood this fixes: the log, the crash report and the database were + // all covered, but `FlutterError.presentError` sat before the check and + // kept printing the same fault every frame. The stored data looked fine + // and the terminal was unusable. + var dumps = 0; + final priorPresent = FlutterError.presentError; + FlutterError.presentError = (_) => dumps++; + addTearDown(() => FlutterError.presentError = priorPresent); + + for (var i = 0; i < 100; i++) { + FlutterError.onError!(overflow()); + } + expect(dumps, lessThan(100), reason: 'the terminal is a resource too'); + expect(dumps, greaterThan(0), reason: 'the first few are how you find it'); + }); + + test('a different fault is never suppressed by another', () { + for (var i = 0; i < 50; i++) { + FlutterError.onError!(overflow()); + } + final before = Log.talker.history.length; + FlutterError.onError!( + FlutterErrorDetails( + exception: StateError('something else entirely'), + library: 'dpip', + context: ErrorDescription('unrelated'), + ), + ); + expect( + Log.talker.history.length, + greaterThan(before), + reason: 'suppression must be per fault, not global', + ); + }); + + test('the suppression notice cannot itself be the next turn', () { + // It goes through `info`, so it is not an error and cannot re-enter + // FlutterError.onError. + final baseline = Log.talker.history.length; + for (var i = 0; i < 40; i++) { + FlutterError.onError!(overflow()); + } + // Counted from a baseline: the Talker instance is a singleton, so its + // history outlives the test that produced it. + final notices = Log.talker.history + .skip(baseline) + .where((e) => e.generateTextMessage().contains('suppressing')) + .length; + expect(notices, 1, reason: 'said once, not once per frame'); + }); +} diff --git a/test/core/logging/log_store_test.dart b/test/core/logging/log_store_test.dart index 4b82ac602..fefa9c1ad 100644 --- a/test/core/logging/log_store_test.dart +++ b/test/core/logging/log_store_test.dart @@ -101,6 +101,33 @@ void main() { expect(messages, ['new']); }); + test('the row ceiling is applied on write, not only on the sweep', () async { + // A fault that logs every frame writes faster than any sweep runs, so the + // ceiling has to hold between sweeps — the freeze this guards against was + // the log screen feeding itself. + final (store, db) = await makeStore(flushAt: logMaxRows * 2); + for (var i = 0; i < logMaxRows + 250; i++) { + store.add(line('line $i', at: clock.add(Duration(seconds: i)))); + } + await store.flush(); + final rows = await db.rawQuery('SELECT COUNT(*) AS n FROM $logTable'); + expect(rows.single['n'], logMaxRows); + }); + + test('the ceiling keeps the newest lines, not the oldest', () async { + final (store, _) = await makeStore(flushAt: logMaxRows * 2); + for (var i = 0; i < logMaxRows + 5; i++) { + store.add(line('line $i', at: clock.add(Duration(seconds: i)))); + } + await store.flush(); + expect( + (await store.recent(limit: 1)).single.message, + 'line ${logMaxRows + 4}', + ); + final all = await store.recent(limit: logMaxRows); + expect(all.map((e) => e.message), isNot(contains('line 0'))); + }); + test('reads come back newest first', () async { final (store, _) = await makeStore(); for (var i = 0; i < 3; i++) { diff --git a/test/core/network/api_client_test.dart b/test/core/network/api_client_test.dart index 8cd7d4c9b..b4e940346 100644 --- a/test/core/network/api_client_test.dart +++ b/test/core/network/api_client_test.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/network/region_selection.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:dpip/core/settings/settings_store.dart'; @@ -50,6 +51,11 @@ void main() { ApiClient clientWith(_FakeAdapter adapter) => ApiClient(Dio()..httpClientAdapter = adapter, regions); + ApiClient monitoredClient( + _FakeAdapter adapter, + EndpointHealthMonitor health, + ) => ApiClient(Dio()..httpClientAdapter = adapter, regions, health); + test('first host succeeds → no failover', () async { final adapter = _FakeAdapter((_, _) => _json('{"ok":true}', 200)); final res = await clientWith(adapter).request(ApiTier.lbApi, '/x'); @@ -121,4 +127,106 @@ void main() { ); expect(adapter.hits, hasLength(1)); }); + + test('a successful request marks the host healthy', () async { + final health = EndpointHealthMonitor(); + final adapter = _FakeAdapter((_, _) => _json('{"ok":true}', 200)); + await monitoredClient(adapter, health).request(ApiTier.lbApi, '/x'); + + expect( + health.of( + EndpointService.other, + ApiTier.lbApi, + 'api.lb-tpe1.exptech.dev', + ), + isNotNull, + ); + expect(health.summary, EndpointState.healthy); + expect( + health + .of(EndpointService.other, ApiTier.lbApi, 'api.lb-tpe1.exptech.dev')! + .lastSuccess, + isNotNull, + ); + }); + + test('failed-over request marks the dead host and the healthy one', () async { + final health = EndpointHealthMonitor(); + final adapter = _FakeAdapter( + (call, _) => call == 1 ? _json('{}', 503) : _json('{"ok":true}', 200), + ); + await monitoredClient(adapter, health).request(ApiTier.lbApi, '/x'); + + // First host (tpe1) got a 503 → degraded after one failure. + final tpe1 = health.of( + EndpointService.other, + ApiTier.lbApi, + 'api.lb-tpe1.exptech.dev', + )!; + expect(tpe1.consecutiveFailures, 1); + expect(tpe1.state, EndpointState.degraded); + // Second host (khh1) served the 200 → healthy. + final khh1 = health.of( + EndpointService.other, + ApiTier.lbApi, + 'api.lb-khh1.exptech.dev', + )!; + expect(khh1.state, EndpointState.healthy); + expect(health.summary, EndpointState.degraded); + }); + + test('exclusive and core tiers track the same host separately', () async { + final health = EndpointHealthMonitor(); + final adapter = _FakeAdapter((_, _) => _json('{"ok":true}', 200)); + final client = monitoredClient(adapter, health); + await client.request(ApiTier.coreApi, '/x'); + await client.request(ApiTier.coreExclusiveApi, '/x'); + + // Both hit api.core-tnn1, but they are different services: two entries. + expect(health.entries, hasLength(2)); + expect( + health + .of( + EndpointService.other, + ApiTier.coreApi, + 'api.core-tnn1.exptech.dev', + )! + .state, + EndpointState.healthy, + ); + expect( + health + .of( + EndpointService.other, + ApiTier.coreExclusiveApi, + 'api.core-tnn1.exptech.dev', + )! + .state, + EndpointState.healthy, + ); + }); + + test('two consecutive failures mark the host down', () async { + final health = EndpointHealthMonitor(); + // Every request 503s; tpe1 is the first host attempted in each run, so + // two runs accumulate a two-failure streak on it. + final adapter = _FakeAdapter((_, _) => _json('{}', 503)); + final client = monitoredClient(adapter, health); + await expectLater( + () => client.request(ApiTier.lbApi, '/x'), + throwsA(isA()), + ); + await expectLater( + () => client.request(ApiTier.lbApi, '/x'), + throwsA(isA()), + ); + + final tpe1 = health.of( + EndpointService.other, + ApiTier.lbApi, + 'api.lb-tpe1.exptech.dev', + )!; + expect(tpe1.state, EndpointState.down); + expect(health.summary, EndpointState.down); + }); } diff --git a/test/core/network/endpoint_health_test.dart b/test/core/network/endpoint_health_test.dart new file mode 100644 index 000000000..235f126f3 --- /dev/null +++ b/test/core/network/endpoint_health_test.dart @@ -0,0 +1,171 @@ +/// Client-side endpoint health — the judgements the 伺服器狀態 screen renders +/// as the "本機狀態" block. +library; + +import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _eew = '/api/v2/eq/eew?sse=1'; +const _rts = '/api/v2/trem/rts?sse=1'; + +void main() { + test('unknown until a request lands', () { + final m = EndpointHealthMonitor(); + expect(m.summary, EndpointState.unknown); + expect(m.entries, isEmpty); + expect(m.needsAttention, isFalse); + }); + + test('one success → healthy, keyed by hostname without scheme', () { + final m = EndpointHealthMonitor(); + m.success(ApiTier.lbApi, 'https://api.lb-tpe1.exptech.dev/path?x=1', _eew); + + final h = m.of( + EndpointService.eew, + ApiTier.lbApi, + 'api.lb-tpe1.exptech.dev', + ); + expect(h, isNotNull); + expect(h!.host, 'api.lb-tpe1.exptech.dev'); + expect(h.tier, ApiTier.lbApi); + expect(h.service, EndpointService.eew); + expect(h.regionCode, 'TPE1'); + expect(h.state, EndpointState.healthy); + expect(h.lastSuccess, isNotNull); + expect(h.lastFailure, isNull); + expect(h.consecutiveFailures, 0); + expect(m.summary, EndpointState.healthy); + }); + + test('the same host from a bare name is the same entry', () { + final m = EndpointHealthMonitor(); + m.success(ApiTier.lbApi, 'api.lb-tpe1.exptech.dev', _eew); + // Feeding a full URL collapses onto the same record. + m.failure(ApiTier.lbApi, 'https://api.lb-tpe1.exptech.dev', _eew); + expect(m.entries, hasLength(1)); + }); + + test('one failure → degraded, a second consecutive → down', () { + final m = EndpointHealthMonitor(); + m.failure(ApiTier.lbApi, 'https://api.lb-tpe1.exptech.dev', _eew); + expect( + m + .of(EndpointService.eew, ApiTier.lbApi, 'api.lb-tpe1.exptech.dev')! + .state, + EndpointState.degraded, + ); + expect(m.summary, EndpointState.degraded); + expect(m.needsAttention, isTrue); + + m.failure(ApiTier.lbApi, 'https://api.lb-tpe1.exptech.dev', _eew); + expect( + m + .of(EndpointService.eew, ApiTier.lbApi, 'api.lb-tpe1.exptech.dev')! + .state, + EndpointState.down, + ); + expect(m.summary, EndpointState.down); + }); + + test('a success clears the failure streak', () { + final m = EndpointHealthMonitor(); + m.failure(ApiTier.lbApi, 'https://api.lb-tpe1.exptech.dev', _eew); + m.failure(ApiTier.lbApi, 'https://api.lb-tpe1.exptech.dev', _eew); + expect( + m + .of(EndpointService.eew, ApiTier.lbApi, 'api.lb-tpe1.exptech.dev')! + .state, + EndpointState.down, + ); + + m.success(ApiTier.lbApi, 'https://api.lb-tpe1.exptech.dev', _eew); + final h = m.of( + EndpointService.eew, + ApiTier.lbApi, + 'api.lb-tpe1.exptech.dev', + )!; + expect(h.state, EndpointState.healthy); + expect(h.consecutiveFailures, 0); + expect(h.lastFailure, isNotNull); // history kept, streak reset + expect(m.summary, EndpointState.healthy); + expect(m.needsAttention, isFalse); + }); + + test( + 'summary is down when any host is down, degraded when any is degraded', + () { + final m = EndpointHealthMonitor(); + m.success(ApiTier.coreApi, 'https://api.core-tnn1.exptech.dev', _eew); + m.failure(ApiTier.lbApi, 'https://api.lb-khh1.exptech.dev', _eew); + expect(m.summary, EndpointState.degraded); + expect(m.needsAttention, isTrue); + + m.failure(ApiTier.lbApi, 'https://api.lb-khh1.exptech.dev', _eew); + expect(m.summary, EndpointState.down); + }, + ); + + test('same host on different tiers is tracked separately', () { + final m = EndpointHealthMonitor(); + // core-tnn1 carries both the redundant coreApi and the exclusive + // coreExclusiveApi; one failing must not taint the other. + m.success(ApiTier.coreApi, 'https://api.core-tnn1.exptech.dev', _eew); + m.failure( + ApiTier.coreExclusiveApi, + 'https://api.core-tnn1.exptech.dev', + _eew, + ); + + final core = m.of( + EndpointService.eew, + ApiTier.coreApi, + 'api.core-tnn1.exptech.dev', + )!; + expect(core.state, EndpointState.healthy); + final exclusive = m.of( + EndpointService.eew, + ApiTier.coreExclusiveApi, + 'api.core-tnn1.exptech.dev', + )!; + expect(exclusive.state, EndpointState.degraded); + // Same hostname, two tiers → two entries. + expect(m.entries, hasLength(2)); + }); + + test('same host on different services is tracked separately', () { + final m = EndpointHealthMonitor(); + // EEW and RTS both ride lbApi — one failing must not taint the other. + m.success(ApiTier.lbApi, 'https://api.lb-tpe1.exptech.dev', _eew); + m.failure(ApiTier.lbApi, 'https://api.lb-tpe1.exptech.dev', _rts); + + final eew = m.of( + EndpointService.eew, + ApiTier.lbApi, + 'api.lb-tpe1.exptech.dev', + )!; + expect(eew.state, EndpointState.healthy); + final rts = m.of( + EndpointService.rts, + ApiTier.lbApi, + 'api.lb-tpe1.exptech.dev', + )!; + expect(rts.state, EndpointState.degraded); + expect(m.entries, hasLength(2)); + }); + + test('regionCode derives from the hostname', () { + final m = EndpointHealthMonitor(); + m.success( + ApiTier.lbApi, + 'https://api.lb-khh1.exptech.dev', + '/api/v2/eq/eew', + ); + expect( + m + .of(EndpointService.eew, ApiTier.lbApi, 'api.lb-khh1.exptech.dev')! + .regionCode, + 'KHH1', + ); + }); +} diff --git a/test/core/network/etag_interceptor_test.dart b/test/core/network/etag_interceptor_test.dart index c3c49f987..147684ca8 100644 --- a/test/core/network/etag_interceptor_test.dart +++ b/test/core/network/etag_interceptor_test.dart @@ -70,6 +70,9 @@ void main() { Dio dioWith(HttpClientAdapter adapter) => createDio(etagCache: store)..httpClientAdapter = adapter; + Future settle() => + Future.delayed(const Duration(milliseconds: 150)); + test('a 304 is served from cache as a 200 with the cached body', () async { final adapter = _FakeAdapter(body: '{"n":1}', etag: 'v1'); final dio = dioWith(adapter); @@ -165,6 +168,93 @@ void main() { expect(again.data, isEmpty); expect(adapter.calls, 1); }); + + test('a status-dashboard POST is cached under the URL hash', () async { + const url = 'https://status.exptech.dev/api/ds/query'; + final adapter = _FakeAdapter(body: '{"results":{}}', etag: 'no-etag'); + final dio = dioWith(adapter); + + final first = await dio.post(url, data: {'queries': []}); + expect(first.statusCode, 200); + expect(first.data, {'results': {}}); + // Immutable POST: stored under a synthetic URL-hash ETag, ignoring the + // server's (here absent) ETag. + await settle(); + final cached = await store.readJson(url); + expect(cached, isNotNull); + expect(cached!.etag, EtagInterceptor.etagFromUrl(Uri.parse(url))); + expect(cached.data, {'results': {}}); + + // A follow-up POST still goes to the network — online must always refresh — + // and again overwrites the entry (same URL hash, `replace`). + await dio.post(url, data: {'queries': []}); + expect(adapter.calls, 2); + // Writes are fire-and-forget inside the interceptor (unawaited), so give + // the gzip+insert hop a beat before the read. + await settle(); + expect(await store.readJson(url), isNotNull); + }); + + test('a status-dashboard POST serves the cached snapshot when offline', () async { + const url = 'https://status.exptech.dev/api/ds/query'; + // Prime the store with a good snapshot, then make every network call fail. + final okAdapter = _FakeAdapter(body: '{"results":{"status":0}}'); + final onlineDio = dioWith(okAdapter); + await onlineDio.post(url, data: {'queries': []}); + await settle(); // let the fire-and-forget write land + + final offline = _NetworkDownAdapter(); + final dio = dioWith(offline); + final response = await dio.post(url, data: {'queries': []}); + expect(response.statusCode, 200); + expect(response.data, { + 'results': {'status': 0}, + }); + expect( + offline.calls, + 1, + reason: 'network was attempted before falling back', + ); + }); + + test( + 'a failing POST on a non-dashboard host is not served from cache', + () async { + const url = 'https://status.other.test/api/ds/query'; + final okAdapter = _FakeAdapter(body: '{"n":1}'); + final onlineDio = dioWith(okAdapter); + await onlineDio.post(url, data: {'queries': []}); + // Only status.exptech.dev is content-addressed for POST; a different host + // with a status-like URL must not grow an offline fallback policy by + // accident. + final dio = dioWith(_NetworkDownAdapter()); + await expectLater( + dio.post(url, data: {'queries': []}), + throwsA(isA()), + ); + }, + ); +} + +/// Adapter that always fails with a connection error — models being offline. +class _NetworkDownAdapter implements HttpClientAdapter { + int calls = 0; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + calls++; + throw DioException.connectionError( + requestOptions: options, + reason: 'no network', + ); + } + + @override + void close({bool force = false}) {} } /// Adapter that always returns [status] with an empty body. diff --git a/test/core/platform/background_location_test.dart b/test/core/platform/background_location_test.dart index 2a8391ba1..2f196bc29 100644 --- a/test/core/platform/background_location_test.dart +++ b/test/core/platform/background_location_test.dart @@ -62,4 +62,34 @@ void main() { await expectLater(service.start('tok'), completes); }); + + test('a missing plugin does not surface as a thrown breadcrumb drain', () async { + // A channel with no platform implementation answers MissingPluginException + // — the test-harness / unsupported-platform case that bootstrap hits. + messenger.setMockMethodCallHandler(channel, null); + final service = BackgroundLocationService( + platform: 1, + version: '1', + channel: channel, + ); + + await expectLater(service.drainBreadcrumbs(), completes); + }); + + test( + 'breadcrumbs land in the log rather than the exception stream', + () async { + messenger.setMockMethodCallHandler( + channel, + (_) async => ['100\tfix: thing'], + ); + final service = BackgroundLocationService( + platform: 1, + version: '1', + channel: channel, + ); + + await expectLater(service.drainBreadcrumbs(), completes); + }, + ); } diff --git a/test/core/version/app_build_test.dart b/test/core/version/app_build_test.dart index 7697e521b..5e8c544d4 100644 --- a/test/core/version/app_build_test.dart +++ b/test/core/version/app_build_test.dart @@ -10,7 +10,7 @@ void main() { // The case this exists for: `flutter run` never goes through CI, so there // is no --dart-define and the pubspec placeholder would have it report // `26.1.0 (1)` — a version that exists nowhere. The git hooks write the - // same values tool/version.sh gives CI. + // same values tool/release/version.sh gives CI. await AppBuild.ensureLoaded(); expect(AppBuild.label, kBuildLabel); expect(AppBuild.code, kBuildCode); diff --git a/test/features/changelog/changelog_page_test.dart b/test/features/changelog/changelog_page_test.dart index 593d8daf5..f6ff475a7 100644 --- a/test/features/changelog/changelog_page_test.dart +++ b/test/features/changelog/changelog_page_test.dart @@ -6,6 +6,9 @@ /// front-end compiler. library; +import 'dart:typed_data'; + +import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/error/result.dart'; import 'package:dpip/features/changelog/domain/changelog_repository.dart'; import 'package:dpip/features/changelog/domain/release_note.dart'; @@ -36,6 +39,10 @@ class _PagedRepository implements ChangelogRepository { if (page > pages.length) return const Ok([]); return Ok(pages[page - 1]); } + + @override + Future> avatarBytes(String login) async => + const Err(UnexpectedFailure('no network')); } Widget _wrap(ChangelogRepository repo) => Provider.value( @@ -109,4 +116,92 @@ void main() { expect(find.text('26w33a'), findsNothing); expect(find.text('v26.1'), findsWidgets); }); + + testWidgets('a release card foots the contributor avatars from its body', ( + tester, + ) async { + final repo = _PagedRepository([ + [ + ReleaseNote( + tagName: 'v26.1', + name: 'v26.1', + body: '- a change — @whes1015\n- another — @ExpTechTW', + prerelease: false, + publishedAt: DateTime.utc(2026, 8, 1), + ), + ], + ]); + await tester.pumpWidget(_wrap(repo)); + await tester.pumpAndSettle(); + + // Both @handles from the release body become a stacked avatar pile — no + // pill, no name — and a lingering tap target is the avatar itself. + expect(find.byType(CircleAvatar), findsNWidgets(2)); + }); + + testWidgets('tapping a contributor avatar is safe even with no launcher', ( + tester, + ) async { + final repo = _PagedRepository([ + [ + ReleaseNote( + tagName: 'v26.1', + name: 'v26.1', + body: '- a change — @whes1015', + prerelease: false, + publishedAt: DateTime.utc(2026, 8, 1), + ), + ], + ]); + await tester.pumpWidget(_wrap(repo)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CircleAvatar)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('a release without @handles has no contributor strip', ( + tester, + ) async { + final repo = _PagedRepository([ + [ + ReleaseNote( + tagName: 'v26.1', + name: 'v26.1', + body: 'plain', + prerelease: false, + htmlUrl: 'https://github.com/ExpTechTW/DPIP/releases/tag/v26.1', + publishedAt: DateTime.utc(2026, 8, 1), + ), + ], + ]); + await tester.pumpWidget(_wrap(repo)); + await tester.pumpAndSettle(); + + expect(find.byType(CircleAvatar), findsNothing); + // Every card still carries the button to its release on GitHub. + expect(find.text('View on GitHub'), findsOneWidget); + }); + + testWidgets('the GitHub button opens the release page', (tester) async { + final repo = _PagedRepository([ + [ + ReleaseNote( + tagName: 'v26.1', + name: 'v26.1', + body: 'plain', + prerelease: false, + htmlUrl: 'https://github.com/ExpTechTW/DPIP/releases/tag/v26.1', + publishedAt: DateTime.utc(2026, 8, 1), + ), + ], + ]); + await tester.pumpWidget(_wrap(repo)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('View on GitHub')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); } diff --git a/test/features/changelog/update_check_test.dart b/test/features/changelog/update_check_test.dart index a4080e005..52f906d9c 100644 --- a/test/features/changelog/update_check_test.dart +++ b/test/features/changelog/update_check_test.dart @@ -218,4 +218,64 @@ void main() { ); }); }); + + group('channelFor identifies the running build by its ordinal', () { + // Every snapshot of a year parses to the same number — `26w34a` and + // `26w40a` are both `26` — so a version-string match finds *a* release of + // that year rather than the one running. + ReleaseNote build(String tag, int code, {required bool pre}) => ReleaseNote( + tagName: tag, + name: tag, + body: '', + prerelease: pre, + publishedAt: DateTime.utc(2026, 8, 20).subtract(Duration(days: _day++)), + htmlUrl: '', + ); + + test('a snapshot stays on pre-release once a stable release exists', () { + final releases = [ + build('v26.1', 426000400, pre: false), + build('26w34a', 426000331, pre: true), + ]; + expect( + channelFor( + releases: releases, + currentVersion: '26w34a', + currentBuild: 426000331, + ), + UpdateChannel.preRelease, + ); + }); + + test('an open-beta tester on Play is not called stable', () { + // Internal, open testing and production all install through + // `com.android.vending`, so the install source cannot tell them apart; + // only the ordinal can. + final releases = [ + build('v26.1', 426000400, pre: false), + build('26w34a', 426000331, pre: true), + ]; + expect( + channelFor( + releases: releases, + currentVersion: '26w34a', + currentBuild: 426000331, + installSource: InstallSource.playStore, + ), + UpdateChannel.preRelease, + ); + }); + + test('a build the fetched page does not contain falls back', () { + expect( + channelFor( + releases: [build('v26.1', 426000400, pre: false)], + currentVersion: '26w34a', + currentBuild: 426000331, + installSource: InstallSource.testFlight, + ), + UpdateChannel.preRelease, + ); + }); + }); } diff --git a/test/features/changelog/update_ordinal_test.dart b/test/features/changelog/update_ordinal_test.dart index fa4943391..a1bd83bdb 100644 --- a/test/features/changelog/update_ordinal_test.dart +++ b/test/features/changelog/update_ordinal_test.dart @@ -145,7 +145,7 @@ void main() { }); group('localizedReleaseBody, per-language blocks', () { - // Verbatim output of tool/release_notes.sh, so a change to the publishing + // Verbatim output of tool/release/notes.sh, so a change to the publishing // format fails here rather than on a phone. const note = ''' _快照,取自 main 的 `36c65c5`。未經審查,可能有問題。_ diff --git a/test/features/changelog/update_prompt_test.dart b/test/features/changelog/update_prompt_test.dart index 79fedae10..de412db03 100644 --- a/test/features/changelog/update_prompt_test.dart +++ b/test/features/changelog/update_prompt_test.dart @@ -6,6 +6,8 @@ /// into a nag that returns on every launch. library; +import 'dart:typed_data'; + import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/platform/install_source.dart'; @@ -30,6 +32,10 @@ class _FakeRepository implements ChangelogRepository { @override Future>> releases({int page = 1}) async => Ok(notes); + + @override + Future> avatarBytes(String login) async => + const Err(UnexpectedFailure('no network')); } /// A release advertises its ordinal in the note body, invisibly — see @@ -193,4 +199,8 @@ class _FailingRepository implements ChangelogRepository { @override Future>> releases({int page = 1}) async => const Err(NetworkFailure('offline')); + + @override + Future> avatarBytes(String login) async => + const Err(NetworkFailure('offline')); } diff --git a/test/features/home/weather_sky/card_water_pipeline_test.dart b/test/features/home/weather_sky/card_water_pipeline_test.dart index c5ba503fd..9684f2c05 100644 --- a/test/features/home/weather_sky/card_water_pipeline_test.dart +++ b/test/features/home/weather_sky/card_water_pipeline_test.dart @@ -227,7 +227,7 @@ void main() { final normalMap = await _decodeAsset( 'assets/weather/particles/drop_normal.webp', ); - // Both come from `tool/gen_particle_sprites.py`: a 64×64 blurred disc and + // Both come from `tool/gen/particle_sprites.py`: a 64×64 blurred disc and // a 128×128 analytic hemisphere. The numbers below are properties of // those two functions, so they hold for the generator's output as they // did for the textures it replaced. diff --git a/test/features/log/log_page_test.dart b/test/features/log/log_page_test.dart new file mode 100644 index 000000000..ceea9cb31 --- /dev/null +++ b/test/features/log/log_page_test.dart @@ -0,0 +1,41 @@ +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/features/log/presentation/pages/log_page.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:talker_flutter/talker_flutter.dart'; + +void main() { + // A replayed session must not re-trigger the old talker view's overflow + // loop: the flood used to overflow TalkerScreen's header, route the layout + // fault back through Log.handle into the very stream the page listens to, + // and spin until the app hung. The rewritten page lays out with a plain + // AppBar, and every one of these lines has to render without a single + // overflow or exception. + testWidgets('a log flood renders without exceptions or overflow', ( + tester, + ) async { + Log.talker.cleanHistory(); + for (var i = 0; i < 300; i++) { + Log.info('flood line $i'); + } + Log.error( + 'a persisted-looking error', + StateError('boom'), + StackTrace.current, + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const LogPage(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(TalkerDataCard), findsWidgets); + expect(tester.takeException(), isNull); + expect(find.text('flood line 299'), findsOneWidget); + }); +} diff --git a/test/features/log/log_replay_test.dart b/test/features/log/log_replay_test.dart new file mode 100644 index 000000000..d15b73ff6 --- /dev/null +++ b/test/features/log/log_replay_test.dart @@ -0,0 +1,179 @@ +/// A replayed line keeps the level it was written with. +/// +/// The log screen colours a card and filters by `logLevel`; a line that comes +/// back without one is uncoloured and unfilterable, which is most of what the +/// screen is read with. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:talker_flutter/talker_flutter.dart'; + +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/logging/log_store.dart'; +import 'package:dpip/features/log/presentation/pages/log_page.dart'; + +void main() { + test('every level Log persists is read back as itself', () { + for (final level in LogLevel.values) { + // The exact string `Log.persistTo` writes. + final stored = StoredLog( + time: DateTime.utc(2026, 8, 18), + level: level.name, + message: 'a line', + ); + final replayed = PersistedLog(stored); + expect(replayed.logLevel, level, reason: 'level ${level.name}'); + } + }); + + test('an unreadable level is shown, not dropped', () { + final replayed = PersistedLog( + StoredLog( + time: DateTime.utc(2026, 8, 18), + level: 'from-some-future-version', + message: 'a line', + ), + ); + expect(replayed.logLevel, LogLevel.info); + expect(replayed.generateTextMessage(), contains('a line')); + }); + + test('a replayed line lands in the filter chip for its level', () { + // The screen groups the chips and their counts by `TalkerData.key`, and + // colours a card by it too — not by the level and not by the title. A + // line without one is uncounted and grouped under `undefined`. + for (final level in LogLevel.values) { + final replayed = PersistedLog( + StoredLog( + time: DateTime.utc(2026, 8, 18), + level: level.name, + message: 'a line', + ), + ); + expect( + replayed.key, + TalkerKey.fromLogLevel(level), + reason: 'level ${level.name}', + ); + } + }); + + test('replaying fills in the title and pen the logger would have', () { + // `Log.replay` skips `_handleLogData`, which is where a live line gets + // these from its key. + final replayed = PersistedLog( + StoredLog( + time: DateTime.utc(2026, 8, 18), + level: 'warning', + message: 'a line', + ), + ); + Log.reload([replayed]); + expect( + replayed.title, + Log.talker.settings.getTitleByKey(TalkerKey.warning), + ); + expect(replayed.title, isNot('log')); + }); + + test('a stored error is carried with its message', () { + final replayed = PersistedLog( + StoredLog( + time: DateTime.utc(2026, 8, 18), + level: 'error', + message: 'the summary', + error: 'the detail', + ), + ); + expect(replayed.generateTextMessage(), contains('the summary')); + expect(replayed.generateTextMessage(), contains('the detail')); + }); + + test('loading the table does not log it again', () { + // `logCustom` publishes to the stream, which the persister writes from, + // and prints to the console — so loading the table reprinted it and wrote + // every line back into the table it came from. + var streamed = 0; + final sub = Log.talker.stream.listen((_) => streamed++); + addTearDown(sub.cancel); + + Log.reload([ + PersistedLog( + StoredLog( + time: DateTime.utc(2026, 8, 18), + level: 'info', + message: 'from the table', + ), + ), + ]); + + expect(Log.talker.history.map((e) => e.message), ['from the table']); + expect(streamed, 0, reason: 'nothing may write it back or print it'); + }); + + test('the table replaces history, it is not merged into it', () { + // Every line is persisted and the pre-database ones are copied in when it + // opens, so memory holds nothing the table does not. Merging the two is + // what produced every ordering and eviction fault this screen had. + Log.talker.info('in memory'); + Log.reload([ + PersistedLog( + StoredLog( + time: DateTime.utc(2026, 8, 17, 2), + level: 'info', + message: 'newer', + ), + ), + PersistedLog( + StoredLog( + time: DateTime.utc(2026, 8, 17, 1), + level: 'info', + message: 'older', + ), + ), + ]); + // Oldest first, and nothing of the merge left behind. + expect(Log.talker.history.map((e) => e.message).toList(), [ + 'older', + 'newer', + ]); + }); + + test('a level is tagged in upper case, in the app and the console alike', () { + // A label, not prose — and it reads as a column when a hundred lines are + // scanned for the one that is not INFO. + const expected = { + TalkerKey.verbose: 'VERBOSE', + TalkerKey.debug: 'DEBUG', + TalkerKey.info: 'INFO', + TalkerKey.warning: 'WARN', + TalkerKey.error: 'ERROR', + TalkerKey.critical: 'CRITICAL', + }; + expected.forEach((key, tag) { + expect(Log.talker.settings.getTitleByKey(key), tag, reason: key); + }); + + // The same tag on a line read back out of the table. + final replayed = PersistedLog( + StoredLog( + time: DateTime.utc(2026, 8, 18), + level: 'warning', + message: 'a line', + ), + ); + Log.reload([replayed]); + expect(replayed.title, 'WARN'); + expect(replayed.generateTextMessage(), contains('[WARN]')); + }); + + test('the stored level stays the enum name, not the tag', () { + // Display is upper case; the column is data, and `PersistedLog` parses it + // back by `LogLevel.name`. + Log.talker.cleanHistory(); + Log.warning('a line'); + final data = Log.talker.history.single; + expect(data.logLevel?.name, 'warning'); + expect(data.title, 'WARN'); + }); +} diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index c1eefc91d..c72269385 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -7,9 +7,11 @@ /// is a failure that only shows up on the day it matters. library; +import 'package:dpip/app/theme/app_gold.dart'; import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/meshtastic/mesh_unread.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/notifications/notification_service.dart'; import 'package:dpip/core/permissions/permission_health.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; @@ -49,7 +51,15 @@ GoRouter _router(List visited) => GoRouter( return const SizedBox.shrink(); }, ), - // The version card opens this one. + // The version card opens the highlights — which links onward to notes. + GoRoute( + path: AppRoutes.releaseHighlightsPath, + name: AppRoutes.releaseHighlights, + builder: (_, _) { + visited.add(AppRoutes.releaseHighlights); + return const SizedBox.shrink(); + }, + ), GoRoute( path: AppRoutes.versionNotesPath, name: AppRoutes.versionNotes, @@ -85,6 +95,8 @@ Future _pump( ChangeNotifierProvider(create: (_) => RegionStore(settings)), Provider(create: (_) => const TownDirectory({})), ChangeNotifierProvider(create: (_) => unread ?? MeshUnread(null)), + // The status card wears the same dot as the More tab. + ChangeNotifierProvider(create: (_) => EndpointHealthMonitor()), // MorePage badges its permission row from this. Both services are pure // constructors and nothing calls start(), so it holds its optimistic // defaults and the row renders unbadged — which is what these tests are @@ -120,6 +132,31 @@ void main() { } }); + testWidgets('the beta and partners groups sit under 取得 App', (tester) async { + await _pump(tester, _router([])); + final l10n = AppLocalizations.of(tester.element(find.byType(MorePage))); + // Both beta channels plus both partners are rows. + expect(find.widgetWithText(ListTile, l10n.moreAndroidBeta), findsOneWidget); + expect(find.widgetWithText(ListTile, l10n.moreTestFlight), findsOneWidget); + expect( + find.widgetWithText(ListTile, l10n.morePartnerGeoscience), + findsOneWidget, + ); + expect(find.widgetWithText(ListTile, l10n.morePartnerTwds), findsOneWidget); + // And they land below the store rows, in the 取得 App order. + final play = tester.getTopLeft( + find.widgetWithText(ListTile, 'Google Play'), + ); + final beta = tester.getTopLeft( + find.widgetWithText(ListTile, l10n.moreAndroidBeta), + ); + final partner = tester.getTopLeft( + find.widgetWithText(ListTile, l10n.morePartnerGeoscience), + ); + expect(play.dy, lessThan(beta.dy)); + expect(beta.dy, lessThan(partner.dy)); + }); + testWidgets('permission check sits with the notification settings', ( tester, ) async { @@ -143,22 +180,23 @@ void main() { }); } - testWidgets('the three top entries lead the page, in rank order', ( + testWidgets('the three entries lead the page, support full-width last', ( tester, ) async { await _pump(tester, _router([])); - final support = tester.getTopLeft(find.text('Support DPIP')).dy; final discord = tester.getTopLeft(find.text('Discord community')).dy; final announcements = tester.getTopLeft(find.text('Announcements')).dy; - // Support first, Discord immediately under it, announcements next… - expect(discord, greaterThan(support)); + final support = tester.getTopLeft(find.text('Support DPIP')).dy; + // The right column stacks Discord above announcements; the full-width + // support card sits on its own line beneath both. expect(announcements, greaterThan(discord)); + expect(support, greaterThan(announcements)); // …and all three above every menu group. expect( tester .getTopLeft(find.widgetWithText(ListTile, 'Notification settings')) .dy, - greaterThan(announcements), + greaterThan(support), ); }); @@ -187,14 +225,52 @@ void main() { testWidgets('the support callout outranks Discord visually', (tester) async { await _pump(tester, _router([])); - // The gradient + shadow belong to support alone: if Discord grew them too, - // neither would read as the lead. - final decorated = tester - .widgetList(find.byType(DecoratedBox)) - .map((d) => d.decoration) - .whereType() - .where((d) => d.gradient != null && d.boxShadow != null); - expect(decorated, hasLength(1)); + // The gold belongs to support alone: if Discord were gold too, neither + // would read as the lead. Both are flat now — the colour is the whole + // ranking, so assert that the two fills differ. + final gold = AppGold.of(tester.element(find.text('Support DPIP'))); + final support = tester.widget( + find + .ancestor( + of: find.text('Support DPIP'), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final discord = tester.widget( + find + .ancestor( + of: find.text('Discord community'), + matching: find.byType(Material), + ) + .first, + ); + final supportDecoration = support.decoration as BoxDecoration; + expect(supportDecoration.color, gold.fill); + expect(discord.color, isNot(gold.fill)); + }); + + testWidgets('the hero-card rows in the right column share one left edge', ( + tester, + ) async { + await _pump(tester, _router([])); + // Discord, the announcement and the status card stack in the right + // column; their icon circles and labels must start at the same left edge + // for the stack to read as aligned rows (vertical position differs by + // design). + final iconXs = [ + tester.getCenter(find.byIcon(Icons.discord)).dx, + tester.getCenter(find.byIcon(Icons.campaign_outlined)).dx, + tester.getCenter(find.byIcon(Icons.dns_outlined)).dx, + ]; + expect(iconXs.toSet(), hasLength(1)); + + final textXs = [ + tester.getTopLeft(find.text('Discord community')).dx, + tester.getTopLeft(find.text('Announcements')).dx, + tester.getTopLeft(find.text('Server status')).dx, + ]; + expect(textXs.toSet(), hasLength(1)); }); testWidgets('the Meshtastic row carries a dot only while unread exists', ( @@ -223,21 +299,40 @@ void main() { tester, ) async { await _pump(tester, _router([])); - // The label the build reports — a fixed fake under test. + // The card leads with the train number (26.1 for both release and + // snapshot). Fine print under it: a snapshot prints its own label + // (26w34a), a release prints the platform's recorded version. + final label = AppBuild.label; + final stable = RegExp(r'^\d+\.\d+$').hasMatch(label); + expect(find.text(AppBuild.train), findsWidgets); + if (stable) { + // The platform version is what Settings → app shows for a release; in + // these tests it is unset so the card falls back to the train, which is + // the same string the lead number printed — so it may appear twice. + expect(find.text(AppBuild.train), findsNWidgets(2)); + } else { + expect(find.text(label), findsOneWidget); + } expect( find.descendant(of: find.byType(InkWell), matching: find.text('DPIP')), findsWidgets, ); - expect(find.text(AppBuild.label), findsOneWidget); expect(find.text('Snapshot'), findsOneWidget); + // The badge carries the day the build was cut — what the card's own + // stamp says, so a tester can tell which snapshot they are running. + if (AppBuild.buildDate.isNotEmpty) { + expect(find.text(AppBuild.buildDate), findsOneWidget); + } }); - testWidgets('the version card opens this version\x27s notes', (tester) async { + testWidgets('the version card opens this version\x27s highlights', ( + tester, + ) async { final visited = []; await _pump(tester, _router(visited)); // The card is the DPIP row with the chevron — tap its label. await tester.tap(find.text('DPIP').first); await tester.pumpAndSettle(); - expect(visited, [AppRoutes.versionNotes]); + expect(visited, [AppRoutes.releaseHighlights]); }); } diff --git a/test/features/release_highlights/release_highlight_repository_test.dart b/test/features/release_highlights/release_highlight_repository_test.dart new file mode 100644 index 000000000..98a847da9 --- /dev/null +++ b/test/features/release_highlights/release_highlight_repository_test.dart @@ -0,0 +1,46 @@ +/// Release-highlight repository: the current version's Dart content decodes +/// into valid decks — the data the app actually ships. +library; + +import 'package:dpip/features/release_highlights/data/release_highlight_repository.dart'; +import 'package:dpip/features/release_highlights/domain/release_highlight.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const repo = ReleaseHighlightRepositoryImpl(); + + for (final kind in HighlightKind.values) { + final name = kind.name; + group('$name deck', () { + final deck = repo.load(kind); + + test('has a title and subtitle in every shipped locale', () { + final tags = {'zh_Hant', 'en'}; + for (final t in tags) { + expect(deck.title[t], isNotNull, reason: 'title[$t]'); + expect(deck.subtitle[t], isNotNull, reason: 'subtitle[$t]'); + } + }); + + test('every card has an id, icon, and localized title', () { + expect(deck.cards, isNotEmpty); + + for (final card in deck.cards) { + expect(card.id, isNotEmpty); + expect(card.icon, isNotEmpty); + expect(card.title['zh_Hant'], isNotEmpty); + } + }); + + test('technical cards carry at least one detail row, others none', () { + for (final card in deck.cards) { + expect( + card.isTechnical, + card.details.isNotEmpty, + reason: '${card.id} isTechnical vs details: ${card.details.length}', + ); + } + }); + }); + } +} diff --git a/test/features/release_highlights/release_highlight_test.dart b/test/features/release_highlights/release_highlight_test.dart new file mode 100644 index 000000000..c4d95c966 --- /dev/null +++ b/test/features/release_highlights/release_highlight_test.dart @@ -0,0 +1,76 @@ +/// Release-highlight domain: multi-locale lookup and JSON decoding. +library; + +import 'dart:convert'; + +import 'package:dpip/features/release_highlights/domain/release_highlight.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('localized', () { + const t = {'zh_Hant': '台灣', 'en': 'Taiwan', 'ja': '台湾'}; + + test('exact tag wins', () { + expect(localized(t, 'ja'), '台湾'); + expect(localized(t, 'en'), 'Taiwan'); + }); + + test('base language is a fallback for specific variants', () { + expect(localized(t, 'zh_Hant_HK'), '台灣'); + expect(localized(t, 'en_US'), 'Taiwan'); + }); + + test('authoring locale, then first value, as last resorts', () { + expect(localized({'ja': '台湾'}, 'en'), '台湾'); + }); + + test('unknown tag falls back to zh_Hant', () { + expect(localized(t, 'th'), '台灣'); + }); + }); + + group('ReleaseHighlightCard', () { + test('decodes a full card with details and stats', () { + final json = jsonDecode(''' + { + "id": "networking-etag", + "icon": "data_saver_on", + "title": {"zh_Hant": "網路省電", "en": "Network"}, + "stat": {"zh_Hant": "98%", "en": "98%"}, + "statLabel": {"zh_Hant": "快取命中率", "en": "hit rate"}, + "highlights": [ + {"zh_Hant": "點一", "en": "one"}, + {"zh_Hant": "點二", "en": "two"} + ], + "details": [ + { + "key": {"zh_Hant": "協定", "en": "protocol"}, + "value": {"zh_Hant": "ETag", "en": "ETag"} + } + ], + "stats": [ + {"value": {"zh_Hant": "100", "en": "100"}, "label": {"zh_Hant": "節點", "en": "nodes"}} + ] + } + ''') as Map; + + final card = ReleaseHighlightCard.fromJson(json); + + expect(card.id, 'networking-etag'); + expect(card.title['en'], 'Network'); + expect(card.highlights.length, 2); + expect(card.details.single.key['en'], 'protocol'); + expect(card.stats.single.value['zh_Hant'], '100'); + expect(card.isTechnical, isTrue); + }); + + test('a card without details is not technical', () { + final json = jsonDecode(''' + {"id": "a", "icon": "bolt", "title": {"zh_Hant": "甲", "en": "A"}} + ''') as Map; + + final card = ReleaseHighlightCard.fromJson(json); + expect(card.isTechnical, isFalse); + }); + }); +} diff --git a/test/features/status/cloudflare_status_parse_test.dart b/test/features/status/cloudflare_status_parse_test.dart new file mode 100644 index 000000000..09ead87c1 --- /dev/null +++ b/test/features/status/cloudflare_status_parse_test.dart @@ -0,0 +1,86 @@ +/// parseCloudflareStatus — keeps only Taipei/Kaohsiung components and maps +/// their states. +library; + +import 'package:dpip/features/status/data/cloudflare_status_api.dart'; +import 'package:dpip/features/status/domain/cloudflare_status.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('keeps only taipei and kaohsiung components', () { + final status = parseCloudflareStatus({ + 'page': {'id': 'x'}, + 'components': [ + { + 'id': 'a', + 'name': 'Taipei - (TPE)', + 'status': 'operational', + 'updated_at': '2026-08-01T00:00:00.000Z', + }, + { + 'id': 'b', + 'name': 'Kaohsiung City - (KHH)', + 'status': 'degraded_performance', + 'updated_at': '2026-08-01T00:00:00.000Z', + }, + {'id': 'c', 'name': 'Tokyo', 'status': 'operational'}, + {'id': 'd', 'name': 'Osaka', 'status': 'operational'}, + ], + }, at: DateTime.utc(2026, 8, 1, 12)); + + expect(status.recordedAt, DateTime.utc(2026, 8, 1, 12)); + expect(status.components.map((c) => c.name), [ + 'Taipei - (TPE)', + 'Kaohsiung City - (KHH)', + ]); + expect(status.components[0].state, CloudflareComponentState.operational); + expect( + status.components[1].state, + CloudflareComponentState.degradedPerformance, + ); + expect(status.allOperational, isFalse); + }); + + test('allOperational when every component is operational', () { + final status = parseCloudflareStatus({ + 'components': [ + { + 'name': 'Kaohsiung City - (KHH)', + 'status': 'operational', + 'updated_at': '2026-08-01T00:00:00.000Z', + }, + { + 'name': 'Taipei - (TPE)', + 'status': 'operational', + 'updated_at': '2026-08-01T00:00:00.000Z', + }, + ], + }); + + expect(status.allOperational, isTrue); + // Taipei sorts first regardless of wire order. + expect(status.components.first.name, 'Taipei - (TPE)'); + }); + + test('unknown state and missing body degrade gracefully', () { + final status = parseCloudflareStatus({ + 'components': [ + { + 'name': 'Taipei - (TPE)', + 'status': 'something_else', + 'updated_at': 'not-a-date', + }, + {'name': 'Tokyo', 'status': 'operational'}, + ], + }); + + expect(status.components, hasLength(1)); + expect(status.components.single.state, CloudflareComponentState.unknown); + expect(status.allOperational, isFalse); + + final empty = parseCloudflareStatus(null); + expect(empty.components, isEmpty); + // No data is not the same as all-clear. + expect(empty.allOperational, isFalse); + }); +} diff --git a/test/features/status/server_status_page_test.dart b/test/features/status/server_status_page_test.dart new file mode 100644 index 000000000..645894bf4 --- /dev/null +++ b/test/features/status/server_status_page_test.dart @@ -0,0 +1,329 @@ +/// The 伺服器狀態 page — Grafana server metrics on top, the client's own +/// reading of the multi-active endpoints ("本機狀態") below. +library; + +import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; +import 'package:dpip/features/status/domain/cloudflare_status.dart'; +import 'package:dpip/features/status/domain/cloudflare_status_repository.dart'; +import 'package:dpip/features/status/domain/server_status.dart'; +import 'package:dpip/features/status/domain/server_status_repository.dart'; +import 'package:dpip/features/status/presentation/pages/server_status_page.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + Widget wrap( + ServerStatusRepository repo, { + EndpointHealthMonitor? health, + CloudflareStatusRepository? cloudflare, + }) { + final cloudflareRepo = + cloudflare ?? _FakeCloudflareRepository(Ok(_okCloudflare())); + return MultiProvider( + providers: [ + Provider.value(value: repo), + Provider.value(value: cloudflareRepo), + ChangeNotifierProvider.value(value: health ?? EndpointHealthMonitor()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const ServerStatusPage(), + ), + ); + } + + ServerStatus okStatus({double errorRate = 0.02, double latency = 12}) => + ServerStatus( + recordedAt: DateTime.utc(2026, 8, 1, 12, 30), + down: const StatusMetric(value: 0), + errorRate: StatusMetric(value: errorRate, instance: 'lb-tpe1'), + latency: StatusMetric(value: latency, instance: 'lb-tnn1'), + ); + + testWidgets('an ok dashboard shows the three metrics and a healthy banner', ( + tester, + ) async { + final repo = _FakeRepository(Ok(okStatus())); + await tester.pumpWidget(wrap(repo)); + await tester.pumpAndSettle(); + + final l10n = l10nOf(tester); + expect(find.text(l10n.serverStatusAllUp), findsOneWidget); + expect(find.text('0'), findsOneWidget); + expect(find.text('0.02%'), findsOneWidget); + expect(find.text('12ms'), findsOneWidget); + // The instance labels render under the values. + expect(find.text('lb-tpe1'), findsOneWidget); + expect(find.text('lb-tnn1'), findsOneWidget); + // Updated time appears on the ExpTech banner and each Cloudflare tile. + expect(find.textContaining(l10n.serverStatusUpdated), findsNWidgets(3)); + }); + + testWidgets('a down node shows the error banner', (tester) async { + final status = ServerStatus( + recordedAt: DateTime.utc(2026, 8, 1, 12, 30), + down: const StatusMetric(value: 2), + errorRate: const StatusMetric(value: 0.9, instance: 'lb-tpe1'), + latency: const StatusMetric(value: 800, instance: 'lb-tnn1'), + ); + final repo = _FakeRepository(Ok(status)); + await tester.pumpWidget(wrap(repo)); + await tester.pumpAndSettle(); + + final l10n = l10nOf(tester); + expect(find.text(l10n.serverStatusDown), findsWidgets); + expect(find.text('2'), findsOneWidget); + expect(find.text('0.90%'), findsOneWidget); + expect(find.text('800ms'), findsOneWidget); + }); + + testWidgets('a failure shows the retry surface, and retry re-runs the repo', ( + tester, + ) async { + var calls = 0; + final repo = _FakeRepository( + Err(const NetworkFailure('no connection')), + onStatus: () => calls++, + ); + await tester.pumpWidget(wrap(repo)); + await tester.pumpAndSettle(); + + final l10n = l10nOf(tester); + expect(find.text(l10n.commonFetchFailed), findsOneWidget); + // Failed repo → error view, not a blank screen. + expect(find.text('0'), findsNothing); + + // Retry (still failing) re-invokes the repository. + repo.next = Ok(okStatus()); + await tester.tap(find.text(l10n.commonRetry)); + await tester.pumpAndSettle(); + expect(calls, 2); + expect(find.text(l10n.serverStatusAllUp), findsOneWidget); + }); + + testWidgets('endpoint health block renders tier tables per api.md', ( + tester, + ) async { + final repo = _FakeRepository(Ok(okStatus())); + final health = EndpointHealthMonitor(); + health.success( + ApiTier.lbApi, + 'https://api.lb-tpe1.exptech.dev', + '/api/v2/eq/eew', + ); + health.success( + ApiTier.coreApi, + 'https://api.core-tnn1.exptech.dev', + '/api/v2/eq/eew', + ); + health.failure( + ApiTier.lbApi, + 'https://api.lb-khh1.exptech.dev', + '/api/v2/eq/eew', + ); + health.failure( + ApiTier.lbApi, + 'https://api.lb-khh1.exptech.dev', + '/api/v2/eq/eew', + ); + + tester.view.physicalSize = const Size(800, 5000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(wrap(repo, health: health)); + await tester.pumpAndSettle(); + + final l10n = l10nOf(tester); + expect(find.text(l10n.serverStatusLocal), findsOneWidget); + // All four fixed tables render (the user-facing categorisation). + for (final label in [ + l10n.endpointTierLbApi, + l10n.endpointTierLbStatic, + l10n.endpointTierCoreApi, + l10n.endpointTierCoreStatic, + ]) { + expect(find.text(label), findsOneWidget, reason: 'table $label'); + } + // EEW row: LB API (TPE1/KHH1) and Core API (TNN1). + expect(find.text(l10n.endpointServiceEew), findsNWidgets(2)); + // Region columns are fixed: LB TPE1/KHH1; Core TYO1/TNN1/API-1. + expect(find.text('TPE1'), findsNWidgets(2)); // LB API + LB Static headers + expect(find.text('KHH1'), findsNWidgets(2)); // LB API + LB Static headers + expect(find.text('TYO1'), findsNWidgets(2)); // Core API + Core Static + expect(find.text('TNN1'), findsNWidgets(2)); // Core API + Core Static + expect(find.text('API-1'), findsOneWidget); // Core API header + // lb-khh1 doubled-failed → down summary. + expect(find.text(l10n.endpointHealthDown), findsOneWidget); + }); + + testWidgets('cloudflare block shows the observed regions and their state', ( + tester, + ) async { + final repo = _FakeRepository(Ok(okStatus())); + final cloudflare = _FakeCloudflareRepository( + Ok( + CloudflareStatus( + recordedAt: DateTime.utc(2026, 8, 18, 3, 0), + components: [ + CloudflareComponent( + name: 'Taipei - (TPE)', + state: CloudflareComponentState.operational, + updatedAt: DateTime.utc(2026, 8, 18, 3, 0), + ), + CloudflareComponent( + name: 'Kaohsiung City - (KHH)', + state: CloudflareComponentState.degradedPerformance, + updatedAt: DateTime.utc(2026, 8, 18, 3, 0), + ), + ], + ), + ), + ); + await tester.pumpWidget(wrap(repo, cloudflare: cloudflare)); + await tester.pumpAndSettle(); + + final l10n = l10nOf(tester); + expect(find.text(l10n.serverStatusExpTech), findsOneWidget); + expect(find.text(l10n.serverStatusCloudflare), findsOneWidget); + expect(find.text('Taipei - (TPE)'), findsOneWidget); + expect(find.text('Kaohsiung City - (KHH)'), findsOneWidget); + expect(find.text(l10n.serverStatusCloudflareAllOperational), findsNothing); + expect(find.text(l10n.serverStatusCloudflareOutage), findsOneWidget); + }); + + testWidgets('empty endpoint health shows the no-observations tables', ( + tester, + ) async { + final repo = _FakeRepository(Ok(okStatus())); + await tester.pumpWidget(wrap(repo)); + tester.view.physicalSize = const Size(800, 5000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpAndSettle(); + + final l10n = l10nOf(tester); + expect(find.text(l10n.serverStatusLocal), findsOneWidget); + // Four fixed tables still render; every cell is an honest em-dash. + expect(find.text(l10n.endpointTierLbApi), findsOneWidget); + expect(find.text(l10n.endpointHealthUnknown), findsOneWidget); + }); + + testWidgets('local status legend spells out the four cell states', ( + tester, + ) async { + final repo = _FakeRepository(Ok(okStatus())); + tester.view.physicalSize = const Size(800, 5000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(wrap(repo)); + await tester.pumpAndSettle(); + + final l10n = l10nOf(tester); + // The passive probe note sits above the tables. + expect(find.text(l10n.serverStatusLocalBody), findsWidgets); + // Legend carries all four states. + expect(find.text(l10n.endpointStateOk), findsWidgets); + expect(find.text(l10n.endpointStateDown), findsWidgets); + expect(find.text(l10n.statusLegendUnprobed), findsOneWidget); + expect(find.text(l10n.statusLegendUnsupported), findsOneWidget); + }); + + testWidgets('core static tyo1 column is unsupported, not unprobed', ( + tester, + ) async { + final repo = _FakeRepository(Ok(okStatus())); + final health = EndpointHealthMonitor(); + health.success( + ApiTier.lbApi, + 'https://api.lb-tpe1.exptech.dev', + '/api/v2/eq/eew', + ); + health.failure( + ApiTier.lbApi, + 'https://api.lb-khh1.exptech.dev', + '/api/v2/eq/eew', + ); + health.failure( + ApiTier.lbApi, + 'https://api.lb-khh1.exptech.dev', + '/api/v2/eq/eew', + ); + health.success( + ApiTier.coreApi, + 'https://api.core-tnn1.exptech.dev', + '/api/v2/eq/eew', + ); + + tester.view.physicalSize = const Size(800, 5000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(wrap(repo, health: health)); + await tester.pumpAndSettle(); + + final l10n = l10nOf(tester); + + // Icon-based cells: a healthy check and a down error are both present. + expect(find.byIcon(Icons.check_circle), findsWidgets); + expect(find.byIcon(Icons.error), findsWidgets); + // The Core Static table's TYO1 column is entirely 不支援 — no static host + // exists for tyo1, so not even the radar row shows a probe state. + expect(find.byIcon(Icons.block), findsWidgets); + // The unprobed question-marks outnumber everything: the LB API rows and + // Core API rows for every service/region the probe did not touch. + expect(find.byIcon(Icons.help_outline), findsWidgets); + // Radar rows exist in both Core tables; TYO1's radar cell is 不支援. + expect(find.text(l10n.endpointServiceRadar), findsNWidgets(2)); + }); +} + +AppLocalizations l10nOf(WidgetTester tester) => + AppLocalizations.of(tester.element(find.byType(Scaffold))); + +CloudflareStatus _okCloudflare() { + final now = DateTime.utc(2026, 8, 18, 3, 0); + return CloudflareStatus( + recordedAt: now, + components: [ + CloudflareComponent( + name: 'Taipei - (TPE)', + state: CloudflareComponentState.operational, + updatedAt: now, + ), + CloudflareComponent( + name: 'Kaohsiung City - (KHH)', + state: CloudflareComponentState.operational, + updatedAt: now, + ), + ], + ); +} + +class _FakeCloudflareRepository implements CloudflareStatusRepository { + _FakeCloudflareRepository(this.result); + + Result result; + + @override + Future> status() async => result; +} + +class _FakeRepository implements ServerStatusRepository { + _FakeRepository(this.result, {this.onStatus}); + + Result result; + final void Function()? onStatus; + + set next(Result value) => result = value; + + @override + Future> status() async { + onStatus?.call(); + return result; + } +} diff --git a/test/features/status/server_status_parse_test.dart b/test/features/status/server_status_parse_test.dart new file mode 100644 index 000000000..ea5872e9c --- /dev/null +++ b/test/features/status/server_status_parse_test.dart @@ -0,0 +1,168 @@ +/// The server-status dashboard parsing — every field the page renders flows +/// through here, so a Grafana shape change (a new field ordinal, a missing +/// frame) surfaces here instead of blanking the page silently. +library; + +import 'package:dpip/features/status/data/server_status_api.dart'; +import 'package:dpip/features/status/domain/server_status.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('parseStatus', () { + Object? body({ + Object? status, + Object? errorRate, + Object? latency, + Map? errorLabels, + Map? latencyLabels, + }) => { + 'results': { + 'status': { + 'frames': [ + { + 'data': { + // Grafana returns column-arrays: `values[0]` is the time row, + // `values[1]` the value row. Instant queries carry one sample. + 'values': [ + [1720000000000], + [status ?? 0], + ], + }, + 'schema': { + 'fields': [ + {'name': 'Time'}, + {'name': 'Value'}, + ], + }, + }, + ], + }, + 'error_rate_5xx': { + 'frames': [ + { + 'data': { + 'values': [ + [1720000000000], + [errorRate ?? 0], + ], + }, + 'schema': { + 'fields': [ + {'name': 'Time'}, + { + 'name': 'Value', + 'labels': errorLabels ?? {'instance': 'lb-tpe1'}, + }, + ], + }, + }, + ], + }, + 'avg_latency': { + 'frames': [ + { + 'data': { + 'values': [ + [1720000000000], + [latency ?? 0], + ], + }, + 'schema': { + 'fields': [ + {'name': 'Time'}, + { + 'name': 'Value', + 'labels': latencyLabels ?? {'instance': 'lb-tnn1'}, + }, + ], + }, + }, + ], + }, + }, + }; + + test('reads all three scalars and the instance labels', () { + final status = parseStatus( + body(status: 0, errorRate: 0.05, latency: 23.7), + at: DateTime.utc(2026, 8, 1, 12), + ); + expect(status.down.value, 0); + expect(status.errorRate.value, closeTo(0.05, 1e-9)); + expect(status.errorRate.instance, 'lb-tpe1'); + expect(status.latency.value, closeTo(23.7, 1e-9)); + expect(status.latency.instance, 'lb-tnn1'); + expect(status.allUp, isTrue); + expect(status.health, StatusHealth.ok); + }); + + test('a down node flips the health to down', () { + final status = parseStatus( + body(status: 2, errorRate: 0, latency: 5), + at: DateTime.utc(2026, 8, 1, 12), + ); + expect(status.allUp, isFalse); + expect(status.health, StatusHealth.down); + }); + + test('degraded when the error rate is high', () { + final status = parseStatus( + body(status: 0, errorRate: 0.2, latency: 5), + at: DateTime.utc(2026, 8, 1, 12), + ); + expect(status.health, StatusHealth.degraded); + }); + + test('degraded when the latency is high', () { + final status = parseStatus( + body(status: 0, errorRate: 0, latency: 75), + at: DateTime.utc(2026, 8, 1, 12), + ); + expect(status.health, StatusHealth.degraded); + }); + + test('a missing refId degrades to zeros instead of throwing', () { + final status = parseStatus({ + 'results': {}, + }, at: DateTime.utc(2026, 8, 1, 12)); + expect(status.down.value, 0); + expect(status.errorRate.value, 0); + expect(status.latency.value, 0); + expect(status.health, StatusHealth.ok); + }); + + test('null scalars read as zero', () { + final status = parseStatus( + body(status: null, errorRate: null, latency: null), + at: DateTime.utc(2026, 8, 1, 12), + ); + expect(status.down.value, 0); + expect(status.errorRate.value, 0); + }); + + test('string scalars are parsed numerically', () { + final status = parseStatus( + body(status: '1', errorRate: '0.25', latency: '10.5'), + at: DateTime.utc(2026, 8, 1, 12), + ); + expect(status.down.value, 1); + expect(status.errorRate.value, closeTo(0.25, 1e-9)); + expect(status.latency.value, closeTo(10.5, 1e-9)); + }); + + test('a body that is not a map throws a format failure', () { + expect(() => parseStatus('oops'), throwsFormatException); + }); + }); + + test('the dashboard query is a constant, cachable POST body', () { + // The URL pins the content (same body every call), so the ETag store keys + // it like an immutable tile. If someone edits the query to take + // parameters, the caching contract breaks silently. + final queries = ServerStatusApi.query['queries'] as List; + expect(queries, hasLength(3)); + for (final q in queries.cast>()) { + expect(q['instant'], isTrue); + } + }); +} diff --git a/test/shared/map/map_timeline_test.dart b/test/shared/map/map_timeline_test.dart index 4a521c187..bd3ea6c17 100644 --- a/test/shared/map/map_timeline_test.dart +++ b/test/shared/map/map_timeline_test.dart @@ -207,6 +207,36 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('a UTC-flagged frame renders in local time, not verbatim UTC', ( + tester, + ) async { + // The lightning bug: DateFormat prints a `isUtc: true` DateTime as + // UTC (+00:00), so a strike at 22:34 Taipei read as 22:34 only because + // devices here share the zone — anywhere else it read an hour/… off. + // Same for any layer minting UTC frames (moon page). The ruler must + // show the frame in the device's local time. + final utc = DateTime.utc(2026, 7, 13, 14, 30); // 22:30 in UTC+8 + final frames = [MapFrame(id: '0', time: utc)]; + await tester.pumpWidget( + _wrap(frames: frames, selectedIndex: 0, onSelected: (_) {}), + ); + await tester.pumpAndSettle(); + + final localHours = utc.toLocal(); + final expected = + '${localHours.hour.toString().padLeft(2, '0')}:' + '${localHours.minute.toString().padLeft(2, '0')}'; + // The big label uses HH:mm; ticks format the same instant. + expect(find.text(expected), findsWidgets); + // And the date line (yyyy/MM/dd) must reflect the local day too — a UTC + // frame across midnight would otherwise print the UTC date. + final localDate = + '${localHours.year}/${localHours.month.toString().padLeft(2, '0')}/' + '${localHours.day.toString().padLeft(2, '0')}'; + expect(find.text(localDate), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets( 'switching to another layer\u0027s frames re-centres on its newest frame', (tester) async { diff --git a/test/shared/widgets/dump_link_dialog_test.dart b/test/shared/widgets/dump_link_dialog_test.dart new file mode 100644 index 000000000..7f4f8154d --- /dev/null +++ b/test/shared/widgets/dump_link_dialog_test.dart @@ -0,0 +1,102 @@ +/// The dialog is the only place the uploaded link is ever shown. If it drops +/// the URL, truncates it, or closes itself, the dump that was just uploaded is +/// unreachable — the paste exists and nobody knows where. So the tests here are +/// about the link surviving: shown whole, re-copyable, and not dismissed by the +/// button that copies it. +library; + +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/dump_link_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const String _url = 'https://haste.exptech.dev/ZeGjHxfZ'; + +/// Opens the dialog over a throwaway page, and returns a live record of what +/// has been written to the clipboard. +Future> _open(WidgetTester tester, {String url = _url}) async { + final copied = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied.add((call.arguments as Map)['text'] as String); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: ElevatedButton( + onPressed: () => showDumpLinkDialog(context, url), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return copied; +} + +void main() { + testWidgets('the link is shown in full', (tester) async { + await _open(tester); + + // `find.text` matches the whole string, so this fails on any truncation — + // an ellipsis in the middle of a URL gives somebody a link that resolves + // to nothing and no way to tell it apart from one that works. + expect(find.text(_url), findsOneWidget); + }); + + testWidgets('the link can be selected, for the screenshot case', ( + tester, + ) async { + await _open(tester); + + expect(find.byType(SelectableText), findsOneWidget); + }); + + testWidgets('copying again does not close the dialog', (tester) async { + final copied = await _open(tester); + + await tester.tap(find.text('Copy again')); + await tester.pumpAndSettle(); + + expect(copied, [_url]); + // Still open. Closing on copy would be the trap: the one reason to press + // it is that the first copy was lost, and a dialog that leaves on the + // press cannot be pressed twice. + expect(find.text(_url), findsOneWidget); + }); + + testWidgets('closing dismisses it', (tester) async { + await _open(tester); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + + expect(find.text(_url), findsNothing); + }); + + testWidgets('a long link is not cut', (tester) async { + final long = 'https://haste.exptech.dev/${'A' * 64}'; + await _open(tester, url: long); + + expect(find.text(long), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/tool/check_cache_test.dart b/test/tool/check_cache_test.dart new file mode 100644 index 000000000..8ce30a9ed --- /dev/null +++ b/test/tool/check_cache_test.dart @@ -0,0 +1,190 @@ +/// The content-hash cache in `tool/dev/_lib.sh`, which lets `tool/check.sh` +/// reproduce all of CI in about a second. +/// +/// A cache in front of a gate has exactly one dangerous failure: handing back a +/// pass for a tree that has since changed. Nothing would say so — the gate +/// would simply never run again, and the first sign would be a red CI on a +/// branch that had been green locally all day. So these are mostly tests that +/// the key *does* change, and one that it deliberately does not. +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// A throwaway git repo with [files] in it, plus `tool/dev/_lib.sh` copied from +/// this repository — the real one, so it cannot drift from what ships. +Directory repoWith(Map files) { + final dir = Directory.systemTemp.createTempSync('dpipcache'); + addTearDown(() => dir.deleteSync(recursive: true)); + Process.runSync('git', ['init', '-q', dir.path]); + + for (final entry in files.entries) { + final file = File('${dir.path}/${entry.key}') + ..parent.createSync(recursive: true) + ..writeAsStringSync(entry.value); + expect(file.existsSync(), isTrue); + } + Directory('${dir.path}/tool/dev').createSync(recursive: true); + File('${Directory.current.path}/tool/dev/_lib.sh') + .copySync('${dir.path}/tool/dev/_lib.sh'); + return dir; +} + +/// Sources the real helper and runs [script] inside [repo]. +ProcessResult sh(Directory repo, String script, {String? noCache}) { + return Process.runSync( + 'bash', + ['-c', 'source tool/dev/_lib.sh\n$script'], + workingDirectory: repo.path, + // Set either way, never merely omitted. `Process.run` inherits the parent + // environment, so a developer running the suite under DPIP_NO_CACHE=1 — + // which is exactly what somebody debugging the cache does — would have + // every caching test silently assert the opposite of what it says. + environment: {'DPIP_NO_CACHE': noCache ?? ''}, + ); +} + +String keyOf(Directory repo, String paths) => + (sh(repo, 'cache_key $paths').stdout as String).trim(); + +void main() { + test('the key follows the contents', () { + final repo = repoWith({'lib/a.dart': 'one'}); + final before = keyOf(repo, 'lib'); + + File('${repo.path}/lib/a.dart').writeAsStringSync('two'); + + expect(keyOf(repo, 'lib'), isNot(before)); + }); + + test('a touch alone does not invalidate', () { + // The reason this is content-hashed and not mtime-based. A format pass, a + // branch switch and a checkout all bump mtimes without changing what the + // code says, and a cache that re-runs the whole suite after `git switch` + // is a cache people turn off. + final repo = repoWith({'lib/a.dart': 'one'}); + final before = keyOf(repo, 'lib'); + + final f = File('${repo.path}/lib/a.dart'); + f.setLastModifiedSync(DateTime(2030)); + + expect(keyOf(repo, 'lib'), before); + }); + + test('a rename invalidates, even with identical bytes', () { + final repo = repoWith({'lib/a.dart': 'one'}); + final before = keyOf(repo, 'lib'); + + File('${repo.path}/lib/a.dart').renameSync('${repo.path}/lib/b.dart'); + + expect(keyOf(repo, 'lib'), isNot(before)); + }); + + test('a deletion invalidates', () { + final repo = repoWith({'lib/a.dart': 'one', 'lib/b.dart': 'two'}); + final before = keyOf(repo, 'lib'); + + File('${repo.path}/lib/b.dart').deleteSync(); + + expect(keyOf(repo, 'lib'), isNot(before)); + }); + + test('a new untracked file invalidates', () { + // Untracked-but-not-ignored counts: a test file somebody just wrote is the + // most important thing in the input set and it has never been committed. + final repo = repoWith({'lib/a.dart': 'one'}); + final before = keyOf(repo, 'lib'); + + File('${repo.path}/lib/new.dart').writeAsStringSync('three'); + + expect(keyOf(repo, 'lib'), isNot(before)); + }); + + test('an ignored file does not', () { + final repo = repoWith({'lib/a.dart': 'one', '.gitignore': 'lib/junk/\n'}); + final before = keyOf(repo, 'lib'); + + File('${repo.path}/lib/junk/x.dart') + ..parent.createSync(recursive: true) + ..writeAsStringSync('build output'); + + expect(keyOf(repo, 'lib'), before); + }); + + test('a passing command is cached, and does not run twice', () { + final repo = repoWith({'lib/a.dart': 'one'}); + const script = ''' + run() { echo ran >> ran.log; } + cached demo "\$(cache_key lib)" run + cached demo "\$(cache_key lib)" run + '''; + + sh(repo, script); + + expect(File('${repo.path}/ran.log').readAsLinesSync(), ['ran']); + }); + + test('a failing command is never cached', () { + // The one thing worse than re-running a slow check is skipping it because + // it failed last time. + final repo = repoWith({'lib/a.dart': 'one'}); + const script = ''' + run() { echo ran >> ran.log; return 1; } + cached demo "\$(cache_key lib)" run || true + cached demo "\$(cache_key lib)" run || true + '''; + + sh(repo, script); + + expect(File('${repo.path}/ran.log').readAsLinesSync(), ['ran', 'ran']); + }); + + test('DPIP_NO_CACHE forces a run', () { + final repo = repoWith({'lib/a.dart': 'one'}); + const script = ''' + run() { echo ran >> ran.log; } + cached demo "\$(cache_key lib)" run + cached demo "\$(cache_key lib)" run + '''; + + sh(repo, script, noCache: '1'); + + expect(File('${repo.path}/ran.log').readAsLinesSync(), ['ran', 'ran']); + }); + + test('an edit re-runs, and undoing the edit does not', () { + // The frequent-commit case this exists for: try something, undo it, and the + // proven state is still proven. Keeping one stamp per check would charge a + // full run for the round trip. + final repo = repoWith({'lib/a.dart': 'one'}); + const script = ''' + run() { echo ran >> ran.log; } + cached demo "\$(cache_key lib)" run + '''; + + sh(repo, script); + File('${repo.path}/lib/a.dart').writeAsStringSync('two'); + sh(repo, script); + File('${repo.path}/lib/a.dart').writeAsStringSync('one'); + sh(repo, script); + + expect(File('${repo.path}/ran.log').readAsLinesSync(), ['ran', 'ran']); + }); + + test('stamps do not accumulate without bound', () { + final repo = repoWith({'lib/a.dart': 'seed'}); + const script = ''' + run() { :; } + cached demo "\$(cache_key lib)" run + '''; + + for (var i = 0; i < 20; i++) { + File('${repo.path}/lib/a.dart').writeAsStringSync('content $i'); + sh(repo, script); + } + + final stamps = Directory('${repo.path}/.git/dpip-checks').listSync(); + expect(stamps.length, lessThanOrEqualTo(8)); + }); +} diff --git a/test/tool/colorize_logs_test.dart b/test/tool/colorize_logs_test.dart new file mode 100644 index 000000000..103fcaeae --- /dev/null +++ b/test/tool/colorize_logs_test.dart @@ -0,0 +1,89 @@ +/// `tool/internal/colorize_logs.sh` — colour added where ANSI actually works. +/// +/// The app writes plain text on purpose: on iOS the platform's log path +/// escapes the escape character, so a terminal that supports ANSI still +/// receives a backslash and the sequence and prints it +/// (flutter/flutter#20663). A pipe in the terminal has neither problem. +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +const _esc = 27; + +/// Runs the script over [input]. Without a pty, stdout is not a terminal. +String run(String input, {bool tty = false}) { + final script = '${Directory.current.path}/tool/internal/colorize_logs.sh'; + final result = tty + // `script` lends the pipeline a pty, which is the only way to exercise + // the branch that decides whether to emit anything at all. + ? Process.runSync('script', [ + '-q', + '/dev/null', + 'bash', + '-c', + 'printf %s ${_quote(input)} | $script', + ]) + : Process.runSync('bash', ['-c', 'printf %s ${_quote(input)} | $script']); + expect(result.exitCode, 0, reason: result.stderr.toString()); + return result.stdout.toString(); +} + +String _quote(String s) => "'${s.replaceAll("'", r"'\''")}'"; + +void main() { + const line = 'flutter: [4:45:48][WARN] : eew SSE not connected\n'; + + test('the flutter: prefix is dropped', () { + expect(run(line), startsWith('[4:45:48][WARN]')); + }); + + test('nothing is coloured when the output is not a terminal', () { + // Redirected to a file or another program, escapes are exactly the noise + // this exists to remove. + expect(run(line).codeUnits, isNot(contains(_esc))); + }); + + test('the tag is coloured when it is', () { + final out = run(line, tty: true); + expect(out.codeUnits, contains(_esc)); + expect(out, contains('eew SSE not connected')); + }); + + test('a line that is not ours passes through untouched', () { + const other = '-[WFIsolatedShortcutRunner init] Taking sandbox\n'; + expect(run(other), other); + }); + + test('every level the app can emit is recognised', () { + for (final level in [ + 'CRITICAL', + 'ERROR', + 'WARN', + 'INFO', + 'DEBUG', + 'VERBOSE', + ]) { + final out = run('flutter: [1:00:00][$level] : x\n', tty: true); + expect(out.codeUnits, contains(_esc), reason: level); + } + }); + + test('an interrupt does not kill it before the writer finishes', () { + // Ctrl-C reaches every process in the foreground group. Without the trap + // the filter dies first and `flutter run` — still shutting down, still + // printing — writes into a closed pipe and reports EPIPE as an unhandled + // exception. + final script = '${Directory.current.path}/tool/internal/colorize_logs.sh'; + final result = Process.runSync('bash', [ + '-c', + // A writer that keeps printing after the signal, as flutter does. + '( for i in 1 2 3; do echo "flutter: [INFO] | 1:00:0\$i 1ms | line \$i";' + ' sleep 0.2; done ) | $script & ' + 'pid=\$!; sleep 0.3; kill -INT \$pid 2>/dev/null; wait \$pid', + ]); + expect(result.exitCode, 0, reason: result.stderr.toString()); + expect(result.stdout, contains('line 3'), reason: 'it read to the end'); + }); +} diff --git a/test/tool/commit_gate_test.dart b/test/tool/commit_gate_test.dart index f30253b71..83f69581e 100644 --- a/test/tool/commit_gate_test.dart +++ b/test/tool/commit_gate_test.dart @@ -1,4 +1,4 @@ -/// `tool/check_commits.sh` — the gate that decides what may be committed. +/// `tool/check/commits.sh` — the gate that decides what may be committed. /// /// Tested because its failure branches only run on bad input, so nothing /// exercises them until CI rejects somebody. One of them referenced a variable @@ -19,7 +19,7 @@ import 'package:flutter_test/flutter_test.dart'; '${Directory.systemTemp.createTempSync('gate').path}/msg.txt', )..writeAsStringSync(message); final r = Process.runSync('bash', [ - 'tool/check_commits.sh', + 'tool/check/commits.sh', '--message', file.path, ]); diff --git a/test/tool/devfs_sweep_test.dart b/test/tool/devfs_sweep_test.dart new file mode 100644 index 000000000..472a435da --- /dev/null +++ b/test/tool/devfs_sweep_test.dart @@ -0,0 +1,178 @@ +/// `tool/run.sh`'s DevFS sweep — the one thing in this repo that calls +/// `rm -rf` on a path built from a glob. +/// +/// Two failures matter and neither announces itself. A glob that reaches one +/// component too far deletes somebody's app data; a sweep that runs while a +/// `flutter run` is attached deletes the kernel that run is executing from, and +/// the damage there is silent — the dills grow back, so the mistake looks +/// harmless, while the synced asset bundle does not and a shader edit quietly +/// reverts on the next restart. +/// +/// So these run the real function out of the real script against a fake +/// simulator tree, and check what it deleted and what it refused to touch. +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Sources `tool/run.sh`'s sweep with `$HOME` pointed at [home], and returns +/// what the script printed. +/// +/// The script is sourced rather than executed: the sweep has to be the code +/// that actually ships, not a copy of it in this file that can drift. `mise` is +/// stubbed so sourcing stops short of launching anything. +/// +/// `pgrep` is stubbed too, and that is not a shortcut — it is the only thing +/// that makes these tests mean anything. Left real, every one of them would +/// pass by accident on a machine with a `flutter run` attached, because the +/// sweep would correctly refuse to do anything at all. [running] is what the +/// guard sees. +ProcessResult sweepIn(Directory home, {bool running = false}) { + final root = Directory.current.path; + return Process.runSync('bash', [ + '-c', + 'set -euo pipefail\n' + 'mise() { :; }\n' + 'pgrep() { return ${running ? 0 : 1}; }\n' + 'HOME=${_q(home.path)}\n' + 'source ${_q('$root/tool/run.sh')} >/dev/null 2>/tmp/dpip_sweep_err\n' + 'cat /tmp/dpip_sweep_err\n', + ]); +} + +String _q(String s) => "'${s.replaceAll("'", r"'\''")}'"; + +/// A simulator tree with one container holding [leaked] DevFS roots. +Directory fakeHome({int leaked = 2, List decoys = const []}) { + final home = Directory.systemTemp.createTempSync('dpiphome'); + addTearDown(() => home.deleteSync(recursive: true)); + final tmp = Directory( + '${home.path}/Library/Developer/CoreSimulator/Devices/DEV-1/data' + '/Containers/Data/Application/APP-1/tmp', + )..createSync(recursive: true); + for (var i = 0; i < leaked; i++) { + // Six characters after the prefix, which is what the VM's `createTemp` + // produces. + Directory('${tmp.path}/DPIP${'abcdef'.substring(0, 5)}$i') + ..createSync() + ..childFile('main.dart.dill'); + } + for (final decoy in decoys) { + Directory('${tmp.path}/$decoy').createSync(recursive: true); + } + return home; +} + +extension on Directory { + void childFile(String name) => + File('$path/$name').writeAsStringSync('kernel'); +} + +List namesIn(Directory home) { + final tmp = Directory( + '${home.path}/Library/Developer/CoreSimulator/Devices/DEV-1/data' + '/Containers/Data/Application/APP-1/tmp', + ); + return tmp.listSync().map((e) => e.path.split('/').last).toList()..sort(); +} + +void main() { + test('leaked DevFS roots are swept and the freed space is reported', () { + final home = fakeHome(leaked: 3); + + final result = sweepIn(home); + + expect(namesIn(home), isEmpty); + expect(result.stdout, contains('swept 3 leaked DevFS dirs')); + }); + + test('a container tmp holding nothing DevFS-shaped is left alone', () { + // Every one of these is something the app or the OS put there. The sweep + // names exactly one shape and must not widen to "things in tmp". + final home = fakeHome( + leaked: 0, + decoys: [ + 'DPIP', // the prefix alone — no random suffix + 'DPIPtooLongToBeSix', + 'DPIPabc', // three characters, not six + 'com.apple.something', + 'MapLibreTiles', + ], + ); + + sweepIn(home); + + expect(namesIn(home), [ + 'DPIP', + 'DPIPabc', + 'DPIPtooLongToBeSix', + 'MapLibreTiles', + 'com.apple.something', + ]); + }); + + test('nothing outside a container tmp is reachable', () { + final home = fakeHome(leaked: 1); + // The two neighbours a wrong glob would reach: a sibling of `tmp/` inside + // the same container, and a DevFS-shaped directory one level up. + Directory( + '${home.path}/Library/Developer/CoreSimulator/Devices/DEV-1/data' + '/Containers/Data/Application/APP-1/Library/DPIPabcde0', + ).createSync(recursive: true); + Directory( + '${home.path}/Library/Developer/CoreSimulator/Devices/DEV-1/data' + '/Containers/Data/Application/DPIPabcde1', + ).createSync(recursive: true); + + sweepIn(home); + + expect( + Directory( + '${home.path}/Library/Developer/CoreSimulator/Devices/DEV-1/data' + '/Containers/Data/Application/APP-1/Library/DPIPabcde0', + ).existsSync(), + isTrue, + ); + expect( + Directory( + '${home.path}/Library/Developer/CoreSimulator/Devices/DEV-1/data' + '/Containers/Data/Application/DPIPabcde1', + ).existsSync(), + isTrue, + ); + }); + + test('a live flutter run stops the sweep dead', () { + final home = fakeHome(leaked: 3); + + final result = sweepIn(home, running: true); + + // The kernel the other terminal is executing from is in one of these. + expect(namesIn(home), hasLength(3)); + expect(result.stdout, isEmpty); + }); + + test('no simulator directory at all is a silent no-op', () { + final home = Directory.systemTemp.createTempSync('dpipbare'); + addTearDown(() => home.deleteSync(recursive: true)); + + final result = sweepIn(home); + + // Silent, not merely harmless: a launcher that narrates on every start + // gets skipped over, and this line has to be readable when it does appear. + expect(result.stdout, isEmpty); + expect(result.exitCode, 0); + }); + + test('an empty match does not hand the pattern itself to rm', () { + final home = fakeHome(leaked: 0); + + final result = sweepIn(home); + + // Without `nullglob` the unmatched pattern survives as a literal, `du` + // fails on it, and `set -e` takes the launcher down before it launches. + expect(result.exitCode, 0); + expect(result.stdout, isEmpty); + }); +} diff --git a/test/tool/launch_marker_test.dart b/test/tool/launch_marker_test.dart new file mode 100644 index 000000000..2edac8405 --- /dev/null +++ b/test/tool/launch_marker_test.dart @@ -0,0 +1,47 @@ +/// The launch guard's marker, and the trap it fell into. +/// +/// `bool.fromEnvironment` reads only the exact string `true` and answers +/// `false` to everything else — including `1`, which is what the run script +/// passed at first. The guard then fired on the very launch that had obeyed +/// it, which is the worst possible failure for a guard: it punishes the +/// correct behaviour and teaches people to ignore it. +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// The marker exactly as bootstrap reads it. +bool launchedByTool() => const String.fromEnvironment('DPIP_RUN_SH') != ''; + +String script(String name) => + File('${Directory.current.path}/tool/$name').readAsStringSync(); + +void main() { + test('the run scripts pass a value bool.fromEnvironment would accept', () { + // Belt and braces: the reader takes any value, and the writers still send + // the one that would survive being read the other way. + for (final name in ['run.sh', 'run.ps1']) { + expect( + script(name), + contains('--dart-define=DPIP_RUN_SH=true'), + reason: name, + ); + expect(script(name), isNot(contains('DPIP_RUN_SH=1')), reason: name); + } + }); + + test('the marker is read in a way that accepts any value', () { + // `1` must work as well as `true`, because the next person to edit the + // script will not remember this. + expect('1' != '', isTrue); + expect(const bool.fromEnvironment('DPIP_RUN_SH'), isFalse); + expect(launchedByTool(), isFalse, reason: 'unset in a test run'); + }); + + test('both scripts mark the launch at all', () { + for (final name in ['run.sh', 'run.ps1']) { + expect(script(name), contains('DPIP_RUN_SH'), reason: name); + } + }); +} diff --git a/test/tool/run_script_test.dart b/test/tool/run_script_test.dart new file mode 100644 index 000000000..6025edf80 --- /dev/null +++ b/test/tool/run_script_test.dart @@ -0,0 +1,175 @@ +/// `tool/run.sh` — `flutter run` with the log coloured, on the pinned SDK. +/// +/// A wrapper around a pipeline has one classic defect: the pipeline reports the +/// *last* command's status, so a failed build exits 0 and the wrapper hides the +/// thing it wraps. That is what these check. +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Runs the wrapper's pipeline with a stub in place of `flutter`. +ProcessResult runWith({required int exitCode, String stdout = ''}) { + final bin = Directory.systemTemp.createTempSync('fakeflutter'); + final stub = File('${bin.path}/flutter') + ..writeAsStringSync( + '#!/bin/sh\nprintf "%s" ${_q(stdout)}\nexit $exitCode\n', + ); + Process.runSync('chmod', ['+x', stub.path]); + addTearDown(() => bin.deleteSync(recursive: true)); + final root = Directory.current.path; + return Process.runSync('bash', [ + '-c', + 'set -euo pipefail\n' + '${bin.path}/flutter run | $root/tool/internal/colorize_logs.sh', + ]); +} + +String _q(String s) => "'${s.replaceAll("'", r"'\''")}'"; + +void main() { + test('a failed build is not reported as success', () { + // Without `pipefail` this is 0, because the colouriser succeeded. + expect(runWith(exitCode: 7).exitCode, 7); + }); + + test('a successful run stays successful', () { + expect(runWith(exitCode: 0).exitCode, 0); + }); + + test('the output still passes through', () { + final result = runWith( + exitCode: 0, + stdout: 'flutter: [INFO] | 1:00:00 1ms | started\n', + ); + expect(result.stdout, contains('started')); + expect(result.stdout, isNot(contains('flutter: '))); + }); + + test('the wrapper marks the launch as its own', () { + // bootstrap warns when this is absent, because a launch that skips the + // script gets a different SDK and an uncoloured log, and says so nowhere. + // The exact value is pinned in launch_marker_test.dart — `=1` passed this + // assertion while being read as `false`, so the value is checked where the + // reader's rule is documented, not here. + expect(_script(), contains('DPIP_RUN_SH')); + }); + + test('the wrapper runs flutter through the pinned toolchain', () { + // A shell's PATH is resolved once and goes stale; `mise exec` re-reads + // mise.toml every time. See AGENTS.md → Toolchain. + expect(_script(), contains('pinned flutter run')); + expect(_script(), isNot(contains('\nflutter run'))); + }); + + test('the toolchain is named in exactly one place', () { + // The rule the whole tool/ layout exists to make true: `mise exec` appears + // in the one helper every script sources, and nowhere else. A second copy + // is a second thing to forget when the toolchain moves — and forgetting is + // silent, because the wrong SDK builds fine. + final offenders = + Directory('${Directory.current.path}/tool') + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.sh')) + .where((f) => f.readAsStringSync().contains('mise exec')) + .map((f) => f.path.split('/tool/').last) + .toList() + ..sort(); + + // tooling.sh has to spell the string out to search for it. + expect(offenders, ['check/tooling.sh', 'dev/_lib.sh']); + }); + + test('the launcher refuses to start without mise', () { + // The rule that has no second chance: a bare `flutter` builds, runs and + // passes, off an SDK nobody chose. The launcher is where everybody passes + // through, so it is where the refusal has to live. + expect(_script(), contains('require_mise')); + }); + + test('the launcher checks the scripts before it starts anything', () { + expect(_script(), contains('tool/check/tooling.sh')); + }); + + test('the Windows launcher refuses too', () { + final ps1 = File('${Directory.current.path}/tool/run.ps1') + .readAsStringSync(); + expect(ps1, contains('Get-Command mise')); + }); + + test('the commit briefing is enforced, not merely printed', () { + // It printed "behind origin/main" and was read and ignored in the same + // minute. A check nothing enforces is a check, then a habit, then neither. + final preCommit = File('${Directory.current.path}/.githooks/pre-commit') + .readAsStringSync(); + final prePush = File('${Directory.current.path}/.githooks/pre-push') + .readAsStringSync(); + + expect(preCommit, contains('tool/commit.sh')); + expect(prePush, contains('tool/commit.sh')); + + // The two ask different questions. Commit time skips the gates, because a + // minute per commit teaches everybody --no-verify; push time runs them, + // because that is the last moment a mistake is still free. + expect(preCommit, contains('--no-check')); + expect(prePush, contains('--push')); + }); + + test('pre-commit stands aside mid-rebase and mid-merge', () { + // `git commit` runs the hook during a conflicted rebase, with HEAD detached + // and the branch state meaningless. Every answer it could give there is + // about a tree that exists for the next few seconds. + final hook = File('${Directory.current.path}/.githooks/pre-commit') + .readAsStringSync(); + + for (final state in [ + 'rebase-merge', + 'rebase-apply', + 'MERGE_HEAD', + 'CHERRY_PICK_HEAD', + ]) { + expect(hook, contains(state), reason: '$state is not exempted'); + } + }); + + test('the git hooks point at a script that exists', () { + // The hooks have no file extension, so a rename sweep over `*.sh` misses + // them — and the failure is one line of shell noise on every commit that + // nobody reads, while build_info.g.dart quietly stops being refreshed and + // the Debug-info page names the wrong build. + final hooks = Directory('${Directory.current.path}/.githooks').listSync(); + expect(hooks, isNotEmpty); + + for (final hook in hooks.whereType()) { + final target = RegExp(r'show-toplevel\)"?/(\S+?)"') + .firstMatch(hook.readAsStringSync()) + ?.group(1); + if (target == null) continue; + expect( + File('${Directory.current.path}/$target').existsSync(), + isTrue, + reason: '${hook.path.split('/').last} runs $target, which is not there', + ); + } + }); + + test('every dev script goes through that helper', () { + final scripts = Directory('${Directory.current.path}/tool/dev') + .listSync() + .whereType() + .where((f) => f.path.endsWith('.sh') && !f.path.endsWith('_lib.sh')); + + for (final script in scripts) { + expect( + script.readAsStringSync(), + contains('_lib.sh'), + reason: '${script.path.split('/').last} does not source the helper', + ); + } + }); +} + +String _script() => + File('${Directory.current.path}/tool/run.sh').readAsStringSync(); diff --git a/test/tool/version_script_test.dart b/test/tool/version_script_test.dart index 85ac23a55..3558ca7d5 100644 --- a/test/tool/version_script_test.dart +++ b/test/tool/version_script_test.dart @@ -1,4 +1,4 @@ -/// `tool/version.sh` — the one place a version is decided. +/// `tool/release/version.sh` — the one place a version is decided. /// /// Tested because its three outputs fail late and expensively: a code that /// repeats is refused by a store permanently, and a train that is not one to @@ -11,7 +11,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; Map _run() { - final result = Process.runSync('bash', ['tool/version.sh', '--json']); + final result = Process.runSync('bash', ['tool/release/version.sh', '--json']); expect(result.exitCode, 0, reason: result.stderr.toString()); return jsonDecode(result.stdout.toString()) as Map; } @@ -88,14 +88,16 @@ void main() { test('the label is free to be a name, and the train is not it', () { // The separation *is* the feature: a snapshot is named for the week it was - // cut and uploads under the number of the release it precedes. + // cut and uploads under the number of the release it precedes. A release's + // train carries its major.minor, which is the number a user compares + // against the store page — never the patch. final v = _run(); final label = v['label']! as String; if (label.contains('w')) { expect(label, matches(r'^\d{2}w\d{2}[a-z]+$')); expect(label, isNot(v['train'])); } else { - expect(label, v['train']); + expect(v['train'], matches(r'^\d+\.\d+$')); } }); @@ -108,4 +110,26 @@ void main() { .firstWhere((l) => l.startsWith('version:')); expect(line.split(':')[1].trim(), matches(r'^\d+\.\d+\.\d+\+\d+$')); }); + + test('a three-part release advertises its major.minor as the train', () { + // Apple's marketing version and the hero card's big number are the same + // thing, and neither wants the patch: `v26.2.1` is the full version (the + // label), but the train — and the card's leading number — is `26.2`. A + // two-part label (`26.1`) already is its own train. + const tag = 'v26.2.1-test-temp'; + addTearDown(() { + Process.runSync('git', ['tag', '-d', tag]); + }); + final tagResult = Process.runSync('git', ['tag', tag]); + expect(tagResult.exitCode, 0, reason: tagResult.stderr.toString()); + + final result = Process.runSync('bash', [ + 'tool/release/version.sh', + '--json', + ]); + expect(result.exitCode, 0, reason: result.stderr.toString()); + final v = jsonDecode(result.stdout.toString()) as Map; + expect(v['label'], '26.2.1-test-temp'); + expect(v['train'], '26.2'); + }); } diff --git a/test/tool/version_sequence_test.dart b/test/tool/version_sequence_test.dart index 37a271732..dd52b483f 100644 --- a/test/tool/version_sequence_test.dart +++ b/test/tool/version_sequence_test.dart @@ -15,7 +15,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { late Directory repo; - final script = '${Directory.current.path}/tool/version.sh'; + final script = '${Directory.current.path}/tool/release/version.sh'; void git(List args, {String? at}) { final result = Process.runSync( @@ -55,6 +55,25 @@ void main() { tearDown(() => repo.deleteSync(recursive: true)); + test('a week begins when it begins in Taipei, not in UTC', () { + // 26w33e was built at 07:47 on Monday 2026-08-17 — week 34 for everyone + // who reads the label, week 33 in UTC. It shipped as `26w33e`, and the + // build an hour later became `26w34a`: two consecutive snapshots a week + // apart by name. Every Monday has an eight-hour window that does this. + commit('2026-08-17T01:06:24Z'); // 09:06 Monday, Taipei — week 34 both ways + expect(version()['label'], '26w34a'); + + commit('2026-08-16T23:47:23Z'); // 07:47 Monday, Taipei — week 34, UTC 33 + expect( + version()['label'], + startsWith('26w34'), + reason: 'a Monday-morning build belongs to the week Taipei is in', + ); + + commit('2026-08-16T15:00:00Z'); // 23:00 Sunday, Taipei — still week 33 + expect(version()['label'], startsWith('26w33')); + }); + test('a snapshot is named for its week, and lettered in order', () { expect(version()['label'], '26w33a'); git(['tag', '26w33a']); @@ -83,11 +102,13 @@ void main() { expect(release['train'], '26.1'); }); - test('the tag is the release name, verbatim', () { + test('the tag is the release name, verbatim; the train drops the patch', () { git(['tag', 'v26.1.1']); final v = version(); expect(v['label'], '26.1.1'); - expect(v['train'], '26.1.1'); + // Apple's marketing version (and the hero card's big number) is the + // major.minor; the patch lives in the label/versionName alone. + expect(v['train'], '26.1'); }); test('a snapshot is named for the release it precedes', () { diff --git a/tool/check.sh b/tool/check.sh new file mode 100755 index 000000000..adabb2cbf --- /dev/null +++ b/tool/check.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Everything .github/workflows/ci.yml runs, in one command. +# +# tool/check.sh # cached where it is safe to cache +# DPIP_NO_CACHE=1 tool/check.sh # run everything, trusting nothing +# +# A green run here means a green CI. The list below is the authority for that +# claim — when a step is added to ci.yml it has to be added here too, or this +# starts lying and the next person finds out from a red PR on a branch that was +# green. +# +# Ordered by how fast it fails. Format and the analyzer come first because they +# fail on files you did not touch; the shell gates are cheap and need no +# toolchain; the tests are last because they are the only part measured in +# minutes. +# +# Three steps are content-hash cached (see `cached` in dev/_lib.sh): the +# analyzer, codegen and the test suite. Together they are almost all of the +# wall-clock, and all three depend only on files — so a second run over an +# unchanged tree is free, and a one-byte edit anywhere in their inputs re-runs +# them. The cheap gates are never cached: they finish in about a second each, +# and a stale answer from a cache is worth less than that. +# +# Not run here, and on purpose: +# - `pub get`. CI installs dependencies; a developer already has them, and +# `check/pubspec_lock.sh` is what catches a lockfile that disagrees. +# - actionlint. CI downloads it; nothing pins it locally. +# - `check/commits.sh`. It belongs to `tool/commit.sh`, which is where you +# are when a commit message is the thing being decided. +source "$(dirname "${BASH_SOURCE[0]}")/dev/_lib.sh" +cd "$(repo_root)" + +# What each cached step reads. Anything a step's result depends on has to be in +# its list, or the cache will happily hand back an answer about a file that has +# since changed — the one failure mode of a cache like this, and a silent one. +readonly -a CODE_INPUTS=( + lib test tool + pubspec.yaml pubspec.lock analysis_options.yaml l10n.yaml build.yaml +) +readonly -a TEST_INPUTS=("${CODE_INPUTS[@]}" assets shaders) + +step 'format + analyze' +cached analyze "$(cache_key "${CODE_INPUTS[@]}")" tool/dev/analyze.sh + +# Five of these finish in under a quarter of a second and are run every time: +# hashing their inputs would cost as much as running them, and a cache that +# saves nothing is a cache that can only be wrong. +for gate in tooling l10n pubspec_lock notification_sounds build_info; do + step "check/$gate" + "tool/check/$gate.sh" +done + +# These two walk every .dart file in lib/ and take 5.9 s and 2.2 s, which is +# most of what is left once the toolchain steps are cached. Both read lib/ and +# nothing else, so the key is narrow and invalidates exactly when it should. +for gate in layering storage; do + step "check/$gate" + cached "$gate" "$(cache_key lib "tool/check/$gate.sh")" "tool/check/$gate.sh" +done + +step 'codegen is up to date' +# A stale *.freezed.dart / *.g.dart compiles and ships, so CI regenerates and +# fails if anything moved. CI can do that with `git diff --exit-code` because +# its checkout is clean; here it cannot — a developer running this has +# uncommitted work by definition, and diffing against HEAD would report their +# own edits as stale codegen. +# +# So compare the generated files against *themselves*, before and after. That +# asks the real question — does a fresh run produce what is already on disk — +# and it is indifferent to everything else in the tree. +# +# build_info.g.dart is excluded because it can never match: it holds the hash +# of the commit it is committed in. +generated_hashes() { + git ls-files -co --exclude-standard -- '*.g.dart' '*.freezed.dart' | + grep -v '^lib/core/build_info\.g\.dart$' | + LC_ALL=C sort | tr '\n' '\0' | xargs -0 shasum -a 256 +} + +codegen_check() { + local before after + before="$(generated_hashes)" + tool/dev/codegen.sh + after="$(generated_hashes)" + [[ $before == "$after" ]] && return 0 + + printf 'Generated files changed — the committed codegen is stale:\n' >&2 + diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") | + sed -n 's/^[<>] *[0-9a-f]* */ /p' | LC_ALL=C sort -u >&2 + return 1 +} +cached codegen "$(cache_key "${CODE_INPUTS[@]}")" codegen_check + +step 'test' +cached test "$(cache_key "${TEST_INPUTS[@]}")" tool/dev/test.sh + +step 'all gates passed' diff --git a/tool/check_build_info.sh b/tool/check/build_info.sh similarity index 81% rename from tool/check_build_info.sh rename to tool/check/build_info.sh index 418ff60dc..27a8df2f2 100755 --- a/tool/check_build_info.sh +++ b/tool/check/build_info.sh @@ -4,13 +4,13 @@ # to the stub compiles on the author's machine — the file is `skip-worktree`, so # their copy has it — and fails on any fresh checkout. set -euo pipefail -cd "$(dirname "$0")/.." +cd "$(dirname "$0")/../.." names() { grep -oE '^const [A-Za-z<>]+ (k[A-Za-z]+)' "$1" | awk '{print $3}' | sort; } tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT git show HEAD:lib/core/build_info.g.dart > "$tmp" 2>/dev/null || cp lib/core/build_info.g.dart "$tmp" -bash tool/gen_build_info.sh +bash tool/release/build_info.sh if ! diff -q <(names "$tmp") <(names lib/core/build_info.g.dart) >/dev/null; then - echo "::error::the committed build_info.g.dart stub and tool/gen_build_info.sh declare different symbols" + echo "::error::the committed build_info.g.dart stub and tool/release/build_info.sh declare different symbols" diff <(names "$tmp") <(names lib/core/build_info.g.dart) || true exit 1 fi diff --git a/tool/check_commits.sh b/tool/check/commits.sh similarity index 88% rename from tool/check_commits.sh rename to tool/check/commits.sh index e4f4d3b5d..e9942b0d7 100755 --- a/tool/check_commits.sh +++ b/tool/check/commits.sh @@ -2,15 +2,15 @@ # Rejects a commit message that the release notes cannot be built from. # # This is a gate rather than a linter because the message *is* the changelog: -# `tool/release_notes.sh` reads these bodies and publishes them, so a commit +# `tool/release/notes.sh` reads these bodies and publishes them, so a commit # that does not carry both languages leaves a hole in a note that users read. # There is no second place to fix it — the message is immutable once pushed, # and the only repair is a rebase. Catching it at the gate is what keeps that # repair small. # -# Usage: tool/check_commits.sh [] -# tool/check_commits.sh origin/main..HEAD -# tool/check_commits.sh --message # a literal message +# Usage: tool/check/commits.sh [] +# tool/check/commits.sh origin/main..HEAD +# tool/check/commits.sh --message # a literal message # # With no range it checks HEAD alone, which is what a commit-msg hook wants. # @@ -98,16 +98,26 @@ check_one() { #