From b0fb79d6808dbf1528e7f8c56be13ff4b6e7278e Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 09:20:44 +0800 Subject: [PATCH 01/62] fix: name a build for the week Taipei is in, not the week UTC is in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正週一上午建置的版本號會標成上一週 Fix(en-US): fix a Monday-morning build being named for the previous week --- test/tool/version_sequence_test.dart | 19 +++++++++++++++++++ tool/version.sh | 20 +++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/test/tool/version_sequence_test.dart b/test/tool/version_sequence_test.dart index 37a271732..7e6ec6b81 100644 --- a/test/tool/version_sequence_test.dart +++ b/test/tool/version_sequence_test.dart @@ -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']); diff --git a/tool/version.sh b/tool/version.sh index d70892490..545d871c0 100755 --- a/tool/version.sh +++ b/tool/version.sh @@ -114,14 +114,28 @@ commit_ts="$(commit_epoch HEAD)" # Two-digit year and ISO week of the commit, so a build is named after when it # was made rather than when it was published. -year="$(date -u -r "$commit_ts" +%y 2>/dev/null || date -u -d "@$commit_ts" +%y)" -week="$(date -u -r "$commit_ts" +%V 2>/dev/null || date -u -d "@$commit_ts" +%V)" +# +# **Asia/Taipei, not UTC.** A week here is the week the people who read the +# label are living in, and they are eight hours ahead: computed in UTC, every +# Monday between 00:00 and 08:00 Taipei falls in the *previous* ISO week. That +# is not theoretical — `26w33e` was built at 07:47 on Monday 2026-08-17, which +# is week 34 in Taipei and week 33 in UTC, and it shipped as `26w33e` while the +# build an hour later became `26w34a`. Two consecutive snapshots, a week apart +# by name. The rest of the app already reads dates this way (report days are +# `Asia/Taipei 當日` — see api.md). +readonly TZONE='Asia/Taipei' +stamp() { # + TZ="$TZONE" date -r "$commit_ts" "+$1" 2>/dev/null || + TZ="$TZONE" date -d "@$commit_ts" "+$1" +} +year="$(stamp %y)" +week="$(stamp %V)" # %V has a leading zero; the label does not want one. week=$((10#$week)) # Commits since this year began, on the same clock the year came from. Needs # the full history — a shallow clone counts only what it fetched. -year_start="$(date -u -r "$commit_ts" +%Y 2>/dev/null || date -u -d "@$commit_ts" +%Y)-01-01T00:00:00Z" +year_start="$(stamp %Y)-01-01T00:00:00+08:00" commits="$(git rev-list --count HEAD --since="$year_start")" code=$((SCHEME * 100000000 + 10#$year * 1000000 + commits)) From cc26892b02ca0e2f5b263ce5a96e28008ca1f1f1 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 09:27:32 +0800 Subject: [PATCH 02/62] feat(settings): row-align the sponsor card with the hero stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 贊助卡片改為橫式排版,與右欄其他卡片對齊更好看 Optimization(en-US): the sponsor card now row-aligns with the right column --- .../more/presentation/pages/more_page.dart | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index bdbf100be..81da100d1 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -641,9 +641,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 +/// 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: 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. +/// along the edge, and a filled badge holding the most saturated step. class _SupportCallout extends StatelessWidget { const _SupportCallout(); @@ -678,31 +680,41 @@ class _SupportCallout extends StatelessWidget { onTap: () => context.pushNamed(AppRoutes.sponsor), child: Padding( padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, + horizontal: AppSpacing.sm, vertical: AppSpacing.sm, ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, 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), + Flexible( + child: Text( + l10n.sponsorTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: gold.ink, + ), ), ), + const SizedBox(width: AppSpacing.xs), + Icon( + Icons.chevron_right, + size: 14, + color: gold.ink.withValues(alpha: 0.7), + ), ], ), ), @@ -714,9 +726,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(); From 7b296481e21d47667249cce979eee3a47b7ef9ee Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 10:20:06 +0800 Subject: [PATCH 03/62] build: stamp the last release into every build --- .github/workflows/release.yml | 4 +++- lib/core/build_info.g.dart | 5 +++++ lib/core/version/app_build.dart | 16 +++++++++++++++- test/tool/version_script_test.dart | 9 +++++++++ tool/gen_build_info.sh | 6 ++++++ tool/version.sh | 8 ++++++-- 6 files changed, 44 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 974e27c52..79e5407a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,7 @@ jobs: echo "label=$DPIP_LABEL" >> "$GITHUB_OUTPUT" echo "train=$DPIP_TRAIN" >> "$GITHUB_OUTPUT" echo "code=$DPIP_CODE" >> "$GITHUB_OUTPUT" + echo "last=$DPIP_LAST_RELEASE" >> "$GITHUB_OUTPUT" # A tag is a release; anything else is a snapshot. if [ "${GITHUB_REF_TYPE}" = "tag" ]; then echo "prerelease=false" >> "$GITHUB_OUTPUT" @@ -196,7 +197,8 @@ jobs: 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_LAST_RELEASE=${{ needs.version.outputs.last }}" if [ "${{ matrix.platform }}" = "android" ]; then mise exec -- flutter build apk --release $COMMON mise exec -- flutter build appbundle --release $COMMON diff --git a/lib/core/build_info.g.dart b/lib/core/build_info.g.dart index 3a9137c9f..2681c3bdc 100644 --- a/lib/core/build_info.g.dart +++ b/lib/core/build_info.g.dart @@ -18,3 +18,8 @@ const String kBuildLabel = ''; /// The ordinal that goes with it; 0 when git could not answer. const int kBuildCode = 0; + +/// The newest release tag this history knows, 'v' stripped. Empty until +/// tool/version.sh can read the history; the version card falls back to the +/// label then. +const String kLastRelease = ''; \ No newline at end of file diff --git a/lib/core/version/app_build.dart b/lib/core/version/app_build.dart index da285a35c..1604cb4be 100644 --- a/lib/core/version/app_build.dart +++ b/lib/core/version/app_build.dart @@ -40,17 +40,26 @@ 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 _definedLast = String.fromEnvironment( + 'DPIP_LAST_RELEASE', + ); /// What the git hooks wrote, empty outside a repository. static String get _generatedLabel => kBuildLabel; static int get _generatedCode => kBuildCode; + /// The newest release this history knows, 'v' stripped. Empty before the + /// first release; a version card that wants a stable anchor number falls + /// back to [label] when it is. + static String get lastRelease => _last; + static String get _bestLabel => _definedLabel.isNotEmpty ? _definedLabel : _generatedLabel; static int get _bestCode => _definedCode > 0 ? _definedCode : _generatedCode; static String? _label; static int? _code; + static String _last = _definedLast.isNotEmpty ? _definedLast : kLastRelease; /// Reads the platform's own version, for the builds CI did not stamp. /// @@ -99,8 +108,13 @@ 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? last, + }) { _label = label; _code = code; + if (last != null) _last = last; } } diff --git a/test/tool/version_script_test.dart b/test/tool/version_script_test.dart index 85ac23a55..10769346b 100644 --- a/test/tool/version_script_test.dart +++ b/test/tool/version_script_test.dart @@ -24,6 +24,15 @@ void main() { expect(v['code'], isA()); }); + test('last names the newest release tag, v stripped', () { + // `26w34a` has no release tag under it yet, but the script must still + // answer — either a `26.1`-style value once the first modern release + // exists, or the pre-26 legacy tag (empty string if no tag at all). + final v = _run(); + expect(v['last'], isA()); + expect(v['last'], isNot(contains('v'))); + }); + test('the train is something Apple will accept', () { // `CFBundleShortVersionString` must be "a period-separated list of at most // three non-negative integers" — ERROR ITMS-90060, enforced at upload, so diff --git a/tool/gen_build_info.sh b/tool/gen_build_info.sh index f1e81e4e6..447b8191c 100755 --- a/tool/gen_build_info.sh +++ b/tool/gen_build_info.sh @@ -24,10 +24,12 @@ commit="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" # than printing something that is not true. label="" code="0" +last_release="" if version="$(tool/version.sh 2>/dev/null)"; then eval "$version" label="${DPIP_LABEL:-}" code="${DPIP_CODE:-0}" + last_release="${DPIP_LAST_RELEASE:-}" fi new="$(cat < Date: Mon, 17 Aug 2026 10:20:44 +0800 Subject: [PATCH 04/62] feat(settings): rework the More hero cards, flat and number-led MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 更多頁上方卡片重新設計,版本以漸層數字顯示,pre-release 顯示上一版主版本號 New(en-US): the More hero cards go flat, and the version card leads with a gradient major.minor named after the last release --- lib/app/theme/app_gold.dart | 27 +-- .../more/presentation/pages/more_page.dart | 192 +++++++++--------- test/app/theme/app_gold_test.dart | 27 +-- test/features/more/more_page_test.dart | 41 +++- 4 files changed, 148 insertions(+), 139 deletions(-) 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/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 81da100d1..699e6d876 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -643,9 +643,9 @@ class _HeroCards extends StatelessWidget { /// /// 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: 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 holding the most saturated step. +/// 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(); @@ -656,22 +656,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, @@ -703,8 +693,8 @@ class _SupportCallout extends StatelessWidget { l10n.sponsorTitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, color: gold.ink, ), ), @@ -866,10 +856,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(); @@ -883,6 +875,19 @@ class _VersionCard extends StatelessWidget { static const Color _stableColor = Color(0xFF2E7D32); static const Color _snapshotColor = Color(0xFFEF6C00); + /// The number the card leads with. A release names itself; a snapshot is + /// named for the week it was cut (`26w34a`), which reads as noise next to a + /// store listing — so it shows the release it builds toward, the last one + /// cut, reduced to `major.minor`. No release yet (legacy `3.9.9` answers + /// before the first modern tag): fall back to the label so the card never + /// quotes a version that does not exist. + static String _displayNumber(String label) { + if (_releaseLabel.hasMatch(label)) return label; + final last = AppBuild.lastRelease; + if (last.isEmpty) return label; + return last.split('.').take(2).join('.'); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -891,87 +896,88 @@ class _VersionCard extends StatelessWidget { final label = AppBuild.label; final stable = _releaseLabel.hasMatch(label); final typeColor = stable ? _stableColor : _snapshotColor; - return DecoratedBox( - decoration: BoxDecoration( + final number = _displayNumber(label); + 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, - ), - ), - const Spacer(), - Icon( - Icons.chevron_right, - size: 20, - color: colors.onSurfaceVariant.withValues(alpha: 0.7), + 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, ), - ], - ), - const Spacer(), - Text( - 'DPIP', - style: theme.textTheme.labelLarge?.copyWith( - color: colors.onSurfaceVariant, - fontWeight: FontWeight.w600, ), + const Spacer(), + Icon( + Icons.chevron_right, + size: 20, + color: colors.onSurfaceVariant.withValues(alpha: 0.7), + ), + ], + ), + const Spacer(), + Text( + 'DPIP', + style: theme.textTheme.labelLarge?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, ), - const SizedBox(height: 2), - Text( - label, + ), + const SizedBox(height: 2), + // 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: [colors.primary, colors.tertiary], + ).createShader(rect), + child: Text( + number, style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w800, - color: colors.onSurface, + color: Colors.white, fontFeatures: const [FontFeature.tabularFigures()], ), ), - 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, - ), + ), + 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. + 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, ), ), - ], - ), + ), + ], ), ), ), 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/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index c1eefc91d..535964456 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -7,6 +7,7 @@ /// 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'; @@ -187,14 +188,29 @@ 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 Meshtastic row carries a dot only while unread exists', ( @@ -223,12 +239,17 @@ void main() { tester, ) async { await _pump(tester, _router([])); - // The label the build reports — a fixed fake under test. + // The card leads with a number: a release names itself, a snapshot names + // the release it builds toward (the last one cut, reduced to major.minor). + final label = AppBuild.label; + final expected = RegExp(r'^\d+\.\d+$').hasMatch(label) + ? label + : AppBuild.lastRelease.split('.').take(2).join('.'); expect( find.descendant(of: find.byType(InkWell), matching: find.text('DPIP')), findsWidgets, ); - expect(find.text(AppBuild.label), findsOneWidget); + expect(find.text(expected), findsOneWidget); expect(find.text('Snapshot'), findsOneWidget); }); From 67fd44ff567a850dbe54ded89b2957bde347c74c Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 16:02:09 +0800 Subject: [PATCH 05/62] fix(map): show timeline labels in the device's local time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正圖層時間軸在某些時區會顯示成 UTC 時間 Fix(en-US): map timelines now show frame times in the device's local time instead of UTC --- lib/shared/map/map_timeline.dart | 17 ++++++++++++--- test/shared/map/map_timeline_test.dart | 30 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) 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/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 { From be1f313917ea176943148265a37f6de0fc380494 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 16:02:40 +0800 Subject: [PATCH 06/62] refactor(build): feed the version card its train, not the last release --- .github/workflows/release.yml | 3 +-- lib/core/build_info.g.dart | 19 ++++++------------- lib/core/version/app_build.dart | 18 ++++++++---------- test/tool/version_script_test.dart | 9 --------- tool/gen_build_info.sh | 10 +++++----- tool/version.sh | 8 ++------ 6 files changed, 22 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79e5407a9..ea9d08378 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,6 @@ jobs: echo "label=$DPIP_LABEL" >> "$GITHUB_OUTPUT" echo "train=$DPIP_TRAIN" >> "$GITHUB_OUTPUT" echo "code=$DPIP_CODE" >> "$GITHUB_OUTPUT" - echo "last=$DPIP_LAST_RELEASE" >> "$GITHUB_OUTPUT" # A tag is a release; anything else is a snapshot. if [ "${GITHUB_REF_TYPE}" = "tag" ]; then echo "prerelease=false" >> "$GITHUB_OUTPUT" @@ -198,7 +197,7 @@ jobs: --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_LAST_RELEASE=${{ needs.version.outputs.last }}" + --dart-define=DPIP_TRAIN=${{ needs.version.outputs.train }}" if [ "${{ matrix.platform }}" = "android" ]; then mise exec -- flutter build apk --release $COMMON mise exec -- flutter build appbundle --release $COMMON diff --git a/lib/core/build_info.g.dart b/lib/core/build_info.g.dart index 2681c3bdc..c50af1cda 100644 --- a/lib/core/build_info.g.dart +++ b/lib/core/build_info.g.dart @@ -1,25 +1,18 @@ // 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 = '31da8b50'; /// 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 = '26w34a'; /// The ordinal that goes with it; 0 when git could not answer. -const int kBuildCode = 0; +const int kBuildCode = 426000335; -/// The newest release tag this history knows, 'v' stripped. Empty until -/// tool/version.sh can read the history; the version card falls back to the -/// label then. -const String kLastRelease = ''; \ No newline at end of file +/// 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'; diff --git a/lib/core/version/app_build.dart b/lib/core/version/app_build.dart index 1604cb4be..cbf023516 100644 --- a/lib/core/version/app_build.dart +++ b/lib/core/version/app_build.dart @@ -40,18 +40,16 @@ 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 _definedLast = String.fromEnvironment( - 'DPIP_LAST_RELEASE', - ); + static const String _definedTrain = String.fromEnvironment('DPIP_TRAIN'); /// What the git hooks wrote, empty outside a repository. static String get _generatedLabel => kBuildLabel; static int get _generatedCode => kBuildCode; - /// The newest release this history knows, 'v' stripped. Empty before the - /// first release; a version card that wants a stable anchor number falls - /// back to [label] when it is. - static String get lastRelease => _last; + /// 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; static String get _bestLabel => _definedLabel.isNotEmpty ? _definedLabel : _generatedLabel; @@ -59,7 +57,7 @@ abstract final class AppBuild { static String? _label; static int? _code; - static String _last = _definedLast.isNotEmpty ? _definedLast : kLastRelease; + static String _train = _definedTrain.isNotEmpty ? _definedTrain : kBuildTrain; /// Reads the platform's own version, for the builds CI did not stamp. /// @@ -111,10 +109,10 @@ abstract final class AppBuild { static void debugSet({ required String label, required int code, - String? last, + String? train, }) { _label = label; _code = code; - if (last != null) _last = last; + if (train != null) _train = train; } } diff --git a/test/tool/version_script_test.dart b/test/tool/version_script_test.dart index 10769346b..85ac23a55 100644 --- a/test/tool/version_script_test.dart +++ b/test/tool/version_script_test.dart @@ -24,15 +24,6 @@ void main() { expect(v['code'], isA()); }); - test('last names the newest release tag, v stripped', () { - // `26w34a` has no release tag under it yet, but the script must still - // answer — either a `26.1`-style value once the first modern release - // exists, or the pre-26 legacy tag (empty string if no tag at all). - final v = _run(); - expect(v['last'], isA()); - expect(v['last'], isNot(contains('v'))); - }); - test('the train is something Apple will accept', () { // `CFBundleShortVersionString` must be "a period-separated list of at most // three non-negative integers" — ERROR ITMS-90060, enforced at upload, so diff --git a/tool/gen_build_info.sh b/tool/gen_build_info.sh index 447b8191c..8f023f42e 100755 --- a/tool/gen_build_info.sh +++ b/tool/gen_build_info.sh @@ -24,12 +24,12 @@ commit="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" # than printing something that is not true. label="" code="0" -last_release="" +train="" if version="$(tool/version.sh 2>/dev/null)"; then eval "$version" label="${DPIP_LABEL:-}" code="${DPIP_CODE:-0}" - last_release="${DPIP_LAST_RELEASE:-}" + train="${DPIP_TRAIN:-}" fi new="$(cat < Date: Mon, 17 Aug 2026 16:04:59 +0800 Subject: [PATCH 07/62] fix(settings): align the three hero-card rows on the same left edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正「更多」頁上方三張卡片的圖示與文字起點對齊 Fix(en-US): align the icons and text of the three hero cards on the More page --- .../more/presentation/pages/more_page.dart | 17 ++++++-------- test/features/more/more_page_test.dart | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 699e6d876..6a77981a6 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -669,12 +669,9 @@ class _SupportCallout extends StatelessWidget { borderRadius: AppRadius.large, onTap: () => context.pushNamed(AppRoutes.sponsor), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.sm, - ), + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, children: [ // Filled, not outlined: the one active affordance on a page // whose every other row is an outlined icon. @@ -688,7 +685,7 @@ class _SupportCallout extends StatelessWidget { child: Icon(Icons.favorite, color: gold.onBadge, size: 19), ), const SizedBox(width: AppSpacing.sm), - Flexible( + Expanded( child: Text( l10n.sponsorTitle, maxLines: 1, @@ -739,7 +736,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, @@ -751,7 +748,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, @@ -801,7 +798,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, @@ -817,7 +814,7 @@ class _AnnouncementCard extends StatelessWidget { ), ), const SizedBox(width: AppSpacing.sm), - Flexible( + Expanded( child: Text( l10n.moreAnnouncements, maxLines: 1, diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index 535964456..e8bc72e49 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -213,6 +213,29 @@ void main() { expect(discord.color, isNot(gold.fill)); }); + testWidgets('the three hero-card badges and labels share one line', ( + tester, + ) async { + await _pump(tester, _router([])); + // The three cards 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 — the cards have different + // heights by design). + final iconXs = [ + tester.getCenter(find.byIcon(Icons.favorite)).dx, + tester.getCenter(find.byIcon(Icons.discord)).dx, + tester.getCenter(find.byIcon(Icons.campaign_outlined)).dx, + ]; + expect(iconXs.toSet(), hasLength(1)); + + final textXs = [ + tester.getTopLeft(find.text('Support DPIP')).dx, + tester.getTopLeft(find.text('Discord community')).dx, + tester.getTopLeft(find.text('Announcements')).dx, + ]; + expect(textXs.toSet(), hasLength(1)); + }); + testWidgets('the Meshtastic row carries a dot only while unread exists', ( tester, ) async { From afe4132ed26be6584bf90d04214b5057fdbff536 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 16:07:00 +0800 Subject: [PATCH 08/62] feat(settings): lead the version card with the train number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 「更多」頁版本卡改以版號 26.1 為主,測試版同時標示完整版號 New(en-US): the version card on the More page leads with 26.1 and shows the full snapshot name beneath it --- .../more/presentation/pages/more_page.dart | 34 ++++++++++--------- test/features/more/more_page_test.dart | 14 ++++---- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 6a77981a6..d0568a794 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -872,19 +872,6 @@ class _VersionCard extends StatelessWidget { static const Color _stableColor = Color(0xFF2E7D32); static const Color _snapshotColor = Color(0xFFEF6C00); - /// The number the card leads with. A release names itself; a snapshot is - /// named for the week it was cut (`26w34a`), which reads as noise next to a - /// store listing — so it shows the release it builds toward, the last one - /// cut, reduced to `major.minor`. No release yet (legacy `3.9.9` answers - /// before the first modern tag): fall back to the label so the card never - /// quotes a version that does not exist. - static String _displayNumber(String label) { - if (_releaseLabel.hasMatch(label)) return label; - final last = AppBuild.lastRelease; - if (last.isEmpty) return label; - return last.split('.').take(2).join('.'); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -893,7 +880,7 @@ class _VersionCard extends StatelessWidget { final label = AppBuild.label; final stable = _releaseLabel.hasMatch(label); final typeColor = stable ? _stableColor : _snapshotColor; - final number = _displayNumber(label); + final train = AppBuild.train; return Material( color: colors.surfaceContainer, borderRadius: AppRadius.large, @@ -943,14 +930,29 @@ class _VersionCard extends StatelessWidget { colors: [colors.primary, colors.tertiary], ).createShader(rect), child: Text( - number, - style: theme.textTheme.headlineSmall?.copyWith( + train, + style: theme.textTheme.displaySmall?.copyWith( fontWeight: FontWeight.w800, color: Colors.white, fontFeatures: const [FontFeature.tabularFigures()], + height: 1, ), ), ), + // A snapshot is named for the week it was cut, not a store + // version, so the label goes below the number as fine print — + // smaller, but still a headline next to the badge. + if (!stable) ...[ + const SizedBox(height: 2), + Text( + label, + style: theme.textTheme.titleMedium?.copyWith( + color: colors.onSurfaceVariant, + 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, diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index e8bc72e49..099a89aab 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -262,17 +262,19 @@ void main() { tester, ) async { await _pump(tester, _router([])); - // The card leads with a number: a release names itself, a snapshot names - // the release it builds toward (the last one cut, reduced to major.minor). + // The card leads with the train number (26.1 for both release and + // snapshot); a snapshot additionally prints its own label under it as + // fine print, so a tester can quote the exact build. final label = AppBuild.label; - final expected = RegExp(r'^\d+\.\d+$').hasMatch(label) - ? label - : AppBuild.lastRelease.split('.').take(2).join('.'); + final stable = RegExp(r'^\d+\.\d+$').hasMatch(label); + expect(find.text(AppBuild.train), findsWidgets); + if (!stable) { + expect(find.text(label), findsOneWidget); + } expect( find.descendant(of: find.byType(InkWell), matching: find.text('DPIP')), findsWidgets, ); - expect(find.text(expected), findsOneWidget); expect(find.text('Snapshot'), findsOneWidget); }); From f6f15c5c859a0dbbd01306fbd9547c397c441044 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 17:05:42 +0800 Subject: [PATCH 09/62] feat(settings): fold the server-status card into the hero stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 更多頁英雄區加入伺服器狀態卡,並縮小版本卡 New(en-US): Add the server-status card to the More hero block and slim the version card --- .../more/presentation/pages/more_page.dart | 214 ++++++++++++++---- lib/l10n/app_en.arb | 3 +- lib/l10n/app_fil.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_th.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hans.arb | 1 + lib/l10n/app_zh_Hant_HK.arb | 1 + lib/l10n/app_zh_TW.arb | 1 + lib/l10n/gen/app_localizations.dart | 6 + lib/l10n/gen/app_localizations_en.dart | 3 + lib/l10n/gen/app_localizations_fil.dart | 4 + lib/l10n/gen/app_localizations_id.dart | 3 + lib/l10n/gen/app_localizations_ja.dart | 3 + lib/l10n/gen/app_localizations_ko.dart | 3 + lib/l10n/gen/app_localizations_th.dart | 3 + lib/l10n/gen/app_localizations_vi.dart | 3 + lib/l10n/gen/app_localizations_zh.dart | 12 + test/features/more/more_page_test.dart | 25 +- 22 files changed, 231 insertions(+), 61 deletions(-) diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index d0568a794..c1f6d8bdc 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -162,12 +162,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, @@ -596,38 +590,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()), + ], ), ); } @@ -839,6 +854,69 @@ 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(); + + static const String _url = 'https://status.exptech.dev/status'; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Material( + color: colors.surfaceContainerHigh, + borderRadius: AppRadius.large, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => openExternalLink(context, _url), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + 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.open_in_new, + 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. /// @@ -872,6 +950,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); @@ -903,7 +1005,33 @@ class _VersionCard extends StatelessWidget { height: 36, ), ), - const Spacer(), + 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 SizedBox(width: AppSpacing.xs), Icon( Icons.chevron_right, size: 20, @@ -912,14 +1040,6 @@ class _VersionCard extends StatelessWidget { ], ), const Spacer(), - Text( - 'DPIP', - style: theme.textTheme.labelLarge?.copyWith( - color: colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), // 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. @@ -927,11 +1047,11 @@ class _VersionCard extends StatelessWidget { shaderCallback: (rect) => LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [colors.primary, colors.tertiary], + colors: _hashGradient(train, theme.brightness), ).createShader(rect), child: Text( train, - style: theme.textTheme.displaySmall?.copyWith( + style: theme.textTheme.displayMedium?.copyWith( fontWeight: FontWeight.w800, color: Colors.white, fontFeatures: const [FontFeature.tabularFigures()], @@ -946,8 +1066,8 @@ class _VersionCard extends StatelessWidget { const SizedBox(height: 2), Text( label, - style: theme.textTheme.titleMedium?.copyWith( - color: colors.onSurfaceVariant, + style: theme.textTheme.headlineSmall?.copyWith( + color: colors.onSurface, fontWeight: FontWeight.w600, letterSpacing: 0.2, ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8c0736cf7..6afdadff4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2775,12 +2775,11 @@ "description": "AED city / district row label" }, "moreAnnouncements": "Announcements", + "moreTagline": "Disaster Prevention Information Platform", "moreVersionStable": "Release", "moreVersionNotes": "This version", "moreVersionNotesEmpty": "No changelog for this build", "moreVersionSnapshot": "Snapshot", - "moreVersionStable": "Release", - "moreVersionSnapshot": "Snapshot", "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", "@meshtasticScanning": { "description": "Scan in progress" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 92b9a8420..d0070816a 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -980,6 +980,7 @@ "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", "moreVersionNotesEmpty": "Walang changelog para sa build na ito", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index c2cd07d8b..6cc5f4f47 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -980,6 +980,7 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "JMA grayscale — colder is whiter", "moreAnnouncements": "Pengumuman", + "moreTagline": "Platform Integrasi Informasi Bencana", "moreVersionStable": "Versi resmi", "moreVersionNotes": "Versi saat ini", "moreVersionNotesEmpty": "Tidak ada changelog untuk build ini", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index f20af76c0..679a770d3 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -980,6 +980,7 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "気象庁の赤外画像の慣例:温度が低いほど白", "moreAnnouncements": "お知らせ", + "moreTagline": "防災情報統合プラットフォーム", "moreVersionStable": "正式版", "moreVersionNotes": "現在のバージョン", "moreVersionNotesEmpty": "このビルドの更新履歴が見つかりません", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6463b3abf..f01c30ca9 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -980,6 +980,7 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "기상청 적외 영상 관례:온도가 낮을수록 흰색", "moreAnnouncements": "공지사항", + "moreTagline": "재해 정보 통합 플랫폼", "moreVersionStable": "정식 버전", "moreVersionNotes": "현재 버전", "moreVersionNotesEmpty": "이 빌드의 업데이트 내역을 찾을 수 없습니다", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 4db238132..74fcbe0a2 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -980,6 +980,7 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "JMA grayscale — colder is whiter", "moreAnnouncements": "ประกาศ", + "moreTagline": "แพลตฟอร์มรวมข้อมูลป้องกันภัยพิบัติ", "moreVersionStable": "เวอร์ชันเต็ม", "moreVersionNotes": "เวอร์ชันปัจจุบัน", "moreVersionNotesEmpty": "ไม่พบประวัติการอัปเดตสำหรับบิลด์นี้", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index ae0ec8efb..ec4700474 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -980,6 +980,7 @@ "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", "moreVersionNotesEmpty": "Không tìm thấy nhật ký cập nhật cho bản này", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 29f17b7da..0f1dc8a83 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -980,6 +980,7 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "氣象廳灰階慣例:溫度越低越白", "moreAnnouncements": "公告", + "moreTagline": "防災資訊整合平台", "moreVersionStable": "正式版", "moreVersionNotes": "目前版本", "moreVersionNotesEmpty": "找不到目前版本的更新日誌", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index bf4880c45..9302c6228 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -980,6 +980,7 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "气象厅灰度惯例:温度越低越白", "moreAnnouncements": "公告", + "moreTagline": "防灾信息整合平台", "moreVersionStable": "正式版", "moreVersionNotes": "当前版本", "moreVersionNotesEmpty": "找不到当前版本的更新日志", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 0b40d162d..b6c945e02 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -980,6 +980,7 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "氣象廳灰階慣例:溫度越低越白", "moreAnnouncements": "公告", + "moreTagline": "防災資訊整合平台", "moreVersionStable": "正式版", "moreVersionNotes": "目前版本", "moreVersionNotesEmpty": "找不到目前版本的更新日誌", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index bc212ae24..23faaf67d 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -980,6 +980,7 @@ "typhoonPickerNamed": "{name} TY {no}", "mapLayerStyleGrayTooltip": "氣象廳灰階慣例:溫度越低越白", "moreAnnouncements": "公告", + "moreTagline": "防災資訊整合平台", "moreVersionStable": "正式版", "moreVersionNotes": "目前版本", "moreVersionNotesEmpty": "找不到目前版本的更新日誌", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 7997c0bd5..b535046ca 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3873,6 +3873,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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 67a7b5e54..182e94a3a 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -2044,6 +2044,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get moreAnnouncements => 'Announcements'; + @override + String get moreTagline => 'Disaster Prevention Information Platform'; + @override String get moreVersionStable => 'Release'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 39e2e9897..086631679 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -2054,6 +2054,10 @@ 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'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 5dd818220..04db0ed37 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -2045,6 +2045,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get moreAnnouncements => 'Pengumuman'; + @override + String get moreTagline => 'Platform Integrasi Informasi Bencana'; + @override String get moreVersionStable => 'Versi resmi'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 3e21688e3..cfaf8f610 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -2011,6 +2011,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get moreAnnouncements => 'お知らせ'; + @override + String get moreTagline => '防災情報統合プラットフォーム'; + @override String get moreVersionStable => '正式版'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 6098f2948..2e062a80b 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -2018,6 +2018,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get moreAnnouncements => '공지사항'; + @override + String get moreTagline => '재해 정보 통합 플랫폼'; + @override String get moreVersionStable => '정식 버전'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 53be9c03a..bca75e782 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -2039,6 +2039,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get moreAnnouncements => 'ประกาศ'; + @override + String get moreTagline => 'แพลตฟอร์มรวมข้อมูลป้องกันภัยพิบัติ'; + @override String get moreVersionStable => 'เวอร์ชันเต็ม'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 09a4dc518..22ffe5cbc 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -2044,6 +2044,9 @@ 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'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index eda79edff..21453a7d8 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -2000,6 +2000,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get moreAnnouncements => '公告'; + @override + String get moreTagline => '防災資訊整合平台'; + @override String get moreVersionStable => '正式版'; @@ -4880,6 +4883,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get moreAnnouncements => '公告'; + @override + String get moreTagline => '防灾信息整合平台'; + @override String get moreVersionStable => '正式版'; @@ -7760,6 +7766,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get moreAnnouncements => '公告'; + @override + String get moreTagline => '防災資訊整合平台'; + @override String get moreVersionStable => '正式版'; @@ -10640,6 +10649,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get moreAnnouncements => '公告'; + @override + String get moreTagline => '防災資訊整合平台'; + @override String get moreVersionStable => '正式版'; diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index 099a89aab..dee2ed420 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -144,22 +144,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), ); }); @@ -213,25 +214,25 @@ void main() { expect(discord.color, isNot(gold.fill)); }); - testWidgets('the three hero-card badges and labels share one line', ( + testWidgets('the hero-card rows in the right column share one left edge', ( tester, ) async { await _pump(tester, _router([])); - // The three cards 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 — the cards have different - // heights by design). + // 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.favorite)).dx, 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('Support DPIP')).dx, 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)); }); From 3fa86e3fd5aaebdd4143a4dfd92c434e4dcc43f1 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 18:14:31 +0800 Subject: [PATCH 10/62] feat(settings): always print a fine-print version line on the hero card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 版本卡片正式版也會顯示完整版本號 New(en-US): The hero version card now shows a version line for releases too --- lib/core/version/app_build.dart | 19 ++++++++++++++ .../more/presentation/pages/more_page.dart | 25 +++++++++---------- test/features/more/more_page_test.dart | 11 +++++--- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/lib/core/version/app_build.dart b/lib/core/version/app_build.dart index cbf023516..fafddf35b 100644 --- a/lib/core/version/app_build.dart +++ b/lib/core/version/app_build.dart @@ -51,12 +51,20 @@ abstract final class AppBuild { /// 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; + 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. @@ -65,6 +73,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; @@ -110,9 +127,11 @@ abstract final class AppBuild { 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/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index c1f6d8bdc..7ec15424d 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -1059,20 +1059,19 @@ class _VersionCard extends StatelessWidget { ), ), ), - // A snapshot is named for the week it was cut, not a store - // version, so the label goes below the number as fine print — - // smaller, but still a headline next to the badge. - if (!stable) ...[ - const SizedBox(height: 2), - Text( - label, - style: theme.textTheme.headlineSmall?.copyWith( - color: colors.onSurface, - fontWeight: FontWeight.w600, - letterSpacing: 0.2, - ), + // 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, diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index dee2ed420..d32f76d36 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -264,12 +264,17 @@ void main() { ) async { await _pump(tester, _router([])); // The card leads with the train number (26.1 for both release and - // snapshot); a snapshot additionally prints its own label under it as - // fine print, so a tester can quote the exact build. + // 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) { + 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( From e5b7e3936f0bf3b1e09564188c9901345ed9eb69 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 18:19:37 +0800 Subject: [PATCH 11/62] feat(build): let a release's train drop its patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 新版號 26.2.1 的版型只顯示 26.2 New(en-US): A three-part release (26.2.1) advertises as 26.2; the patch stays in the label --- test/tool/version_script_test.dart | 25 +++++++++++++++++++++++-- test/tool/version_sequence_test.dart | 6 ++++-- tool/version.sh | 11 +++++++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/test/tool/version_script_test.dart b/test/tool/version_script_test.dart index 85ac23a55..aca22bd10 100644 --- a/test/tool/version_script_test.dart +++ b/test/tool/version_script_test.dart @@ -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,23 @@ 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/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 7e6ec6b81..7cd7226b7 100644 --- a/test/tool/version_sequence_test.dart +++ b/test/tool/version_sequence_test.dart @@ -102,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/version.sh b/tool/version.sh index 545d871c0..e11632662 100755 --- a/tool/version.sh +++ b/tool/version.sh @@ -140,9 +140,16 @@ commits="$(git rev-list --count HEAD --since="$year_start")" code=$((SCHEME * 100000000 + 10#$year * 1000000 + commits)) if [ -n "$exact_tag" ]; then - # A release: the tag is the label, and the label is the train. + # A release: the tag is the label. Apple is told a marketing version without + # the patch — `26.2.1` advertises as `26.2` — and the hero card's big number + # is the same `major.minor`, because that is the number a user compares + # against the store page. A two-part label (`26.1`) is its own train. label="${exact_tag#v}" - train="$label" + if [[ "$label" =~ ^([0-9]+)\.([0-9]+)(\..*)?$ ]]; then + train="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + else + train="$label" + fi else # A snapshot. The letter counts the snapshots already *published* this week, # so this build is the next one: the first is `a`, the second `b`. From 9a50a6946f3e7ffabae866fc48679cfd552d6e9e Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 18:23:24 +0800 Subject: [PATCH 12/62] feat(build): stamp the build date alongside the label and train MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 版本卡片徽章加上 build 日期 New(en-US): The More page badge shows the day the build was cut --- lib/core/build_info.g.dart | 10 +++++++--- tool/gen_build_info.sh | 6 ++++++ tool/version.sh | 12 ++++++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/core/build_info.g.dart b/lib/core/build_info.g.dart index c50af1cda..76f185ab2 100644 --- a/lib/core/build_info.g.dart +++ b/lib/core/build_info.g.dart @@ -4,15 +4,19 @@ library; /// Short git commit hash of HEAD at generation time ('unknown' outside a repo). -const String kGitCommit = '31da8b50'; +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 = '26w34a'; +const String kBuildLabel = '26w34b'; /// The ordinal that goes with it; 0 when git could not answer. -const int kBuildCode = 426000335; +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/tool/gen_build_info.sh b/tool/gen_build_info.sh index 8f023f42e..089d8c4d4 100755 --- a/tool/gen_build_info.sh +++ b/tool/gen_build_info.sh @@ -25,11 +25,13 @@ commit="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" label="" code="0" train="" +date="" if version="$(tool/version.sh 2>/dev/null)"; then eval "$version" label="${DPIP_LABEL:-}" code="${DPIP_CODE:-0}" train="${DPIP_TRAIN:-}" + date="${DPIP_DATE:-}" fi new="$(cat < Date: Mon, 17 Aug 2026 18:27:41 +0800 Subject: [PATCH 13/62] feat(settings): show the build date next to the version badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 版本徽章右側顯示 build 日期 New(en-US): The version badge now carries the day this build was cut --- .github/workflows/release.yml | 7 ++- lib/core/version/app_build.dart | 7 +++ .../more/presentation/pages/more_page.dart | 55 +++++++++++++------ test/features/more/more_page_test.dart | 5 ++ 4 files changed, 54 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea9d08378..7d7cbf41c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -40,13 +41,14 @@ jobs: 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, @@ -197,7 +199,8 @@ jobs: --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_TRAIN=${{ needs.version.outputs.train }}" + --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 diff --git a/lib/core/version/app_build.dart b/lib/core/version/app_build.dart index fafddf35b..d5e2c7f76 100644 --- a/lib/core/version/app_build.dart +++ b/lib/core/version/app_build.dart @@ -41,6 +41,7 @@ abstract final class AppBuild { 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; @@ -58,6 +59,12 @@ abstract final class AppBuild { /// 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; diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 7ec15424d..8849078ae 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -1075,25 +1075,44 @@ class _VersionCard extends StatelessWidget { 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. - 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, + // 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, + ), + ), + ], + ], ), ], ), diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index d32f76d36..19e0b9444 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -282,6 +282,11 @@ void main() { findsWidgets, ); 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 { From a0cbc2e62f31630f1435b1a6e62347d831a30b6c Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 22:08:17 +0800 Subject: [PATCH 14/62] feat(home): float a gold support pill under the region bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 首頁地區列下方浮一條金色支持橫條,點擊前往贊助頁 New(en-US): Home floats a gold support pill under the region bar, opening the sponsor page --- .../home/presentation/pages/home_page.dart | 113 +++++++++++++++--- 1 file changed, 99 insertions(+), 14 deletions(-) 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. /// From 6a2a45abb76f6b6087e6a44e680004d62412cb3a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 23:08:34 +0800 Subject: [PATCH 15/62] fix(l10n): retitle the Traditional Chinese sponsor copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 繁體贊助相關文案改用「支援」 New(en-US): Traditional-Chinese support copy now reads 支援, not 支持 --- lib/l10n/app_zh.arb | 6 +++--- lib/l10n/app_zh_Hant_HK.arb | 6 +++--- lib/l10n/app_zh_TW.arb | 6 +++--- lib/l10n/gen/app_localizations_zh.dart | 18 +++++++++--------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0f1dc8a83..c44193dbd 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": { @@ -766,7 +766,7 @@ "reportDetailOriginTime": "發震時間", "trendNoData": "沒有趨勢資料", "onboardingPermLocation": "定位", - "sponsorCalloutBody": "沒有廣告,你的支持讓伺服器持續運作。", + "sponsorCalloutBody": "沒有廣告,你的支援讓伺服器持續運作。", "moreDiscordCalloutBody": "加入社群,直接和開發團隊交流。", "moreDiscord": "Discord 社群", "mapNavPressure": "氣壓", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index b6c945e02..5f5be0df8 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": { @@ -766,7 +766,7 @@ "reportDetailOriginTime": "發震時間", "trendNoData": "沒有趨勢資料", "onboardingPermLocation": "定位", - "sponsorCalloutBody": "沒有廣告,你的支持讓伺服器持續運作。", + "sponsorCalloutBody": "沒有廣告,你的支援讓伺服器持續運作。", "moreDiscordCalloutBody": "加入社群,直接和開發團隊交流。", "moreDiscord": "Discord 社群", "mapNavPressure": "氣壓", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 23faaf67d..932e29f8b 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": { @@ -766,7 +766,7 @@ "reportDetailOriginTime": "發震時間", "trendNoData": "沒有趨勢資料", "onboardingPermLocation": "定位", - "sponsorCalloutBody": "沒有廣告,你的支持讓伺服器持續運作。", + "sponsorCalloutBody": "沒有廣告,你的支援讓伺服器持續運作。", "moreDiscordCalloutBody": "加入社群,直接和開發團隊交流。", "moreDiscord": "Discord 社群", "mapNavPressure": "氣壓", diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 21453a7d8..208dae7bf 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 => '地址'; @@ -1561,7 +1561,7 @@ class AppLocalizationsZh extends AppLocalizations { String get onboardingPermLocation => '定位'; @override - String get sponsorCalloutBody => '沒有廣告,你的支持讓伺服器持續運作。'; + String get sponsorCalloutBody => '沒有廣告,你的支援讓伺服器持續運作。'; @override String get moreDiscordCalloutBody => '加入社群,直接和開發團隊交流。'; @@ -6030,7 +6030,7 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get typhoonLegendCircle25 => '十級風暴風圈'; @override - String get sponsorTitle => '支持 DPIP'; + String get sponsorTitle => '支援 DPIP'; @override String get mapNavSatellite => '衛星'; @@ -6356,7 +6356,7 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get sponsorIntro => - 'DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支持能幫助我們維持伺服器運作並持續開發。'; + 'DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支援能幫助我們維持伺服器運作並持續開發。'; @override String get shelterAddressLabel => '地址'; @@ -7327,7 +7327,7 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get onboardingPermLocation => '定位'; @override - String get sponsorCalloutBody => '沒有廣告,你的支持讓伺服器持續運作。'; + String get sponsorCalloutBody => '沒有廣告,你的支援讓伺服器持續運作。'; @override String get moreDiscordCalloutBody => '加入社群,直接和開發團隊交流。'; @@ -8913,7 +8913,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get typhoonLegendCircle25 => '十級風暴風圈'; @override - String get sponsorTitle => '支持 DPIP'; + String get sponsorTitle => '支援 DPIP'; @override String get mapNavSatellite => '衛星'; @@ -9239,7 +9239,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get sponsorIntro => - 'DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支持能幫助我們維持伺服器運作並持續開發。'; + 'DPIP 致力於提供即時防災資訊,沒有廣告或其他營利模式。您的支援能幫助我們維持伺服器運作並持續開發。'; @override String get shelterAddressLabel => '地址'; @@ -10210,7 +10210,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get onboardingPermLocation => '定位'; @override - String get sponsorCalloutBody => '沒有廣告,你的支持讓伺服器持續運作。'; + String get sponsorCalloutBody => '沒有廣告,你的支援讓伺服器持續運作。'; @override String get moreDiscordCalloutBody => '加入社群,直接和開發團隊交流。'; From f7996df6a555f6b64782ed4562f43a63672338aa Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 17 Aug 2026 23:59:36 +0800 Subject: [PATCH 16/62] docs(readme): list the Android and iOS beta channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): README 新增 Android 測試版與 iOS TestFlight 連結 New(en-US): README now links the Android testing track and the iOS TestFlight beta --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 85d4c5f5f..34dcd89b4 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) 上進行,挑一個你熟悉的語言就能開始。 From 15a8d24f773a98859d601c5437048a6629fa55cd Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 00:28:18 +0800 Subject: [PATCH 17/62] fix(changelog): read the channel from the build, not its name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正公測使用者可能被當成正式版使用者,收到比手上還舊的更新提示 Fix(en-US): fix a beta tester being treated as a stable user and offered an older build --- .../changelog/domain/update_check.dart | 25 ++++++++ .../features/changelog/update_check_test.dart | 60 +++++++++++++++++++ 2 files changed, 85 insertions(+) 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/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, + ); + }); + }); } From ef2802ce7062d873d68345f27c420c7e0c077f41 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 00:57:30 +0800 Subject: [PATCH 18/62] feat(changelog): contributor avatar strip, parsed from release bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 更新日誌每張版本卡片下方新增 Contributors 頭像列 New(en-US): changelog cards foot a Contributors avatar strip --- lib/core/network/etag_interceptor.dart | 1 + .../changelog/data/changelog_api.dart | 11 + .../data/changelog_repository_impl.dart | 6 + .../domain/changelog_repository.dart | 9 + .../changelog/domain/release_note.dart | 44 +++ .../domain/release_note.freezed.dart | 270 ++++++++++++++++++ .../changelog/domain/release_note.g.dart | 9 + .../presentation/pages/changelog_page.dart | 13 + .../pages/version_notes_page.dart | 43 ++- .../widgets/release_contributors.dart | 134 +++++++++ .../changelog/changelog_page_test.dart | 48 ++++ .../changelog/update_prompt_test.dart | 10 + 12 files changed, 586 insertions(+), 12 deletions(-) create mode 100644 lib/features/changelog/presentation/widgets/release_contributors.dart diff --git a/lib/core/network/etag_interceptor.dart b/lib/core/network/etag_interceptor.dart index 316e5d492..df9340532 100644 --- a/lib/core/network/etag_interceptor.dart +++ b/lib/core/network/etag_interceptor.dart @@ -84,6 +84,7 @@ class EtagInterceptor extends Interceptor { '${ApiPaths.tiles}/wind/', '${ApiPaths.dpm}/', '/gh/exptechtw/map-assets/', // glyph PBFs (jsDelivr) + 'avatars.githubusercontent.com/', // contributor avatars (content-addressed) ]; /// Whether [uri] names a content-addressed asset — see 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/presentation/pages/changelog_page.dart b/lib/features/changelog/presentation/pages/changelog_page.dart index 41afb0597..cf5321351 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,18 @@ class _ReleaseTile extends StatelessWidget { ) : const SizedBox(width: double.infinity), ), + // The GitHub release footer — divider, then the contributor + // avatar stack. Always at the card's foot, expanded or not, so + // the strip reads as part of the release the way GitHub's page + // does. + if (contributorsFromBody(note.body).isNotEmpty) ...[ + Divider( + height: 1, + thickness: 1, + color: colors.outlineVariant.withValues(alpha: 0.55), + ), + ContributorStrip(body: note.body), + ], ], ), ), 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..e12fd2db9 --- /dev/null +++ b/lib/features/changelog/presentation/widgets/release_contributors.dart @@ -0,0 +1,134 @@ +/// The contributor strip under a changelog entry — the GitHub release footer +/// look: a stack of avatars for every `@handle` mentioned in the body. +/// +/// 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 slot is one `CircleAvatar` +/// that fills when its bytes arrive and shows the login's initial otherwise. +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'; + +/// How many avatars show before the rest collapse into a `+N` tail. +const int _maxShown = 4; + +/// One row: overlapping avatar circles, then the `+N` overflow pill. +class ContributorStrip extends StatelessWidget { + const ContributorStrip({super.key, required this.body}); + + /// The release body to scan for `@login` handles. + final String body; + + @override + Widget build(BuildContext context) { + final contributors = contributorsFromBody(body); + if (contributors.isEmpty) return const SizedBox.shrink(); + final shown = contributors.take(_maxShown).toList(); + final avatarWidth = 26 * shown.length - 6 * (shown.length - 1); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + AppSpacing.md, + ), + child: Row( + children: [ + SizedBox( + width: avatarWidth.toDouble(), + height: 26, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < shown.length; i++) + Positioned( + left: (i * 20).toDouble(), + child: _Avatar(contributor: shown[i]), + ), + ], + ), + ), + if (contributors.length > _maxShown) ...[ + const SizedBox(width: AppSpacing.xs), + Text( + '+${contributors.length - _maxShown}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.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. +class _Avatar extends StatefulWidget { + const _Avatar({required this.contributor}); + + 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( + decoration: BoxDecoration( + shape: BoxShape.circle, + // A ring of the card colour keeps overlapping avatars separable. + border: Border.all(color: colors.surface, width: 2), + ), + child: CircleAvatar( + radius: 13, + 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( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ) + : null, + ), + ); + } +} diff --git a/test/features/changelog/changelog_page_test.dart b/test/features/changelog/changelog_page_test.dart index 593d8daf5..3fed85743 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,45 @@ 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 avatars. + expect(find.byType(CircleAvatar), findsNWidgets(2)); + }); + + 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, + publishedAt: DateTime.utc(2026, 8, 1), + ), + ], + ]); + await tester.pumpWidget(_wrap(repo)); + await tester.pumpAndSettle(); + + expect(find.byType(CircleAvatar), findsNothing); + }); } 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')); } From a516d7f03255758d3af327797f197ff2fbf384f8 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 01:33:11 +0800 Subject: [PATCH 19/62] ci: refuse a branch that merges the base or lags behind it --- .github/workflows/ci.yml | 57 +++++++++++++++++++++++++++++++++++----- AGENTS.md | 4 +++ commit.md | 22 ++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 993be4c1e..ef85861ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,23 +27,68 @@ 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" + # 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 # and description — which this gate never saw. That is not hypothetical: diff --git a/AGENTS.md b/AGENTS.md index 6c04c0f05..695ac7e18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,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 diff --git a/commit.md b/commit.md index c2506433b..1e85096a6 100644 --- a/commit.md +++ b/commit.md @@ -307,6 +307,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 會印出哪一則、哪裡不對。因為訊息無法事後修改: From a0c6fe299a7428ba64b6b979d64c5c3461943405 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 02:25:44 +0800 Subject: [PATCH 20/62] fix(log): stop the log screen from feeding itself until it freezes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正在「更多」點開 App 日誌後畫面狂刷並卡死 Fix(en-US): fix the app freezing after opening the log screen from More --- lib/core/logging/log.dart | 51 +++++++ .../log/presentation/pages/log_page.dart | 124 ++++++++++++++++-- test/core/logging/log_repeat_test.dart | 81 ++++++++++++ 3 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 test/core/logging/log_repeat_test.dart diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 8c6afbf50..1ee292ec9 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -91,6 +91,53 @@ 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 = {}; + + /// 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(); + /// Routes uncaught Flutter and async errors into the log and the [crashSink] /// (as fatal reports). /// @@ -112,6 +159,9 @@ abstract final class Log { // creation `file:line`. Keep that rich dump in debug so such errors stay // locatable; release stays quiet (presentError is a near no-op there). if (kDebugMode) FlutterError.presentError(details); + // The library and summary rather than the stack: a layout fault reports + // a different stack every frame while being the same fault. + if (!_admitError('${details.library}/${details.summary}')) return; talker.handle( details.exception, details.stack, @@ -125,6 +175,7 @@ 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; diff --git a/lib/features/log/presentation/pages/log_page.dart b/lib/features/log/presentation/pages/log_page.dart index 9131077eb..81a236816 100644 --- a/lib/features/log/presentation/pages/log_page.dart +++ b/lib/features/log/presentation/pages/log_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/logging/log_store.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; @@ -14,6 +15,13 @@ import 'package:talker_flutter/talker_flutter.dart'; /// 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. +/// +/// These lines are our own layout, not `TalkerScreen`. Talker's screen ships a +/// `SliverAppBar` whose expanded header is taller than its initial +/// `expandedHeight`, so the first frame overflows — and because an overflow is +/// routed through `Log.handle` into the very stream this page listens to, the +/// layout fault re-triggers on every rebuild: a log-flooding loop that ends in +/// a hang. A plain `AppBar` and a fixed toolbar cannot overflow. class LogPage extends StatefulWidget { const LogPage({super.key}); @@ -22,12 +30,40 @@ class LogPage extends StatefulWidget { } class _LogPageState extends State { + String _query = ''; + + /// The history, refreshed on a timer rather than per line. + /// + /// `TalkerBuilder` rebuilds on every entry, and this screen is the one place + /// where that closes a circle: a fault raised while rendering it is logged, + /// which rebuilds it, which raises the fault again. `Log` now refuses the + /// repeat, so the loop terminates — but a screen that rebuilds once per line + /// is still the wrong shape while something is logging hard, and the user + /// cannot scroll a list that rebuilds under them. A tick decouples the two. + static const _refreshInterval = Duration(milliseconds: 400); + Timer? _refresh; + List _entries = const []; + @override void initState() { super.initState(); + _entries = Log.talker.history.toList(); + _refresh = Timer.periodic(_refreshInterval, (_) => _pull()); unawaited(_replayPersisted()); } + @override + void dispose() { + _refresh?.cancel(); + super.dispose(); + } + + void _pull() { + final history = Log.talker.history; + if (history.length == _entries.length) return; + if (mounted) setState(() => _entries = history.toList()); + } + /// Pulls the persisted log into Talker's history, oldest first, so the /// screen reads in the order things happened. /// @@ -49,19 +85,91 @@ class _LogPageState extends State { } Log.talker.logCustom(_PersistedLog(entry)); } - if (mounted) setState(() {}); + _pull(); + } + + /// The raw history, filtered to lines whose rendered text contains [_query]. + List _filtered(List data) { + final query = _query.trim().toLowerCase(); + if (query.isEmpty) return data; + return [ + for (final item in data) + if (item.generateTextMessage().toLowerCase().contains(query)) item, + ]; } @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, + ); + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar(title: Text(l10n.appLogs)), + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.xs, + AppSpacing.md, + AppSpacing.sm, + ), + child: TextField( + onChanged: (value) => setState(() => _query = value), + decoration: InputDecoration( + isDense: true, + prefixIcon: const Icon(Icons.search, size: 20), + hintText: l10n.appLogsSearch, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + Expanded( + child: Builder( + builder: (context) { + final items = _filtered(_entries); + if (items.isEmpty) { + return Center( + child: Text( + _query.isEmpty + ? l10n.appLogsEmpty + : l10n.appLogsNoMatch, + style: TextStyle(color: colors.onSurfaceVariant), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm, + 0, + AppSpacing.sm, + AppSpacing.lg, + ), + itemCount: items.length, + // Newest first, like the old talker view. + itemBuilder: (context, i) { + final item = items[items.length - 1 - i]; + return TalkerDataCard( + data: item, + backgroundColor: theme.cardColor, + color: item.getFlutterColor(theme), + expanded: false, + ); + }, + ); + }, + ), + ), + ], + ), ), ); } diff --git a/test/core/logging/log_repeat_test.dart b/test/core/logging/log_repeat_test.dart new file mode 100644 index 000000000..123ffc81e --- /dev/null +++ b/test/core/logging/log_repeat_test.dart @@ -0,0 +1,81 @@ +/// 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('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'); + }); +} From 611b7dced6fbd9a31f48cfd3c5b13a3b08b3ef53 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 02:39:52 +0800 Subject: [PATCH 21/62] refactor(log): use Talker's own screen and cap the table at 5000 rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): App 日誌改用內建畫面,多了搜尋、等級篩選與分享 Optimization(en-US): the log screen gains search, level filtering and sharing --- lib/core/logging/log_store.dart | 30 ++- .../log/presentation/pages/log_page.dart | 171 +++------------- lib/l10n/app_en.arb | 20 +- lib/l10n/app_fil.arb | 16 ++ lib/l10n/app_id.arb | 16 ++ lib/l10n/app_ja.arb | 16 ++ lib/l10n/app_ko.arb | 16 ++ lib/l10n/app_th.arb | 16 ++ lib/l10n/app_vi.arb | 16 ++ lib/l10n/app_zh.arb | 16 ++ lib/l10n/app_zh_Hans.arb | 16 ++ lib/l10n/app_zh_Hant_HK.arb | 16 ++ lib/l10n/app_zh_TW.arb | 16 ++ lib/l10n/gen/app_localizations.dart | 96 +++++++++ lib/l10n/gen/app_localizations_en.dart | 50 +++++ lib/l10n/gen/app_localizations_fil.dart | 51 +++++ lib/l10n/gen/app_localizations_id.dart | 51 +++++ lib/l10n/gen/app_localizations_ja.dart | 50 +++++ lib/l10n/gen/app_localizations_ko.dart | 50 +++++ lib/l10n/gen/app_localizations_th.dart | 51 +++++ lib/l10n/gen/app_localizations_vi.dart | 51 +++++ lib/l10n/gen/app_localizations_zh.dart | 192 ++++++++++++++++++ test/core/logging/log_store_test.dart | 27 +++ 23 files changed, 899 insertions(+), 151 deletions(-) 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/features/log/presentation/pages/log_page.dart b/lib/features/log/presentation/pages/log_page.dart index 81a236816..c7a322c2f 100644 --- a/lib/features/log/presentation/pages/log_page.dart +++ b/lib/features/log/presentation/pages/log_page.dart @@ -1,27 +1,28 @@ import 'dart:async'; -import 'package:dpip/app/theme/app_spacing.dart'; +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'; /// 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. /// -/// These lines are our own layout, not `TalkerScreen`. Talker's screen ships a -/// `SliverAppBar` whose expanded header is taller than its initial -/// `expandedHeight`, so the first frame overflows — and because an overflow is -/// routed through `Log.handle` into the very stream this page listens to, the -/// layout fault re-triggers on every rebuild: a log-flooding loop that ends in -/// a hang. A plain `AppBar` and a fixed toolbar cannot overflow. +/// 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}); @@ -30,40 +31,12 @@ class LogPage extends StatefulWidget { } class _LogPageState extends State { - String _query = ''; - - /// The history, refreshed on a timer rather than per line. - /// - /// `TalkerBuilder` rebuilds on every entry, and this screen is the one place - /// where that closes a circle: a fault raised while rendering it is logged, - /// which rebuilds it, which raises the fault again. `Log` now refuses the - /// repeat, so the loop terminates — but a screen that rebuilds once per line - /// is still the wrong shape while something is logging hard, and the user - /// cannot scroll a list that rebuilds under them. A tick decouples the two. - static const _refreshInterval = Duration(milliseconds: 400); - Timer? _refresh; - List _entries = const []; - @override void initState() { super.initState(); - _entries = Log.talker.history.toList(); - _refresh = Timer.periodic(_refreshInterval, (_) => _pull()); unawaited(_replayPersisted()); } - @override - void dispose() { - _refresh?.cancel(); - super.dispose(); - } - - void _pull() { - final history = Log.talker.history; - if (history.length == _entries.length) return; - if (mounted) setState(() => _entries = history.toList()); - } - /// Pulls the persisted log into Talker's history, oldest first, so the /// screen reads in the order things happened. /// @@ -78,99 +51,29 @@ class _LogPageState extends State { final oldestInMemory = Log.talker.history.isEmpty ? null : Log.talker.history.first.time; - final stored = await store.recent(limit: 2000); + final stored = await store.recent(limit: logMaxRows); for (final entry in stored.reversed) { if (oldestInMemory != null && !entry.time.isBefore(oldestInMemory)) { continue; } Log.talker.logCustom(_PersistedLog(entry)); } - _pull(); - } - - /// The raw history, filtered to lines whose rendered text contains [_query]. - List _filtered(List data) { - final query = _query.trim().toLowerCase(); - if (query.isEmpty) return data; - return [ - for (final item in data) - if (item.generateTextMessage().toLowerCase().contains(query)) item, - ]; } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final l10n = AppLocalizations.of(context); - final theme = TalkerScreenTheme( - backgroundColor: colors.surface, - textColor: colors.onSurface, - cardColor: colors.surfaceContainer, - ); - return Scaffold( - backgroundColor: colors.surface, - appBar: AppBar(title: Text(l10n.appLogs)), - body: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.xs, - AppSpacing.md, - AppSpacing.sm, - ), - child: TextField( - onChanged: (value) => setState(() => _query = value), - decoration: InputDecoration( - isDense: true, - prefixIcon: const Icon(Icons.search, size: 20), - hintText: l10n.appLogsSearch, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - ), - ), - Expanded( - child: Builder( - builder: (context) { - final items = _filtered(_entries); - if (items.isEmpty) { - return Center( - child: Text( - _query.isEmpty - ? l10n.appLogsEmpty - : l10n.appLogsNoMatch, - style: TextStyle(color: colors.onSurfaceVariant), - ), - ); - } - return ListView.builder( - padding: const EdgeInsets.fromLTRB( - AppSpacing.sm, - 0, - AppSpacing.sm, - AppSpacing.lg, - ), - itemCount: items.length, - // Newest first, like the old talker view. - itemBuilder: (context, i) { - final item = items[items.length - 1 - i]; - return TalkerDataCard( - data: item, - backgroundColor: theme.cardColor, - color: item.getFlutterColor(theme), - expanded: false, - ); - }, - ); - }, - ), - ), - ], - ), + return TalkerScreen( + talker: Log.talker, + appBarTitle: AppLocalizations.of(context).appLogs, + theme: TalkerScreenTheme( + backgroundColor: colors.surface, + textColor: colors.onSurface, + cardColor: colors.surfaceContainer, ), + // Collapsed: a log this screen exists to scan is read by its summaries, + // and an expanded card is mostly stack trace. + isLogsExpanded: false, ); } } @@ -184,25 +87,9 @@ class _PersistedLog extends TalkerLog { final StoredLog entry; @override - String get title => entry.level; + String get title => 'stored'; @override - AnsiPen get pen => switch (entry.level) { - 'error' || 'critical' => AnsiPen()..red(), - 'warning' => AnsiPen()..yellow(), - 'debug' => AnsiPen()..gray(), - _ => AnsiPen()..blue(), - }; - - @override - String generateTextMessage({ - TimeFormat timeFormat = TimeFormat.timeAndSeconds, - }) { - return [ - '[${entry.level}] ${entry.time.toIso8601String()}', - entry.message, - ?entry.error, - ?entry.stackTrace, - ].join('\n'); - } + String? get message => + entry.error == null ? entry.message : '${entry.message}\n${entry.error}'; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6afdadff4..ce18e47de 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" @@ -2718,6 +2727,15 @@ "lightningLegendCg": "Cloud-to-ground · {minutes} min", "skyTimeAuto": "Auto", "appLogs": "App logs", + "serverStatusBody": "Live health of the ExpTech servers.", + "serverStatusLocal": "Local status", + "serverStatusLocalBody": "A healthy server is not enough — alerts also need your device's permissions and background execution:", + "serverStatusAllUp": "All services operational", + "serverStatusDegraded": "Services degraded", + "serverStatusDown": "Service down", + "serverStatusErrorRate": "5xx error rate", + "serverStatusLatency": "Avg latency", + "serverStatusUpdated": "Updated", "feedConnecting": "Connecting…", "notifyBannerDisabled": "Notifications are off — you won't receive disaster alerts.", "@meshtasticNoNodes": { diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index d0070816a..0e9107508 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", @@ -954,6 +961,15 @@ "lightningLegendCg": "Ulap–lupa · {minutes} min", "skyTimeAuto": "Awtomatiko", "appLogs": "Mga log ng app", + "serverStatusBody": "Real-time na kalusugan ng mga server ng ExpTech.", + "serverStatusLocal": "Katayuan ng device", + "serverStatusLocalBody": "Hindi sapat na normal ang server — kailangan ding gumana ang mga pahintulot at background execution:", + "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", "feedConnecting": "Kumokonekta…", "notifyBannerDisabled": "Naka-off ang mga notification — hindi ka makakatanggap ng mga alerto sa sakuna.", "@meshtasticNoNodes": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 6cc5f4f47..7c35aa72d 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", @@ -954,6 +961,15 @@ "lightningLegendCg": "Awan–tanah · {minutes} mnt", "skyTimeAuto": "Otomatis", "appLogs": "Log aplikasi", + "serverStatusBody": "Status kesehatan server ExpTech secara real-time.", + "serverStatusLocal": "Status perangkat", + "serverStatusLocalBody": "Server normal belum tentu notifikasi sampai. Periksa izin dan eksekusi latar:", + "serverStatusAllUp": "Semua layanan normal", + "serverStatusDegraded": "Kinerja menurun", + "serverStatusDown": "Layanan bermasalah", + "serverStatusErrorRate": "Tingkat error 5xx", + "serverStatusLatency": "Latensi rata-rata", + "serverStatusUpdated": "Diperbarui", "feedConnecting": "Menghubungkan…", "notifyBannerDisabled": "Notifikasi mati — Anda tidak akan menerima peringatan bencana.", "@meshtasticNoNodes": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 679a770d3..248e65103 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": "定量降水予報レイヤー設定", @@ -954,6 +961,15 @@ "lightningLegendCg": "対地 · {minutes} 分以内", "skyTimeAuto": "自動", "appLogs": "アプリログ", + "serverStatusBody": "ExpTech サーバーのリアルタイムの健全性です。", + "serverStatusLocal": "デバイスの状態", + "serverStatusLocalBody": "サーバーが正常でも警報が届くとは限りません。権限とバックグラウンド実行を確認してください:", + "serverStatusAllUp": "すべて正常", + "serverStatusDegraded": "パフォーマンス低下", + "serverStatusDown": "サービス異常", + "serverStatusErrorRate": "5xx エラー率", + "serverStatusLatency": "平均遅延", + "serverStatusUpdated": "更新", "feedConnecting": "接続中…", "notifyBannerDisabled": "通知がオフです — 災害警報を受け取れません。", "@meshtasticNoNodes": { diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index f01c30ca9..9a35c389b 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": "정량 강수 예보 레이어 옵션", @@ -954,6 +961,15 @@ "lightningLegendCg": "대지로 · {minutes}분 이내", "skyTimeAuto": "자동", "appLogs": "앱 로그", + "serverStatusBody": "ExpTech 서버의 실시간 상태입니다.", + "serverStatusLocal": "기기 상태", + "serverStatusLocalBody": "서버가 정상이어도 알림이 도착하지 않을 수 있습니다. 권한과 백그라운드 실행을 확인하세요:", + "serverStatusAllUp": "모든 서비스 정상", + "serverStatusDegraded": "성능 저하", + "serverStatusDown": "서비스 이상", + "serverStatusErrorRate": "5xx 오류율", + "serverStatusLatency": "평균 지연", + "serverStatusUpdated": "업데이트", "feedConnecting": "연결 중…", "notifyBannerDisabled": "알림이 꺼져 있어 재난 경보를 받을 수 없습니다.", "@meshtasticNoNodes": { diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 74fcbe0a2..35e40ca21 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": "ตัวเลือกชั้นพยากรณ์น้ำฝน", @@ -954,6 +961,15 @@ "lightningLegendCg": "เมฆสู่พื้น · {minutes} นาที", "skyTimeAuto": "อัตโนมัติ", "appLogs": "บันทึกแอป", + "serverStatusBody": "สถานะสุขภาพแบบเรียลไทม์ของเซิร์ฟเวอร์ ExpTech", + "serverStatusLocal": "สถานะอุปกรณ์", + "serverStatusLocalBody": "เซิร์ฟเวอร์ปกติไม่ได้แปลว่าการแจ้งเตือนจะถึงเสมอ ตรวจสอบสิทธิ์และการทำงานเบื้องหลัง:", + "serverStatusAllUp": "บริการทั้งหมดปกติ", + "serverStatusDegraded": "ประสิทธิภาพลดลง", + "serverStatusDown": "บริการผิดปกติ", + "serverStatusErrorRate": "อัตราข้อผิดพลาด 5xx", + "serverStatusLatency": "ความหน่วงเฉลี่ย", + "serverStatusUpdated": "อัปเดต", "feedConnecting": "กำลังเชื่อมต่อ…", "notifyBannerDisabled": "ปิดการแจ้งเตือนอยู่ — คุณจะไม่ได้รับการเตือนภัยพิบัติ", "@meshtasticNoNodes": { diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index ec4700474..cfc606fe4 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", @@ -954,6 +961,15 @@ "lightningLegendCg": "Mây–đất · {minutes} phút", "skyTimeAuto": "Tự động", "appLogs": "Nhật ký ứng dụng", + "serverStatusBody": "Tình trạng thời gian thực của máy chủ ExpTech.", + "serverStatusLocal": "Trạng thái thiết bị", + "serverStatusLocalBody": "Máy chủ bình thường chưa chắc cảnh báo đã đến được. Kiểm tra quyền và chạy nền:", + "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", "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": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c44193dbd..9b0d0b655 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.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": "定量降水預報圖層選項", @@ -954,6 +961,15 @@ "lightningLegendCg": "對地 · {minutes} 分內", "skyTimeAuto": "自動", "appLogs": "App 日誌", + "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", + "serverStatusLocal": "本機狀態", + "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", + "serverStatusAllUp": "所有服務正常", + "serverStatusDegraded": "服務效能下降", + "serverStatusDown": "服務異常", + "serverStatusErrorRate": "5xx 錯誤率", + "serverStatusLatency": "平均延遲", + "serverStatusUpdated": "更新於", "feedConnecting": "連線中…", "notifyBannerDisabled": "通知已關閉,將收不到災害警報。", "@meshtasticNoNodes": { diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 9302c6228..a0068792b 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": "定量降水预报图层选项", @@ -954,6 +961,15 @@ "lightningLegendCg": "对地 · {minutes} 分钟内", "skyTimeAuto": "自动", "appLogs": "应用日志", + "serverStatusBody": "目前 ExpTech 服务器的实时健康状态。", + "serverStatusLocal": "本机状态", + "serverStatusLocalBody": "服务器正常不代表警报一定到达,请检查本机权限与后台执行状态:", + "serverStatusAllUp": "所有服务正常", + "serverStatusDegraded": "服务性能下降", + "serverStatusDown": "服务异常", + "serverStatusErrorRate": "5xx 错误率", + "serverStatusLatency": "平均延迟", + "serverStatusUpdated": "更新于", "feedConnecting": "连接中…", "notifyBannerDisabled": "通知已关闭,将收不到灾害警报。", "@meshtasticNoNodes": { diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 5f5be0df8..3286b81b4 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.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": "定量降水預報圖層選項", @@ -954,6 +961,15 @@ "lightningLegendCg": "對地 · {minutes} 分內", "skyTimeAuto": "自動", "appLogs": "App 日誌", + "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", + "serverStatusLocal": "本機狀態", + "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", + "serverStatusAllUp": "所有服務正常", + "serverStatusDegraded": "服務效能下降", + "serverStatusDown": "服務異常", + "serverStatusErrorRate": "5xx 錯誤率", + "serverStatusLatency": "平均延遲", + "serverStatusUpdated": "更新於", "feedConnecting": "連接中…", "notifyBannerDisabled": "通知已關閉,將收不到災害警報。", "@meshtasticNoNodes": { diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 932e29f8b..d995527de 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.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": "定量降水預報圖層選項", @@ -954,6 +961,15 @@ "lightningLegendCg": "對地 · {minutes} 分內", "skyTimeAuto": "自動", "appLogs": "App 日誌", + "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", + "serverStatusLocal": "本機狀態", + "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", + "serverStatusAllUp": "所有服務正常", + "serverStatusDegraded": "服務效能下降", + "serverStatusDown": "服務異常", + "serverStatusErrorRate": "5xx 錯誤率", + "serverStatusLatency": "平均延遲", + "serverStatusUpdated": "更新於", "feedConnecting": "連線中…", "notifyBannerDisabled": "通知已關閉,將收不到災害警報。", "@meshtasticNoNodes": { diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index b535046ca..63d5e40ca 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: @@ -3735,6 +3777,60 @@ abstract class AppLocalizations { /// **'App logs'** String get appLogs; + /// No description provided for @serverStatusBody. + /// + /// In en, this message translates to: + /// **'Live health of the ExpTech servers.'** + String get serverStatusBody; + + /// 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: + /// **'A healthy server is not enough — alerts also need your device\'s permissions and background execution:'** + 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; + /// A realtime feed is establishing its first data /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 182e94a3a..c09d51629 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.'; @@ -1964,6 +1986,34 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appLogs => 'App logs'; + @override + String get serverStatusBody => 'Live health of the ExpTech servers.'; + + @override + String get serverStatusLocal => 'Local status'; + + @override + String get serverStatusLocalBody => + 'A healthy server is not enough — alerts also need your device\'s permissions and background execution:'; + + @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 feedConnecting => 'Connecting…'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 086631679..4b84176aa 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+.'; @@ -1974,6 +1996,35 @@ class AppLocalizationsFil extends AppLocalizations { @override String get appLogs => 'Mga log ng app'; + @override + String get serverStatusBody => + 'Real-time na kalusugan ng mga server ng ExpTech.'; + + @override + String get serverStatusLocal => 'Katayuan ng device'; + + @override + String get serverStatusLocalBody => + 'Hindi sapat na normal ang server — kailangan ding gumana ang mga pahintulot at background execution:'; + + @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 feedConnecting => 'Kumokonekta…'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 04db0ed37..eff4d99b1 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+.'; @@ -1965,6 +1987,35 @@ class AppLocalizationsId extends AppLocalizations { @override String get appLogs => 'Log aplikasi'; + @override + String get serverStatusBody => + 'Status kesehatan server ExpTech secara real-time.'; + + @override + String get serverStatusLocal => 'Status perangkat'; + + @override + String get serverStatusLocalBody => + 'Server normal belum tentu notifikasi sampai. Periksa izin dan eksekusi latar:'; + + @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 feedConnecting => 'Menghubungkan…'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index cfaf8f610..276a9ebc0 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強の区分はありません。'; @@ -1932,6 +1954,34 @@ class AppLocalizationsJa extends AppLocalizations { @override String get appLogs => 'アプリログ'; + @override + String get serverStatusBody => 'ExpTech サーバーのリアルタイムの健全性です。'; + + @override + String get serverStatusLocal => 'デバイスの状態'; + + @override + String get serverStatusLocalBody => + 'サーバーが正常でも警報が届くとは限りません。権限とバックグラウンド実行を確認してください:'; + + @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 feedConnecting => '接続中…'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 2e062a80b..912c4b2f5 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강 구분이 없습니다.'; @@ -1939,6 +1961,34 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appLogs => '앱 로그'; + @override + String get serverStatusBody => 'ExpTech 서버의 실시간 상태입니다.'; + + @override + String get serverStatusLocal => '기기 상태'; + + @override + String get serverStatusLocalBody => + '서버가 정상이어도 알림이 도착하지 않을 수 있습니다. 권한과 백그라운드 실행을 확인하세요:'; + + @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 feedConnecting => '연결 중…'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index bca75e782..816bec255 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+'; @@ -1959,6 +1981,35 @@ class AppLocalizationsTh extends AppLocalizations { @override String get appLogs => 'บันทึกแอป'; + @override + String get serverStatusBody => + 'สถานะสุขภาพแบบเรียลไทม์ของเซิร์ฟเวอร์ ExpTech'; + + @override + String get serverStatusLocal => 'สถานะอุปกรณ์'; + + @override + String get serverStatusLocalBody => + 'เซิร์ฟเวอร์ปกติไม่ได้แปลว่าการแจ้งเตือนจะถึงเสมอ ตรวจสอบสิทธิ์และการทำงานเบื้องหลัง:'; + + @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 feedConnecting => 'กำลังเชื่อมต่อ…'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 22ffe5cbc..5777b60da 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+.'; @@ -1964,6 +1986,35 @@ class AppLocalizationsVi extends AppLocalizations { @override String get appLogs => 'Nhật ký ứng dụng'; + @override + String get serverStatusBody => + 'Tình trạng thời gian thực của máy chủ ExpTech.'; + + @override + String get serverStatusLocal => 'Trạng thái thiết bị'; + + @override + String get serverStatusLocalBody => + 'Máy chủ bình thường chưa chắc cảnh báo đã đến được. Kiểm tra quyền và chạy nền:'; + + @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 feedConnecting => 'Đang kết nối…'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 208dae7bf..ecda26a60 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -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強。'; @@ -1922,6 +1943,33 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appLogs => 'App 日誌'; + @override + String get serverStatusBody => '目前 ExpTech 伺服器的即時健康狀態。'; + + @override + String get serverStatusLocal => '本機狀態'; + + @override + String get serverStatusLocalBody => '伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:'; + + @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 feedConnecting => '連線中…'; @@ -3801,6 +3849,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强。'; @@ -4805,6 +4874,33 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get appLogs => '应用日志'; + @override + String get serverStatusBody => '目前 ExpTech 服务器的实时健康状态。'; + + @override + String get serverStatusLocal => '本机状态'; + + @override + String get serverStatusLocalBody => '服务器正常不代表警报一定到达,请检查本机权限与后台执行状态:'; + + @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 feedConnecting => '连接中…'; @@ -6684,6 +6780,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強。'; @@ -7688,6 +7805,33 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get appLogs => 'App 日誌'; + @override + String get serverStatusBody => '目前 ExpTech 伺服器的即時健康狀態。'; + + @override + String get serverStatusLocal => '本機狀態'; + + @override + String get serverStatusLocalBody => '伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:'; + + @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 feedConnecting => '連接中…'; @@ -9567,6 +9711,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強。'; @@ -10571,6 +10736,33 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get appLogs => 'App 日誌'; + @override + String get serverStatusBody => '目前 ExpTech 伺服器的即時健康狀態。'; + + @override + String get serverStatusLocal => '本機狀態'; + + @override + String get serverStatusLocalBody => '伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:'; + + @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 feedConnecting => '連線中…'; 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++) { From 5e01931d1f7e269a9c18f00b4a8f46e068f4b59c Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 02:44:44 +0800 Subject: [PATCH 22/62] fix(log): suppress the console dump, and keep a replayed level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正日誌頁把同一個錯誤不斷印到主控台,回放的紀錄也不再遺失等級 Fix(en-US): stop the log screen reprinting one fault forever, and keep replayed levels --- lib/core/logging/log.dart | 13 +++-- .../log/presentation/pages/log_page.dart | 37 ++++++++----- test/core/logging/log_repeat_test.dart | 17 ++++++ test/features/log/log_replay_test.dart | 52 +++++++++++++++++++ 4 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 test/features/log/log_replay_test.dart diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 1ee292ec9..2ea70204c 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -153,15 +153,22 @@ abstract final class Log { (details.exception as PlatformException).code == 'recreating_view') { 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}/${details.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); - // The library and summary rather than the stack: a layout fault reports - // a different stack every frame while being the same fault. - if (!_admitError('${details.library}/${details.summary}')) return; talker.handle( details.exception, details.stack, diff --git a/lib/features/log/presentation/pages/log_page.dart b/lib/features/log/presentation/pages/log_page.dart index c7a322c2f..cdf801908 100644 --- a/lib/features/log/presentation/pages/log_page.dart +++ b/lib/features/log/presentation/pages/log_page.dart @@ -56,7 +56,7 @@ class _LogPageState extends State { if (oldestInMemory != null && !entry.time.isBefore(oldestInMemory)) { continue; } - Log.talker.logCustom(_PersistedLog(entry)); + Log.talker.logCustom(PersistedLog(entry)); } } @@ -78,18 +78,27 @@ class _LogPageState extends State { } } -/// 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 => 'stored'; +/// 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 and unfilterable — the two things the log screen is read +/// with. The stored string is a [LogLevel] name, written by `Log.persistTo`. +class PersistedLog extends TalkerLog { + PersistedLog(StoredLog entry) + : super( + entry.error == null + ? entry.message + : '${entry.message}\n${entry.error}', + time: entry.time, + logLevel: _level(entry.level), + stackTrace: null, + ); - @override - String? get message => - entry.error == null ? entry.message : '${entry.message}\n${entry.error}'; + /// 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/test/core/logging/log_repeat_test.dart b/test/core/logging/log_repeat_test.dart index 123ffc81e..f84a86194 100644 --- a/test/core/logging/log_repeat_test.dart +++ b/test/core/logging/log_repeat_test.dart @@ -44,6 +44,23 @@ void main() { 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()); diff --git a/test/features/log/log_replay_test.dart b/test/features/log/log_replay_test.dart new file mode 100644 index 000000000..debb84d75 --- /dev/null +++ b/test/features/log/log_replay_test.dart @@ -0,0 +1,52 @@ +/// 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_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 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')); + }); +} From 36087497d6f486dca4001a5312779a939be92e73 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 02:52:14 +0800 Subject: [PATCH 23/62] fix(log): replay into history without logging it again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正開啟日誌頁會把整份紀錄重印到主控台,並重複寫回資料庫 Fix(en-US): opening the log no longer reprints the whole table or writes it back --- lib/core/logging/log.dart | 28 ++++++++- .../log/presentation/pages/log_page.dart | 63 ++++++++++++------- test/features/log/log_replay_test.dart | 39 ++++++++++++ 3 files changed, 105 insertions(+), 25 deletions(-) diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 2ea70204c..e647f668e 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -16,11 +16,33 @@ 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, ); + /// Held so [replay] can write to it. Talker builds one itself otherwise, and + /// keeps it private. + static final TalkerHistory _history = DefaultTalkerHistory(_settings); + + /// The underlying Talker instance — used by the log screen and error hooks. + static final Talker talker = Talker(settings: _settings, history: _history); + + /// 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; + + /// Puts a line the log screen should show into its history, **without + /// logging it**. + /// + /// `logCustom` is the obvious way and the wrong one: it publishes to the + /// stream, which the persister writes from, and prints to the console when + /// console logs are on. Replaying a day of stored lines through it therefore + /// reprinted the whole table to the terminal *and* wrote every line back + /// into the table it came from, growing a duplicate on each visit. + /// + /// Writing to history is the whole intent: the screen reads history. + static void replay(TalkerData data) => _history.write(data); + /// Optional crash-reporting destination. When set (in `bootstrap`), handled /// and uncaught errors are forwarded here in addition to the in-app log. static CrashSink? crashSink; diff --git a/lib/features/log/presentation/pages/log_page.dart b/lib/features/log/presentation/pages/log_page.dart index cdf801908..59a01f03d 100644 --- a/lib/features/log/presentation/pages/log_page.dart +++ b/lib/features/log/presentation/pages/log_page.dart @@ -7,6 +7,7 @@ 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:dpip/shared/widgets/loading_view.dart'; /// In-app log viewer. Reachable from the More tab; pushed as a full-screen /// route. @@ -31,11 +32,7 @@ class LogPage extends StatefulWidget { } class _LogPageState extends State { - @override - void initState() { - super.initState(); - unawaited(_replayPersisted()); - } + late final Future _replayed = _replayPersisted(); /// Pulls the persisted log into Talker's history, oldest first, so the /// screen reads in the order things happened. @@ -51,29 +48,47 @@ class _LogPageState extends State { final oldestInMemory = Log.talker.history.isEmpty ? null : Log.talker.history.first.time; - final stored = await store.recent(limit: logMaxRows); + // No more than the history can hold: reading further only evicts the lines + // read just before it. + final stored = await store.recent(limit: Log.historyLimit); for (final entry in stored.reversed) { if (oldestInMemory != null && !entry.time.isBefore(oldestInMemory)) { continue; } - Log.talker.logCustom(PersistedLog(entry)); + Log.replay(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.surfaceContainer, - ), - // Collapsed: a log this screen exists to scan is read by its summaries, - // and an expanded card is mostly stack trace. - isLogsExpanded: false, + 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: _replayed, + 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, + ); + }, ); } } @@ -82,16 +97,20 @@ class _LogPageState extends State { /// /// 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 and unfilterable — the two things the log screen is read -/// with. The stored string is a [LogLevel] name, written by `Log.persistTo`. +/// one is uncoloured and unfilterable. The card's heading is `title`, which +/// defaults to the literal string `log`, so both have to be given or a +/// replayed line arrives grey, unfilterable, and labelled `log`. class PersistedLog extends TalkerLog { - PersistedLog(StoredLog entry) + PersistedLog(StoredLog entry) : this._(entry, _level(entry.level)); + + PersistedLog._(StoredLog entry, LogLevel level) : super( entry.error == null ? entry.message : '${entry.message}\n${entry.error}', time: entry.time, - logLevel: _level(entry.level), + title: level.name, + logLevel: level, stackTrace: null, ); diff --git a/test/features/log/log_replay_test.dart b/test/features/log/log_replay_test.dart index debb84d75..739f5f321 100644 --- a/test/features/log/log_replay_test.dart +++ b/test/features/log/log_replay_test.dart @@ -8,6 +8,7 @@ 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'; @@ -37,6 +38,21 @@ void main() { expect(replayed.generateTextMessage(), contains('a line')); }); + test('the card is labelled with the level, not the word log', () { + // `TalkerData.title` defaults to the literal string `log`, so a replayed + // line has to be given one or every row reads `log`. + final replayed = PersistedLog( + StoredLog( + time: DateTime.utc(2026, 8, 18), + level: 'warning', + message: 'a line', + ), + ); + expect(replayed.title, 'warning'); + expect(replayed.generateTextMessage(), contains('warning')); + expect(replayed.generateTextMessage(), isNot(contains('[log]'))); + }); + test('a stored error is carried with its message', () { final replayed = PersistedLog( StoredLog( @@ -49,4 +65,27 @@ void main() { expect(replayed.generateTextMessage(), contains('the summary')); expect(replayed.generateTextMessage(), contains('the detail')); }); + + test('a replayed line reaches history without being logged again', () { + // `logCustom` publishes to the stream, which the persister writes from, + // and prints to the console — so replaying 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); + + final before = Log.talker.history.length; + Log.replay( + PersistedLog( + StoredLog( + time: DateTime.utc(2026, 8, 18), + level: 'info', + message: 'replayed', + ), + ), + ); + + expect(Log.talker.history.length, before + 1); + expect(streamed, 0, reason: 'nothing may write it back or print it'); + }); } From a77586ff730add0be02fd1450f891119af325771 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 02:55:21 +0800 Subject: [PATCH 24/62] fix(log): make clearing the log clear the stored log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正日誌頁的清除只清畫面,重開又整份回來 Fix(en-US): clearing the log no longer leaves everything to come back on reopen --- lib/core/logging/log.dart | 37 +++++++++++++++-- test/core/logging/log_clean_test.dart | 59 +++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 test/core/logging/log_clean_test.dart diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index e647f668e..38f65634b 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -20,9 +20,9 @@ abstract final class Log { useConsoleLogs: kDebugMode, ); - /// Held so [replay] can write to it. Talker builds one itself otherwise, and - /// keeps it private. - static final TalkerHistory _history = DefaultTalkerHistory(_settings); + /// Held so [replay] can write to it, and so clearing the screen clears the + /// table too. Talker builds one itself otherwise, and keeps it private. + static final _PersistedHistory _history = _PersistedHistory(_settings); /// The underlying Talker instance — used by the log screen and error hooks. static final Talker talker = Talker(settings: _settings, history: _history); @@ -211,3 +211,34 @@ abstract final class Log { }; } } + +/// Talker's history, with the stored log tied to it. +/// +/// 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. Clearing that +/// looked broken because it was: the one thing a user presses it for is the +/// one thing it did not do. +/// +/// [clean] is synchronous and the delete is not, so the write is started and +/// not waited on. Nothing reads the table between the two, and a failure there +/// is already swallowed by [LogStore.clear] — reporting a logging failure +/// through the logger is how a write loop starts. +class _PersistedHistory implements TalkerHistory { + _PersistedHistory(TalkerSettings settings) + : _inMemory = DefaultTalkerHistory(settings); + + final DefaultTalkerHistory _inMemory; + + @override + List get history => _inMemory.history; + + @override + void write(TalkerData data) => _inMemory.write(data); + + @override + void clean() { + _inMemory.clean(); + unawaited(Log.store?.clear() ?? Future.value()); + } +} diff --git a/test/core/logging/log_clean_test.dart b/test/core/logging/log_clean_test.dart new file mode 100644 index 000000000..ef61959a8 --- /dev/null +++ b/test/core/logging/log_clean_test.dart @@ -0,0 +1,59 @@ +/// 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); + }); +} From d133721d22d32ecff4946c828ac248d5189e92df Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 02:59:59 +0800 Subject: [PATCH 25/62] fix(log): count a replayed line under its own level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正日誌頁上方等級篩選器把回放的紀錄全算成 undefined Fix(en-US): the level filter counts replayed lines under their level again --- lib/core/logging/log.dart | 15 +++++++- .../log/presentation/pages/log_page.dart | 11 +++--- test/features/log/log_replay_test.dart | 35 +++++++++++++++---- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 38f65634b..ce82e0c31 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -41,7 +41,20 @@ abstract final class Log { /// into the table it came from, growing a duplicate on each visit. /// /// Writing to history is the whole intent: the screen reads history. - static void replay(TalkerData data) => _history.write(data); + /// + /// The normalisation below is what `_handleLogData` does on the way past, + /// and skipping the logger skips it too. The screen groups its filter chips + /// by `key` and colours a card by `key` first — so a line that arrives + /// without one is uncounted, uncoloured, and lands in a chip labelled + /// `undefined` together with every other level. + static void replay(TalkerData data) { + final key = data.key; + if (key != null) { + data.title = talker.settings.getTitleByKey(key); + data.pen = talker.settings.getPenByKey(key, fallbackPen: data.pen); + } + _history.write(data); + } /// Optional crash-reporting destination. When set (in `bootstrap`), handled /// and uncaught errors are forwarded here in addition to the in-app log. diff --git a/lib/features/log/presentation/pages/log_page.dart b/lib/features/log/presentation/pages/log_page.dart index 59a01f03d..415b530fd 100644 --- a/lib/features/log/presentation/pages/log_page.dart +++ b/lib/features/log/presentation/pages/log_page.dart @@ -97,9 +97,9 @@ class _LogPageState extends State { /// /// 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 and unfilterable. The card's heading is `title`, which -/// defaults to the literal string `log`, so both have to be given or a -/// replayed line arrives grey, unfilterable, and labelled `log`. +/// 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)); @@ -109,7 +109,10 @@ class PersistedLog extends TalkerLog { ? entry.message : '${entry.message}\n${entry.error}', time: entry.time, - title: level.name, + // 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, ); diff --git a/test/features/log/log_replay_test.dart b/test/features/log/log_replay_test.dart index 739f5f321..d3aeaa668 100644 --- a/test/features/log/log_replay_test.dart +++ b/test/features/log/log_replay_test.dart @@ -38,9 +38,29 @@ void main() { expect(replayed.generateTextMessage(), contains('a line')); }); - test('the card is labelled with the level, not the word log', () { - // `TalkerData.title` defaults to the literal string `log`, so a replayed - // line has to be given one or every row reads `log`. + 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), @@ -48,9 +68,12 @@ void main() { message: 'a line', ), ); - expect(replayed.title, 'warning'); - expect(replayed.generateTextMessage(), contains('warning')); - expect(replayed.generateTextMessage(), isNot(contains('[log]'))); + Log.replay(replayed); + expect( + replayed.title, + Log.talker.settings.getTitleByKey(TalkerKey.warning), + ); + expect(replayed.title, isNot('log')); }); test('a stored error is carried with its message', () { From 7ef2faa295f32af794087e725035002e93ea979f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 03:07:48 +0800 Subject: [PATCH 26/62] fix(log): stop the replay from evicting the running session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正開啟日誌頁會把本次啟動的紀錄擠掉,順序也錯亂 Fix(en-US): opening the log no longer pushes out the running session's lines --- lib/core/logging/log.dart | 56 +++++++++++++------ .../log/presentation/pages/log_page.dart | 4 +- test/features/log/log_replay_test.dart | 31 ++++++++++ 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index ce82e0c31..f7554e273 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -53,7 +53,7 @@ abstract final class Log { data.title = talker.settings.getTitleByKey(key); data.pen = talker.settings.getPenByKey(key, fallbackPen: data.pen); } - _history.write(data); + _history.replay(data); } /// Optional crash-reporting destination. When set (in `bootstrap`), handled @@ -225,33 +225,55 @@ abstract final class Log { } } -/// Talker's history, with the stored log tied to it. +/// Talker's history, with the stored log tied to it and replay kept in its +/// place. /// -/// 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. Clearing that -/// looked broken because it was: the one thing a user presses it for is the -/// one thing it did not do. +/// Two things the default could not do. /// -/// [clean] is synchronous and the delete is not, so the write is started and -/// not waited on. Nothing reads the table between the two, and a failure there -/// is already swallowed by [LogStore.clear] — reporting a logging failure -/// through the logger is how a write loop starts. +/// **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(TalkerSettings settings) - : _inMemory = DefaultTalkerHistory(settings); + _PersistedHistory(this._settings); + + final TalkerSettings _settings; + final _entries = []; - final DefaultTalkerHistory _inMemory; + bool get _writable => _settings.useHistory && _settings.enabled; @override - List get history => _inMemory.history; + List get history => _entries; @override - void write(TalkerData data) => _inMemory.write(data); + void write(TalkerData data) { + if (!_writable) return; + if (_entries.length >= _settings.maxHistoryItems) _entries.removeAt(0); + _entries.add(data); + } + + /// A line read back off disk: older than everything here, and never worth + /// evicting something that is not. + void replay(TalkerData data) { + if (!_writable) return; + if (_entries.length >= _settings.maxHistoryItems) return; + _entries.insert(0, data); + } @override void clean() { - _inMemory.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()); } } diff --git a/lib/features/log/presentation/pages/log_page.dart b/lib/features/log/presentation/pages/log_page.dart index 415b530fd..779758e44 100644 --- a/lib/features/log/presentation/pages/log_page.dart +++ b/lib/features/log/presentation/pages/log_page.dart @@ -51,7 +51,9 @@ class _LogPageState extends State { // No more than the history can hold: reading further only evicts the lines // read just before it. final stored = await store.recent(limit: Log.historyLimit); - for (final entry in stored.reversed) { + // Newest first, as the query returns them: each is inserted at the front, + // so the oldest ends up furthest forward and the run reads in order. + for (final entry in stored) { if (oldestInMemory != null && !entry.time.isBefore(oldestInMemory)) { continue; } diff --git a/test/features/log/log_replay_test.dart b/test/features/log/log_replay_test.dart index d3aeaa668..c81b3410d 100644 --- a/test/features/log/log_replay_test.dart +++ b/test/features/log/log_replay_test.dart @@ -111,4 +111,35 @@ void main() { expect(Log.talker.history.length, before + 1); expect(streamed, 0, reason: 'nothing may write it back or print it'); }); + + group('replay never costs the running session', () { + setUp(Log.talker.cleanHistory); + + PersistedLog stored(String message, DateTime at) => + PersistedLog(StoredLog(time: at, level: 'info', message: message)); + + test('a live line survives a full replay', () { + // The stored log is on disk and can be read again; the running session + // cannot. Evicting it to make room for history is backwards. + Log.talker.info('DPIP starting up'); + for (var i = 0; i < Log.historyLimit + 50; i++) { + Log.replay(stored('old \$i', DateTime.utc(2026, 8, 17, 0, 0, i))); + } + final messages = Log.talker.history.map((e) => e.message).toList(); + expect(messages, contains('DPIP starting up')); + expect(Log.talker.history.length, lessThanOrEqualTo(Log.historyLimit)); + }); + + test('replayed lines read oldest first, in front of the live ones', () { + Log.talker.info('live'); + // Newest first, as the query returns them. + Log.replay(stored('newer', DateTime.utc(2026, 8, 17, 2))); + Log.replay(stored('older', DateTime.utc(2026, 8, 17, 1))); + expect(Log.talker.history.map((e) => e.message).toList(), [ + 'older', + 'newer', + 'live', + ]); + }); + }); } From f50fd144e0c02ad0c001e1be49700b5934a67572 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 03:14:10 +0800 Subject: [PATCH 27/62] refactor(log): let the table be the log, and load it whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正啟動早期的紀錄從來沒被存下來,當機時最該看的那幾行都不見 Fix(en-US): fix the launch's own log lines never being stored, the ones a crash needs --- lib/core/logging/log.dart | 119 +++++++++--------- .../log/presentation/pages/log_page.dart | 30 ++--- test/core/logging/log_clean_test.dart | 19 +++ test/features/log/log_replay_test.dart | 72 +++++------ 4 files changed, 126 insertions(+), 114 deletions(-) diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index f7554e273..86a7225f1 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -20,42 +20,6 @@ abstract final class Log { useConsoleLogs: kDebugMode, ); - /// Held so [replay] can write to it, and so clearing the screen clears the - /// table too. Talker builds one itself otherwise, and keeps it private. - static final _PersistedHistory _history = _PersistedHistory(_settings); - - /// The underlying Talker instance — used by the log screen and error hooks. - static final Talker talker = Talker(settings: _settings, history: _history); - - /// 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; - - /// Puts a line the log screen should show into its history, **without - /// logging it**. - /// - /// `logCustom` is the obvious way and the wrong one: it publishes to the - /// stream, which the persister writes from, and prints to the console when - /// console logs are on. Replaying a day of stored lines through it therefore - /// reprinted the whole table to the terminal *and* wrote every line back - /// into the table it came from, growing a duplicate on each visit. - /// - /// Writing to history is the whole intent: the screen reads history. - /// - /// The normalisation below is what `_handleLogData` does on the way past, - /// and skipping the logger skips it too. The screen groups its filter chips - /// by `key` and colours a card by `key` first — so a line that arrives - /// without one is uncounted, uncoloured, and lands in a chip labelled - /// `undefined` together with every other level. - static void replay(TalkerData data) { - final key = data.key; - if (key != null) { - data.title = talker.settings.getTitleByKey(key); - data.pen = talker.settings.getPenByKey(key, fallbackPen: data.pen); - } - _history.replay(data); - } - /// Optional crash-reporting destination. When set (in `bootstrap`), handled /// and uncaught errors are forwarded here in addition to the in-app log. static CrashSink? crashSink; @@ -67,6 +31,18 @@ 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); + + /// The underlying Talker instance — used by the log screen and error hooks. + static final Talker talker = Talker(settings: _settings, history: _history); + + /// 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 @@ -76,17 +52,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 @@ -247,24 +253,25 @@ class _PersistedHistory implements TalkerHistory { final TalkerSettings _settings; final _entries = []; - bool get _writable => _settings.useHistory && _settings.enabled; - @override List get history => _entries; @override void write(TalkerData data) { - if (!_writable) return; + if (!_settings.useHistory || !_settings.enabled) return; if (_entries.length >= _settings.maxHistoryItems) _entries.removeAt(0); _entries.add(data); } - /// A line read back off disk: older than everything here, and never worth - /// evicting something that is not. - void replay(TalkerData data) { - if (!_writable) return; - if (_entries.length >= _settings.maxHistoryItems) return; - _entries.insert(0, 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 diff --git a/lib/features/log/presentation/pages/log_page.dart b/lib/features/log/presentation/pages/log_page.dart index 779758e44..acf68b1a9 100644 --- a/lib/features/log/presentation/pages/log_page.dart +++ b/lib/features/log/presentation/pages/log_page.dart @@ -32,33 +32,23 @@ class LogPage extends StatefulWidget { } class _LogPageState extends State { - late final Future _replayed = _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; - // No more than the history can hold: reading further only evicts the lines - // read just before it. final stored = await store.recent(limit: Log.historyLimit); - // Newest first, as the query returns them: each is inserted at the front, - // so the oldest ends up furthest forward and the run reads in order. - for (final entry in stored) { - if (oldestInMemory != null && !entry.time.isBefore(oldestInMemory)) { - continue; - } - Log.replay(PersistedLog(entry)); - } + Log.reload([for (final entry in stored) PersistedLog(entry)]); } @override @@ -73,7 +63,7 @@ class _LogPageState extends State { // 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: _replayed, + future: _loaded, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return Scaffold( diff --git a/test/core/logging/log_clean_test.dart b/test/core/logging/log_clean_test.dart index ef61959a8..80d0bfeda 100644 --- a/test/core/logging/log_clean_test.dart +++ b/test/core/logging/log_clean_test.dart @@ -56,4 +56,23 @@ void main() { 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/features/log/log_replay_test.dart b/test/features/log/log_replay_test.dart index c81b3410d..9bf5ec387 100644 --- a/test/features/log/log_replay_test.dart +++ b/test/features/log/log_replay_test.dart @@ -68,7 +68,7 @@ void main() { message: 'a line', ), ); - Log.replay(replayed); + Log.reload([replayed]); expect( replayed.title, Log.talker.settings.getTitleByKey(TalkerKey.warning), @@ -89,57 +89,53 @@ void main() { expect(replayed.generateTextMessage(), contains('the detail')); }); - test('a replayed line reaches history without being logged again', () { + 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 replaying the table reprinted it and - // wrote every line back into the table it came 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); - final before = Log.talker.history.length; - Log.replay( + Log.reload([ PersistedLog( StoredLog( time: DateTime.utc(2026, 8, 18), level: 'info', - message: 'replayed', + message: 'from the table', ), ), - ); + ]); - expect(Log.talker.history.length, before + 1); + expect(Log.talker.history.map((e) => e.message), ['from the table']); expect(streamed, 0, reason: 'nothing may write it back or print it'); }); - group('replay never costs the running session', () { - setUp(Log.talker.cleanHistory); - - PersistedLog stored(String message, DateTime at) => - PersistedLog(StoredLog(time: at, level: 'info', message: message)); - - test('a live line survives a full replay', () { - // The stored log is on disk and can be read again; the running session - // cannot. Evicting it to make room for history is backwards. - Log.talker.info('DPIP starting up'); - for (var i = 0; i < Log.historyLimit + 50; i++) { - Log.replay(stored('old \$i', DateTime.utc(2026, 8, 17, 0, 0, i))); - } - final messages = Log.talker.history.map((e) => e.message).toList(); - expect(messages, contains('DPIP starting up')); - expect(Log.talker.history.length, lessThanOrEqualTo(Log.historyLimit)); - }); - - test('replayed lines read oldest first, in front of the live ones', () { - Log.talker.info('live'); - // Newest first, as the query returns them. - Log.replay(stored('newer', DateTime.utc(2026, 8, 17, 2))); - Log.replay(stored('older', DateTime.utc(2026, 8, 17, 1))); - expect(Log.talker.history.map((e) => e.message).toList(), [ - 'older', - 'newer', - 'live', - ]); - }); + 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', + ]); }); } From 84c7cb746172973029955e094169c85ca6c3e44c Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 04:22:13 +0800 Subject: [PATCH 28/62] refactor(log): tag each line with an upper-case level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 日誌的等級標籤改成大寫,一眼看得出哪一行不是 INFO Optimization(en-US): log lines are tagged INFO / WARN / ERROR, readable at a glance --- lib/core/logging/log.dart | 17 ++++++++++++ test/features/log/log_replay_test.dart | 38 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 86a7225f1..235f2dc70 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -18,6 +18,23 @@ abstract final class Log { 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 diff --git a/test/features/log/log_replay_test.dart b/test/features/log/log_replay_test.dart index 9bf5ec387..d15b73ff6 100644 --- a/test/features/log/log_replay_test.dart +++ b/test/features/log/log_replay_test.dart @@ -138,4 +138,42 @@ void main() { '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'); + }); } From bb6d418c4de0876a65746cb23ac0a76b793635c4 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 04:26:58 +0800 Subject: [PATCH 29/62] refactor(log): print one plain line per entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 主控台的日誌改成一則一行,不再有框線與跑掉的顏色碼 Optimization(en-US): console logs are one plain line each, without borders or stray colour codes --- lib/core/logging/log.dart | 31 ++++++++++++++- test/core/logging/log_console_test.dart | 53 +++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 test/core/logging/log_console_test.dart diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 235f2dc70..6c41fa6aa 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -53,8 +53,28 @@ abstract final class Log { /// and keeps it private. static final _PersistedHistory _history = _PersistedHistory(_settings); + /// 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: false), + formatter: const _PlainFormatter(), + ); + /// The underlying Talker instance — used by the log screen and error hooks. - static final Talker talker = Talker(settings: _settings, history: _history); + 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. @@ -301,3 +321,12 @@ class _PersistedHistory implements TalkerHistory { unawaited(Log.store?.clear() ?? Future.value()); } } + +/// One line, exactly the message. No border, no underline, no colour. +class _PlainFormatter implements LoggerFormatter { + const _PlainFormatter(); + + @override + String fmt(LogDetails details, TalkerLoggerSettings settings) => + details.message?.toString() ?? ''; +} 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')); + }); +} From d1a81a85289d09083b310f2a6aa1766fd5208f96 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 04:31:09 +0800 Subject: [PATCH 30/62] feat(log): colour the level tag when the terminal can show it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 主控台的等級標籤可以上色,用 --dart-define=DPIP_LOG_COLOR=true 開啟 Optimization(en-US): the console level tag can be coloured with --dart-define=DPIP_LOG_COLOR=true --- lib/core/logging/log.dart | 40 +++++++++++++--- test/core/logging/log_formatter_test.dart | 58 +++++++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 test/core/logging/log_formatter_test.dart diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 6c41fa6aa..2cad25c86 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -53,6 +53,20 @@ abstract final class Log { /// 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, and a choice rather than a detection: the bytes are + /// written on the device and read in whatever window is attached to + /// `flutter run`, which the app cannot see. VS Code's **Debug Console** does + /// not interpret ANSI — that is where `^[[38;5;4m` came from — while its + /// **integrated terminal** does. Same app, same build, different window. + /// + /// Not a font, either: a font supplies glyphs, and an escape sequence is an + /// instruction the terminal either acts on or prints. + static const bool enableConsoleColor = bool.fromEnvironment('DPIP_LOG_COLOR'); + /// Console output, one plain line per entry. /// /// The default draws every line inside a box and paints it with ANSI escapes. @@ -65,8 +79,8 @@ abstract final class Log { /// 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: false), - formatter: const _PlainFormatter(), + settings: TalkerLoggerSettings(enableColors: enableConsoleColor), + formatter: const TagFormatter(), ); /// The underlying Talker instance — used by the log screen and error hooks. @@ -322,11 +336,23 @@ class _PersistedHistory implements TalkerHistory { } } -/// One line, exactly the message. No border, no underline, no colour. -class _PlainFormatter implements LoggerFormatter { - const _PlainFormatter(); +/// 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. +class TagFormatter implements LoggerFormatter { + const TagFormatter(); @override - String fmt(LogDetails details, TalkerLoggerSettings settings) => - details.message?.toString() ?? ''; + String fmt(LogDetails details, TalkerLoggerSettings settings) { + final message = details.message?.toString() ?? ''; + if (!settings.enableColors) return message; + // `[WARN] | 4:23:50 79ms | …` — the tag is everything to the first `]`. + final end = message.indexOf(']'); + if (end < 0) return message; + return details.pen.write(message.substring(0, end + 1)) + + message.substring(end + 1); + } } diff --git a/test/core/logging/log_formatter_test.dart b/test/core/logging/log_formatter_test.dart new file mode 100644 index 000000000..e2b875d59 --- /dev/null +++ b/test/core/logging/log_formatter_test.dart @@ -0,0 +1,58 @@ +/// 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 '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 | hi', colour: false); + expect(out, '[WARN] | 12:00 | hi'); + expect(out.codeUnits, isNot(contains(_esc))); + }); + + test('colour on: only the tag is painted', () { + final out = format('[WARN] | 12:00 | 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); + }); +} From 95b422b03dab819956a06dc3e0fdbda2d854ce59 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 04:43:26 +0800 Subject: [PATCH 31/62] fix(log): never emit colour on iOS, where it cannot survive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正 iOS 上開啟主控台顏色只會多出跳脫字元,看不到顏色 Fix(en-US): enabling console colour on iOS added escape characters instead of colour --- lib/core/logging/log.dart | 22 ++++++++++++++++------ test/core/logging/log_formatter_test.dart | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 2cad25c86..856f8799f 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'; @@ -57,15 +58,24 @@ abstract final class Log { /// /// flutter run --dart-define=DPIP_LOG_COLOR=true /// - /// Off by default, and a choice rather than a detection: the bytes are - /// written on the device and read in whatever window is attached to - /// `flutter run`, which the app cannot see. VS Code's **Debug Console** does - /// not interpret ANSI — that is where `^[[38;5;4m` came from — while its - /// **integrated terminal** does. Same app, same build, different window. + /// 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 const bool enableConsoleColor = bool.fromEnvironment('DPIP_LOG_COLOR'); + static final bool enableConsoleColor = + const bool.fromEnvironment('DPIP_LOG_COLOR') && !Platform.isIOS; /// Console output, one plain line per entry. /// diff --git a/test/core/logging/log_formatter_test.dart b/test/core/logging/log_formatter_test.dart index e2b875d59..8387e8c51 100644 --- a/test/core/logging/log_formatter_test.dart +++ b/test/core/logging/log_formatter_test.dart @@ -5,6 +5,8 @@ /// rather than a detection. library; +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:talker_flutter/talker_flutter.dart'; @@ -55,4 +57,21 @@ void main() { // 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); + }); } From 2045c42f1504afc0d2c7b3e4e689b2e592b1c68e Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 04:52:53 +0800 Subject: [PATCH 32/62] feat(log): colour the log in the terminal, where ANSI works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 新增 tool/colorize_logs.sh,在終端機端替日誌上色 Optimization(en-US): add tool/colorize_logs.sh, which colours the log in the terminal --- AGENTS.md | 11 +++++ test/tool/colorize_logs_test.dart | 72 +++++++++++++++++++++++++++++++ tool/colorize_logs.sh | 42 ++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 test/tool/colorize_logs_test.dart create mode 100755 tool/colorize_logs.sh diff --git a/AGENTS.md b/AGENTS.md index 695ac7e18..edd94e8ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,17 @@ mise exec -- flutter run -d "iPhone 17 Pro" 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`. +- Colour the log by piping it, not by asking the app for colour: + + ```sh + mise exec -- flutter run | tool/colorize_logs.sh + ``` + + On iOS an escape sequence cannot survive the trip — the platform's log path + escapes the escape character, so even a terminal that supports ANSI prints it + (flutter/flutter#20663). `dart:developer`'s `log` gets them through but + truncates past ~128 characters, which is where the diagnostic lines live. A + pipe has neither problem, and it also drops the `flutter: ` prefix. - If `flutter run` / `pub get` stalls at **Downloading packages**, resolve from the local cache first: `mise exec -- flutter pub get --offline`, then re-run. - The visible simulator window in Xcode 26+ is **DeviceHub.app** — it replaced diff --git a/test/tool/colorize_logs_test.dart b/test/tool/colorize_logs_test.dart new file mode 100644 index 000000000..f00635ad9 --- /dev/null +++ b/test/tool/colorize_logs_test.dart @@ -0,0 +1,72 @@ +/// `tool/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/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: [WARN] | 4:45:48 492ms | eew SSE not connected\n'; + + test('the flutter: prefix is dropped', () { + expect(run(line), startsWith('[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: [$level] | 1:00:00 1ms | x\n', tty: true); + expect(out.codeUnits, contains(_esc), reason: level); + } + }); +} diff --git a/tool/colorize_logs.sh b/tool/colorize_logs.sh new file mode 100755 index 000000000..713b6a1f5 --- /dev/null +++ b/tool/colorize_logs.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Colours DPIP's log lines on their way past, in the terminal. +# +# Usage: +# mise exec -- flutter run | tool/colorize_logs.sh +# +# The app writes plain text on purpose. Colour has to be added *here* rather +# than there, because on iOS it cannot survive the trip: the platform's log +# path escapes the escape character itself, so a terminal that fully supports +# ANSI still receives a backslash followed by the sequence and prints it +# (flutter/flutter#20663). `dart:developer`'s `log` does get escapes through, +# but truncates anything past ~128 characters to `` — and the long +# lines are the diagnostic ones, so that trade buys colour with the content. +# +# A pipe has neither problem. The bytes are written by this script, in this +# terminal, which is the one place that knows whether ANSI works. +# +# Only the tag is coloured: a fully coloured line is harder to read than a +# plain one, and the tag is what is being scanned for. +set -euo pipefail + +# Off when the output is not a terminal — piped to a file or another program, +# escapes would be exactly the noise this exists to avoid. +if [ -t 1 ]; then + readonly DIM=$'\033[2m' RESET=$'\033[0m' + readonly RED=$'\033[31m' YELLOW=$'\033[33m' + readonly BLUE=$'\033[34m' GREY=$'\033[90m' MAGENTA=$'\033[35m' +else + readonly DIM='' RESET='' RED='' YELLOW='' BLUE='' GREY='' MAGENTA='' +fi + +# `flutter: ` prefixes every line the device prints; dropping it gives back a +# terminal's worth of width, and nothing distinguishes those lines but it. +sed -E \ + -e "s/^flutter: //" \ + -e "s/^\[CRITICAL\]/${MAGENTA}[CRITICAL]${RESET}/" \ + -e "s/^\[ERROR\]/${RED}[ERROR]${RESET}/" \ + -e "s/^\[WARN\]/${YELLOW}[WARN]${RESET}/" \ + -e "s/^\[INFO\]/${BLUE}[INFO]${RESET}/" \ + -e "s/^\[DEBUG\]/${GREY}[DEBUG]${RESET}/" \ + -e "s/^\[VERBOSE\]/${GREY}[VERBOSE]${RESET}/" \ + -e "s/\| ([0-9]+:[0-9]{2}:[0-9]{2} [0-9]+ms) \|/| ${DIM}\1${RESET} |/" From 3c636887621e8f934e1db34919c525b08f75bd2b Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 04:59:07 +0800 Subject: [PATCH 33/62] feat(log): wrap the run and its colouring in tool/run.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 新增 tool/run.sh,一個指令跑起來就有上色的日誌 Optimization(en-US): add tool/run.sh so one command runs the app with a coloured log --- AGENTS.md | 25 ++++++++------- test/tool/run_script_test.dart | 57 ++++++++++++++++++++++++++++++++++ tool/run.sh | 29 +++++++++++++++++ 3 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 test/tool/run_script_test.dart create mode 100755 tool/run.sh diff --git a/AGENTS.md b/AGENTS.md index edd94e8ea..df22289be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,23 +35,24 @@ mise exec -- flutter analyze ## Running ```sh -mise exec -- flutter run -d "iPhone 17 Pro" +tool/run.sh -d "iPhone 17 Pro" ``` +`flutter run` on the pinned toolchain with the log coloured — arguments pass +through untouched, and hot reload still works, because 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`. -- Colour the log by piping it, not by asking the app for colour: - - ```sh - mise exec -- flutter run | tool/colorize_logs.sh - ``` - - On iOS an escape sequence cannot survive the trip — the platform's log path - escapes the escape character, so even a terminal that supports ANSI prints it - (flutter/flutter#20663). `dart:developer`'s `log` gets them through but - truncates past ~128 characters, which is where the diagnostic lines live. A - pipe has neither problem, and it also drops the `flutter: ` prefix. +- `tool/run.sh` is `mise exec -- flutter run … | tool/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 `flutter run` / `pub get` stalls at **Downloading packages**, resolve from the local cache first: `mise exec -- flutter pub get --offline`, then re-run. - The visible simulator window in Xcode 26+ is **DeviceHub.app** — it replaced diff --git a/test/tool/run_script_test.dart b/test/tool/run_script_test.dart new file mode 100644 index 000000000..936290caf --- /dev/null +++ b/test/tool/run_script_test.dart @@ -0,0 +1,57 @@ +/// `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/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 runs flutter through mise', () { + // A shell's PATH is resolved once and goes stale; `mise exec` re-reads + // mise.toml every time. See AGENTS.md → Toolchain. + final script = File('${Directory.current.path}/tool/run.sh') + .readAsStringSync(); + expect(script, contains('mise exec -- flutter run "\$@"')); + }); +} diff --git a/tool/run.sh b/tool/run.sh new file mode 100755 index 000000000..4619ca972 --- /dev/null +++ b/tool/run.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# `flutter run`, on the pinned toolchain, with the log coloured. +# +# tool/run.sh -d "iPhone 17 Pro" +# +# Every argument is passed through untouched. +# +# **Hot reload still works.** The tool decides those two things from different +# places — `supportsColor` reads *stdout*, `singleCharMode` reads *stdin* — and +# a pipe only touches the first. `r`, `R` and `q` go to stdin, which is still +# the terminal. What is lost is flutter's own colour and its progress spinner, +# which are redraw sequences that a pipe turns into litter anyway. +# +# `mise exec --` and not a bare `flutter`, because a shell's PATH is resolved +# once and goes stale: `mise activate` caches it, so a toolchain bump leaves +# the old version on PATH until the session is replaced. `mise exec` re-reads +# mise.toml every time. See AGENTS.md → Toolchain. +# +# `pipefail` is the part a wrapper like this usually gets wrong: without it the +# pipeline reports the *colouriser's* status, so a build that failed would exit +# 0 and the wrapper would hide the thing it wraps. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Worth passing `-d`: piped, the tool cannot draw its interactive device picker +# (target_devices.dart gates that on the logger's colour), so an ambiguous +# device list falls back to a plain prompt. +mise exec -- flutter run "$@" | "$here/colorize_logs.sh" From 097185a8fa07ec48eed8ce526626c2300b639aed Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:04:28 +0800 Subject: [PATCH 34/62] fix(log): keep the colouriser alive while the run shuts down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正結束執行時出現 Broken pipe 的未處理例外 Fix(en-US): fix the unhandled Broken pipe exception when a run is stopped --- AGENTS.md | 7 +- CLAUDE.md | 8 +- lib/app/router/app_router.dart | 6 + lib/app/shell/main_shell.dart | 14 +- lib/bootstrap.dart | 30 +- lib/core/di/core_providers.dart | 5 + lib/core/di/shared_deps.dart | 6 + lib/core/network/endpoint_health.dart | 247 +++++++ .../more/presentation/pages/more_page.dart | 38 +- .../status/data/server_status_api.dart | 120 +++ .../data/server_status_repository_impl.dart | 20 + lib/features/status/domain/server_status.dart | 58 ++ .../domain/server_status_repository.dart | 16 + .../pages/server_status_page.dart | 689 ++++++++++++++++++ lib/l10n/app_en.arb | 38 +- lib/l10n/app_fil.arb | 38 +- lib/l10n/app_id.arb | 38 +- lib/l10n/app_ja.arb | 38 +- lib/l10n/app_ko.arb | 38 +- lib/l10n/app_th.arb | 38 +- lib/l10n/app_vi.arb | 38 +- lib/l10n/app_zh.arb | 38 +- lib/l10n/app_zh_Hans.arb | 38 +- lib/l10n/app_zh_Hant_HK.arb | 38 +- lib/l10n/app_zh_TW.arb | 38 +- lib/l10n/gen/app_localizations.dart | 218 +++++- lib/l10n/gen/app_localizations_en.dart | 112 ++- lib/l10n/gen/app_localizations_fil.dart | 112 ++- lib/l10n/gen/app_localizations_id.dart | 112 ++- lib/l10n/gen/app_localizations_ja.dart | 110 ++- lib/l10n/gen/app_localizations_ko.dart | 110 ++- lib/l10n/gen/app_localizations_th.dart | 112 ++- lib/l10n/gen/app_localizations_vi.dart | 112 ++- lib/l10n/gen/app_localizations_zh.dart | 444 ++++++++++- lib/shared/navigation/app_routes.dart | 4 + test/core/network/api_client_test.dart | 108 +++ test/core/network/endpoint_health_test.dart | 171 +++++ test/features/more/more_page_test.dart | 3 + .../status/server_status_page_test.dart | 180 +++++ .../status/server_status_parse_test.dart | 168 +++++ test/tool/colorize_logs_test.dart | 17 + test/tool/run_script_test.dart | 14 +- tool/add_endpoint_health_keys.py | 191 +++++ tool/add_service_keys.py | 96 +++ tool/add_status_page_keys.py | 162 ++++ tool/add_status_web_keys.py | 170 +++++ tool/colorize_logs.sh | 13 + tool/run.sh | 6 +- tool/tighten_status_web_label.py | 49 ++ 49 files changed, 4425 insertions(+), 51 deletions(-) create mode 100644 lib/core/network/endpoint_health.dart create mode 100644 lib/features/status/data/server_status_api.dart create mode 100644 lib/features/status/data/server_status_repository_impl.dart create mode 100644 lib/features/status/domain/server_status.dart create mode 100644 lib/features/status/domain/server_status_repository.dart create mode 100644 lib/features/status/presentation/pages/server_status_page.dart create mode 100644 test/core/network/endpoint_health_test.dart create mode 100644 test/features/status/server_status_page_test.dart create mode 100644 test/features/status/server_status_parse_test.dart create mode 100644 tool/add_endpoint_health_keys.py create mode 100644 tool/add_service_keys.py create mode 100644 tool/add_status_page_keys.py create mode 100644 tool/add_status_web_keys.py create mode 100644 tool/tighten_status_web_label.py diff --git a/AGENTS.md b/AGENTS.md index df22289be..c7de984d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,8 +38,11 @@ mise exec -- flutter analyze tool/run.sh -d "iPhone 17 Pro" ``` -`flutter run` on the pinned toolchain with the log coloured — arguments pass -through untouched, and hot reload still works, because the tool reads +**This is the only supported way to start the app.** Not `flutter run`, and not +`mise exec -- flutter run` — both work, and both are wrong in ways nothing +tells you about, so a debug build started any other way says so in its log. + +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. diff --git a/CLAUDE.md b/CLAUDE.md index cf7a6e4d7..8957d540a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,13 @@ gate or the analyzer will tell you. - **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) + than CI is. 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. + → [AGENTS.md § Toolchain](AGENTS.md#toolchain) +- **Start the app with `tool/run.sh`**, never `flutter run` directly. Both run; + the difference is the SDK it resolves and whether the log is readable, and + neither is visible at the time. → [AGENTS.md § Running](AGENTS.md#running) - **No `Co-Authored-By`, no tool attribution, ever.** → [AGENTS.md § Commits](AGENTS.md#commits) diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 8956bdb40..afbd71965 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -32,6 +32,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'; @@ -249,6 +250,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/bootstrap.dart b/lib/bootstrap.dart index a956ce8c7..c56b1f55b 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'; @@ -103,11 +104,34 @@ Stream _weatherIconLicense() async* { ); } +/// Set by `tool/run.sh`, which is how the app is meant to be started. +const bool _launchedByTool = bool.fromEnvironment('DPIP_RUN_SH'); + +/// Says so when it was not. +/// +/// Debug only, and a warning rather than a refusal — a disaster app that will +/// not start is worse than one started the wrong way. +/// +/// It is worth saying at all because both failures are silent. A bare +/// `flutter run` uses whatever Flutter the shell's PATH resolved, which +/// `mise activate` caches and does not refresh when mise.toml changes — so the +/// app builds against a different SDK than CI with no sign of it. And the log +/// arrives uncoloured, because colour is added by the pipe rather than by the +/// app (see tool/colorize_logs.sh for why it cannot be added here). +void _warnIfNotLaunchedByTool() { + if (!kDebugMode || _launchedByTool) return; + Log.warning( + 'started outside tool/run.sh — this build may be on a different Flutter ' + 'than CI, and the log will not be coloured. Use: tool/run.sh -d ', + ); +} + Future bootstrap() async { WidgetsFlutterBinding.ensureInitialized(); Log.installErrorHandlers(); Log.info('DPIP starting up'); + _warnIfNotLaunchedByTool(); // 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 +174,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 +332,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, diff --git a/lib/core/di/core_providers.dart b/lib/core/di/core_providers.dart index 254e74016..d73b9a638 100644 --- a/lib/core/di/core_providers.dart +++ b/lib/core/di/core_providers.dart @@ -16,6 +16,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'; @@ -76,6 +77,10 @@ List coreProviders(SharedDeps deps) => [ Provider.value(value: deps.meshStore), Provider.value(value: deps.meshGateway), Provider.value(value: 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/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/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 8849078ae..b0984ef5c 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'; @@ -860,35 +861,42 @@ class _AnnouncementCard extends StatelessWidget { class _StatusCard extends StatelessWidget { const _StatusCard(); - static const String _url = 'https://status.exptech.dev/status'; - @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: () => openExternalLink(context, _url), + onTap: () => context.pushNamed(AppRoutes.serverStatus), child: Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colors.surfaceContainerHighest, - ), - child: Icon( - Icons.dns_outlined, - color: colors.onSurfaceVariant, - size: 19, + 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), @@ -905,7 +913,7 @@ class _StatusCard extends StatelessWidget { ), const SizedBox(width: AppSpacing.xs), Icon( - Icons.open_in_new, + Icons.chevron_right, size: 14, color: colors.onSurfaceVariant.withValues(alpha: 0.6), ), 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/server_status.dart b/lib/features/status/domain/server_status.dart new file mode 100644 index 000000000..e3451521d --- /dev/null +++ b/lib/features/status/domain/server_status.dart @@ -0,0 +1,58 @@ +/// Server status snapshot from the ExpTech status dashboard. +library; + +import 'package:flutter/foundation.dart'; + +/// One Grafana query result — a single number plus the instance (host) it was +/// measured on, when the query reports one. +@immutable +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. +@immutable +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..60adcdd6c --- /dev/null +++ b/lib/features/status/presentation/pages/server_status_page.dart @@ -0,0 +1,689 @@ +/// 伺服器狀態 — 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/api_client.dart'; +import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/core/network/endpoint_health.dart'; +import 'package:dpip/l10n/gen/app_localizations.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/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}); + + /// Injectable for tests; defaults to the live Grafana-backed implementation. + final ServerStatusRepository? repository; + + /// 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 ?? + ServerStatusRepositoryImpl(ServerStatusApi(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.lg), + Text( + l10n.serverStatusBody, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.lg), + AsyncView( + future: repo.status, + builder: (context, status) => _StatusGrid(status: status), + ), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.serverStatusLocal, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + 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(), + ], + ), + ); + } +} + +/// 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 tables — one per concrete tier, +/// two fixed region columns each, cells showing which services ran on that +/// region. 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. +class _ClientEndpoints extends StatelessWidget { + const _ClientEndpoints(); + + /// The four tables, each a tier + its two fixed regions. + static const _tables = [ + (tier: ApiTier.lbApi, regions: ['TPE1', 'KHH1']), + (tier: ApiTier.lbStatic, regions: ['TPE1', 'KHH1']), + (tier: ApiTier.coreApi, regions: ['TYO1', 'TNN1']), + (tier: ApiTier.coreStatic, regions: ['TYO1', 'TNN1']), + ]; + + @override + Widget build(BuildContext context) { + final monitor = context.watch(); + final entries = monitor.entries; + final summary = monitor.summary; + final colors = context.colorScheme; + + if (entries.isEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SummaryBanner(summary: summary), + const SizedBox(height: AppSpacing.md), + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + ), + child: Text( + context.l10n.endpointHealthNone, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant), + ), + ), + ], + ); + } + + // Rows: services in first-seen order. + final services = []; + for (final h in entries) { + if (!services.contains(h.service)) services.add(h.service); + } + + // Cell content: service × tier × region → health. + final cell = <(EndpointService, ApiTier, String), EndpointHealth>{}; + for (final h in entries) { + cell[(h.service, h.tier, h.regionCode)] = h; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SummaryBanner(summary: summary), + const SizedBox(height: AppSpacing.md), + for (final t in _tables) ...[ + _ServiceTable( + title: _tierShortLabel(context, t.tier), + services: services, + tier: t.tier, + regions: t.regions, + cell: cell, + ), + if (t != _tables.last) const SizedBox(height: AppSpacing.md), + ], + ], + ); + } + + String _tierShortLabel(BuildContext context, ApiTier tier) { + final l10n = context.l10n; + return switch (tier) { + ApiTier.lbApi => l10n.endpointTierLbApi, + ApiTier.lbStatic => l10n.endpointTierLbStatic, + ApiTier.coreApi => l10n.endpointTierCoreApi, + ApiTier.coreStatic => l10n.endpointTierCoreStatic, + ApiTier.coreExclusiveApi => l10n.endpointTierCoreExclusiveApi, + ApiTier.coreStaticExclusive => l10n.endpointTierCoreStaticExclusive, + ApiTier.legacyApi => l10n.endpointTierLegacyApi, + }; + } +} + +class _ServiceTable extends StatelessWidget { + const _ServiceTable({ + required this.title, + required this.services, + required this.tier, + required this.regions, + required this.cell, + }); + + final String title; + final List services; + final ApiTier tier; + final List regions; + final Map<(EndpointService, ApiTier, String), EndpointHealth> cell; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + // Header + services rows + tier footer. + final rows = >[]; + + // Header row: corner cell + region names. + rows.add([ + _corner(context), + for (final r in regions) _headerCell(context, r), + ]); + + for (final s in services) { + rows.add([ + _serviceCell(context, s), + for (final r in regions) _regionCell(context, s, r), + ]); + } + + 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 chip for that host, or an em-dash + /// when the service was never seen on this tier × region. + Widget _regionCell(BuildContext context, EndpointService s, String region) { + final h = cell[(s, tier, region)]; + if (h == null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Text( + '—', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(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), + ); + } + + 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 region chip inside a service × tier cell: the region code (`TPE1`, +/// `KHH1`…), 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); + return Tooltip( + message: '$host\n$label', + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: color.withValues(alpha: 0.14), + ), + child: Text( + region, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ), + ); + } +} + +(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), + ), + ), + ], + ), + ); + } +} + +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/l10n/app_en.arb b/lib/l10n/app_en.arb index ce18e47de..0e2c2e16e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2729,13 +2729,49 @@ "appLogs": "App logs", "serverStatusBody": "Live health of the ExpTech servers.", "serverStatusLocal": "Local status", - "serverStatusLocalBody": "A healthy server is not enough — alerts also need your device's permissions and background execution:", + "serverStatusLocalBody": "The server metrics above come from the dashboard; below is this device's own view of the multi-active endpoints — which LB / Core region actually answers:", "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", + "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", + "endpointHealthNone": "This device has not yet sent a request to any endpoint.", + "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": { diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 0e9107508..211b54ff1 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -963,13 +963,49 @@ "appLogs": "Mga log ng app", "serverStatusBody": "Real-time na kalusugan ng mga server ng ExpTech.", "serverStatusLocal": "Katayuan ng device", - "serverStatusLocalBody": "Hindi sapat na normal ang server — kailangan ding gumana ang mga pahintulot at background execution:", + "serverStatusLocalBody": "Ang mga sukatan ng server ay mula sa dashboard; sa ibaba ay ang sariling pagtingin ng device na ito sa mga multi-active endpoint (bawat rehiyon ng LB/Core) na aktwal na kumokonekta:", "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", + "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", + "endpointHealthNone": "Ang device na ito ay hindi pa nagpapadala ng kahit anong request sa endpoint.", + "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": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 7c35aa72d..7a752878c 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -963,13 +963,49 @@ "appLogs": "Log aplikasi", "serverStatusBody": "Status kesehatan server ExpTech secara real-time.", "serverStatusLocal": "Status perangkat", - "serverStatusLocalBody": "Server normal belum tentu notifikasi sampai. Periksa izin dan eksekusi latar:", + "serverStatusLocalBody": "Metrik server berasal dari dashboard; di bawah ini adalah penilaian perangkat ini terhadap endpoint multi-active (tiap wilayah LB/Core) yang benar-benar terhubung:", "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", + "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", + "endpointHealthNone": "Perangkat ini belum mengirim permintaan ke endpoint mana pun.", + "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": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 248e65103..e087da243 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -963,13 +963,49 @@ "appLogs": "アプリログ", "serverStatusBody": "ExpTech サーバーのリアルタイムの健全性です。", "serverStatusLocal": "デバイスの状態", - "serverStatusLocalBody": "サーバーが正常でも警報が届くとは限りません。権限とバックグラウンド実行を確認してください:", + "serverStatusLocalBody": "サーバー指標はダッシュボードから取得し、以下はこの端末が実際に接続しているマルチアクティブエンドポイント(LB / Core 各リージョン)の判定です:", "serverStatusAllUp": "すべて正常", "serverStatusDegraded": "パフォーマンス低下", "serverStatusDown": "サービス異常", "serverStatusErrorRate": "5xx エラー率", "serverStatusLatency": "平均遅延", "serverStatusUpdated": "更新", + "serverStatusWeb": "サーバー状態", + "serverStatusWebUrl": "status.exptech.dev", + "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": "観測データなし", + "endpointHealthNone": "この端末はまだどのエンドポイントにもリクエストしていません。", + "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": { diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9a35c389b..cc7f796a7 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -963,13 +963,49 @@ "appLogs": "앱 로그", "serverStatusBody": "ExpTech 서버의 실시간 상태입니다.", "serverStatusLocal": "기기 상태", - "serverStatusLocalBody": "서버가 정상이어도 알림이 도착하지 않을 수 있습니다. 권한과 백그라운드 실행을 확인하세요:", + "serverStatusLocalBody": "서버 지표는 대시보드에서 가져오며, 아래는 이 기기가 실제로 연결 중인 멀티 액티브 엔드포인트(LB/Core 각 리전)의 판단입니다:", "serverStatusAllUp": "모든 서비스 정상", "serverStatusDegraded": "성능 저하", "serverStatusDown": "서비스 이상", "serverStatusErrorRate": "5xx 오류율", "serverStatusLatency": "평균 지연", "serverStatusUpdated": "업데이트", + "serverStatusWeb": "서버 상태", + "serverStatusWebUrl": "status.exptech.dev", + "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": "관측 데이터 없음", + "endpointHealthNone": "이 기기는 아직 어떤 엔드포인트에도 요청하지 않았습니다.", + "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": { diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 35e40ca21..b160cc785 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -963,13 +963,49 @@ "appLogs": "บันทึกแอป", "serverStatusBody": "สถานะสุขภาพแบบเรียลไทม์ของเซิร์ฟเวอร์ ExpTech", "serverStatusLocal": "สถานะอุปกรณ์", - "serverStatusLocalBody": "เซิร์ฟเวอร์ปกติไม่ได้แปลว่าการแจ้งเตือนจะถึงเสมอ ตรวจสอบสิทธิ์และการทำงานเบื้องหลัง:", + "serverStatusLocalBody": "ตัวชี้วัดเซิร์ฟเวอร์มาจากแดชบอร์ด ด้านล่างคือการตัดสินของอุปกรณ์นี้ต่อจุดเชื่อมต่อแบบ multi-active (แต่ละภูมิภาคของ LB / Core):", "serverStatusAllUp": "บริการทั้งหมดปกติ", "serverStatusDegraded": "ประสิทธิภาพลดลง", "serverStatusDown": "บริการผิดปกติ", "serverStatusErrorRate": "อัตราข้อผิดพลาด 5xx", "serverStatusLatency": "ความหน่วงเฉลี่ย", "serverStatusUpdated": "อัปเดต", + "serverStatusWeb": "สถานะเซิร์ฟเวอร์", + "serverStatusWebUrl": "status.exptech.dev", + "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": "ยังไม่มีข้อมูล", + "endpointHealthNone": "อุปกรณ์นี้ยังไม่ได้ส่งคำขอไปยังจุดเชื่อมต่อใด", + "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": { diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index cfc606fe4..a4e720089 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -963,13 +963,49 @@ "appLogs": "Nhật ký ứng dụng", "serverStatusBody": "Tình trạng thời gian thực của máy chủ ExpTech.", "serverStatusLocal": "Trạng thái thiết bị", - "serverStatusLocalBody": "Máy chủ bình thường chưa chắc cảnh báo đã đến được. Kiểm tra quyền và chạy nền:", + "serverStatusLocalBody": "Chỉ số máy chủ lấy từ dashboard; bên dưới là nhận định của thiết bị này về các máy chủ multi-active (từng khu vực LB / Core) mà thiết bị thực sự kết nối:", "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", + "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", + "endpointHealthNone": "Thiết bị này chưa gửi yêu cầu đến máy chủ nào.", + "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": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9b0d0b655..2f6f1bfce 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -963,13 +963,49 @@ "appLogs": "App 日誌", "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", "serverStatusLocal": "本機狀態", - "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", + "serverStatusLocalBody": "伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:", "serverStatusAllUp": "所有服務正常", "serverStatusDegraded": "服務效能下降", "serverStatusDown": "服務異常", "serverStatusErrorRate": "5xx 錯誤率", "serverStatusLatency": "平均延遲", "serverStatusUpdated": "更新於", + "serverStatusWeb": "伺服器狀態", + "serverStatusWebUrl": "status.exptech.dev", + "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": "尚無觀測資料", + "endpointHealthNone": "本機尚未對任何端點發出請求。", + "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": { diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index a0068792b..2f8b887b6 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -963,13 +963,49 @@ "appLogs": "应用日志", "serverStatusBody": "目前 ExpTech 服务器的实时健康状态。", "serverStatusLocal": "本机状态", - "serverStatusLocalBody": "服务器正常不代表警报一定到达,请检查本机权限与后台执行状态:", + "serverStatusLocalBody": "服务器指标来自控制台,下方是本机对多活端点(LB / Core 各区)的实际连接判断:", "serverStatusAllUp": "所有服务正常", "serverStatusDegraded": "服务性能下降", "serverStatusDown": "服务异常", "serverStatusErrorRate": "5xx 错误率", "serverStatusLatency": "平均延迟", "serverStatusUpdated": "更新于", + "serverStatusWeb": "服务器状态", + "serverStatusWebUrl": "status.exptech.dev", + "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": "暂无观测数据", + "endpointHealthNone": "本机尚未对任何端点发出请求。", + "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": { diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 3286b81b4..c4a7def4b 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -963,13 +963,49 @@ "appLogs": "App 日誌", "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", "serverStatusLocal": "本機狀態", - "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", + "serverStatusLocalBody": "伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:", "serverStatusAllUp": "所有服務正常", "serverStatusDegraded": "服務效能下降", "serverStatusDown": "服務異常", "serverStatusErrorRate": "5xx 錯誤率", "serverStatusLatency": "平均延遲", "serverStatusUpdated": "更新於", + "serverStatusWeb": "伺服器狀態", + "serverStatusWebUrl": "status.exptech.dev", + "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": "尚無觀測資料", + "endpointHealthNone": "本機尚未對任何端點發出請求。", + "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": { diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index d995527de..161656cd0 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -963,13 +963,49 @@ "appLogs": "App 日誌", "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", "serverStatusLocal": "本機狀態", - "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", + "serverStatusLocalBody": "伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:", "serverStatusAllUp": "所有服務正常", "serverStatusDegraded": "服務效能下降", "serverStatusDown": "服務異常", "serverStatusErrorRate": "5xx 錯誤率", "serverStatusLatency": "平均延遲", "serverStatusUpdated": "更新於", + "serverStatusWeb": "伺服器狀態", + "serverStatusWebUrl": "status.exptech.dev", + "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": "尚無觀測資料", + "endpointHealthNone": "本機尚未對任何端點發出請求。", + "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": { diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 63d5e40ca..ebfcdfc84 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3792,7 +3792,7 @@ abstract class AppLocalizations { /// No description provided for @serverStatusLocalBody. /// /// In en, this message translates to: - /// **'A healthy server is not enough — alerts also need your device\'s permissions and background execution:'** + /// **'The server metrics above come from the dashboard; below is this device\'s own view of the multi-active endpoints — which LB / Core region actually answers:'** String get serverStatusLocalBody; /// No description provided for @serverStatusAllUp. @@ -3831,6 +3831,222 @@ abstract class AppLocalizations { /// **'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 @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 @endpointHealthNone. + /// + /// In en, this message translates to: + /// **'This device has not yet sent a request to any endpoint.'** + String get endpointHealthNone; + + /// 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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index c09d51629..09dc26204 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1994,7 +1994,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get serverStatusLocalBody => - 'A healthy server is not enough — alerts also need your device\'s permissions and background execution:'; + 'The server metrics above come from the dashboard; below is this device\'s own view of the multi-active endpoints — which LB / Core region actually answers:'; @override String get serverStatusAllUp => 'All services operational'; @@ -2014,6 +2014,116 @@ class AppLocalizationsEn extends AppLocalizations { @override String get serverStatusUpdated => 'Updated'; + @override + String get serverStatusWeb => 'Server status'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @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 endpointHealthNone => + 'This device has not yet sent a request to any endpoint.'; + + @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…'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 4b84176aa..d774553c2 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -2005,7 +2005,7 @@ class AppLocalizationsFil extends AppLocalizations { @override String get serverStatusLocalBody => - 'Hindi sapat na normal ang server — kailangan ding gumana ang mga pahintulot at background execution:'; + 'Ang mga sukatan ng server ay mula sa dashboard; sa ibaba ay ang sariling pagtingin ng device na ito sa mga multi-active endpoint (bawat rehiyon ng LB/Core) na aktwal na kumokonekta:'; @override String get serverStatusAllUp => 'Lahat ng serbisyo ay normal'; @@ -2025,6 +2025,116 @@ class AppLocalizationsFil extends AppLocalizations { @override String get serverStatusUpdated => 'Na-update'; + @override + String get serverStatusWeb => 'Katayuan ng server'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @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 endpointHealthNone => + 'Ang device na ito ay hindi pa nagpapadala ng kahit anong request sa endpoint.'; + + @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…'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index eff4d99b1..6ea2aedc5 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1996,7 +1996,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get serverStatusLocalBody => - 'Server normal belum tentu notifikasi sampai. Periksa izin dan eksekusi latar:'; + 'Metrik server berasal dari dashboard; di bawah ini adalah penilaian perangkat ini terhadap endpoint multi-active (tiap wilayah LB/Core) yang benar-benar terhubung:'; @override String get serverStatusAllUp => 'Semua layanan normal'; @@ -2016,6 +2016,116 @@ class AppLocalizationsId extends AppLocalizations { @override String get serverStatusUpdated => 'Diperbarui'; + @override + String get serverStatusWeb => 'Status server'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @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 endpointHealthNone => + 'Perangkat ini belum mengirim permintaan ke endpoint mana pun.'; + + @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…'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 276a9ebc0..41f6893b0 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1962,7 +1962,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get serverStatusLocalBody => - 'サーバーが正常でも警報が届くとは限りません。権限とバックグラウンド実行を確認してください:'; + 'サーバー指標はダッシュボードから取得し、以下はこの端末が実際に接続しているマルチアクティブエンドポイント(LB / Core 各リージョン)の判定です:'; @override String get serverStatusAllUp => 'すべて正常'; @@ -1982,6 +1982,114 @@ class AppLocalizationsJa extends AppLocalizations { @override String get serverStatusUpdated => '更新'; + @override + String get serverStatusWeb => 'サーバー状態'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @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 endpointHealthNone => 'この端末はまだどのエンドポイントにもリクエストしていません。'; + + @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 => '接続中…'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 912c4b2f5..bab9035bd 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1969,7 +1969,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get serverStatusLocalBody => - '서버가 정상이어도 알림이 도착하지 않을 수 있습니다. 권한과 백그라운드 실행을 확인하세요:'; + '서버 지표는 대시보드에서 가져오며, 아래는 이 기기가 실제로 연결 중인 멀티 액티브 엔드포인트(LB/Core 각 리전)의 판단입니다:'; @override String get serverStatusAllUp => '모든 서비스 정상'; @@ -1989,6 +1989,114 @@ class AppLocalizationsKo extends AppLocalizations { @override String get serverStatusUpdated => '업데이트'; + @override + String get serverStatusWeb => '서버 상태'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @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 endpointHealthNone => '이 기기는 아직 어떤 엔드포인트에도 요청하지 않았습니다.'; + + @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 => '연결 중…'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 816bec255..e85def82e 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1990,7 +1990,7 @@ class AppLocalizationsTh extends AppLocalizations { @override String get serverStatusLocalBody => - 'เซิร์ฟเวอร์ปกติไม่ได้แปลว่าการแจ้งเตือนจะถึงเสมอ ตรวจสอบสิทธิ์และการทำงานเบื้องหลัง:'; + 'ตัวชี้วัดเซิร์ฟเวอร์มาจากแดชบอร์ด ด้านล่างคือการตัดสินของอุปกรณ์นี้ต่อจุดเชื่อมต่อแบบ multi-active (แต่ละภูมิภาคของ LB / Core):'; @override String get serverStatusAllUp => 'บริการทั้งหมดปกติ'; @@ -2010,6 +2010,116 @@ class AppLocalizationsTh extends AppLocalizations { @override String get serverStatusUpdated => 'อัปเดต'; + @override + String get serverStatusWeb => 'สถานะเซิร์ฟเวอร์'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @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 endpointHealthNone => + 'อุปกรณ์นี้ยังไม่ได้ส่งคำขอไปยังจุดเชื่อมต่อใด'; + + @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 => 'กำลังเชื่อมต่อ…'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 5777b60da..ce33ba648 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1995,7 +1995,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get serverStatusLocalBody => - 'Máy chủ bình thường chưa chắc cảnh báo đã đến được. Kiểm tra quyền và chạy nền:'; + 'Chỉ số máy chủ lấy từ dashboard; bên dưới là nhận định của thiết bị này về các máy chủ multi-active (từng khu vực LB / Core) mà thiết bị thực sự kết nối:'; @override String get serverStatusAllUp => 'Tất cả dịch vụ hoạt động'; @@ -2015,6 +2015,116 @@ class AppLocalizationsVi extends AppLocalizations { @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 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 endpointHealthNone => + 'Thiết bị này chưa gửi yêu cầu đến máy chủ nào.'; + + @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…'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index ecda26a60..8dd5c418e 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1950,7 +1950,8 @@ class AppLocalizationsZh extends AppLocalizations { String get serverStatusLocal => '本機狀態'; @override - String get serverStatusLocalBody => '伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:'; + String get serverStatusLocalBody => + '伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:'; @override String get serverStatusAllUp => '所有服務正常'; @@ -1970,6 +1971,114 @@ class AppLocalizationsZh extends AppLocalizations { @override String get serverStatusUpdated => '更新於'; + @override + String get serverStatusWeb => '伺服器狀態'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @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 endpointHealthNone => '本機尚未對任何端點發出請求。'; + + @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 => '連線中…'; @@ -4881,7 +4990,8 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get serverStatusLocal => '本机状态'; @override - String get serverStatusLocalBody => '服务器正常不代表警报一定到达,请检查本机权限与后台执行状态:'; + String get serverStatusLocalBody => + '服务器指标来自控制台,下方是本机对多活端点(LB / Core 各区)的实际连接判断:'; @override String get serverStatusAllUp => '所有服务正常'; @@ -4901,6 +5011,114 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get serverStatusUpdated => '更新于'; + @override + String get serverStatusWeb => '服务器状态'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @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 endpointHealthNone => '本机尚未对任何端点发出请求。'; + + @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 => '连接中…'; @@ -7812,7 +8030,8 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get serverStatusLocal => '本機狀態'; @override - String get serverStatusLocalBody => '伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:'; + String get serverStatusLocalBody => + '伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:'; @override String get serverStatusAllUp => '所有服務正常'; @@ -7832,6 +8051,114 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get serverStatusUpdated => '更新於'; + @override + String get serverStatusWeb => '伺服器狀態'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @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 endpointHealthNone => '本機尚未對任何端點發出請求。'; + + @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 => '連接中…'; @@ -10743,7 +11070,8 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get serverStatusLocal => '本機狀態'; @override - String get serverStatusLocalBody => '伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:'; + String get serverStatusLocalBody => + '伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:'; @override String get serverStatusAllUp => '所有服務正常'; @@ -10763,6 +11091,114 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get serverStatusUpdated => '更新於'; + @override + String get serverStatusWeb => '伺服器狀態'; + + @override + String get serverStatusWebUrl => 'status.exptech.dev'; + + @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 endpointHealthNone => '本機尚未對任何端點發出請求。'; + + @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 => '連線中…'; diff --git a/lib/shared/navigation/app_routes.dart b/lib/shared/navigation/app_routes.dart index 7583eda14..fe1130eb3 100644 --- a/lib/shared/navigation/app_routes.dart +++ b/lib/shared/navigation/app_routes.dart @@ -142,4 +142,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/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/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index 19e0b9444..8e7d87036 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -11,6 +11,7 @@ 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'; @@ -86,6 +87,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 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..54eee3377 --- /dev/null +++ b/test/features/status/server_status_page_test.dart @@ -0,0 +1,180 @@ +/// 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/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}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: health ?? EndpointHealthMonitor()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ServerStatusPage(repository: repo), + ), + ); + } + + 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 is the localised 12:30. + expect(find.textContaining(l10n.serverStatusUpdated), findsOneWidget); + }); + + 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 four tier tables', (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', + ); + + await tester.pumpWidget(wrap(repo, health: health)); + await tester.pumpAndSettle(); + + final l10n = l10nOf(tester); + expect(find.text(l10n.serverStatusLocal), findsOneWidget); + // Four tier titles, each a table header. + for (final label in [ + l10n.endpointTierLbApi, + l10n.endpointTierLbStatic, + l10n.endpointTierCoreApi, + l10n.endpointTierCoreStatic, + ]) { + expect(find.text(label), findsOneWidget, reason: 'table $label'); + } + // Service rows appear in all four tables. + expect(find.text(l10n.endpointServiceEew), findsNWidgets(4)); + // Region codes head the columns: LB tables share TPE1/KHH1, Core share + // TYO1/TNN1. Each also appears once as a chip where EEW was observed. + expect(find.text('TPE1'), findsNWidgets(3)); // 2 headers + 1 chip + expect(find.text('KHH1'), findsNWidgets(3)); // 2 headers + 1 chip + expect(find.text('TYO1'), findsNWidgets(2)); // 2 headers, unobserved + expect(find.text('TNN1'), findsNWidgets(3)); // 2 headers + 1 chip + // lb-khh1 doubled-failed → down summary. + expect(find.text(l10n.endpointHealthDown), findsOneWidget); + }); + + testWidgets('empty endpoint health shows the no-observations placeholder', ( + tester, + ) async { + final repo = _FakeRepository(Ok(okStatus())); + await tester.pumpWidget(wrap(repo)); + await tester.pumpAndSettle(); + + final l10n = l10nOf(tester); + expect(find.text(l10n.serverStatusLocal), findsOneWidget); + expect(find.text(l10n.endpointHealthUnknown), findsOneWidget); + expect(find.text(l10n.endpointHealthNone), findsOneWidget); + }); +} + +AppLocalizations l10nOf(WidgetTester tester) => + AppLocalizations.of(tester.element(find.byType(Scaffold))); + +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/tool/colorize_logs_test.dart b/test/tool/colorize_logs_test.dart index f00635ad9..5b8a147ac 100644 --- a/test/tool/colorize_logs_test.dart +++ b/test/tool/colorize_logs_test.dart @@ -69,4 +69,21 @@ void main() { 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/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/run_script_test.dart b/test/tool/run_script_test.dart index 936290caf..2035fa5b6 100644 --- a/test/tool/run_script_test.dart +++ b/test/tool/run_script_test.dart @@ -47,11 +47,19 @@ void main() { 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. + expect(_script(), contains('--dart-define=DPIP_RUN_SH=1')); + }); + test('the wrapper runs flutter through mise', () { // A shell's PATH is resolved once and goes stale; `mise exec` re-reads // mise.toml every time. See AGENTS.md → Toolchain. - final script = File('${Directory.current.path}/tool/run.sh') - .readAsStringSync(); - expect(script, contains('mise exec -- flutter run "\$@"')); + expect(_script(), contains('mise exec -- flutter run')); + expect(_script(), isNot(contains('\nflutter run'))); }); } + +String _script() => + File('${Directory.current.path}/tool/run.sh').readAsStringSync(); diff --git a/tool/add_endpoint_health_keys.py b/tool/add_endpoint_health_keys.py new file mode 100644 index 000000000..3a4cdcd74 --- /dev/null +++ b/tool/add_endpoint_health_keys.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Rewrite the "本機狀態" copy and add the endpoint-health keys in every ARB. + +The local-status block used to be the OS permission checklist; it is now the +client's own reading of the multi-active endpoints (which region actually +answers). Run from repo root: python3 tool/add_endpoint_health_keys.py +""" + +import json +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ARB_DIR = ROOT / "lib" / "l10n" + +# (locale) -> (updated serverStatusLocalBody, new endpoint keys) +LOCALES = { + "zh": { + "serverStatusLocalBody": "伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:", + "endpointHealthOk": "本機連線正常", + "endpointHealthDegraded": "有端點連線不穩", + "endpointHealthDown": "本機連線異常", + "endpointHealthUnknown": "尚無觀測資料", + "endpointHealthNone": "本機尚未對任何端點發出請求。", + "endpointStateOk": "正常", + "endpointStateDegraded": "不穩", + "endpointStateDown": "異常", + "endpointStateUnknown": "未知", + "endpointLastSuccessNever": "尚未成功", + }, + "zh_TW": { + "serverStatusLocalBody": "伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:", + "endpointHealthOk": "本機連線正常", + "endpointHealthDegraded": "有端點連線不穩", + "endpointHealthDown": "本機連線異常", + "endpointHealthUnknown": "尚無觀測資料", + "endpointHealthNone": "本機尚未對任何端點發出請求。", + "endpointStateOk": "正常", + "endpointStateDegraded": "不穩", + "endpointStateDown": "異常", + "endpointStateUnknown": "未知", + "endpointLastSuccessNever": "尚未成功", + }, + "zh_Hant_HK": { + "serverStatusLocalBody": "伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:", + "endpointHealthOk": "本機連線正常", + "endpointHealthDegraded": "有端點連線不穩", + "endpointHealthDown": "本機連線異常", + "endpointHealthUnknown": "尚無觀測資料", + "endpointHealthNone": "本機尚未對任何端點發出請求。", + "endpointStateOk": "正常", + "endpointStateDegraded": "不穩", + "endpointStateDown": "異常", + "endpointStateUnknown": "未知", + "endpointLastSuccessNever": "尚未成功", + }, + "zh_Hans": { + "serverStatusLocalBody": "服务器指标来自控制台,下方是本机对多活端点(LB / Core 各区)的实际连接判断:", + "endpointHealthOk": "本机连接正常", + "endpointHealthDegraded": "有端点连接不稳", + "endpointHealthDown": "本机连接异常", + "endpointHealthUnknown": "暂无观测数据", + "endpointHealthNone": "本机尚未对任何端点发出请求。", + "endpointStateOk": "正常", + "endpointStateDegraded": "不稳", + "endpointStateDown": "异常", + "endpointStateUnknown": "未知", + "endpointLastSuccessNever": "尚未成功", + }, + "en": { + "serverStatusLocalBody": "The server metrics above come from the dashboard; below is this device's own view of the multi-active endpoints — which LB / Core region actually answers:", + "endpointHealthOk": "Local connections healthy", + "endpointHealthDegraded": "Some endpoints unstable", + "endpointHealthDown": "Local connections failing", + "endpointHealthUnknown": "No observations yet", + "endpointHealthNone": "This device has not yet sent a request to any endpoint.", + "endpointStateOk": "OK", + "endpointStateDegraded": "Unstable", + "endpointStateDown": "Failing", + "endpointStateUnknown": "Unknown", + "endpointLastSuccessNever": "never succeeded", + }, + "ja": { + "serverStatusLocalBody": "サーバー指標はダッシュボードから取得し、以下はこの端末が実際に接続しているマルチアクティブエンドポイント(LB / Core 各リージョン)の判定です:", + "endpointHealthOk": "接続正常", + "endpointHealthDegraded": "不安定なエンドポイントあり", + "endpointHealthDown": "接続異常", + "endpointHealthUnknown": "観測データなし", + "endpointHealthNone": "この端末はまだどのエンドポイントにもリクエストしていません。", + "endpointStateOk": "正常", + "endpointStateDegraded": "不安定", + "endpointStateDown": "異常", + "endpointStateUnknown": "不明", + "endpointLastSuccessNever": "未成功", + }, + "ko": { + "serverStatusLocalBody": "서버 지표는 대시보드에서 가져오며, 아래는 이 기기가 실제로 연결 중인 멀티 액티브 엔드포인트(LB/Core 각 리전)의 판단입니다:", + "endpointHealthOk": "연결 정상", + "endpointHealthDegraded": "불안정한 엔드포인트 있음", + "endpointHealthDown": "연결 이상", + "endpointHealthUnknown": "관측 데이터 없음", + "endpointHealthNone": "이 기기는 아직 어떤 엔드포인트에도 요청하지 않았습니다.", + "endpointStateOk": "정상", + "endpointStateDegraded": "불안정", + "endpointStateDown": "이상", + "endpointStateUnknown": "알 수 없음", + "endpointLastSuccessNever": "미성공", + }, + "th": { + "serverStatusLocalBody": "ตัวชี้วัดเซิร์ฟเวอร์มาจากแดชบอร์ด ด้านล่างคือการตัดสินของอุปกรณ์นี้ต่อจุดเชื่อมต่อแบบ multi-active (แต่ละภูมิภาคของ LB / Core):", + "endpointHealthOk": "การเชื่อมต่อปกติ", + "endpointHealthDegraded": "มีจุดเชื่อมต่อไม่เสถียร", + "endpointHealthDown": "การเชื่อมต่อผิดปกติ", + "endpointHealthUnknown": "ยังไม่มีข้อมูล", + "endpointHealthNone": "อุปกรณ์นี้ยังไม่ได้ส่งคำขอไปยังจุดเชื่อมต่อใด", + "endpointStateOk": "ปกติ", + "endpointStateDegraded": "ไม่เสถียร", + "endpointStateDown": "ผิดปกติ", + "endpointStateUnknown": "ไม่ทราบ", + "endpointLastSuccessNever": "ยังไม่สำเร็จ", + }, + "vi": { + "serverStatusLocalBody": "Chỉ số máy chủ lấy từ dashboard; bên dưới là nhận định của thiết bị này về các máy chủ multi-active (từng khu vực LB / Core) mà thiết bị thực sự kết nối:", + "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", + "endpointHealthNone": "Thiết bị này chưa gửi yêu cầu đến máy chủ nào.", + "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", + }, + "id": { + "serverStatusLocalBody": "Metrik server berasal dari dashboard; di bawah ini adalah penilaian perangkat ini terhadap endpoint multi-active (tiap wilayah LB/Core) yang benar-benar terhubung:", + "endpointHealthOk": "Koneksi normal", + "endpointHealthDegraded": "Ada endpoint tidak stabil", + "endpointHealthDown": "Koneksi bermasalah", + "endpointHealthUnknown": "Belum ada data", + "endpointHealthNone": "Perangkat ini belum mengirim permintaan ke endpoint mana pun.", + "endpointStateOk": "Normal", + "endpointStateDegraded": "Tidak stabil", + "endpointStateDown": "Bermasalah", + "endpointStateUnknown": "Tidak diketahui", + "endpointLastSuccessNever": "belum berhasil", + }, + "fil": { + "serverStatusLocalBody": "Ang mga sukatan ng server ay mula sa dashboard; sa ibaba ay ang sariling pagtingin ng device na ito sa mga multi-active endpoint (bawat rehiyon ng LB/Core) na aktwal na kumokonekta:", + "endpointHealthOk": "Normal ang koneksyon", + "endpointHealthDegraded": "May endpoint na hindi matatag", + "endpointHealthDown": "May problema ang koneksyon", + "endpointHealthUnknown": "Wala pang datos", + "endpointHealthNone": "Ang device na ito ay hindi pa nagpapadala ng kahit anong request sa endpoint.", + "endpointStateOk": "Normal", + "endpointStateDegraded": "Hindi matatag", + "endpointStateDown": "May problema", + "endpointStateUnknown": "Hindi alam", + "endpointLastSuccessNever": "hindi pa nagtagumpay", + }, +} + +# Where to insert the new keys so they stay grouped with the server-status ones. +ANCHOR = "serverStatusUpdated" + + +def main() -> None: + for path in sorted(ARB_DIR.glob("app_*.arb")): + locale = path.stem.removeprefix("app_") + if locale not in LOCALES: + print(f"skip {path.name} (no translations)") + continue + updates = LOCALES[locale] + data = json.loads(path.read_text(encoding="utf-8")) + out = {} + for key, value in data.items(): + out[key] = value + if key == "serverStatusLocalBody": + out[key] = updates["serverStatusLocalBody"] + if key == ANCHOR: + for k, v in updates.items(): + if k != "serverStatusLocalBody": + out[k] = v + path.write_text( + json.dumps(out, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"updated {path.name}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tool/add_service_keys.py b/tool/add_service_keys.py new file mode 100644 index 000000000..efb78e86c --- /dev/null +++ b/tool/add_service_keys.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Insert endpointService* keys after endpointLastSuccessNever in every app_*.arb. + +en-US and the zh variants get proper translations; every other locale starts as +the en-US value (placeholder) so l10n generation succeeds, then a translator +pass replaces them. +""" +import json +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent / "lib" / "l10n" + +# Order matters: insertion follows this sequence. +SERVICES = [ + "Eew", "Rts", "Radar", "Satellite", "Qpesums", "Wind", "Dpm", "Weather", + "Rain", "Lightning", "Typhoon", "Report", "TremStation", "Event", + "Location", "Notify", "Other", +] + +EN = { + "Eew": "EEW", + "Rts": "RTS", + "Radar": "Radar", + "Satellite": "Satellite", + "Qpesums": "QPE", + "Wind": "Wind", + "Dpm": "Disaster points", + "Weather": "Weather", + "Rain": "Rain", + "Lightning": "Lightning", + "Typhoon": "Typhoon", + "Report": "EQ reports", + "TremStation": "Tremor station", + "Event": "Events", + "Location": "Location", + "Notify": "Notifications", + "Other": "Other", +} + +ZH = { + "Eew": "地震速報", + "Rts": "強震即時警報", + "Radar": "雷達", + "Satellite": "衛星", + "Qpesums": "定量降水", + "Wind": "風場", + "Dpm": "災害點位", + "Weather": "天氣", + "Rain": "降雨", + "Lightning": "閃電", + "Typhoon": "颱風", + "Report": "地震報告", + "TremStation": "震度站", + "Event": "事件", + "Location": "定位", + "Notify": "通知", + "Other": "其他", +} + +# Files written in the zh family (traditional/simplified). +ZH_FILES = {"app_zh.arb", "app_zh_Hans.arb", "app_zh_Hant_HK.arb", "app_zh_TW.arb"} + + +def main() -> None: + for path in sorted(ROOT.glob("app_*.arb")): + data = json.loads(path.read_text(encoding="utf-8")) + anchor = "endpointLastSuccessNever" + if anchor not in data: + print(f"skip {path.name}: missing anchor") + continue + + values = ZH if path.name in ZH_FILES else EN + # Re-run protection: if any service key already exists the file is + # already populated; leave it alone rather than duplicating entries. + if any(k.startswith("endpointService") for k in data): + print(f"skip {path.name}: keys already present") + continue + + entries = [] + for key, value in list(data.items()): + if key == anchor: + entries.append((key, value)) + for k in SERVICES: + entries.append((f"endpointService{k}", values[k])) + else: + entries.append((key, value)) + + out = "{\n" + ",\n".join( + f' "{k}": {json.dumps(v, ensure_ascii=False)}' for k, v in entries + ) + "\n}\n" + path.write_text(out, encoding="utf-8") + print(f"updated {path.name}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tool/add_status_page_keys.py b/tool/add_status_page_keys.py new file mode 100644 index 000000000..f508fff65 --- /dev/null +++ b/tool/add_status_page_keys.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Insert server-status l10n keys after appLogsNoMatch in every ARB. + +Run from repo root: python3 tool/add_status_page_keys.py +""" + +import json +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ARB_DIR = ROOT / "lib" / "l10n" + +NEW_KEYS = { + "zh": { + "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", + "serverStatusLocal": "本機狀態", + "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", + "serverStatusAllUp": "所有服務正常", + "serverStatusDegraded": "服務效能下降", + "serverStatusDown": "服務異常", + "serverStatusErrorRate": "5xx 錯誤率", + "serverStatusLatency": "平均延遲", + "serverStatusUpdated": "更新於", + }, + "zh_TW": { + "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", + "serverStatusLocal": "本機狀態", + "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", + "serverStatusAllUp": "所有服務正常", + "serverStatusDegraded": "服務效能下降", + "serverStatusDown": "服務異常", + "serverStatusErrorRate": "5xx 錯誤率", + "serverStatusLatency": "平均延遲", + "serverStatusUpdated": "更新於", + }, + "zh_Hant_HK": { + "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", + "serverStatusLocal": "本機狀態", + "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", + "serverStatusAllUp": "所有服務正常", + "serverStatusDegraded": "服務效能下降", + "serverStatusDown": "服務異常", + "serverStatusErrorRate": "5xx 錯誤率", + "serverStatusLatency": "平均延遲", + "serverStatusUpdated": "更新於", + }, + "zh_Hans": { + "serverStatusBody": "目前 ExpTech 服务器的实时健康状态。", + "serverStatusLocal": "本机状态", + "serverStatusLocalBody": "服务器正常不代表警报一定到达,请检查本机权限与后台执行状态:", + "serverStatusAllUp": "所有服务正常", + "serverStatusDegraded": "服务性能下降", + "serverStatusDown": "服务异常", + "serverStatusErrorRate": "5xx 错误率", + "serverStatusLatency": "平均延迟", + "serverStatusUpdated": "更新于", + }, + "en": { + "serverStatusBody": "Live health of the ExpTech servers.", + "serverStatusLocal": "Local status", + "serverStatusLocalBody": "A healthy server is not enough — alerts also need your device's permissions and background execution:", + "serverStatusAllUp": "All services operational", + "serverStatusDegraded": "Services degraded", + "serverStatusDown": "Service down", + "serverStatusErrorRate": "5xx error rate", + "serverStatusLatency": "Avg latency", + "serverStatusUpdated": "Updated", + }, + "ja": { + "serverStatusBody": "ExpTech サーバーのリアルタイムの健全性です。", + "serverStatusLocal": "デバイスの状態", + "serverStatusLocalBody": "サーバーが正常でも警報が届くとは限りません。権限とバックグラウンド実行を確認してください:", + "serverStatusAllUp": "すべて正常", + "serverStatusDegraded": "パフォーマンス低下", + "serverStatusDown": "サービス異常", + "serverStatusErrorRate": "5xx エラー率", + "serverStatusLatency": "平均遅延", + "serverStatusUpdated": "更新", + }, + "ko": { + "serverStatusBody": "ExpTech 서버의 실시간 상태입니다.", + "serverStatusLocal": "기기 상태", + "serverStatusLocalBody": "서버가 정상이어도 알림이 도착하지 않을 수 있습니다. 권한과 백그라운드 실행을 확인하세요:", + "serverStatusAllUp": "모든 서비스 정상", + "serverStatusDegraded": "성능 저하", + "serverStatusDown": "서비스 이상", + "serverStatusErrorRate": "5xx 오류율", + "serverStatusLatency": "평균 지연", + "serverStatusUpdated": "업데이트", + }, + "th": { + "serverStatusBody": "สถานะสุขภาพแบบเรียลไทม์ของเซิร์ฟเวอร์ ExpTech", + "serverStatusLocal": "สถานะอุปกรณ์", + "serverStatusLocalBody": "เซิร์ฟเวอร์ปกติไม่ได้แปลว่าการแจ้งเตือนจะถึงเสมอ ตรวจสอบสิทธิ์และการทำงานเบื้องหลัง:", + "serverStatusAllUp": "บริการทั้งหมดปกติ", + "serverStatusDegraded": "ประสิทธิภาพลดลง", + "serverStatusDown": "บริการผิดปกติ", + "serverStatusErrorRate": "อัตราข้อผิดพลาด 5xx", + "serverStatusLatency": "ความหน่วงเฉลี่ย", + "serverStatusUpdated": "อัปเดต", + }, + "vi": { + "serverStatusBody": "Tình trạng thời gian thực của máy chủ ExpTech.", + "serverStatusLocal": "Trạng thái thiết bị", + "serverStatusLocalBody": "Máy chủ bình thường chưa chắc cảnh báo đã đến được. Kiểm tra quyền và chạy nền:", + "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", + }, + "id": { + "serverStatusBody": "Status kesehatan server ExpTech secara real-time.", + "serverStatusLocal": "Status perangkat", + "serverStatusLocalBody": "Server normal belum tentu notifikasi sampai. Periksa izin dan eksekusi latar:", + "serverStatusAllUp": "Semua layanan normal", + "serverStatusDegraded": "Kinerja menurun", + "serverStatusDown": "Layanan bermasalah", + "serverStatusErrorRate": "Tingkat error 5xx", + "serverStatusLatency": "Latensi rata-rata", + "serverStatusUpdated": "Diperbarui", + }, + "fil": { + "serverStatusBody": "Real-time na kalusugan ng mga server ng ExpTech.", + "serverStatusLocal": "Katayuan ng device", + "serverStatusLocalBody": "Hindi sapat na normal ang server — kailangan ding gumana ang mga pahintulot at background execution:", + "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", + }, +} + + +def main() -> None: + for path in sorted(ARB_DIR.glob("app_*.arb")): + locale = path.stem.removeprefix("app_") + insert = NEW_KEYS.get(locale) + if insert is None: + print(f"skip {path.name} (no translations)") + continue + data = json.loads(path.read_text(encoding="utf-8")) + if "serverStatusBody" in data: + print(f"skip {path.name} (already present)") + continue + out = {} + for key, value in data.items(): + out[key] = value + if key == "appLogsNoMatch": + out.update(insert) + path.write_text( + json.dumps(out, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"updated {path.name}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tool/add_status_web_keys.py b/tool/add_status_web_keys.py new file mode 100644 index 000000000..e7c917bde --- /dev/null +++ b/tool/add_status_web_keys.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Insert the server-status page's new keys in every ARB. + +Adds: + serverStatusWeb — full-width link to the web dashboard + serverStatusWebUrl — subtitle (the web URL) + endpointTierLbApi / endpointTierCoreApi / endpointTierCoreExclusiveApi / + endpointTierCoreStatic / endpointTierLegacyApi — tier section headers + +Run from repo root: python3 tool/add_status_web_keys.py +""" + +import json +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ARB_DIR = ROOT / "lib" / "l10n" + +LOCALES = { + "zh": { + "serverStatusWeb": "在瀏覽器中開啟完整狀態頁", + "serverStatusWebUrl": "status.exptech.dev", + "endpointTierLbApi": "LB API(EEW / 強震警報)", + "endpointTierLbStatic": "LB 靜態資源", + "endpointTierCoreApi": "Core API(地震報告)", + "endpointTierCoreStatic": "Core 靜態資源", + "endpointTierCoreExclusiveApi": "Core 專屬 API(雷達 / 氣象 / 風場)", + "endpointTierCoreStaticExclusive": "Core 專屬靜態資源", + "endpointTierLegacyApi": "舊版 API(api-1)", + }, + "zh_TW": { + "serverStatusWeb": "在瀏覽器中開啟完整狀態頁", + "serverStatusWebUrl": "status.exptech.dev", + "endpointTierLbApi": "LB API(EEW / 強震警報)", + "endpointTierLbStatic": "LB 靜態資源", + "endpointTierCoreApi": "Core API(地震報告)", + "endpointTierCoreStatic": "Core 靜態資源", + "endpointTierCoreExclusiveApi": "Core 專屬 API(雷達 / 氣象 / 風場)", + "endpointTierCoreStaticExclusive": "Core 專屬靜態資源", + "endpointTierLegacyApi": "舊版 API(api-1)", + }, + "zh_Hant_HK": { + "serverStatusWeb": "在瀏覽器中開啟完整狀態頁", + "serverStatusWebUrl": "status.exptech.dev", + "endpointTierLbApi": "LB API(EEW / 強震警報)", + "endpointTierLbStatic": "LB 靜態資源", + "endpointTierCoreApi": "Core API(地震報告)", + "endpointTierCoreStatic": "Core 靜態資源", + "endpointTierCoreExclusiveApi": "Core 專屬 API(雷達 / 氣象 / 風場)", + "endpointTierCoreStaticExclusive": "Core 專屬靜態資源", + "endpointTierLegacyApi": "舊版 API(api-1)", + }, + "zh_Hans": { + "serverStatusWeb": "在浏览器中打开完整状态页", + "serverStatusWebUrl": "status.exptech.dev", + "endpointTierLbApi": "LB API(EEW / 强震警报)", + "endpointTierLbStatic": "LB 静态资源", + "endpointTierCoreApi": "Core API(地震报告)", + "endpointTierCoreStatic": "Core 静态资源", + "endpointTierCoreExclusiveApi": "Core 专属 API(雷达 / 气象 / 风场)", + "endpointTierCoreStaticExclusive": "Core 专属静态资源", + "endpointTierLegacyApi": "旧版 API(api-1)", + }, + "en": { + "serverStatusWeb": "Open the full status page in your browser", + "serverStatusWebUrl": "status.exptech.dev", + "endpointTierLbApi": "LB API (EEW / strong-motion alerts)", + "endpointTierLbStatic": "LB static", + "endpointTierCoreApi": "Core API (earthquake reports)", + "endpointTierCoreStatic": "Core static", + "endpointTierCoreExclusiveApi": "Core-exclusive API (radar / weather / wind)", + "endpointTierCoreStaticExclusive": "Core-exclusive static", + "endpointTierLegacyApi": "Legacy API (api-1)", + }, + "ja": { + "serverStatusWeb": "完全なステータスページをブラウザで開く", + "serverStatusWebUrl": "status.exptech.dev", + "endpointTierLbApi": "LB API(EEW / 強震警報)", + "endpointTierLbStatic": "LB 静的リソース", + "endpointTierCoreApi": "Core API(地震報告)", + "endpointTierCoreStatic": "Core 静的リソース", + "endpointTierCoreExclusiveApi": "Core 専用 API(レーダー / 気象 / 風)", + "endpointTierCoreStaticExclusive": "Core 専用静的リソース", + "endpointTierLegacyApi": "レガシー API(api-1)", + }, + "ko": { + "serverStatusWeb": "브라우저에서 전체 상태 페이지 열기", + "serverStatusWebUrl": "status.exptech.dev", + "endpointTierLbApi": "LB API (EEW / 강진 경보)", + "endpointTierLbStatic": "LB 정적 리소스", + "endpointTierCoreApi": "Core API (지진 보고)", + "endpointTierCoreStatic": "Core 정적 리소스", + "endpointTierCoreExclusiveApi": "Core 전용 API (레이다 / 기상 / 바람)", + "endpointTierCoreStaticExclusive": "Core 전용 정적 리소스", + "endpointTierLegacyApi": "레거시 API (api-1)", + }, + "th": { + "serverStatusWeb": "เปิดหน้าสถานะเต็มในเบราว์เซอร์", + "serverStatusWebUrl": "status.exptech.dev", + "endpointTierLbApi": "LB API (EEW / แจ้งเตือนแผ่นดินไหวรุนแรง)", + "endpointTierLbStatic": "LB ทรัพยากรคงที่", + "endpointTierCoreApi": "Core API (รายงานแผ่นดินไหว)", + "endpointTierCoreStatic": "Core ทรัพยากรคงที่", + "endpointTierCoreExclusiveApi": "Core เฉพาะ API (เรดาร์ / อากาศ / ลม)", + "endpointTierCoreStaticExclusive": "Core เฉพาะทรัพยากรคงที่", + "endpointTierLegacyApi": "API เดิม (api-1)", + }, + "vi": { + "serverStatusWeb": "Mở trang trạng thái đầy đủ trong trình duyệt", + "serverStatusWebUrl": "status.exptech.dev", + "endpointTierLbApi": "LB API (EEW / cảnh báo rung lắc mạnh)", + "endpointTierLbStatic": "LB tĩnh", + "endpointTierCoreApi": "Core API (báo cáo động đất)", + "endpointTierCoreStatic": "Core tĩnh", + "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)", + }, + "id": { + "serverStatusWeb": "Buka halaman status lengkap di browser", + "serverStatusWebUrl": "status.exptech.dev", + "endpointTierLbApi": "LB API (EEW / peringatan gempa kuat)", + "endpointTierLbStatic": "LB statis", + "endpointTierCoreApi": "Core API (laporan gempa)", + "endpointTierCoreStatic": "Core statis", + "endpointTierCoreExclusiveApi": "Core eksklusif API (radar / cuaca / angin)", + "endpointTierCoreStaticExclusive": "Core eksklusif statis", + "endpointTierLegacyApi": "API lama (api-1)", + }, + "fil": { + "serverStatusWeb": "Buksan ang buong status page sa browser", + "serverStatusWebUrl": "status.exptech.dev", + "endpointTierLbApi": "LB API (EEW / malalakas na alerto sa pagyanig)", + "endpointTierLbStatic": "LB static", + "endpointTierCoreApi": "Core API (ulat ng lindol)", + "endpointTierCoreStatic": "Core static", + "endpointTierCoreExclusiveApi": "Core-eksklusibong API (radar / panahon / hangin)", + "endpointTierCoreStaticExclusive": "Core-eksklusibong static", + "endpointTierLegacyApi": "Legacy API (api-1)", + }, +} + +ANCHOR = "serverStatusUpdated" + + +def main() -> None: + for path in sorted(ARB_DIR.glob("app_*.arb")): + locale = path.stem.removeprefix("app_") + if locale not in LOCALES: + print(f"skip {path.name} (no translations)") + continue + updates = LOCALES[locale] + data = json.loads(path.read_text(encoding="utf-8")) + if "serverStatusWeb" in data: + print(f"skip {path.name} (already present)") + continue + out = {} + for key, value in data.items(): + out[key] = value + if key == ANCHOR: + out.update(updates) + path.write_text( + json.dumps(out, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"updated {path.name}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tool/colorize_logs.sh b/tool/colorize_logs.sh index 713b6a1f5..8860a52c5 100755 --- a/tool/colorize_logs.sh +++ b/tool/colorize_logs.sh @@ -19,6 +19,19 @@ # plain one, and the tag is what is being scanned for. set -euo pipefail +# Outlive the thing being coloured. +# +# Ctrl-C goes to every process in the foreground group, so without this the +# filter dies first and `flutter run` — still shutting down, still printing — +# writes into a closed pipe and takes `EPIPE` as an unhandled exception: +# +# FileSystemException: writeFrom failed (OS Error: Broken pipe, errno = 32) +# … ResidentRunner._serviceDisconnected +# +# Ignoring the interrupt leaves this reading until its stdin closes, which is +# when the writer has genuinely finished. +trap '' INT + # Off when the output is not a terminal — piped to a file or another program, # escapes would be exactly the noise this exists to avoid. if [ -t 1 ]; then diff --git a/tool/run.sh b/tool/run.sh index 4619ca972..5f8ccb1f8 100755 --- a/tool/run.sh +++ b/tool/run.sh @@ -26,4 +26,8 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Worth passing `-d`: piped, the tool cannot draw its interactive device picker # (target_devices.dart gates that on the logger's colour), so an ambiguous # device list falls back to a plain prompt. -mise exec -- flutter run "$@" | "$here/colorize_logs.sh" +# `DPIP_RUN_SH` is how the app knows it was started properly. A launch that +# skips this script gets the wrong toolchain and an uncoloured log, and neither +# announces itself — so bootstrap says so instead, in debug only. +mise exec -- flutter run --dart-define=DPIP_RUN_SH=1 "$@" \ + | "$here/colorize_logs.sh" diff --git a/tool/tighten_status_web_label.py b/tool/tighten_status_web_label.py new file mode 100644 index 000000000..1c6e8dc67 --- /dev/null +++ b/tool/tighten_status_web_label.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Tighten the server-status page's web-dashboard button to just its name. + +The full-width card is the entry to the web dashboard; its title reads the +same as the page's own name instead of a wordy prompt. The subtitle still +shows the host so the row stays a link, not a duplicate header. +""" + +import json +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ARB_DIR = ROOT / "lib" / "l10n" + +LOCALES = { + "zh": "伺服器狀態", + "zh_TW": "伺服器狀態", + "zh_Hant_HK": "伺服器狀態", + "zh_Hans": "服务器状态", + "en": "Server status", + "ja": "サーバー状態", + "ko": "서버 상태", + "th": "สถานะเซิร์ฟเวอร์", + "vi": "Trạng thái máy chủ", + "id": "Status server", + "fil": "Katayuan ng server", +} + + +def main() -> None: + for path in sorted(ARB_DIR.glob("app_*.arb")): + locale = path.stem.removeprefix("app_") + label = LOCALES.get(locale) + if label is None: + continue + data = json.loads(path.read_text(encoding="utf-8")) + if "serverStatusWeb" not in data: + print(f"skip {path.name} (missing key)") + continue + data["serverStatusWeb"] = label + path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"updated {path.name}") + + +if __name__ == "__main__": + main() \ No newline at end of file From 00f72443b83b923a3c8996b75a85a2da65a775ac Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:09:43 +0800 Subject: [PATCH 35/62] fix(status): restore the ApiClient hooks the status page needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 修復伺服器狀態頁依賴的網路層支援 New(en-US): restore the ApiClient hooks the status page relies on --- lib/core/network/api_client.dart | 40 +++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) 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( From b405be4a2b9703dd2d5fd45e0e5d9dedd98a7a33 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:11:09 +0800 Subject: [PATCH 36/62] build: refuse a debug launch that skipped the run script --- AGENTS.md | 11 ++++++++-- CLAUDE.md | 8 ++++--- README.md | 12 +++++++++-- lib/bootstrap.dart | 52 ++++++++++++++++++++++++++++++++-------------- tool/run.ps1 | 27 ++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 23 deletions(-) create mode 100755 tool/run.ps1 diff --git a/AGENTS.md b/AGENTS.md index c7de984d7..8c2dfd128 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,9 +38,16 @@ mise exec -- flutter analyze 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.** Not `flutter run`, and not -`mise exec -- flutter run` — both work, and both are wrong in ways nothing -tells you about, so a debug build started any other way says so in its log. +`mise exec -- flutter run` — both start it, and both are wrong in ways 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 diff --git a/CLAUDE.md b/CLAUDE.md index 8957d540a..431fc73ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,9 +34,11 @@ gate or the analyzer will tell you. a toolchain bump leaves the old SDK on PATH until the session is replaced — and a build against the wrong SDK announces nothing. → [AGENTS.md § Toolchain](AGENTS.md#toolchain) -- **Start the app with `tool/run.sh`**, never `flutter run` directly. Both run; - the difference is the SDK it resolves and whether the log is readable, and - neither is visible at the time. → [AGENTS.md § Running](AGENTS.md#running) +- **Start the app with `tool/run.sh`** (`tool\run.ps1` on Windows), never + `flutter run` directly. 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) - **No `Co-Authored-By`, no tool attribution, ever.** → [AGENTS.md § Commits](AGENTS.md#commits) diff --git a/README.md b/README.md index 34dcd89b4..2189aff2b 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ 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 @@ -102,9 +102,17 @@ cd DPIP mise install # 安裝 mise.toml 釘選的 Flutter bash tool/setup.sh # 一次性設定:git hooks、產生建置資訊 mise exec -- flutter pub get -mise exec -- flutter run ``` +啟動: + +| 系統 | 指令 | +|---|---| +| macOS、Linux | `tool/run.sh -d <裝置>` | +| Windows | `tool\run.ps1 -d <裝置>`(或用 Git Bash/WSL 跑 `bash tool/run.sh`,日誌會上色) | + +**一定要用這個腳本,不要直接 `flutter run`。** debug 版本偵測到不是這樣啟動會拒絕執行並印出正確指令 —— 直接跑起來的話,用到的是你 shell 快取的那個 Flutter 而不是 `mise.toml` 釘選的那個,而且當下不會有任何徵兆。 + 建置成安裝檔: ```bash diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index c56b1f55b..41165d166 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -104,26 +104,46 @@ Stream _weatherIconLicense() async* { ); } -/// Set by `tool/run.sh`, which is how the app is meant to be started. +/// Set by `tool/run.sh` and `tool/run.ps1`, which is how the app is started. const bool _launchedByTool = bool.fromEnvironment('DPIP_RUN_SH'); -/// Says so when it was not. +/// Refuses to start when it was not, and says what to run instead. /// -/// Debug only, and a warning rather than a refusal — a disaster app that will -/// not start is worse than one started the wrong way. +/// Debug only — a release build is produced by `flutter build` in CI, which +/// never sets this and must never be blocked by it. /// -/// It is worth saying at all because both failures are silent. A bare -/// `flutter run` uses whatever Flutter the shell's PATH resolved, which -/// `mise activate` caches and does not refresh when mise.toml changes — so the -/// app builds against a different SDK than CI with no sign of it. And the log -/// arrives uncoloured, because colour is added by the pipe rather than by the -/// app (see tool/colorize_logs.sh for why it cannot be added here). -void _warnIfNotLaunchedByTool() { +/// 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/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; - Log.warning( - 'started outside tool/run.sh — this build may be on a different Flutter ' - 'than CI, and the log will not be coloured. Use: tool/run.sh -d ', - ); + 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 { @@ -131,7 +151,7 @@ Future bootstrap() async { Log.installErrorHandlers(); Log.info('DPIP starting up'); - _warnIfNotLaunchedByTool(); + _refuseUnlessLaunchedByTool(); // The bundled weather glyphs are Material Symbols (Apache-2.0). Registering // the licence puts it in the app's own 開放原始碼授權 page (More → licences), diff --git a/tool/run.ps1 b/tool/run.ps1 new file mode 100755 index 000000000..b9be5141e --- /dev/null +++ b/tool/run.ps1 @@ -0,0 +1,27 @@ +# `flutter run`, on the pinned toolchain. Windows. +# +# tool\run.ps1 -d "Pixel 9" +# +# The PowerShell counterpart of tool/run.sh. Every argument is passed through +# untouched. +# +# **It deliberately does not pipe.** The bash script colours the log by piping +# it through tool/colorize_logs.sh, and the same shape here would be wrong: +# `$LASTEXITCODE` is not set reliably when a native command's output goes to a +# cmdlet (PowerShell/PowerShell#19848), so a failed build would exit 0 and this +# wrapper would hide the thing it wraps. That is the one defect a wrapper like +# this must not have, and it is worth more than colour. +# +# For a coloured log on Windows, run the bash script under Git Bash or WSL: +# +# bash tool/run.sh -d "Pixel 9" +# +# `mise exec --` and not a bare `flutter`: a shell's PATH is resolved once and +# goes stale, while `mise exec` re-reads mise.toml every time. +# See AGENTS.md -> Toolchain. + +$ErrorActionPreference = 'Stop' + +& mise exec -- flutter run --dart-define=DPIP_RUN_SH=1 @args + +exit $LASTEXITCODE From 89c877e713c8661e5b73f6b52e337ca69d4d8f8a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:11:32 +0800 Subject: [PATCH 37/62] feat(run): refuse to start outside the run script, with Windows support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 除錯版偵測到非腳本啟動將直接拒絕執行並提示正確指令,新增 Windows 啟動腳本 New(en-US): the debug build refuses non-script launches and there is now a Windows run script --- test/features/log/log_page_test.dart | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 test/features/log/log_page_test.dart 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); + }); +} From 4f1dab5183dde9248ec2d333b172e8d98555836f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:12:18 +0800 Subject: [PATCH 38/62] feat(more): add the beta channels and partner tiles under Get the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 取得 App 下方新增 測試版 與 合作夥伴 區塊 New(en-US): beta channels and partner tiles now sit under Get the app --- .../more/presentation/pages/more_page.dart | 50 +++++++++++ test/features/more/more_page_test.dart | 25 ++++++ tool/add_more_beta_partners.py | 87 +++++++++++++++++++ tool/add_more_partners_note.py | 50 +++++++++++ 4 files changed, 212 insertions(+) create mode 100644 tool/add_more_beta_partners.py create mode 100644 tool/add_more_partners_note.py diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index b0984ef5c..7d5e7efd0 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -201,6 +201,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: [ diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index 8e7d87036..c79632758 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -124,6 +124,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 { diff --git a/tool/add_more_beta_partners.py b/tool/add_more_beta_partners.py new file mode 100644 index 000000000..d84aab889 --- /dev/null +++ b/tool/add_more_beta_partners.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Insert the More-page beta/partner keys after each locale's +`"moreSectionApp"` line. Idempotent. Run from repo root, then gen-l10n. +""" + +import pathlib +import re + +ROOT = pathlib.Path(__file__).resolve().parent.parent + +# key -> per-locale value; locale key is the ARB file's locale code +KEYS = { + "moreSectionBeta": { + "zh_TW": "\u6e2c\u8a66\u7248", "zh_Hant_HK": "\u6e2c\u8a66\u7248", + "zh": "\u6e2c\u8a66\u7248", "zh_Hans": "\u6d4b\u8bd5\u7248", + "en": "Beta", "ko": "\ud14c\uc2a4\ud2b8 \ubc84\uc804", "ja": "\u30c6\u30b9\u30c8\u7248", + "vi": "B\u1ea3n th\u1eed nghi\u1ec7m", "id": "Versi uji", + "th": "\u0e40\u0e27\u0e2d\u0e23\u0e4c\u0e0a\u0e31\u0e19\u0e17\u0e14\u0e2a\u0e2d\u0e1a", + "fil": "Bersyon ng pagsubok", + }, + "moreAndroidBeta": { + "zh_TW": "Android \u6e2c\u8a66\u7248", "zh_Hant_HK": "Android \u6e2c\u8a66\u7248", + "zh": "Android \u6e2c\u8a66\u7248", "zh_Hans": "Android \u6d4b\u8bd5\u7248", + "en": "Android beta", "ko": "Android \ud14c\uc2a4\ud2b8 \ubc84\uc804", + "ja": "Android \u30c6\u30b9\u30c8\u7248", "vi": "B\u1ea3n th\u1eed nghi\u1ec7m Android", + "id": "Versi uji Android", "th": "\u0e40\u0e27\u0e2d\u0e23\u0e4c\u0e0a\u0e31\u0e19\u0e17\u0e14\u0e2d\u0e1a Android", + "fil": "Bersyon ng pagsubok sa Android", + }, + "moreTestFlight": { + "zh_TW": "iOS \u6e2c\u8a66\u7248\uff08TestFlight\uff09", "zh_Hant_HK": "iOS \u6e2c\u8a66\u7248\uff08TestFlight\uff09", + "zh": "iOS \u6e2c\u8a66\u7248\uff08TestFlight\uff09", "zh_Hans": "iOS \u6d4b\u8bd5\u7248\uff08TestFlight\uff09", + "en": "iOS beta (TestFlight)", "ko": "iOS \ud14c\uc2a4\ud2b8 \ubc84\uc804 (TestFlight)", + "ja": "iOS \u30c6\u30b9\u30c8\u7248\uff08TestFlight\uff09", + "vi": "B\u1ea3n th\u1eed nghi\u1ec7m iOS (TestFlight)", "id": "Versi uji iOS (TestFlight)", + "th": "\u0e40\u0e27\u0e2d\u0e23\u0e4c\u0e0a\u0e31\u0e19\u0e17\u0e14\u0e2d\u0e1a iOS (TestFlight)", + "fil": "Bersyon ng pagsubok sa iOS (TestFlight)", + }, + "moreSectionPartners": { + "zh_TW": "\u5408\u4f5c\u5925\u4f34", "zh_Hant_HK": "\u5408\u4f5c\u5925\u4f34", + "zh": "\u5408\u4f5c\u5925\u4f34", "zh_Hans": "\u5408\u4f5c\u4f19\u4f34", + "en": "Partners", "ko": "\ud30c\ud2b8\ub108", "ja": "\u30d1\u30fc\u30c8\u30ca\u30fc", + "vi": "\u0110\u1ed1i t\u00e1c", "id": "Mitra", "th": "\u0e1e\u0e31\u0e19\u0e18\u0e21\u0e34\u0e15\u0e23", + "fil": "Mga kasosyo", + }, + "morePartnerGeoscience": { + "zh_TW": "\u5de8\u79d1\u8cc7\u8a0a\u6709\u9650\u516c\u53f8", "zh_Hant_HK": "\u5de8\u79d1\u8cc7\u8a0a\u6709\u9650\u516c\u53f8", + "zh": "\u5de8\u79d1\u8cc7\u8a0a\u6709\u9650\u516c\u53f8", "zh_Hans": "\u5de8\u79d1\u8d44\u8baf\u6709\u9650\u516c\u53f8", + "en": "Geoscience", "ko": "Geoscience", "ja": "Geoscience", "vi": "Geoscience", + "id": "Geoscience", "th": "Geoscience", "fil": "Geoscience", + }, + "morePartnerTwds": { + "zh_TW": "\u53f0\u7063\u6578\u4f4d\u4e32\u6d41\u6709\u9650\u516c\u53f8", "zh_Hant_HK": "\u53f0\u7063\u6578\u4f4d\u4e32\u6d41\u6709\u9650\u516c\u53f8", + "zh": "\u53f0\u7063\u6578\u4f4d\u4e32\u6d41\u6709\u9650\u516c\u53f8", "zh_Hans": "\u53f0\u6e7e\u6570\u4f4d\u4e32\u6d41\u6709\u9650\u516c\u53f8", + "en": "TWDS", "ko": "TWDS", "ja": "TWDS", "vi": "TWDS", "id": "TWDS", "th": "TWDS", + "fil": "TWDS", + }, +} + +def locale_of(path: pathlib.Path) -> str: + return path.stem[len("app_") :] + +def insert_after_app(data: str, lines: list[str]) -> str: + """Insert `lines` after the first '"moreSectionApp"' line.""" + idx = re.search(r'^ "moreSectionApp": ".*?",?$', data, re.M).start() + line_end = data.index("\n", idx) + # Re-add the trailing comma to the anchor if it was the last key in the file + anchor = data[idx : line_end] + if not anchor.rstrip().endswith(","): + data = data[:line_end] + "," + data[line_end:] + new_block = "\n" + "\n".join(" " + ln for ln in lines) + return data[: line_end + 1] + new_block + "\n" + data[line_end + 1 :] + +def main() -> None: + for path in sorted((ROOT / "lib/l10n").glob("app_*.arb")): + loc = locale_of(path) + original = path.read_text() + if f'"moreSectionBeta"' in original: + print(f"{path.name}: already has keys, skipped") + continue + lines = [] + for key, per_locale in KEYS.items(): + lines.append(f'"{key}": "{per_locale[loc]}",') + path.write_text(insert_after_app(original, lines)) + print(f"{path.name}: +{len(lines)} keys after moreSectionApp") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tool/add_more_partners_note.py b/tool/add_more_partners_note.py new file mode 100644 index 000000000..4fbed690d --- /dev/null +++ b/tool/add_more_partners_note.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Insert the partners-note key after each locale's `"moreSectionPartners"` +line. Idempotent. Run from repo root, then gen-l10n. +""" + +import pathlib +import re + +ROOT = pathlib.Path(__file__).resolve().parent.parent + +NOTES = { + "zh_TW": "\u4f9d\u6642\u9593\u5408\u4f5c\u79a9\u5e8f\u4f86\u6392\u5217\u3002\u611f\u8b1d\u9019\u4e9b\u500b\u4eba\u8207\u516c\u53f8\u5c0d\u9632\u707d\u4e8b\u696d\u7684\u8ca2\u737b\uff0c\u4ed6\u5011\u7684\u4ed8\u51fa\u8b93 DPIP \u8b8a\u5f97\u53ef\u80fd\u3002", + "zh_Hant_HK": "\u4f9d\u6642\u9593\u5408\u4f5c\u79a9\u5e8f\u4f86\u6392\u5217\u3002\u611f\u8b1d\u9019\u4e9b\u500b\u4eba\u8207\u516c\u53f8\u5c0d\u9632\u707d\u4e8b\u696d\u7684\u8ca2\u737b\uff0c\u4ed6\u5011\u7684\u4ed8\u51fa\u8b93 DPIP \u8b8a\u5f97\u53ef\u80fd\u3002", + "zh": "\u4f9d\u6642\u9593\u5408\u4f5c\u79a9\u5e8f\u4f86\u6392\u5217\u3002\u611f\u8b1d\u9019\u4e9b\u500b\u4eba\u8207\u516c\u53f8\u5c0d\u9632\u707d\u4e8b\u696d\u7684\u8ca2\u737b\uff0c\u4ed6\u5011\u7684\u4ed8\u51fa\u8b93 DPIP \u8b8a\u5f97\u53ef\u80fd\u3002", + "zh_Hans": "\u6309\u65f6\u95f4\u5408\u4f5c\u79e9\u5e8f\u6392\u5217\u3002\u611f\u8c22\u8fd9\u4e9b\u4e2a\u4eba\u4e0e\u516c\u53f8\u5bf9\u9632\u707e\u4e8b\u4e1a\u7684\u8d21\u732e\uff0c\u4ed6\u4eec\u7684\u4ed8\u51fa\u8ba9 DPIP \u53d8\u5f97\u53ef\u80fd\u3002", + "en": "Listed in order of partnership. Thank you to the individuals and companies whose contributions to disaster preparedness made DPIP possible.", + "ko": "\ud30c\ud2b8\ub108\uc2ed \uc21c\uc11c\ub300\ub85c \ud45c\uc2dc\ub429\ub2c8\ub2e4. \uc7ac\ub09c \uc608\ubc29\uc5d0 \uae30\uc5ec\ud55c \uac1c\uc778\uacfc \uae30\uc5c5\uc5d0 \uac10\uc0ac\ub4dc\ub9bd\ub2c8\ub2e4. \uadf8\ub4e4\uc758 \uae30\uc5ec \ub354\ubd09\uc5d0 DPIP\uac00 \uac00\ub2a5\ud588\uc2b5\ub2c8\ub2e4.", + "ja": "\u63d0\u643a\u9806\u306b\u8868\u793a\u3057\u3066\u3044\u307e\u3059\u3002\u9632\u707d\u3078\u306e\u8ca2\u732e\u3067 DPIP \u3092\u652f\u3048\u3066\u304f\u3060\u3055\u3063\u305f\u500b\u4eba\u30fb\u4f01\u696d\u306e\u7686\u69d8\u306b\u611f\u8b1d\u3057\u307e\u3059\u3002", + "vi": "Theo th\u1ee9 t\u1ef1 h\u1ee3p t\u00e1c. Xin c\u1ea3m \u01a1n c\u00e1c c\u00e1 nh\u00e2n v\u00e0 c\u00f4ng ty \u0111\u00e3 \u0111\u00f3ng g\u00f3p cho c\u00f4ng t\u00e1c ph\u00f2ng ch\u1ed1ng thi\u00ean tai, nh\u1edd \u0111\u00f3 DPIP m\u1edbi c\u00f3 th\u1ec3 ra \u0111\u1eddi.", + "id": "Urut sesuai waktu kemitraan. Terima kasih kepada para individu dan perusahaan yang berkontribusi pada penanggulangan bencana; kontribusi mereka membuat DPIP menjadi mungkin.", + "th": "\u0e40\u0e23\u0e35\u0e22\u0e07\u0e15\u0e32\u0e21\u0e25\u0e33\u0e14\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e04\u0e27\u0e32\u0e21\u0e23\u0e48\u0e27\u0e21\u0e21\u0e37\u0e2d \u0e02\u0e2d\u0e1a\u0e04\u0e38\u0e13\u0e1a\u0e38\u0e04\u0e04\u0e25\u0e41\u0e25\u0e30\u0e1a\u0e23\u0e34\u0e29\u0e31\u0e17\u0e17\u0e35\u0e48\u0e21\u0e35\u0e2a\u0e48\u0e27\u0e19\u0e23\u0e48\u0e27\u0e21\u0e43\u0e19\u0e01\u0e32\u0e23\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19\u0e20\u0e31\u0e22\u0e1e\u0e34\u0e1a\u0e31\u0e15\u0e34 \u0e01\u0e32\u0e23\u0e2a\u0e19\u0e31\u0e1a\u0e2a\u0e19\u0e38\u0e19\u0e02\u0e2d\u0e07\u0e1e\u0e27\u0e01\u0e40\u0e02\u0e32\u0e17\u0e33\u0e43\u0e2b\u0e49 DPIP \u0e40\u0e01\u0e34\u0e14\u0e02\u0e36\u0e49\u0e19\u0e44\u0e14\u0e49", + "fil": "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.", +} + +def locale_of(path: pathlib.Path) -> str: + return path.stem[len("app_") :] + +def main() -> None: + for path in sorted((ROOT / "lib/l10n").glob("app_*.arb")): + loc = locale_of(path) + original = path.read_text() + if '"morePartnersNote"' in original: + print(f"{path.name}: already has key, skipped") + continue + anchor = re.search(r'^ "moreSectionPartners": ".*?",?$', original, re.M) + if anchor is None: + print(f"{path.name}: no moreSectionPartners anchor, SKIPPED") + continue + line_end = original.index("\n", anchor.start()) + anchor_line = original[anchor.start() : line_end] + if not anchor_line.rstrip().endswith(","): + original = original[:line_end] + "," + original[line_end:] + line_end = original.index("\n", anchor.start()) + insert = f' "morePartnersNote": "{NOTES[loc]}",' + original = original[: line_end + 1] + insert + "\n" + original[line_end + 1 :] + path.write_text(original) + print(f"{path.name}: +morePartnersNote") + +if __name__ == "__main__": + main() \ No newline at end of file From ee0a72d95736c8ce0a2435c886c51024fd523561 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:12:28 +0800 Subject: [PATCH 39/62] feat(network): cache status-dashboard POSTs under their URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 伺服器狀態查詢在離線時會改用上次快取結果 New(en-US): status POSTs cache under their URL and serve offline --- lib/core/network/etag_interceptor.dart | 58 +++++++++++-- test/core/network/etag_interceptor_test.dart | 90 ++++++++++++++++++++ 2 files changed, 141 insertions(+), 7 deletions(-) diff --git a/lib/core/network/etag_interceptor.dart b/lib/core/network/etag_interceptor.dart index df9340532..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) { @@ -85,6 +97,7 @@ class EtagInterceptor extends Interceptor { '${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 @@ -187,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); @@ -247,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); @@ -313,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/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. From 5ab22056b985862328c3748306d1797d10c38539 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:12:38 +0800 Subject: [PATCH 40/62] feat(changelog): ground contributor avatars into a tappable name badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 更新日誌的貢獻者改為頭像加名稱的名牌,可點開 GitHub 個人頁 New(en-US): changelog contributors are avatar + name badges that open GitHub --- .../widgets/release_contributors.dart | 129 ++++++++++-------- .../changelog/changelog_page_test.dart | 27 +++- 2 files changed, 98 insertions(+), 58 deletions(-) diff --git a/lib/features/changelog/presentation/widgets/release_contributors.dart b/lib/features/changelog/presentation/widgets/release_contributors.dart index e12fd2db9..76644a312 100644 --- a/lib/features/changelog/presentation/widgets/release_contributors.dart +++ b/lib/features/changelog/presentation/widgets/release_contributors.dart @@ -1,10 +1,10 @@ -/// The contributor strip under a changelog entry — the GitHub release footer -/// look: a stack of avatars for every `@handle` mentioned in the body. +/// 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 slot is one `CircleAvatar` -/// that fills when its bytes arrive and shows the login's initial otherwise. +/// a local read, not a network round trip). Each badge is tappable and opens +/// the contributor's GitHub profile. library; import 'dart:typed_data'; @@ -15,11 +15,9 @@ 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'; -/// How many avatars show before the rest collapse into a `+N` tail. -const int _maxShown = 4; - -/// One row: overlapping avatar circles, then the `+N` overflow pill. +/// One row per contributor: overlapping avatar + `@login`, each tappable. class ContributorStrip extends StatelessWidget { const ContributorStrip({super.key, required this.body}); @@ -30,8 +28,6 @@ class ContributorStrip extends StatelessWidget { Widget build(BuildContext context) { final contributors = contributorsFromBody(body); if (contributors.isEmpty) return const SizedBox.shrink(); - final shown = contributors.take(_maxShown).toList(); - final avatarWidth = 26 * shown.length - 6 * (shown.length - 1); return Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.lg, @@ -39,38 +35,64 @@ class ContributorStrip extends StatelessWidget { AppSpacing.lg, AppSpacing.md, ), - child: Row( + child: Wrap( + spacing: AppSpacing.md, + runSpacing: AppSpacing.xs, + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - SizedBox( - width: avatarWidth.toDouble(), - height: 26, - child: Stack( - clipBehavior: Clip.none, - children: [ - for (var i = 0; i < shown.length; i++) - Positioned( - left: (i * 20).toDouble(), - child: _Avatar(contributor: shown[i]), - ), - ], - ), - ), - if (contributors.length > _maxShown) ...[ - const SizedBox(width: AppSpacing.xs), - Text( - '+${contributors.length - _maxShown}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w700, - ), - ), - ], + for (final contributor in contributors) + _ContributorChip(contributor: contributor), ], ), ); } } +/// One badge — avatar + name on a shared pill, opening the profile on tap. +class _ContributorChip extends StatelessWidget { + const _ContributorChip({required this.contributor}); + + final ReleaseContributor contributor; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final label = Theme.of(context).textTheme.labelLarge + ?.copyWith(color: colors.onSurfaceVariant, fontWeight: FontWeight.w600); + return Material( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _open(context), + child: Padding( + padding: const EdgeInsets.fromLTRB(6, 6, 12, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _Avatar(contributor: contributor), + SizedBox(width: AppSpacing.sm), + Text(contributor.login, style: label), + ], + ), + ), + ), + ); + } + + 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 { @@ -107,28 +129,21 @@ class _AvatarState extends State<_Avatar> { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - return Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - // A ring of the card colour keeps overlapping avatars separable. - border: Border.all(color: colors.surface, width: 2), - ), - child: CircleAvatar( - radius: 13, - 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( - color: colors.onSurfaceVariant, - fontWeight: FontWeight.w700, - ), - ) - : null, - ), + return CircleAvatar( + radius: 12, + 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( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ) + : null, ); } } diff --git a/test/features/changelog/changelog_page_test.dart b/test/features/changelog/changelog_page_test.dart index 3fed85743..46ad862a0 100644 --- a/test/features/changelog/changelog_page_test.dart +++ b/test/features/changelog/changelog_page_test.dart @@ -134,8 +134,33 @@ void main() { await tester.pumpWidget(_wrap(repo)); await tester.pumpAndSettle(); - // Both @handles from the release body become avatars. + // Both @handles from the release body become avatar + name badges, and the + // name carries the login verbatim (no @ prefix). expect(find.byType(CircleAvatar), findsNWidgets(2)); + expect(find.text('whes1015'), findsOneWidget); + expect(find.text('ExpTechTW'), findsOneWidget); + }); + + testWidgets('tapping a contributor name 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.text('whes1015')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); }); testWidgets('a release without @handles has no contributor strip', ( From 4270107bb2667f21dde63e922d1c176a4418fe1c Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:12:47 +0800 Subject: [PATCH 41/62] feat(report): load the CWA report image through the ETag store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 地震報告圖快取到本機,重複查看不再每次重新下載 New(en-US): the report image now round-trips the ETag cache --- .../pages/report_detail_page.dart | 104 +++++++++++------- 1 file changed, 62 insertions(+), 42 deletions(-) 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), + ), + ), + ), + ); + }, ); } } From c7e736ebc0f635f02514c9a558e0b4504b53f534 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:12:57 +0800 Subject: [PATCH 42/62] fix(location): swallow MissingPluginException when draining breadcrumbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 無背景定位支援的平台不再回報麵包屑傾倒錯誤 New(en-US): breadcrumb draining no longer trips on missing plugins --- lib/core/platform/background_location.dart | 2 ++ .../platform/background_location_test.dart | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+) 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/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); + }, + ); } From 5db09ecad178f282d7736fe9104fcae833658bbf Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:14:50 +0800 Subject: [PATCH 43/62] docs(commit): drop the deleted release-note example files from the link --- commit.md | 5 +---- pre-release-example.md | 29 -------------------------- release-example.md | 47 ------------------------------------------ 3 files changed, 1 insertion(+), 80 deletions(-) delete mode 100644 pre-release-example.md delete mode 100644 release-example.md diff --git a/commit.md b/commit.md index 1e85096a6..2ede6e980 100644 --- a/commit.md +++ b/commit.md @@ -296,10 +296,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 的條目會全部掛在同一個作者和同一個快照下。要保留就用 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/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 - - From 759c3897ec82d09ede1a94627d07b3e163e5076a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:29:10 +0800 Subject: [PATCH 44/62] build: stop the launch guard from refusing its own run script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bool.fromEnvironment` reads only the exact string `true` and answers `false` to everything else, including the `1` the script passed — so the guard fired on the very launch that had obeyed it. That is the worst way for a guard to fail: it punishes the correct behaviour and teaches people to ignore it. The scripts send `true` now, and the marker is read with `String.fromEnvironment != ''` so any value counts — the next person to edit that line will not remember this either. The colouriser also missed the prefix on a blank line, which Flutter writes as `flutter:` with no trailing space, so a multi-line message kept it. --- lib/bootstrap.dart | 7 ++++- test/tool/launch_marker_test.dart | 47 +++++++++++++++++++++++++++++++ test/tool/run_script_test.dart | 5 +++- tool/colorize_logs.sh | 2 +- tool/run.ps1 | 2 +- tool/run.sh | 2 +- 6 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 test/tool/launch_marker_test.dart diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 41165d166..d0dd9bc83 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -105,7 +105,12 @@ Stream _weatherIconLicense() async* { } /// Set by `tool/run.sh` and `tool/run.ps1`, which is how the app is started. -const bool _launchedByTool = bool.fromEnvironment('DPIP_RUN_SH'); +/// +/// 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. /// 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 index 2035fa5b6..63e7d7dff 100644 --- a/test/tool/run_script_test.dart +++ b/test/tool/run_script_test.dart @@ -50,7 +50,10 @@ void main() { 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. - expect(_script(), contains('--dart-define=DPIP_RUN_SH=1')); + // 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 mise', () { diff --git a/tool/colorize_logs.sh b/tool/colorize_logs.sh index 8860a52c5..19463d90f 100755 --- a/tool/colorize_logs.sh +++ b/tool/colorize_logs.sh @@ -45,7 +45,7 @@ fi # `flutter: ` prefixes every line the device prints; dropping it gives back a # terminal's worth of width, and nothing distinguishes those lines but it. sed -E \ - -e "s/^flutter: //" \ + -e "s/^flutter: ?//" \ -e "s/^\[CRITICAL\]/${MAGENTA}[CRITICAL]${RESET}/" \ -e "s/^\[ERROR\]/${RED}[ERROR]${RESET}/" \ -e "s/^\[WARN\]/${YELLOW}[WARN]${RESET}/" \ diff --git a/tool/run.ps1 b/tool/run.ps1 index b9be5141e..bdccc62ab 100755 --- a/tool/run.ps1 +++ b/tool/run.ps1 @@ -22,6 +22,6 @@ $ErrorActionPreference = 'Stop' -& mise exec -- flutter run --dart-define=DPIP_RUN_SH=1 @args +& mise exec -- flutter run --dart-define=DPIP_RUN_SH=true @args exit $LASTEXITCODE diff --git a/tool/run.sh b/tool/run.sh index 5f8ccb1f8..d764ae798 100755 --- a/tool/run.sh +++ b/tool/run.sh @@ -29,5 +29,5 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # `DPIP_RUN_SH` is how the app knows it was started properly. A launch that # skips this script gets the wrong toolchain and an uncoloured log, and neither # announces itself — so bootstrap says so instead, in debug only. -mise exec -- flutter run --dart-define=DPIP_RUN_SH=1 "$@" \ +mise exec -- flutter run --dart-define=DPIP_RUN_SH=true "$@" \ | "$here/colorize_logs.sh" From 4d0bda20abd946e96266865cdce05cfa3dfb1426 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 05:49:35 +0800 Subject: [PATCH 45/62] fix(changelog): credit whoever wrote a change, not whoever merged it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 更新日誌改為標示真正寫這項變更的人,並附上可點擊的 commit 連結 Fix(en-US): each entry credits who wrote it and links the commit it came from GitHub squashes a pull request into one commit and sets its author to whoever pressed the button, demoting everyone who wrote it to a `Co-authored-by:` trailer. `41a3c1e8 Fix eew (#534)` is authored by the maintainer who merged it and was written, every commit of it, by somebody else — so 26w34b credited the wrong person, which is worse than crediting nobody. A summary carrying `(#N)` now takes its authors from that pull request's own commits, which is the only place they survive a squash intact; trailers are merged in for a merge without a number, and the commit's own author is the fallback. Every entry also links its commit, which for a squash is also the link to the request. The gate had made this worse by banning `Co-authored-by:` outright. It was aimed at a tool crediting itself, and it destroyed the one record of human attribution a squash leaves behind. It now judges the trailer by who it names. --- commit.md | 9 +- lib/core/logging/log.dart | 52 +++++++++-- test/core/logging/log_formatter_test.dart | 8 +- test/core/logging/log_line_test.dart | 83 +++++++++++++++++ test/tool/colorize_logs_test.dart | 6 +- tool/check_commits.sh | 16 +++- tool/colorize_logs.sh | 20 +++-- tool/release_notes.sh | 105 ++++++++++++++++------ 8 files changed, 245 insertions(+), 54 deletions(-) create mode 100644 test/core/logging/log_line_test.dart diff --git a/commit.md b/commit.md index 2ede6e980..e97fd75ae 100644 --- a/commit.md +++ b/commit.md @@ -271,8 +271,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 顯示名稱 —— 顯示名稱 @ +不到任何人。 | | 涵蓋範圍 | 為什麼 | |---|---|---| diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 856f8799f..cc5b7c7ee 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -352,17 +352,55 @@ class _PersistedHistory implements TalkerHistory { /// 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 message = details.message?.toString() ?? ''; - if (!settings.enableColors) return message; - // `[WARN] | 4:23:50 79ms | …` — the tag is everything to the first `]`. - final end = message.indexOf(']'); - if (end < 0) return message; - return details.pen.write(message.substring(0, end + 1)) + - message.substring(end + 1); + 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/test/core/logging/log_formatter_test.dart b/test/core/logging/log_formatter_test.dart index 8387e8c51..a08af7250 100644 --- a/test/core/logging/log_formatter_test.dart +++ b/test/core/logging/log_formatter_test.dart @@ -34,17 +34,17 @@ void main() { tearDown(() => ansiColorDisabled = true); test('colour off: the line is exactly the message', () { - final out = format('[WARN] | 12:00 | hi', colour: false); - expect(out, '[WARN] | 12:00 | hi'); + 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 | hi', colour: true); + 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('|')); + final afterTag = out.substring(out.indexOf(': ')); expect(afterTag.codeUnits, isNot(contains(_esc))); expect(out, contains('hi')); }); 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/tool/colorize_logs_test.dart b/test/tool/colorize_logs_test.dart index 5b8a147ac..36128772b 100644 --- a/test/tool/colorize_logs_test.dart +++ b/test/tool/colorize_logs_test.dart @@ -33,10 +33,10 @@ String run(String input, {bool tty = false}) { String _quote(String s) => "'${s.replaceAll("'", r"'\''")}'"; void main() { - const line = 'flutter: [WARN] | 4:45:48 492ms | eew SSE not connected\n'; + const line = 'flutter: [4:45:48][WARN] : eew SSE not connected\n'; test('the flutter: prefix is dropped', () { - expect(run(line), startsWith('[WARN]')); + expect(run(line), startsWith('[4:45:48][WARN]')); }); test('nothing is coloured when the output is not a terminal', () { @@ -65,7 +65,7 @@ void main() { 'DEBUG', 'VERBOSE', ]) { - final out = run('flutter: [$level] | 1:00:00 1ms | x\n', tty: true); + final out = run('flutter: [1:00:00][$level] : x\n', tty: true); expect(out.codeUnits, contains(_esc), reason: level); } }); diff --git a/tool/check_commits.sh b/tool/check_commits.sh index e4f4d3b5d..08f6105a2 100755 --- a/tool/check_commits.sh +++ b/tool/check_commits.sh @@ -98,11 +98,21 @@ check_one() { #