From bf1bac7610f436a09dbc28f4616013388834a84b Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 1 Sep 2026 14:03:24 +0800 Subject: [PATCH 1/2] fix(home): draw the sky on the first frame its shader is ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正天氣背景會在雲層著色器載入完的第一幀整片不見 Fix(en-US): fix the weather backdrop going blank for one frame after its cloud shader loads --- .../widgets/weather_sky/weather_sky_painter.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart index a39c5a961..0e6b8ef29 100644 --- a/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart @@ -371,7 +371,14 @@ class WeatherSkyPainter extends CustomPainter { ..setFloat(40, hazeSky.r) ..setFloat(41, hazeSky.g) ..setFloat(42, hazeSky.b) - ..setImageSampler(1, skyColumn); + ..setImageSampler(1, skyColumn) + // Slot 0 is per-sprite and the loop below overwrites it — but + // `Paint.shader=` refuses a shader with any sampler still unassigned, + // and on the first frame after `fragmentShader()` slot 0 has never been + // written. Seeding it here is what makes that frame draw at all: the + // throw propagates out of `paint()`, so it took the whole sky down with + // it, not just the clouds. `cloudSprites` is non-empty by the guard. + ..setImageSampler(0, cloudSprites.first); final paint = Paint()..shader = shader; for (final p in placed) { From 4a18f00595541e860ca10845672f1f4293f6aaeb Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 1 Sep 2026 14:03:38 +0800 Subject: [PATCH 2/2] build: carry an SDK patch for the OverlayPortal semantics crash --- AGENTS.md | 28 +++++- .../shared/semantics_overlay_portal_test.dart | 99 +++++++++++++++++++ tool/dev/_lib.sh | 5 + tool/internal/apply_sdk_patches.sh | 69 +++++++++++++ ...0001-semantics-attach-stale-children.patch | 49 +++++++++ 5 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 test/shared/semantics_overlay_portal_test.dart create mode 100755 tool/internal/apply_sdk_patches.sh create mode 100644 tool/patches/0001-semantics-attach-stale-children.patch diff --git a/AGENTS.md b/AGENTS.md index 85dba337e..d7a18b4ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,13 +30,14 @@ 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: +Four 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) | +| `tool/internal/apply_sdk_patches.sh` | an SDK that is missing a patch this repo owns, or a patch that no longer applies to the pinned version — see [SDK patches](#sdk-patches) | +| `tool/run.sh` | runs them before it starts anything (0.34 s) | ```sh tool/dev/analyze.sh @@ -61,6 +62,29 @@ tool/dev/analyze.sh the CI gates, `release/` versioning and notes, `gen/` asset and code generators, `internal/` pieces other scripts call and nobody runs by hand. +### SDK patches + +**The pinned Flutter SDK is not stock.** `tool/patches/*.patch` is applied to it +by `require_mise`, so every script here — and every CI job, which reaches the +toolchain the same way — runs a patched framework. Each patch names its upstream +issue in its own header; read that before touching one. + +This is the one place the repo deliberately does what the rest of this section +exists to prevent, so it is held to the same standard. The patch lives in the +repo rather than in an install, it is applied at the single gate every toolchain +call already passes, and it says so the first time it changes anything. A patch +applied on one laptop and forgotten on a runner would be exactly the +works-here-fails-there the pin was bought to stop. + +Two things follow, and both matter: + +- **A patch that no longer applies is a hard stop**, not a skip. That is almost + always a Flutter bump. Somebody has to decide whether upstream fixed the bug — + delete the patch — or whether it has to be re-cut against the new source. +- **Every patch carries a test that fails without it.** The test, not a check + script, is what proves the patch is really in; it is also what stays behind + once the patch is deleted, to say the upstream fix is real. + ## Running ```sh diff --git a/test/shared/semantics_overlay_portal_test.dart b/test/shared/semantics_overlay_portal_test.dart new file mode 100644 index 000000000..583ed8628 --- /dev/null +++ b/test/shared/semantics_overlay_portal_test.dart @@ -0,0 +1,99 @@ +// The regression guard for `tool/patches/0001-semantics-attach-stale-children.patch`. +// +// Nothing in DPIP is under test here — this is the framework, reproduced at the +// smallest shape that reaches the bug. It lives in the suite because the patch +// has to be kept honest by something that runs on every machine and in CI, and +// a test that fails on an unpatched SDK does that without a check script: the +// patch is applied by `require_mise`, so a run that skipped it fails here. +// +// When upstream fixes flutter#189902 and mise.toml moves to a version that +// carries the fix, the patch stops applying (and says so, loudly) — this test +// should then pass on its own. Keep it. It is the thing that says the fix is +// really in. +// +// Shape credit: the reduction in the upstream issue. Each of the three oddities +// below is load-bearing; drop any one and the assert does not fire. +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Two tooltips, one of which is rigged to reach the bug. +/// +/// Every [Tooltip] is an [OverlayPortal] — `RawTooltip` is built on +/// `OverlayPortal.overlayChildLayoutBuilder` — which is why an app that never +/// names `OverlayPortal` (DPIP does not) still hits an OverlayPortal defect. +class _TwoTooltips extends StatelessWidget { + const _TwoTooltips(); + + /// A border, so the two rows do not collapse into one render object and the + /// semantics subtree has an interior node to lose track of. + Widget _decorate(Widget child) => DecoratedBox( + decoration: BoxDecoration(border: Border.all()), + child: child, + ); + + Widget _tooltip(String label) => Tooltip( + message: '$label tooltip', + // `explicitChildNodes` is what stops the child folding into the tooltip's + // own node, so there is a separate SemanticsNode available to be stolen. + child: Semantics(explicitChildNodes: true, child: Text('$label text')), + ); + + @override + Widget build(BuildContext context) { + Widget bad = _tooltip('bad'); + // The second Overlay is what lets one node end up parented under a subtree + // that a later pass rebuilds from the bottom up — the "stealing". + bad = Overlay.wrap(child: ExcludeSemantics(child: bad)); + // A tight box: it stops the enclosing node being rebuilt on the pass that + // would otherwise refresh the stale `_children` list and hide the bug. + bad = SizedBox(width: 200, height: 100, child: bad); + + return Dialog( + child: Column( + spacing: 20, + children: [_decorate(_tooltip('good')), _decorate(bad)], + ), + ); + } +} + +void main() { + testWidgets('a tooltip shown and dismissed twice keeps the semantics tree ' + 'consistent (flutter#189902)', (tester) async { + // Semantics is only built when something asks for it — a screen reader on + // a device, this handle in a test. Without it `flushSemantics` does no work + // and the bug cannot be reached. + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: _TwoTooltips())), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + + final target = tester.getCenter(find.text('bad text')); + const away = Offset(5, 5); + + // Twice is the whole test. The first show/dismiss leaves a SemanticsNode + // with `attached == true` and `parent == null`; the second dismiss walks + // into it and trips `assert(!child.attached)` in `_replaceChildren`. + for (var pass = 1; pass <= 2; pass++) { + await gesture.moveTo(target); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + await gesture.moveTo(away); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + } + + // Disposed in the body, not `addTearDown`: the binding's own end-of-test + // check for leaked handles runs before tear-downs do. + handle.dispose(); + }); +} diff --git a/tool/dev/_lib.sh b/tool/dev/_lib.sh index 7534a20ea..c64064478 100755 --- a/tool/dev/_lib.sh +++ b/tool/dev/_lib.sh @@ -88,6 +88,11 @@ EOF fi export DPIP_MISE_CHECKED=1 + + # The pinned SDK carries repo-owned patches (tool/patches). Applied here, at + # the one gate every toolchain call already passes through, so no script and + # no machine can be the one that forgot. Silent unless it changes something. + "$root/tool/internal/apply_sdk_patches.sh" } # `flutter`/`dart`/anything else, on the pinned toolchain. diff --git a/tool/internal/apply_sdk_patches.sh b/tool/internal/apply_sdk_patches.sh new file mode 100755 index 000000000..3ac44e48f --- /dev/null +++ b/tool/internal/apply_sdk_patches.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Applies tool/patches/*.patch to the pinned Flutter SDK. +# +# Called from `require_mise` in tool/dev/_lib.sh, so every script that reaches +# the toolchain has already run it. Nobody runs this by hand. +# +# Patching the SDK is the thing this repo is otherwise most careful *not* to +# do — AGENTS.md → Toolchain exists because a build off the wrong SDK announces +# nothing. A patched SDK is a wrong SDK by that definition, so the same rule +# applies to the patch: it lives in the repo, it is applied by a script every +# entry point calls, and it is never left to a machine to have remembered. The +# alternative is one laptop where the bug is fixed and a CI runner where it is +# not, which is exactly the failure the pin was bought to prevent. +# +# Each patch is one of three states, and only one of them is silent: +# +# applies cleanly → apply it, and say so +# already applied → nothing to do, say nothing +# neither → stop. The SDK moved out from under the patch, which +# usually means a Flutter bump. Somebody has to decide +# whether upstream fixed it; a skipped patch would let +# the bug back in without a word. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +patch_dir="$root/tool/patches" + +[[ -d $patch_dir ]] || exit 0 +shopt -s nullglob +patches=("$patch_dir"/*.patch) +((${#patches[@]})) || exit 0 + +# `mise where`, not the resolved binary's parent: this wants the SDK root that +# `packages/flutter/...` hangs off, and require_mise has already established +# that mise owns it. +sdk="$(cd "$root" && mise where flutter 2>/dev/null || true)" +if [[ -z $sdk || ! -d $sdk ]]; then + printf '\n Cannot locate the pinned Flutter SDK to patch it.\n' >&2 + printf ' Run `mise install` from %s.\n\n' "$root" >&2 + exit 1 +fi + +for p in "${patches[@]}"; do + name="$(basename "$p")" + + # --forward alone is not enough to tell "applies" from "already applied": + # both refuse, with different messages. A reverse dry-run answers it + # directly — a patch that can be undone is a patch that is already in. + if patch -p1 -d "$sdk" --forward --dry-run --silent <"$p" >/dev/null 2>&1; then + patch -p1 -d "$sdk" --forward --silent <"$p" + printf ' \033[33m●\033[0m patched SDK: %s\n' "$name" >&2 + elif patch -p1 -d "$sdk" --reverse --dry-run --silent <"$p" >/dev/null 2>&1; then + : # already applied + else + cat >&2 <