Skip to content

feat: rework the log, the launcher and the developer workflow - #533

Merged
whes1015 merged 62 commits into
mainfrom
fix/version-week
Aug 18, 2026
Merged

feat: rework the log, the launcher and the developer workflow#533
whes1015 merged 62 commits into
mainfrom
fix/version-week

Conversation

@whes1015

Copy link
Copy Markdown
Member

這個 PR 做了什麼

相關 issue

  • closes #

怎麼驗

檢查清單

  • tool/check_commits.sh origin/main..HEAD 通過
    —— commit 訊息就是更新日誌,格式見 commit.md
  • 一個 commit 一件事(這條 gate 驗不了,靠自己和 review)
  • mise exec -- flutter analyzemise exec -- flutter test 通過
  • 新的使用者可見字串都走 AppLocalizations,沒有寫死
  • 有 UI 變更的話:用的是 AppSpacing / AppRadius / AppMotion
    深色模式看過,文字對比度可接受

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

🔍 OpenCodeReview found 13 issue(s) in this PR.

  • ✅ Successfully posted inline: 11 comment(s)
  • ❌ Failed to post inline: 2 comment(s)

⚠️ 1 warning(s) occurred during review.


maintainability · low

📄 lib/features/changelog/data/changelog_api.dart (L80-L83)

⚠️ GitHub could not post this as an inline comment: No server is currently available to service your request. Sorry about that. Please try resubmitting your request and contact us if the problem persists.

getAvatarBytes 目前沒有處理網路請求可能產生的異常(例如 404 或網路中斷)。建議確認呼叫端是否有完善的錯誤處理機制,或者在此處加入 try-catch 並回傳 null,以避免非核心的頭像讀取失敗導致整個功能崩潰。

💡 Suggested Change

Before:

  Future<Uint8List> getAvatarBytes(String login) async {
    final payload = await _client.getBytesAbsolute(avatarUrlFor(login));
    return payload.bytes;
  }

After:

  Future<Uint8List?> getAvatarBytes(String login) async {
    try {
      final payload = await _client.getBytesAbsolute(avatarUrlFor(login));
      return payload.bytes;
    } catch (_) {
      return null;
    }
  }

bug · medium

📄 lib/shared/map/map_timeline.dart (L116-L124)

⚠️ GitHub could not post this as an inline comment: Unprocessable Entity: "Line could not be resolved" - https://docs.github.com/rest/pulls/reviews#create-a-review-for-a-pull-request

_bigLabel 中,當 widget.framePeriod 不為 null 時,end 時間使用的是預設的 _time (HH:mm) 格式,而 start 時間使用的是 widget.timeFormat (如果有的話)。這會導致當使用者自定義了 timeFormat 時,顯示的時間範圍格式不一致(例如:"10:00:00 – 11:00")。建議在 _bigLabel 中也使用與 _times 相同的格式。此外,由於 _cacheLabels 只在 frames 改變時才重新計算,如果 widget.timeFormat 改變但 frames 未變,_times 的格式將不會更新。建議在 didUpdateWidget 中也檢查 timeFormat 是否改變。

💡 Suggested Change

Before:

  String get _bigLabel {
    final start = _times[_liveIndex];
    final period = widget.framePeriod;
    if (period == null) return start;
    final end = _time.format(
      widget.frames[_liveIndex].time.toLocal().add(period),
    );
    return '$start – $end';
  }

After:

  String get _bigLabel {
    final start = _times[_liveIndex];
    final period = widget.framePeriod;
    if (period == null) return start;
    final format = widget.timeFormat ?? _time;
    final end = format.format(
      widget.frames[_liveIndex].time.toLocal().add(period),
    );
    return '$start – $end';
  }

  @override
  void didUpdateWidget(covariant MapTimeline oldWidget) {
    super.didUpdateWidget(oldWidget);
    final framesChanged = !identical(oldWidget.frames, widget.frames);
    final formatChanged = oldWidget.timeFormat != widget.timeFormat;
    if (framesChanged || formatChanged) _cacheLabels();
    // ...
  }

⚠️ Warnings:

  • lib/features/changelog/domain/release_note.dart (comment_refiled): comment filed against lib/features/changelog/presentation/widgets/release_contributors.dart describes code in lib/features/changelog/domain/release_note.dart; re-filed

Comment on lines +643 to 676
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),
),
],
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
根據程式碼註解的設計意圖,「右側欄位應呈現為一個對齊的堆疊(the right column reads as one aligned stack)」,意指後方的箭頭(trailing arrow)應該在垂直方向上對齊。

然而,目前的實作使用了 MainAxisAlignment.center 搭配 Flexible,這會導致:

  1. 當文字較短時,整個 Row 的內容會集中在中間,箭頭的位置會隨文字長度而左右移動,無法形成右側對齊的「列」。
  2. Flexible 不會強制佔用剩餘空間,只有在文字過長需要截斷時才會縮減。

建議改為:

  1. MainAxisAlignment.center 改為 MainAxisAlignment.start (或移除,因為預設即為 start)。
  2. Flexible 改為 Expanded。這樣文字會填滿中間的剩餘空間,將箭頭強制推至 Row 的最右側,從而達成註解所述的「右側對齊堆疊」效果。

Suggestion:

Suggested change
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),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Filled, not outlined: the one active affordance on a page
// whose every other row is an outlined icon.
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: gold.badge,
),
child: Icon(Icons.favorite, color: gold.onBadge, size: 19),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
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),
),
],
),

@whes1015
whes1015 requested a review from a team as a code owner August 17, 2026 09:08
Comment on lines +596 to 648
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()),
],
),
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
UI 佈局的高度適應性風險。_HeroCards 使用了硬編碼的高度(_versionHeight = 176, _smallCardHeight = 56)。在不同螢幕尺寸或使用者設定較大字體時,這些固定高度可能導致內容(如文字或圖示)發生垂直溢出(Overflow)或顯示不全。

Comment on lines +958 to +975
static List<Color> _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(),
];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
_hashGradient 函數中使用了多個「魔法數字」(例如:黃金角度 137.508、飽和度 0.62、亮度偏移 0.10 等)。建議將這些設計參數提取為具備明確名稱的私有常數,以提升程式碼的可讀性與維護性。

Comment on lines +120 to +122
final end = _time.format(
widget.frames[_liveIndex].time.toLocal().add(period),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
widget.timeFormat 與預設的 _time 不同時,start 會使用 widget.timeFormat 格式化,而 end 會使用 _time 格式化,導致顯示的範圍標籤格式不一致。建議在計算 end 時也使用 widget.timeFormat ?? _time

Suggestion:

Suggested change
final end = _time.format(
widget.frames[_liveIndex].time.toLocal().add(period),
);
final format = widget.timeFormat ?? _time;
final end = format.format(
widget.frames[_liveIndex].time.toLocal().add(period),
);

Comment on lines 74 to +76
static Future<void> ensureLoaded() async {
if (_label != null) return;
String platformVersion = '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · low
ensureLoaded 方法目前的冪等性檢查依賴於 _label != null。如果 _bestLabel 為空或 _bestCode 不大於 0(例如在某些開發環境下),_label 將保持為 null,導致每次呼叫 ensureLoaded 都會重新執行非同步的 PackageInfo.fromPlatform()。建議使用一個獨立的布林值(如 _isLoaded)來標記載入狀態,以避免重複的 IO 操作。

Suggestion:

Suggested change
static Future<void> ensureLoaded() async {
if (_label != null) return;
String platformVersion = '';
static bool _isLoaded = false;
static Future<void> ensureLoaded() async {
if (_isLoaded) 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;
}
_isLoaded = true;
}

Comment on lines +625 to +639
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()),
],
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
_HeroCards 的佈局使用了固定的高度(如 _versionHeight_smallCardHeight)來包裹包含文字內容的組件(例如 _DiscordCallout_AnnouncementCard_StatusCard_VersionCard)。這在 Flutter 中存在嚴重的佈局溢出(Overflow)風險,特別是當使用者在系統設定中啟用了「大字體模式」(Font Scaling),或者在不同語言的本地化過程中,文字內容長度增加導致高度超過預設值時。建議考慮使用 IntrinsicHeight 來讓 Row 中的元素高度同步,或者改用 BoxConstraintsminHeight 而非固定 height,以允許組件根據內容自動增長。

Comment on lines +958 to +975
static List<Color> _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(),
];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
_hashGradient 函數透過版本字串動態生成顏色,雖然考慮了主題亮度(lightness flips with the theme),但這並不能完全保證產生的顏色與背景色(colors.surfaceContainer)之間具備足夠的對比度以符合無障礙設計標準(WCAG)。在極端情況下,生成的顏色可能與背景色過於接近,導致文字難以辨識。建議在生成顏色後,加入對比度檢查機制,或是在亮度(lightness)範圍上設定更保守的邊界。

Comment on lines +2048 to +2049
@override
String get moreTagline => 'Platform Integrasi Informasi Bencana';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
印尼文翻譯與英文原文語意不一致。英文定義為 'Disaster Prevention Information Platform' (災害預防資訊平台),但目前的印尼文翻譯為 'Platform Integrasi Informasi Bencana' (災害資訊整合平台),其中 'Integrasi' 意為 '整合' 而非 '預防' (Pencegahan)。建議修正以符合原文語意。

Suggestion:

Suggested change
@override
String get moreTagline => 'Platform Integrasi Informasi Bencana';
@override
String get moreTagline => 'Platform Informasi Pencegahan Bencana';

Comment on lines +218 to +222
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: (_) {}),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · medium
該測試案例高度依賴於執行環境的時區。若在 CI 環境(通常時區設定為 UTC)中執行,utc.toLocal() 的結果會與 utc 完全相同。這意味著即使程式碼錯誤地直接顯示了 UTC 時間,測試依然會通過,從而無法達到攔截 Bug 的目的。建議選擇一個在轉換為本地時間後會明顯改變小時或日期的時間點(例如接近 UTC 當天結尾的時間),以增加測試在不同環境下的魯棒性。

Suggestion:

Suggested change
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: (_) {}),
);
// 建議選擇一個轉換後容易產生差異的時間點,例如 UTC 23:30
final utc = DateTime.utc(2026, 7, 13, 23, 30);
final frames = [MapFrame(id: '0', time: utc)];
await tester.pumpWidget(
_wrap(frames: frames, selectedIndex: 0, onSelected: (_) {}),
);

Comment on lines +2048 to +2049
@override
String get moreTagline => 'Platform Integrasi Informasi Bencana';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
印尼文翻譯建議確認語意一致性。目前的翻譯為 "災害資訊整合平台" (Platform Integrasi Informasi Bencana),而英文版本為 "Disaster Prevention Information Platform",中文版本為 "防災資訊整合平台",兩者皆強調了「預防/防災」(Prevention) 的含義。建議確認是否應加入預防含義(例如使用 "Pencegahan Bencana"),以保持各語言間語意的一致性。

Suggestion:

Suggested change
@override
String get moreTagline => 'Platform Integrasi Informasi Bencana';
@override
String get moreTagline => 'Platform Integrasi Informasi Pencegahan Bencana';

Comment on lines +4886 to +4887
@override
String get moreTagline => '防灾信息整合平台';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
AppLocalizationsZhHans 繼承自 AppLocalizationsZh,由於父類使用了繁體中文用語(如 '支援')及繁體字體,且 AppLocalizationsZhHans 未重寫相關屬性,會導致簡體中文版本出現繁體中文內容,造成語言不一致。建議在 AppLocalizationsZhHans 中重寫 sponsorTitle, sponsorIntro 與 sponsorCalloutBody 並使用簡體中文。

return out;
}

final RegExp _atHandle = RegExp(r'@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · low
contributorsFromBody 使用了正則表達式 r'@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)' 來解析 @handle。雖然目前的模式看起來是為了匹配 GitHub 的用戶名規則,且結構相對簡單,但若未來的正則表達式變得複雜,應注意 ReDoS(正規表達式阻斷服務攻擊)的風險。目前此模式風險較低,但仍需保持警惕。

Comment on lines +29 to +32
@override
Widget build(BuildContext context) {
final contributors = contributorsFromBody(body);
if (contributors.isEmpty) return const SizedBox.shrink();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
ContributorStripbuild 方法中直接調用 contributorsFromBody(body)。雖然目前的實作相對簡單(使用 RegExp.allMatches),但如果 body 內容非常龐大,每次 Widget 重繪時都會重新執行正則表達式掃描,可能導致效能問題。建議考慮將解析結果緩存,或者將其作為參數傳入。

Comment on lines +33 to +57
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]),
),
],
),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
ContributorStrip 中的 avatarWidth 計算邏輯與 Stack 中的 Positioned 偏移量(i * 20)使用了硬編碼的數字(magic numbers)。這會增加維護成本,若未來調整頭像半徑或重疊間距,必須確保兩處邏輯同步更新,否則會導致佈局錯誤。建議將這些數值提取為常數或基於頭像半徑計算。

Comment on lines +304 to 319
builder: (context, dials, _) => SafeArea(
bottom: false,
child: Column(
children: [
RegionBar(
blend: dials.blend,
dismiss: dials.dismiss,
skyIsLight: skyIsLightFrom(sky, weatherMode),
),
_GoldSupportBar(
blend: dials.blend,
dismiss: dials.dismiss,
),
],
),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
使用 SafeArea 包裹 Column 可能會導致 RegionBar 的佈局位置發生非預期的偏移。由於 RegionBar 位於 Column 的頂部,而 SafeArea 預設會根據設備的狀態列(Status Bar)添加頂部內邊距,這可能導致原本緊貼螢幕頂部的 RegionBar 被向下推移,破壞 UI 設計。建議確認是否需要 top: false

Suggestion:

Suggested change
builder: (context, dials, _) => SafeArea(
bottom: false,
child: Column(
children: [
RegionBar(
blend: dials.blend,
dismiss: dials.dismiss,
skyIsLight: skyIsLightFrom(sky, weatherMode),
),
_GoldSupportBar(
blend: dials.blend,
dismiss: dials.dismiss,
),
],
),
),
builder: (context, dials, _) => SafeArea(
bottom: false,
top: false, // 建議明確設定 top: false 以確保 RegionBar 仍可緊貼頂部
child: Column(
children: [
RegionBar(
blend: dials.blend,
dismiss: dials.dismiss,
skyIsLight: skyIsLightFrom(sky, weatherMode),
),
_GoldSupportBar(
blend: dials.blend,
dismiss: dials.dismiss,
),
],
),
),

Comment on lines +338 to +395
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),
),
],
),
),
),
),
),
),
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
新組件高度依賴於外部資源,包括本地化字串 l10n.sponsorTitle、主題屬性 AppGold.of(context) 以及路由 AppRoutes.sponsor。根據檢查,這些定義在專案中已存在(例如 l10n.sponsorTitle 在多國語言檔中都有定義,AppRoutes.sponsor 也在路由中定義),因此運行時錯誤的風險較低。

Comment on lines +348 to +353
final hidden = (blend + dismiss).clamp(0.0, 1.0);
return IgnorePointer(
ignoring: hidden > 0.9,
child: Opacity(
opacity: 1 - hidden,
child: FractionalTranslation(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
_GoldSupportBar 的互動體驗可能存在問題。組件利用 blenddismiss 的值來同時控制 OpacityIgnorePointer。目前的邏輯設定在 hidden > 0.9 時即停止接收點擊,這意味著當組件透明度仍有約 10% 時就已無法點擊,可能會造成使用者「看得到卻點不到」的困惑。建議調整 IgnorePointer 的閾值,使其與 Opacity 的變化更同步,或是在透明度較高時才禁用點擊。

Comment on lines +2047 to +2048
@override
String get moreTagline => 'Disaster Prevention Information Platform';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
lib/l10n/gen/app_localizations_en.dart 屬於自動生成的檔案,手動修改此類檔案會導致下次重新生成本地化資源時,您的變更被覆蓋掉。建議應透過修改原始的 .arb 檔案(例如 lib/l10n/app_en.arb)來完成此變更。

Comment on lines +2057 to +2059
@override
String get moreTagline =>
'Platform para sa Integral na Impormasyon sa Kalamidad';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
此檔案似乎是自動生成的(位於 lib/l10n/gen/)。手動在此檔案中新增 moreTagline 可能會導致下次執行生成腳本時改動被覆寫。此外,在 lib/l10n/app_fil.arb 中找不到 moreTagline 的定義。建議將新增的內容寫入 lib/l10n/app_fil.arb 檔案中,然後重新執行生成工具。

Comment on lines +2003 to +2004
@override
String get moreTagline => '防災資訊整合平台';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · high
警告:此檔案位於 lib/l10n/gen/ 目錄下,這通常是一個由工具(如 flutter gen-l10n)自動生成的檔案。直接手動修改此檔案是不正確的做法,因為下次執行生成指令時,這些修改會被原始的資源檔(例如 .arb 檔)內容完全覆蓋。

建議將 moreTagline 的定義以及文字內容的更動(例如「支持」改為「支援」)移至對應的原始 .arb 檔案中,然後重新執行生成指令。

Comment on lines +269 to +272
final label = AppBuild.label;
final stable = RegExp(r'^\d+\.\d+$').hasMatch(label);
expect(find.text(AppBuild.train), findsWidgets);
if (stable) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
test/features/more/more_page_test.dart 中,版本卡片的測試邏輯依賴於 AppBuild.label 的正則表達式來判斷版本類型。這增加了測試對 AppBuild 內部字串格式的耦合度,若未來版本號格式變動,可能導致測試失效。建議透過 AppBuild.debugSet 明確模擬不同版本的狀態,而非透過字串比對來推斷。

expect(label, isNot(v['train']));
} else {
expect(label, v['train']);
expect(v['train'], matches(r'^\d+\.\d+$'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
測試斷言被弱化了。原本的測試會驗證當 label 是兩部分版本(例如 26.1)時,它必須與 train 相等。修改後的版本僅檢查 train 的格式,失去了對 label 與 train 一致性的驗證。根據程式碼註解「A two-part label (26.1) already is its own train」,兩者應該是相等的。

Suggestion:

Suggested change
expect(v['train'], matches(r'^\d+\.\d+$'));
expect(v['train'], label);
expect(label, matches(r'^\d+\.\d+$'));

@whes1015
whes1015 force-pushed the fix/version-week branch 2 times, most recently from 4b5a16f to 6508e17 Compare August 17, 2026 21:21
Fix(zh-Hant): 修正週一上午建置的版本號會標成上一週
Fix(en-US): fix a Monday-morning build being named for the previous week
Optimization(zh-Hant): 贊助卡片改為橫式排版,與右欄其他卡片對齊更好看
Optimization(en-US): the sponsor card now row-aligns with the right column
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
New(zh-Hant): 除錯版偵測到非腳本啟動將直接拒絕執行並提示正確指令,新增 Windows 啟動腳本
New(en-US): the debug build refuses non-script launches and there is now a Windows run script
New(zh-Hant): 取得 App 下方新增 測試版 與 合作夥伴 區塊
New(en-US): beta channels and partner tiles now sit under Get the app
New(zh-Hant): 伺服器狀態查詢在離線時會改用上次快取結果
New(en-US): status POSTs cache under their URL and serve offline
New(zh-Hant): 更新日誌的貢獻者改為頭像加名稱的名牌,可點開 GitHub 個人頁
New(en-US): changelog contributors are avatar + name badges that open GitHub
New(zh-Hant): 地震報告圖快取到本機,重複查看不再每次重新下載
New(en-US): the report image now round-trips the ETag cache
New(zh-Hant): 無背景定位支援的平台不再回報麵包屑傾倒錯誤
New(en-US): breadcrumb draining no longer trips on missing plugins
`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.
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.
New(zh-Hant): 除錯資訊頁新增「傾印除錯資訊及日誌」,上傳後複製連結
New(en-US): the debug page can upload the diagnostics and log, and copies the link back

The two halves answer different questions and a report needs both: the
diagnostics say what this build and this device are, the log says what they
just did. Pasting 4000 characters into a chat buries the conversation it is
part of, so they go to a paste and only the link comes back.

The diagnostics are never trimmed — a partial one reads as a complete one and
gets answered as if it were — so the log takes whatever is left of the 4000,
filled from the newest backwards and written oldest first. A line goes in whole
or not at all, because half a line is a lie about what was logged.

The log is in the same shape the terminal uses, so a line pasted from one and a
line pasted from the other are the same line.
Change(zh-Hant): 「傾印除錯資訊及日誌」移到更多 → 除錯資訊下方,並翻譯成十種語言
Change(en-US): the debug dump moved to More, under Debug info, and is now translated

It was a row at the bottom of the Developer page, which is the page a person
reaches only after being told where it is. The dump exists for the people who
have not been told, so it now sits in the menu they are already in, one row
under the page it dumps.

Two screens asking for the diagnostics meant the collection could no longer
live inside one of them, and the layer rules would not let More reach into
Settings for it — so it moved to core/diagnostics, which is where every source
it reads already lives. The Developer page renders from the same report it
uploads, and the redaction list that keeps the push token and the device
identifier out of a paste is now one list instead of two. Two would have
drifted, and the copy that drifts is the one that leaks.

The row is user-facing, so unlike the Developer page it is translated.
New(zh-Hant): 更新日誌每個版本卡片都可以開啟 GitHub 頁面
New(en-US): every changelog card foots a link to its release on GitHub
Fix(zh-Hant): 修正開啟日誌頁動作選單時,終端機與日誌被大量錯誤洗版
Fix(en-US): opening the log screen's Actions sheet no longer floods the log

talker_flutter draws that sheet as a coloured box with bare ListTiles inside
it, which trips a debug assert Flutter raises from ListTile itself — once per
row, into the log the sheet belongs to. Still true on the package's current
release, and the only thing this app controls is the colour it hands the theme.

Said once per session at warning rather than dropped, because the summary
cannot say who raised it: the framework reports from inside the offending
widget's own build, and the widget that wrapped it built in a different
element and is not on the stack. So the line carries the reason and says where
to look if it ever shows up somewhere this explanation does not fit.
Fix(zh-Hant): run.sh 啟動時清掉舊的 DevFS 殘留,並修正清除快取在 debug 會刪掉執行中核心
Fix(en-US): run.sh sweeps stale DevFS dirs, and clear-cache no longer wipes the live kernel

Every `flutter run` builds its DevFS root inside the app sandbox as
`tmp/DPIP<random>/DPIP/`, and three things stop it being cleaned up: the VM
hands the tool back the inner path, so even a clean `q` orphans the outer
directory; `cleanupAfterSignal()` never calls `_cleanupDevFS()`, so Ctrl-C
orphans the dills; and SIGHUP is not handled, so closing the terminal orphans
everything. Nothing sweeps them — 116 directories and 6.8 GB had piled up here,
the oldest 37 days old.

The guard is a process check and not a timestamp. The outer directory's mtime
is frozen at its birth while the dills inside it keep being rewritten, so an
"older than" rule deletes the session running in the next terminal.

The same directory is why the developer page's clear-cache is now gated to
release. Its comment claimed nothing the app owns lives in tmp, which is false
under `flutter run`: the kernel this process is executing from is in there. The
dills grow back, so the mistake looks harmless — the synced asset bundle does
not, because the tool decides what to resend from host state and never learns
the device copy went away, and a shader edit hot-reloads and then reverts.

Not in run.ps1: the leak is `systemTemp` resolving into an iOS sandbox, and
there is no CoreSimulator on Windows.
New(zh-Hant): 伺服器狀態頁新增 Cloudflare 各地區狀態與本機對多活端點的連線判斷表格
New(en-US): the server status page adds Cloudflare regional status and the
  device's own view of each multi-active endpoint
Fix(zh-Hant): 伺服器狀態的本機狀態不再把不存在的服務當成未探測,改以圖例區分
  正常/異常/未探測/不支援,並標明資料來自被動探測
Fix(en-US): the local probe tables now tell unsupported services apart from
  merely-unprobed ones and note that the data is passively observed
Change(zh-Hant): 所有工作流程改用 tool/ 底下的腳本,不再需要手打 mise exec
Change(en-US): every workflow now has a script under tool/; nobody types the toolchain

The rule was already "run tools through mise exec", and the rule was the
problem: it asked every person and every CI step to remember a prefix, and
forgetting it is silent — a shell's PATH is resolved once and mise activate
caches it, so a copied `flutter test` runs whatever SDK the session started
with, and a run against the wrong SDK looks exactly like a run against the
right one. The toolchain is named in one place now, tool/dev/_lib.sh, and
everything else calls it.

tool/ is organised by what a script is for: dev/ daily workflows, check/ the CI
gates, release/ versioning and notes, gen/ generators, internal/ pieces other
scripts call and nobody runs by hand.

CI calls the same scripts a developer calls, so the two cannot drift — that
drift was only ever found by a red PR on a branch that was green. tool/check.sh
runs the whole gate list in one command.

tool/run.sh installs the git hooks when they are missing. Setup used to be a
step in the README, therefore a step people skipped, and skipping it is silent:
build_info.g.dart keeps whatever commit it was committed with, so the debug
page names a build that is not the one running.

tool/check/tooling.sh keeps it true, and it checks commands rather than
mentions — the documentation has to be able to explain the rule it enforces.
Fix(zh-Hant): 修正每次啟動都要重新解析 Swift Package,啟動快約 12 秒
Fix(en-US): the Swift Package graph no longer re-resolves on every launch

A launch runs `-resolvePackageDependencies` twice, and the two runs read
different files: the prefetch has no container argument so it takes the
.xcodeproj scope, and the build passes `-workspace`. Both wrote the same
build/ios/SourcePackages, and the two committed Package.resolved disagreed on
gtm-session-fetcher — 5.3.1 against 5.3.0 — so each step undid the other's
checkout. It could never converge, and nothing rewrites those files, so it
would not have healed on its own.

Measured here, warm cache, one launch pair:

    before  15.80 s, 30 remote updates, 2 checkouts
    after    3.78 s,  0 remote updates, 0 checkouts

Settled on 5.3.0 because that is what is checked out on disk and what the
workspace file already said, so nothing has to be re-fetched. Both are inside
firebase-ios-sdk's declared 3.4.1 ..< 6.0.0. Verified past the resolve: a full
`tool/dev/build.sh ios` links and produces Runner.app.
Change(zh-Hant): 移除 macOS 平台目錄,專案只保留 Android 與 iOS
Change(en-US): removed the macOS platform; the project targets Android and iOS only

`flutter create --platforms=macos .` ran in #525 and left 32 files behind. It
also rewrote .metadata's platform list to root + macos, dropping the android
and ios entries that were there — so the file has been claiming since then that
this is a macOS project, which is what `flutter migrate` reads. Restored to
what it said before.

The analyzer excluded web/, windows/, macos/ and linux/, none of which exist
now, so those go with it.

The web dependency stays, and it is worth writing down why so nobody deletes it
again: maplibre_gl_web is an endorsed federated implementation, so pub pulls it
in whatever we do. Removing the override does not remove the package — it
resolves it from pub.dev instead of the fork, which is a version that does not
match the forked platform interface. Tried, measured in the lockfile, put back.
A commit message here is the changelog and cannot be edited once pushed, so
every mistake in one costs a rebase and a force-push. The information that
would have prevented it was always available — spread across `git status`,
`git rev-list`, the gate, and commit.md — and nobody assembled it at the
moment it mattered.

This prints it in one place, and only prints: it never commits, stages,
fetches, or changes a file. Branch and base and how stale the base is; how far
behind and whether any merge commit is hiding work from the gate; what is
staged, unstaged and untracked, with the build output and the skip-worktree
file called out; which features and areas the change spans; whether a
`Platform:` trailer is needed; whether an ARB was half-translated; the message
shape and the three rules that fail silently; and the gate's verdict on what
is already committed.

It refuses to guess at "one commit, one thing" — nothing can decide whether
two changes are the same thing, and a guessing check would block honest
cross-module work until people learned to route around it. It asks instead.
New(zh-Hant): 版本卡片可以深入檢視本次更新的內容與技術細節
New(en-US): the version card opens this release's highlights and deep dives
Optimization(zh-Hant): 提交前的檢查改成只跑有變動的部分,沒改東西時約 1 秒跑完
Optimization(en-US): the pre-commit checks only re-run what changed, about a second on an unchanged tree

`tool/commit.sh` now reproduces .github/workflows/ci.yml before you commit, so
a branch stops being green locally and red on the runner. That was already
possible and nobody did it, because it cost a minute every time.

So the expensive steps — the analyzer, codegen and the test suite, plus the two
gates that walk every file in lib/ — are keyed on a hash of the files they read.
An unchanged tree costs 1.0 s instead of 49 s. Change one byte in an input and
that step runs; change it back and the proven answer is still there, because
the last eight keys per check are kept rather than only the newest.

Content and not mtime, deliberately: a format pass, a branch switch and a
checkout all move mtimes without changing what the code says, and a cache that
re-runs everything after `git switch` is one people turn off. Untracked files
count — the test somebody just wrote is the most important input and has never
been committed. Only successes are stored; repeating a failure is cheap and
hiding one is not.

The codegen step cannot use CI's `git diff --exit-code`: a developer running
this has uncommitted work by definition, and that would report their own edits
as stale codegen. It regenerates and compares the generated files against
themselves instead, which is the question actually being asked.
Optimization(zh-Hant): 沒有 mise 或用錯 SDK 時直接拒絕執行,不再無聲建置出不一樣的東西
Optimization(en-US): the build now refuses to start off the wrong SDK instead of using it silently

A bare `flutter` was discouraged and is now impossible to do by accident. The
reason it needs enforcement rather than a rule is that it does no visible
damage: it compiles, it runs, the tests pass, and nothing in any line of output
says which SDK produced them. The difference arrives days later as a failure
nobody can reproduce.

`require_mise` checks the three ways to end up with the wrong SDK, only the
first of which announces itself: mise missing, no pin in the checkout, and —
the one that hides — mise resolving flutter to something outside its own
installs, which is what happens when the tool is not really installed and
`mise exec` forwards to a system SDK. Once per process tree, 0.02 s.

The tooling gate now reads every script in tool/ as a script: parses,
executable, has a shebang, and reaches the toolchain only through `pinned`.
`tool/run.sh` runs both before it starts anything, because the launch is the
one moment everybody passes through. Together 0.34 s.

run.ps1 gets the same refusal. It cannot share the bash helper, so it carries
its own check rather than being the one door left open.
The tool/ reorganisation renamed nineteen scripts, and these are the references
that were not in the sweep: comments and prose in lib/, test/, pubspec.yaml,
the two architecture documents and the shader README. A dead path in a comment
is worse than no comment — it sends the next person looking for a file that is
not there, and nothing fails to tell them otherwise.

The generated header in town_label_points.g.dart is here too, and it matches
what tool/gen/town_label_points.dart now writes, so regenerating does not undo
it.

Four comment paragraphs are re-wrapped: the longer paths pushed their lines
past the column limit.
Fix(zh-Hant): 提交與推送前的檢查改由 git hook 強制執行,不再只是印出來
Fix(en-US): the pre-commit checks are now enforced by git hooks instead of only printed

tool/commit.sh printed "behind origin/main — CI refuses to merge a branch that
is not rebased", exited 1, and the commit went in anyway, because nothing wired
it into git. A check nothing enforces is a check, then a habit, then neither.

So it is a hook now, and the severity is split by when the question is actually
being asked. Being behind the base blocks a *merge*, not a commit: demanding a
rebase to record work that is not going anywhere yet would mean rebasing five
times an afternoon, and would teach everybody --no-verify. At commit time it is
a warning; at push time it stops you, along with every CI gate.

pre-commit stands aside during a rebase, a merge, a cherry-pick and a revert.
`git commit` runs it in all of them, with HEAD detached and the branch state
meaningless, and every answer it could give there is about a tree that exists
for the next few seconds.

The gates run in pre-push only. A minute per commit is how a hook gets removed;
a minute per push is the round trip it saves.
`Process.run` inherits the parent environment, so running the suite under
DPIP_NO_CACHE=1 — which is exactly what somebody debugging the cache does —
made every caching assertion test the opposite of what it says. Two of them
failed only in that one situation, which is the situation they exist for.

The variable is now set either way rather than merely omitted.
@whes1015 whes1015 changed the title Fix/version week feat: rework the log, the launcher and the developer workflow Aug 18, 2026
@whes1015
whes1015 merged commit c5fdbd3 into main Aug 18, 2026
4 of 7 checks passed
@whes1015
whes1015 deleted the fix/version-week branch August 18, 2026 01:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants