From dfb20e4b2a88c626993918f5cee8dae4755fd2c3 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sun, 23 Aug 2026 04:00:41 +0800 Subject: [PATCH 1/8] fix(home): move the monitor link below the support pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 強震監視器連結移到贊助列下方 Fix(en-US): the earthquake monitor link is now below the support pill --- .../home/presentation/pages/home_page.dart | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index f81534211..b35191c62 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -310,21 +310,14 @@ class _HomePageState extends State { 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, ), + // 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), ], ), ), From c3f2e4e2b7f77b6014b4a1f6f023c675c699d4bd Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sun, 23 Aug 2026 04:02:08 +0800 Subject: [PATCH 2/8] fix(settings): center the EEW source icon in its row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): EEW 地震來源卡片的圖示改為垂直置中 Fix(en-US): the EEW source card icon is now vertically centered --- lib/features/settings/presentation/pages/eew_source_page.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/settings/presentation/pages/eew_source_page.dart b/lib/features/settings/presentation/pages/eew_source_page.dart index 5d7f24e7f..4e89aa887 100644 --- a/lib/features/settings/presentation/pages/eew_source_page.dart +++ b/lib/features/settings/presentation/pages/eew_source_page.dart @@ -99,7 +99,6 @@ class _SourceOptionCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(AppSpacing.lg), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.all(AppSpacing.md), From 7917887e12748306b7a3935757d79967797396dd Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sun, 23 Aug 2026 04:03:44 +0800 Subject: [PATCH 3/8] chore(deps): bump archive and image --- pubspec.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 697e98297..409242a02 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: be169cf6ac481e052c4538715d88841d567150dfe1df38aaec76461a4e7b39f2 url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "4.1.0" args: dependency: transitive description: @@ -600,10 +600,10 @@ packages: dependency: transitive description: name: image - sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" url: "https://pub.dev" source: hosted - version: "4.9.1" + version: "4.9.2" in_app_purchase: dependency: "direct main" description: From efb77d20bd0eea0135a2bb416b287afd46bdfbdc Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sun, 23 Aug 2026 11:01:25 +0800 Subject: [PATCH 4/8] fix(home): stop the crash when the monitor banner toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正首頁強震監視器橫幅出現或消失時偶發閃退 Fix(en-US): fix an occasional crash when the Home monitor banner appears or disappears Optimization(zh-Hant): 亮色主題下卡片與圖示對比更清晰,權限提示橫幅不再佔據頂部 Optimization(en-US): clearer card and icon contrast in the light theme; the top permission banners are gone --- lib/app/shell/main_shell.dart | 4 - lib/app/theme/app_glass.dart | 61 +++-- .../home/presentation/pages/home_page.dart | 93 +++++-- .../presentation/widgets/home_content.dart | 245 +++++++++--------- .../widgets/home_eew_section.dart | 12 + .../widgets/home_monitor_banner.dart | 24 +- .../widgets/home_sheet_header.dart | 9 +- .../widgets/location_permission_banner.dart | 64 ----- .../notification_permission_banner.dart | 98 ------- lib/shared/widgets/permission_banners.dart | 23 -- 10 files changed, 279 insertions(+), 354 deletions(-) delete mode 100644 lib/shared/widgets/location_permission_banner.dart delete mode 100644 lib/shared/widgets/notification_permission_banner.dart delete mode 100644 lib/shared/widgets/permission_banners.dart diff --git a/lib/app/shell/main_shell.dart b/lib/app/shell/main_shell.dart index 20dda52ac..c2137b1a5 100644 --- a/lib/app/shell/main_shell.dart +++ b/lib/app/shell/main_shell.dart @@ -13,7 +13,6 @@ import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:dpip/shared/map/map_trace.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; -import 'package:dpip/shared/widgets/permission_banners.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; @@ -204,11 +203,8 @@ class _MainShellState extends State with RouteAware { return Scaffold( // Let the Home weather backdrop show through behind the bar. extendBody: true, - // A location "fix it" banner sits above the active tab (zero height when - // location is healthy, so Home's full-bleed layout is unaffected). body: Column( children: [ - const PermissionBanners(), // Renders nothing; checks once after the first frame whether this // build's channel has a newer release, and says so once per version. const UpdatePrompt(), diff --git a/lib/app/theme/app_glass.dart b/lib/app/theme/app_glass.dart index 400e6f4d3..f5bc297c4 100644 --- a/lib/app/theme/app_glass.dart +++ b/lib/app/theme/app_glass.dart @@ -46,43 +46,60 @@ const double glassRevealedAlpha = 0.20; /// The translucent card/surface tint: resting /// [ColorScheme.surfaceContainerHighest] → the reference's sky-tinted 20 % fill as -/// [reveal] rises. +/// [reveal] rises — **dark theme only**. Light theme skips the fade entirely +/// and stays [ColorScheme.surfaceContainerLow] at every [reveal] — the same +/// solid fill [Card] itself defaults to (see the Material 3 `_CardDefaultsM3` +/// this mirrors), so a light-theme card here reads exactly as crisp as the +/// plain [Card] the EEW alert uses, not merely "less translucent." Any partial +/// alpha over a busy backdrop, even 90-odd percent, still reads as hazy next +/// to that fully opaque comparison. /// /// Without a [sky] colour (no backdrop running, or before the first LUT bake) -/// it falls back to a near-opaque theme surface, because 20 % of nothing is an -/// unreadable card. -Color glassSurface( - ColorScheme colors, - double reveal, { - Color? sky, - int? hour, -}) => Color.lerp( - colors.surfaceContainerHighest.withValues(alpha: 0.55), - sky == null +/// dark theme falls back to a near-opaque theme surface, because 20 % of +/// nothing is an unreadable card. +Color glassSurface(ColorScheme colors, double reveal, {Color? sky, int? hour}) { + if (colors.brightness == Brightness.light) return colors.surfaceContainerLow; + final revealed = sky == null ? colors.surface.withValues(alpha: 0.92) - : skyCardTint(sky, hour: hour ?? AppTime.utc8.hour), - reveal, -)!; + : skyCardTint(sky, hour: hour ?? AppTime.utc8.hour); + return Color.lerp( + colors.surfaceContainerHighest.withValues(alpha: 0.55), + revealed, + reveal, + )!; +} /// Ink for content **inside** a [glassSurface] card. /// -/// At rest the card is its own plate and the theme's on-surface roles are -/// right. Once [reveal] dissolves it to the 20 % fill above, the card is no -/// longer a plate — it is a pane of the sky — so its ink has to follow the sky -/// exactly the way [inkOverWeather] does. Leaving it on [ColorScheme.onSurface] -/// puts near-black text on a rain sky in a light theme. +/// Dark theme: at rest the card is its own plate and the theme's on-surface +/// roles are right. Once [reveal] dissolves it to the 20 % fill above, the +/// card is no longer a plate — it is a pane of the sky — so its ink has to +/// follow the sky exactly the way [inkOverWeather] does. Leaving it on +/// [ColorScheme.onSurface] puts near-black text on a rain sky. +/// +/// Light theme: [glassSurface] stays an opaque plate at every [reveal], so +/// this stays [ColorScheme.onSurface] unconditionally — shifting it toward +/// white for a dark [skyIsLight] would put pale text on that still-opaque, +/// still-light plate. Color glassOnSurface( ColorScheme colors, { double reveal = 0, bool skyIsLight = false, -}) => inkOverWeather(colors, reveal, skyIsLight: skyIsLight); +}) { + if (colors.brightness == Brightness.light) return colors.onSurface; + return inkOverWeather(colors, reveal, skyIsLight: skyIsLight); +} -/// Secondary ink for content inside a [glassSurface] card. +/// Secondary ink for content inside a [glassSurface] card. See [glassOnSurface] +/// for why light theme skips the sky-following shift. Color glassOnSurfaceVariant( ColorScheme colors, { double reveal = 0, bool skyIsLight = false, -}) => inkOverWeatherVariant(colors, reveal, skyIsLight: skyIsLight); +}) { + if (colors.brightness == Brightness.light) return colors.onSurfaceVariant; + return inkOverWeatherVariant(colors, reveal, skyIsLight: skyIsLight); +} /// Whether [mode]'s sky is light enough that dark foregrounds read better than /// white once the weather backdrop is showing. diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index b35191c62..302a6cff7 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -302,24 +302,75 @@ 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), - ), - _GoldSupportBar( - blend: dials.blend, - dismiss: dials.dismiss, - ), - // 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, _) { + // Quick link to 強震監視器. The gold support bar's + // top edge always sits flush under the region bar + // — its position never moves. When an alert is + // live, its card is anchored to that same top + // edge and, being the taller of the two, sits + // directly over it: real Z-axis overlap, with the + // card's own elevation shadow falling across the + // bar underneath, not just first-in-a-list order. + // `isActive` is the same check + // HomeMonitorBanner.build gates its own render on, + // shared so this layout math can't drift from it. + final hasActiveEew = HomeMonitorBanner.isActive( + context, + ); + final stackHeight = hasActiveEew + ? HomeMonitorBanner.height + : _GoldSupportBar.height; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + RegionBar( + blend: dials.blend, + dismiss: dials.dismiss, + skyIsLight: skyIsLightFrom(sky, weatherMode), + ), + SizedBox( + height: stackHeight, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + child: _GoldSupportBar( + blend: dials.blend, + dismiss: dials.dismiss, + ), + ), + // Always mounted — HomeMonitorBanner + // renders SizedBox.shrink() internally + // while calm. Wrapping this in `if + // (hasActiveEew)` instead (removing the + // Positioned from the Stack's children + // entirely on every toggle) is what + // triggered a real Flutter engine crash + // here: a semantics assertion + // (`!child.attached`) when the Material + // + InkWell subtree underneath is + // repeatedly unmounted and remounted at + // this position. Letting the banner's + // own build() switch its child instead + // keeps the element permanently in the + // tree, which does not hit that bug. + Positioned( + top: 0, + left: 0, + right: 0, + child: HomeMonitorBanner( + dismiss: dials.dismiss, + ), + ), + ], + ), + ), + ], + ); + }, ), ), ), @@ -344,6 +395,10 @@ class _GoldSupportBar extends StatelessWidget { final double blend; final double dismiss; + /// This bar's own rendered height — used to size the overlap stack that + /// lets [HomeMonitorBanner] float over its top edge. + static const double height = 30; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -362,7 +417,7 @@ class _GoldSupportBar extends StatelessWidget { child: InkWell( onTap: () => context.pushNamed(AppRoutes.sponsor), child: SizedBox( - height: 30, + height: height, child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.max, diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 2b64e9804..931ef4126 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -123,10 +123,67 @@ class HomeContent extends StatelessWidget { static const double _focusRampExtent = 140; /// Focus dial for the content, `0` (resting) → `1` (scrolled past the hero). - /// See [HomeContent._focusRampExtent]. + /// See [_focusRampExtent]. static double _focus(double offset) => (offset / _focusRampExtent).clamp(0.0, 1.0); + /// Resting-state breathing room between the trend card's own bottom edge + /// and the safe area below it, on top of the safe area itself, so the card + /// doesn't read as glued to the very edge of the screen. + static const double _restBottomGap = AppSpacing.xl; + + /// Scroll distance over which the hero block's *entire* trailing reserve — + /// [_restBottomGap] and the safe area alike — collapses to 0. Short on + /// purpose, same reasoning as `HomeSheet._ScrollBlurredWeather`'s own ramp: + /// both are a resting-state concern, not something to keep paying for once + /// the sheet is actually moving. + /// + /// The safe area has to ramp away too, not just stay as a fixed floor under + /// [_restBottomGap] — it exists only because the trend card sits at the + /// physical bottom of the screen *at rest*. The moment the list scrolls, + /// the card is no longer there and nothing about the device's home + /// indicator applies to it anymore; holding that reserve open regardless + /// just leaves it as dead space between the trend card and the forecast + /// card once scrolled — taller than [AppSpacing.lg], the gap every other + /// pair of cards on the second page actually uses, and visibly + /// inconsistent with them. + static const double _bottomGapRampExtent = 32; + + /// Current size of the hero block's trailing reserve — [_restBottomGap] + /// plus [bottomSafeArea] — for the live scroll [offset]. + static double _heroBottomGap(double offset, double bottomSafeArea) { + final rest = _restBottomGap + bottomSafeArea; + final t = (offset / _bottomGapRampExtent).clamp(0.0, 1.0); + return rest * (1 - t); + } + + /// Scroll distance over which the hero's forecast card grows from its + /// one-glance summary (title + hour chips) to the full card (sparkline + + /// detail band). Independent of the shorter resting-state ramps around it — + /// this one is the *point* of the gesture on a dry hour, so it deserves the + /// distance. + static const double _forecastExpandExtent = 200; + + /// Current growth of the hero forecast card for [offset]. + static double _forecastExpansion(double offset) => + (offset / _forecastExpandExtent).clamp(0.0, 1.0); + + /// How wet the rain-trend card gets for a given backdrop. + /// + /// These are positions on the reference's own weather-type ladder, not a free dial. + /// Both effects switch whole parameter sets by the weather-type enum, + /// and the ladder is counter-intuitive: **lighter rain means smaller, denser + /// beads and sparser edge water**. DPIP's plain rain backdrop is the + /// light-rain end of that ladder — which is what the shipped light-rain + /// capture shows — and only thunderstorm reaches the downpour band. Sitting + /// rain in the middle, as an earlier version did, gave it beads the reference keeps + /// for a storm and an edge-water band the reference never shows at that grade. + static double _cardRain(WeatherMode mode) => switch (mode) { + WeatherMode.rain => 0.3, + WeatherMode.thunderstorm => 0.85, + _ => 0.0, + }; + Widget _build( BuildContext context, int areaIndex, @@ -207,58 +264,69 @@ class HomeContent extends StatelessWidget { key: ValueKey(areaIndex), crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (heroHeight != null) - SizedBox( - height: heroHeight, - // Only the trailing gap depends on scroll offset, so - // that is all this block's Padding re-reads per tick. - child: Padding( - // Deflates the tight SizedBox height so the Expanded - // gap between header and trend card shrinks by exactly - // this much and heroHeight itself — and with it the - // forecast/events fold below — never moves. Collapses - // to 0 as the sheet scrolls — see - // [_bottomGapRampExtent] on why that includes the - // safe area, not just the nicety gap on top of it. - padding: EdgeInsets.only( - bottom: _heroBottomGap(offset, bottomSafeArea), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - RainOnCard( - // Text and icons, not a solid card face — a - // flat top-edge collision would pool water in - // every gap between glyphs. Silhouette - // rasterises the header itself and catches drops - // on its actual outline instead. - // - // Not gated — see [RainOnCard.gated]. The gate - // closes once the card has risen [_gateCloseDistance] - // above where it settled, which is exactly what - // pulling the sheet up does to this header: it - // starts most of a screen down (rest detent) and - // rides up with the drag. Gating it there would - // cut the header's water off the moment the sheet - // is dragged, and the low-water mark ([_restTopY]) - // pins it shut afterwards. The surrounding scroll - // view culls its paint once it truly scrolls out. - intensity: _cardRain(weatherMode), - opacity: reveal, - glass: false, - silhouette: true, - gated: false, - child: HomeSheetHeader( - reveal: reveal, - expanded: expanded, - weatherMode: weatherMode, - sky: sky, - ), + // Unconditional shape, only conditional values — see + // the crash note above _HomeContentState. `heroHeight` + // being null just means a null SizedBox height (no + // constraint, shrinks to content) and an empty tail + // after the header instead of the trend/forecast card; + // HomeSheetHeader's own ancestor chain (this + // SizedBox > Padding > Column > RainOnCard) never + // changes shape, so it never needs to be torn down and + // rebuilt when the sheet opens or closes. + SizedBox( + height: heroHeight, + // Only the trailing gap depends on scroll offset, so + // that is all this block's Padding re-reads per tick. + child: Padding( + // Deflates the tight SizedBox height so the Expanded + // gap between header and trend card shrinks by exactly + // this much and heroHeight itself — and with it the + // forecast/events fold below — never moves. Collapses + // to 0 as the sheet scrolls — see + // [_bottomGapRampExtent] on why that includes the + // safe area, not just the nicety gap on top of it. + padding: EdgeInsets.only( + bottom: heroHeight == null + ? 0 + : _heroBottomGap(offset, bottomSafeArea), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RainOnCard( + // Text and icons, not a solid card face — a + // flat top-edge collision would pool water in + // every gap between glyphs. Silhouette + // rasterises the header itself and catches drops + // on its actual outline instead. + // + // Not gated — see [RainOnCard.gated]. The gate + // closes once the card has risen [_gateCloseDistance] + // above where it settled, which is exactly what + // pulling the sheet up does to this header: it + // starts most of a screen down (rest detent) and + // rides up with the drag. Gating it there would + // cut the header's water off the moment the sheet + // is dragged, and the low-water mark ([_restTopY]) + // pins it shut afterwards. The surrounding scroll + // view culls its paint once it truly scrolls out. + intensity: _cardRain(weatherMode), + opacity: reveal, + glass: false, + silhouette: true, + gated: false, + child: HomeSheetHeader( + reveal: reveal, + expanded: expanded, + weatherMode: weatherMode, + sky: sky, ), - // The gap is the point — open sky between the two - // fixed edges, not a forgotten card. `HomeSheet` - // blurs it back in once the scroll below carries - // the trend card past the top. + ), + if (heroHeight != null) ...[ + // The gap is the point — open sky between the + // two fixed edges, not a forgotten card. + // `HomeSheet` blurs it back in once the scroll + // below carries the trend card past the top. const Expanded(child: SizedBox.shrink()), if (dryTrend) // A dry hour has no rain chart, so the forecast @@ -296,22 +364,22 @@ class HomeContent extends StatelessWidget { rain: _cardRain(weatherMode), ), ], - ), + ], ), - ) - else - HomeSheetHeader( - reveal: reveal, - expanded: expanded, - weatherMode: weatherMode, - sky: sky, ), + ), const SizedBox(height: AppSpacing.lg), // A live EEW alert rides above every card — it is the one // thing on the dashboard that matters in the seconds it // exists. Nothing renders when calm, so the sheet's // ordinary layout is untouched outside an earthquake. const HomeEewSection(), + // The section renders nothing when calm — only reserve + // the gap below it while an alert is actually showing, + // or a calm dashboard gains an extra empty lg here on + // top of the one already before this section. + if (HomeEewSection.isActive(context)) + const SizedBox(height: AppSpacing.lg), // Collapsed, or nothing to anchor a hero to: active events // only. Full-screen township: the hero above, then forecast // → events reached by scrolling past it. 全國: events only @@ -365,63 +433,6 @@ class HomeContent extends StatelessWidget { final height = viewportHeight - topInset; return height < 0 ? 0 : height; } - - /// Resting-state breathing room between the trend card's own bottom edge - /// and the safe area below it, on top of the safe area itself, so the card - /// doesn't read as glued to the very edge of the screen. - static const double _restBottomGap = AppSpacing.xl; - - /// Scroll distance over which the hero block's *entire* trailing reserve — - /// [_restBottomGap] and the safe area alike — collapses to 0. Short on - /// purpose, same reasoning as `HomeSheet._ScrollBlurredWeather`'s own ramp: - /// both are a resting-state concern, not something to keep paying for once - /// the sheet is actually moving. - /// - /// The safe area has to ramp away too, not just stay as a fixed floor under - /// [_restBottomGap] — it exists only because the trend card sits at the - /// physical bottom of the screen *at rest*. The moment the list scrolls, - /// the card is no longer there and nothing about the device's home - /// indicator applies to it anymore; holding that reserve open regardless - /// just leaves it as dead space between the trend card and the forecast - /// card once scrolled — taller than [AppSpacing.lg], the gap every other - /// pair of cards on the second page actually uses, and visibly - /// inconsistent with them. - static const double _bottomGapRampExtent = 32; - - /// Current size of the hero block's trailing reserve — [_restBottomGap] - /// plus [bottomSafeArea] — for the live scroll [offset]. - static double _heroBottomGap(double offset, double bottomSafeArea) { - final rest = _restBottomGap + bottomSafeArea; - final t = (offset / _bottomGapRampExtent).clamp(0.0, 1.0); - return rest * (1 - t); - } - - /// Scroll distance over which the hero's forecast card grows from its - /// one-glance summary (title + hour chips) to the full card (sparkline + - /// detail band). Independent of the shorter resting-state ramps around it — - /// this one is the *point* of the gesture on a dry hour, so it deserves the - /// distance. - static const double _forecastExpandExtent = 200; - - /// Current growth of the hero forecast card for [offset]. - static double _forecastExpansion(double offset) => - (offset / _forecastExpandExtent).clamp(0.0, 1.0); - - /// How wet the rain-trend card gets for a given backdrop. - /// - /// These are positions on the reference's own weather-type ladder, not a free dial. - /// Both effects switch whole parameter sets by the weather-type enum, - /// and the ladder is counter-intuitive: **lighter rain means smaller, denser - /// beads and sparser edge water**. DPIP's plain rain backdrop is the - /// light-rain end of that ladder — which is what the shipped light-rain - /// capture shows — and only thunderstorm reaches the downpour band. Sitting - /// rain in the middle, as an earlier version did, gave it beads the reference keeps - /// for a storm and an edge-water band the reference never shows at that grade. - static double _cardRain(WeatherMode mode) => switch (mode) { - WeatherMode.rain => 0.3, - WeatherMode.thunderstorm => 0.85, - _ => 0.0, - }; } /// Slides its [child] in from the side when the area [index] changes, so a diff --git a/lib/features/home/presentation/widgets/home_eew_section.dart b/lib/features/home/presentation/widgets/home_eew_section.dart index bfae7b50e..2ddd6c212 100644 --- a/lib/features/home/presentation/widgets/home_eew_section.dart +++ b/lib/features/home/presentation/widgets/home_eew_section.dart @@ -45,6 +45,18 @@ import 'package:provider/provider.dart'; class HomeEewSection extends StatelessWidget { const HomeEewSection({super.key}); + /// Whether this section renders anything right now — the same check + /// [build] gates on, exposed so `HomeContent` can decide whether to + /// reserve a gap after it without re-deriving (and risking drifting from) + /// the same liveness condition. + static bool isActive(BuildContext context) { + final state = context.watch>>().state; + final alerts = state.data; + return state.status == RealtimeStatus.live && + alerts != null && + alerts.isNotEmpty; + } + @override Widget build(BuildContext context) { final controller = context.watch>>(); diff --git a/lib/features/home/presentation/widgets/home_monitor_banner.dart b/lib/features/home/presentation/widgets/home_monitor_banner.dart index 0c6284653..7a75ee41c 100644 --- a/lib/features/home/presentation/widgets/home_monitor_banner.dart +++ b/lib/features/home/presentation/widgets/home_monitor_banner.dart @@ -43,17 +43,25 @@ class HomeMonitorBanner extends StatelessWidget { /// appear at any moment and the framing must already have room for it. static const double height = 44; + /// Whether an alert is live right now — the same check [build] gates on, + /// exposed so a caller that needs to lay out *around* this banner (Home's + /// overlap stack with the gold support bar) reads the identical condition + /// instead of re-deriving it and risking the two disagreeing. + static bool isActive(BuildContext context) { + final eew = context.watch>>(); + final alerts = eew.state.data ?? const []; + return eew.state.status == RealtimeStatus.live && alerts.isNotEmpty; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; + if (!isActive(context)) return const SizedBox.shrink(); + final eew = context.watch>>(); final alerts = eew.state.data ?? const []; - final hasActiveEew = - eew.state.status == RealtimeStatus.live && alerts.isNotEmpty; - if (!hasActiveEew) return const SizedBox.shrink(); - final ink = colors.onErrorContainer; final alert = alerts.first; @@ -70,8 +78,12 @@ class HomeMonitorBanner extends StatelessWidget { ), child: Material( color: colors.errorContainer, - elevation: 3, - shadowColor: Colors.black.withValues(alpha: 0.25), + // Higher than the card language elsewhere uses — this one + // genuinely overlaps the gold support bar underneath it (see + // Home's overlap stack), so the shadow needs to read as real + // depth, not just a card outline. + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.35), borderRadius: AppRadius.medium, clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/features/home/presentation/widgets/home_sheet_header.dart b/lib/features/home/presentation/widgets/home_sheet_header.dart index d15f4055d..9080299e1 100644 --- a/lib/features/home/presentation/widgets/home_sheet_header.dart +++ b/lib/features/home/presentation/widgets/home_sheet_header.dart @@ -125,7 +125,14 @@ class HomeSheetHeader extends StatelessWidget { isNight: isNightAt(AppTime.utc), ); final conditionIcon = weather?.$1 ?? cloudy; - final conditionAccent = weather?.$2 ?? secondary; + // Not `weather?.$2` — that accent is a fixed [ColorScheme] role (amber for + // clear, grey for cloudy, …), chosen for the icon on its own without + // knowing what backdrop it will sit on. This icon sits directly on the + // weather sky next to the temperature, so it has to shift with it exactly + // as [foreground] does — a fixed dark accent is invisible against a night + // sky's white ink, and a fixed light accent is invisible against a clear + // noon sky's dark ink, whichever the semantic colour happened to pick. + final conditionAccent = foreground; // 全國 has no township weather — name only. 所在地 without GPS: say so // instead of a dashed reading row. diff --git a/lib/shared/widgets/location_permission_banner.dart b/lib/shared/widgets/location_permission_banner.dart deleted file mode 100644 index 67f75b477..000000000 --- a/lib/shared/widgets/location_permission_banner.dart +++ /dev/null @@ -1,64 +0,0 @@ -/// An app-wide banner shown when location can't be used (services off or -/// permission denied), with a one-tap route to system settings. -library; - -import 'package:dpip/app/theme/app_spacing.dart'; -import 'package:dpip/core/geo/location_monitor.dart'; -import 'package:dpip/core/geo/location_status.dart'; -import 'package:dpip/l10n/gen/app_localizations.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -/// Watches [LocationMonitor]; renders a warning bar when location needs the -/// user's attention, and nothing (zero height) otherwise — so a healthy app is -/// unaffected. Place it at the top of the shell body. -class LocationPermissionBanner extends StatelessWidget { - const LocationPermissionBanner({super.key}); - - @override - Widget build(BuildContext context) { - final monitor = context.watch(); - if (!monitor.needsAttention) return const SizedBox.shrink(); - - final l10n = AppLocalizations.of(context); - final colors = Theme.of(context).colorScheme; - final message = monitor.status == LocationStatus.serviceOff - ? l10n.locationBannerServiceOff - : l10n.locationBannerPermission; - - // SafeArea only while visible — wrapping the empty shell would leave a - // status-bar-sized blank above full-bleed tabs (map). Nested SafeAreas - // collapse: a banner below this one sees top padding already consumed. - return Material( - color: colors.errorContainer, - child: SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.sm, - AppSpacing.sm, - AppSpacing.sm, - ), - child: Row( - children: [ - Icon(Icons.location_off_outlined, color: colors.onErrorContainer), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Text( - message, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colors.onErrorContainer), - ), - ), - TextButton( - onPressed: monitor.openSettings, - child: Text(l10n.locationBannerFix), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/shared/widgets/notification_permission_banner.dart b/lib/shared/widgets/notification_permission_banner.dart deleted file mode 100644 index a9606534f..000000000 --- a/lib/shared/widgets/notification_permission_banner.dart +++ /dev/null @@ -1,98 +0,0 @@ -/// An app-wide banner shown when notifications are disabled (so disaster alerts -/// can't reach the user), with a one-tap route to system settings. -library; - -import 'package:dpip/app/theme/app_spacing.dart'; -import 'package:dpip/core/notifications/notification_service.dart'; -import 'package:dpip/core/permissions/system_settings.dart'; -import 'package:dpip/l10n/gen/app_localizations.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -/// Watches notification permission and renders a warning bar while it's off, -/// re-checking whenever the app returns to the foreground (the user may grant it -/// in Settings). Zero height when notifications are allowed — a healthy app is -/// unaffected. Place it at the top of the shell body, beside the location -/// banner. Notifications can't be *required* (App Store 4.5.4 / 5.1.2(i)), so -/// this is the compliant nudge: visible, dismissable by granting, never a block. -class NotificationPermissionBanner extends StatefulWidget { - const NotificationPermissionBanner({super.key}); - - @override - State createState() => - _NotificationPermissionBannerState(); -} - -class _NotificationPermissionBannerState - extends State - with WidgetsBindingObserver { - // Optimistic: assume allowed so the banner shows only once confirmed off. - bool _allowed = true; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - WidgetsBinding.instance.addPostFrameCallback((_) => _refresh()); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) _refresh(); - } - - Future _refresh() async { - final allowed = await context.read().isAllowed(); - if (!mounted || allowed == _allowed) return; - setState(() => _allowed = allowed); - } - - @override - Widget build(BuildContext context) { - if (_allowed) return const SizedBox.shrink(); - - final l10n = AppLocalizations.of(context); - final colors = Theme.of(context).colorScheme; - // SafeArea only while visible (see LocationPermissionBanner). - return Material( - color: colors.errorContainer, - child: SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.sm, - AppSpacing.sm, - AppSpacing.sm, - ), - child: Row( - children: [ - Icon( - Icons.notifications_off_outlined, - color: colors.onErrorContainer, - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Text( - l10n.notifyBannerDisabled, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colors.onErrorContainer), - ), - ), - TextButton( - onPressed: openAppSettingsPage, - child: Text(l10n.locationBannerFix), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/shared/widgets/permission_banners.dart b/lib/shared/widgets/permission_banners.dart deleted file mode 100644 index 912f7efb5..000000000 --- a/lib/shared/widgets/permission_banners.dart +++ /dev/null @@ -1,23 +0,0 @@ -/// 權限警告堆疊訊息 -library; - -import 'package:dpip/shared/widgets/location_permission_banner.dart'; -import 'package:dpip/shared/widgets/notification_permission_banner.dart'; -import 'package:flutter/material.dart'; - -/// Stacks the location + notification nudges at the shell top. -/// -/// Each banner owns its own [SafeArea] **only when visible**. Do not wrap this -/// stack in SafeArea — empty banners are [SizedBox.shrink], and an outer -/// SafeArea would still eat the status-bar inset above full-bleed tabs. -class PermissionBanners extends StatelessWidget { - const PermissionBanners({super.key}); - - @override - Widget build(BuildContext context) { - return const Column( - mainAxisSize: MainAxisSize.min, - children: [LocationPermissionBanner(), NotificationPermissionBanner()], - ); - } -} From fdfb37389a40b4ad6799bee009af8ebbc89c5c9a Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sun, 23 Aug 2026 11:02:19 +0800 Subject: [PATCH 5/8] feat(map): persist the layer picker scroll offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 圖層選擇清單會記住上次捲到的位置 New(en-US): the layer picker retains your last scroll offset --- lib/shared/map/map_layer_switcher.dart | 79 +++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/lib/shared/map/map_layer_switcher.dart b/lib/shared/map/map_layer_switcher.dart index bc67b61e4..8c33c8c0c 100644 --- a/lib/shared/map/map_layer_switcher.dart +++ b/lib/shared/map/map_layer_switcher.dart @@ -141,8 +141,17 @@ class MapLayerSwitcher extends StatelessWidget { MapLayerCategory.values, orderController.categoryOrder, ); - return ListView( - controller: scrollController, + return _RememberedOffsetList( + // Remembers scroll offset across separate openings + // of this sheet — picking a layer pops the sheet + // immediately (see onTap below), so without this the + // list snapped back to the top every time, forcing a + // re-scroll to reach a nearby, later option. Keyed + // by the layer set so different pickers (radar, + // satellite, …) each keep their own position. + storageKey: + 'map-layer-switcher:${layers.map((l) => l.id).join(',')}', + scrollController: scrollController, padding: EdgeInsets.fromLTRB( AppSpacing.md, 0, @@ -194,6 +203,72 @@ class MapLayerSwitcher extends StatelessWidget { } } +/// Last scroll offset per [_RememberedOffsetList.storageKey], kept for the +/// app's lifetime. +/// +/// Not [PageStorage]: every [showModalBottomSheet] call pushes a fresh +/// [ModalRoute], and `ModalRoute` gives its content its own private +/// [PageStorageBucket] (see `_ModalScopeState.build` in the framework's +/// `routes.dart`) — thrown away the moment the route pops. A `PageStorageKey` +/// on the list, as an earlier version of this used, only ever wrote to that +/// bucket-of-the-moment and had nothing to read back from on the next open. +/// This map outlives the route instead. +final Map _rememberedListOffset = {}; + +/// A [ListView] that restores its scroll offset from +/// [_rememberedListOffset] on first layout and keeps that entry updated as +/// the user scrolls — see [_rememberedListOffset] for why [PageStorage] +/// cannot do this for a widget that lives inside a modal bottom sheet. +class _RememberedOffsetList extends StatefulWidget { + const _RememberedOffsetList({ + required this.storageKey, + required this.scrollController, + required this.padding, + required this.children, + }); + + final String storageKey; + final ScrollController scrollController; + final EdgeInsets padding; + final List children; + + @override + State<_RememberedOffsetList> createState() => _RememberedOffsetListState(); +} + +class _RememberedOffsetListState extends State<_RememberedOffsetList> { + @override + void initState() { + super.initState(); + final offset = _rememberedListOffset[widget.storageKey]; + if (offset == null) return; + // The controller isn't attached to a position until the sheet's first + // frame lays out the list beneath it — jumping any earlier throws. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !widget.scrollController.hasClients) return; + final max = widget.scrollController.position.maxScrollExtent; + widget.scrollController.jumpTo(offset.clamp(0.0, max)); + }); + } + + bool _onScroll(ScrollNotification notification) { + _rememberedListOffset[widget.storageKey] = notification.metrics.pixels; + return false; + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: _onScroll, + child: ListView( + controller: widget.scrollController, + padding: widget.padding, + children: widget.children, + ), + ); + } +} + /// Sheet grab handle — same visual as the typhoon / station 拖盤. class _Grip extends StatelessWidget { const _Grip(); From 4ffc111c3620c0d737d20b4a0a0cb721b8bbf108 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sun, 23 Aug 2026 11:37:27 +0800 Subject: [PATCH 6/8] fix(settings): center the status icon in its permission row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 權限清單的狀態圖示改為垂直置中 Fix(en-US): the permission checklist icons are centred in their rows --- lib/shared/widgets/permission_checklist.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/shared/widgets/permission_checklist.dart b/lib/shared/widgets/permission_checklist.dart index 7d6dfff74..002060f34 100644 --- a/lib/shared/widgets/permission_checklist.dart +++ b/lib/shared/widgets/permission_checklist.dart @@ -692,7 +692,7 @@ class PermissionRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon( icon, From 3498ee37aefdcb9be783cffc48be10c500d4b9a1 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sun, 23 Aug 2026 20:58:54 +0800 Subject: [PATCH 7/8] fix(l10n): fall back to zh_TW when intl lacks date-format data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正粵語等語系下地震報告與更新日誌的日期格式會當掉 Fix(en-US): dates on the changelog and report pages no longer crash under locales intl has no data for --- lib/core/settings/locale_config.dart | 16 ++++++++++++ .../presentation/pages/changelog_page.dart | 6 +++-- .../pages/version_notes_page.dart | 6 +++-- .../presentation/pages/report_list_page.dart | 3 ++- test/core/settings/locale_config_test.dart | 25 +++++++++++++++++++ 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/lib/core/settings/locale_config.dart b/lib/core/settings/locale_config.dart index 32e77af23..2c2c37a3d 100644 --- a/lib/core/settings/locale_config.dart +++ b/lib/core/settings/locale_config.dart @@ -5,6 +5,7 @@ import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart'; /// The app's home locale: Traditional Chinese for Taiwan (`zh_TW`). /// @@ -98,6 +99,21 @@ bool _isBareChinese(Locale locale) => locale.scriptCode == null && locale.countryCode == null; +/// A `DateFormat`-safe locale tag for [locale]. +/// +/// `intl` ships its own locale-data set, entirely separate from +/// `flutter_localizations` — it does not know every locale this app +/// supports either (`yue` included, the same gap the widgets-fallback +/// delegates above patch for Material/Cupertino). Asking `DateFormat` to +/// format with a tag it has no data for throws ("Invalid locale") rather than +/// falling back, so every `DateFormat` call driven by the app's locale must +/// go through this first. Falls back to [kHomeLocale], the same replacement +/// the Material/Cupertino delegates use. +String intlDateLocale(Locale locale) { + final tag = locale.toString(); + return DateFormat.localeExists(tag) ? tag : kHomeLocale.toString(); +} + /// Picks the best supported locale for [deviceLocales]. /// /// Chinese needs an explicit script/region split: bare `zh` and `zh_Hant` map diff --git a/lib/features/changelog/presentation/pages/changelog_page.dart b/lib/features/changelog/presentation/pages/changelog_page.dart index 0892df7f2..f41d777c2 100644 --- a/lib/features/changelog/presentation/pages/changelog_page.dart +++ b/lib/features/changelog/presentation/pages/changelog_page.dart @@ -5,6 +5,7 @@ import 'package:dpip/app/theme/app_motion.dart'; 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/settings/locale_config.dart'; import 'package:dpip/core/version/app_build.dart'; import 'package:dpip/core/error/result.dart'; import 'package:dpip/features/changelog/domain/changelog_repository.dart'; @@ -276,8 +277,9 @@ class _ReleaseTile extends StatelessWidget { ? Icons.science_outlined : Icons.verified_outlined; final title = note.name.isEmpty ? note.tagName : note.name; - final date = DateFormat.yMMMd(Localizations.localeOf(context).toString()) - .format(note.publishedAt.toLocal()); + final date = DateFormat.yMMMd( + intlDateLocale(Localizations.localeOf(context)), + ).format(note.publishedAt.toLocal()); final emphasized = isCurrent || expanded; return CustomPaint( diff --git a/lib/features/changelog/presentation/pages/version_notes_page.dart b/lib/features/changelog/presentation/pages/version_notes_page.dart index 54c4fe765..65c465240 100644 --- a/lib/features/changelog/presentation/pages/version_notes_page.dart +++ b/lib/features/changelog/presentation/pages/version_notes_page.dart @@ -12,6 +12,7 @@ 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/settings/locale_config.dart'; 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'; @@ -137,8 +138,9 @@ class _Header extends StatelessWidget { final colors = theme.colorScheme; final typeColor = isStable ? _stableColor : _snapshotColor; final title = note.name.isEmpty ? note.tagName : note.name; - final date = DateFormat.yMMMd(Localizations.localeOf(context).toString()) - .format(note.publishedAt.toLocal()); + final date = DateFormat.yMMMd( + intlDateLocale(Localizations.localeOf(context)), + ).format(note.publishedAt.toLocal()); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/earthquake/presentation/pages/report_list_page.dart b/lib/features/earthquake/presentation/pages/report_list_page.dart index 0a634d9e4..675be6016 100644 --- a/lib/features/earthquake/presentation/pages/report_list_page.dart +++ b/lib/features/earthquake/presentation/pages/report_list_page.dart @@ -4,6 +4,7 @@ library; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/core/settings/locale_config.dart'; import 'package:dpip/shared/seismic/intensity.dart'; import 'package:dpip/features/earthquake/domain/partial_earthquake_report.dart'; import 'package:dpip/features/earthquake/domain/report_repository.dart'; @@ -238,7 +239,7 @@ class _DaySection extends StatelessWidget { final theme = Theme.of(context); final colors = theme.colorScheme; final l10n = AppLocalizations.of(context); - final locale = Localizations.localeOf(context).toString(); + final locale = intlDateLocale(Localizations.localeOf(context)); return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/test/core/settings/locale_config_test.dart b/test/core/settings/locale_config_test.dart index 144ce8953..6b7a5821d 100644 --- a/test/core/settings/locale_config_test.dart +++ b/test/core/settings/locale_config_test.dart @@ -73,6 +73,31 @@ void main() { ); }); + testWidgets( + 'intlDateLocale falls back to Taiwan for a locale intl has no data for', + (tester) async { + // DateFormat.localeExists needs intl's date-symbol data loaded first + // (LocaleDataException otherwise) — pumping a MaterialApp is what does + // that in the real app too, via GlobalMaterialLocalizations' delegate + // load, so this exercises the same path the changelog page runs + // through rather than calling intl cold. + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: supported, + home: const Scaffold(body: SizedBox.shrink()), + ), + ); + await tester.pumpAndSettle(); + // intl ships its own locale-data set, separate from flutter_localizations + // — it has no `yue` either, and asking DateFormat to format with a tag it + // cannot verify throws "Invalid locale" rather than falling back (the + // changelog crash this guards against). + expect(intlDateLocale(const Locale('yue')), kHomeLocale.toString()); + expect(intlDateLocale(const Locale('ja')), 'ja'); + }, + ); + testWidgets('app delegates serve Cantonese without crashing', (tester) async { // flutter_localizations ships no yue; the fallback delegates must serve // zh_TW widgets strings so a yue MaterialApp builds instead of asserting From 7da3e68534d34f936af7687153d8da349a39fef7 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sun, 23 Aug 2026 20:59:11 +0800 Subject: [PATCH 8/8] fix(map): restore the sheet height when the layer list seeks back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正圖層清單記住的捲動位置在重新開啟時讓選單卡在縮小狀態 Fix(en-US): the layer picker no longer stays stuck small when it restores your last scroll offset --- lib/shared/map/map_layer_switcher.dart | 40 +++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/lib/shared/map/map_layer_switcher.dart b/lib/shared/map/map_layer_switcher.dart index 8c33c8c0c..0ab0f9fd0 100644 --- a/lib/shared/map/map_layer_switcher.dart +++ b/lib/shared/map/map_layer_switcher.dart @@ -93,6 +93,9 @@ class MapLayerSwitcher extends StatelessWidget { Future _pick(BuildContext context) async { final orderController = context.read(); + // Owns restoring the sheet's own height when a remembered scroll offset + // needs one — see `_RememberedOffsetList`'s doc for why. + final sheetController = DraggableScrollableController(); final selected = await showModalBottomSheet( context: context, isScrollControlled: true, @@ -103,6 +106,7 @@ class MapLayerSwitcher extends StatelessWidget { final colors = theme.colorScheme; final bottomInset = MediaQuery.paddingOf(sheetContext).bottom; return DraggableScrollableSheet( + controller: sheetController, expand: false, initialChildSize: _initial, minChildSize: _min, @@ -152,6 +156,7 @@ class MapLayerSwitcher extends StatelessWidget { storageKey: 'map-layer-switcher:${layers.map((l) => l.id).join(',')}', scrollController: scrollController, + sheetController: sheetController, padding: EdgeInsets.fromLTRB( AppSpacing.md, 0, @@ -183,6 +188,7 @@ class MapLayerSwitcher extends StatelessWidget { ); }, ); + sheetController.dispose(); if (selected != null && selected.id != active.id) onSelected(selected); } @@ -223,12 +229,18 @@ class _RememberedOffsetList extends StatefulWidget { const _RememberedOffsetList({ required this.storageKey, required this.scrollController, + required this.sheetController, required this.padding, required this.children, }); final String storageKey; final ScrollController scrollController; + + /// Resizes the *sheet* itself, separately from [scrollController]'s own + /// scroll offset — see [_RememberedOffsetListState.initState] for why + /// restoring a remembered offset needs this too. + final DraggableScrollableController sheetController; final EdgeInsets padding; final List children; @@ -241,13 +253,33 @@ class _RememberedOffsetListState extends State<_RememberedOffsetList> { void initState() { super.initState(); final offset = _rememberedListOffset[widget.storageKey]; - if (offset == null) return; + if (offset == null || offset <= 0) return; // The controller isn't attached to a position until the sheet's first // frame lays out the list beneath it — jumping any earlier throws. WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !widget.scrollController.hasClients) return; - final max = widget.scrollController.position.maxScrollExtent; - widget.scrollController.jumpTo(offset.clamp(0.0, max)); + if (!mounted) return; + // A nonzero list offset can only have been recorded while the sheet + // was already at its max height — `DraggableScrollableSheet` routes a + // drag into resizing the sheet, not scrolling the list, for as long as + // the list's own `pixels` is 0 (see the framework's + // `_DraggableScrollableSheetScrollPosition.listShouldScroll`). + // Restoring the list's `pixels` without first restoring that height + // left the sheet's own size at its small initial value while `pixels` + // read nonzero — which flips `listShouldScroll` permanently true, so + // every drag afterwards scrolled the list instead of resizing the + // sheet, and the sheet could never reach full height again. + widget.sheetController.jumpTo(MapLayerSwitcher._max); + // `jumpTo` resizes the sheet by notifying a listener the surrounding + // `ValueListenableBuilder` rebuilds from — that rebuild, and the + // relayout of this list to the taller viewport it produces, only lands + // on the *next* frame, so the scroll offset restore needs one frame of + // its own after this to read a `maxScrollExtent` that already accounts + // for the full-height list. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !widget.scrollController.hasClients) return; + final max = widget.scrollController.position.maxScrollExtent; + widget.scrollController.jumpTo(offset.clamp(0.0, max)); + }); }); }