From 37142efc4beb5dbbca64e538fa712c089f56cf58 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Wed, 12 Aug 2026 03:28:22 +0800 Subject: [PATCH 1/6] feat: home header weather meta + view-on-map link Show the nearest-station name and observation time (Taipei wall clock) as small print under the home header name, and open the map on the temperature layer at that same station from a link shown when the sheet is expanded. Realtime readings are gated to the selected township so a stale previous-area observation never masquerades as the new one while its fetch runs; the debug GPS read rides alongside the data fetches so a fix inside its 10s timeout window can no longer hold up the sheet. --- lib/features/home/home_providers.dart | 2 + .../presentation/home_weather_controller.dart | 61 ++++- .../widgets/home_sheet_header.dart | 103 ++++++- lib/l10n/app_en.arb | 16 ++ lib/l10n/app_fil.arb | 3 +- lib/l10n/app_id.arb | 3 +- lib/l10n/app_ja.arb | 3 +- lib/l10n/app_ko.arb | 3 +- lib/l10n/app_th.arb | 3 +- lib/l10n/app_vi.arb | 3 +- lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_Hans.arb | 3 +- lib/l10n/app_zh_Hant_HK.arb | 3 +- lib/l10n/app_zh_TW.arb | 2 + lib/l10n/gen/app_localizations.dart | 12 + lib/l10n/gen/app_localizations_en.dart | 8 + lib/l10n/gen/app_localizations_fil.dart | 8 + lib/l10n/gen/app_localizations_id.dart | 8 + lib/l10n/gen/app_localizations_ja.dart | 8 + lib/l10n/gen/app_localizations_ko.dart | 10 +- lib/l10n/gen/app_localizations_th.dart | 8 + lib/l10n/gen/app_localizations_vi.dart | 10 +- lib/l10n/gen/app_localizations_zh.dart | 32 +++ .../widgets/home_sheet_header_test.dart | 254 ++++++++++++++++++ 24 files changed, 549 insertions(+), 19 deletions(-) create mode 100644 test/features/home/presentation/widgets/home_sheet_header_test.dart diff --git a/lib/features/home/home_providers.dart b/lib/features/home/home_providers.dart index 0480037b3..35668c4a6 100644 --- a/lib/features/home/home_providers.dart +++ b/lib/features/home/home_providers.dart @@ -1,3 +1,4 @@ +import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/events/domain/event_repository.dart'; @@ -26,6 +27,7 @@ List homeProviders() => [ context.read(), context.read(), context.read(), + gpsFix: context.read().currentFix, ), ), ChangeNotifierProvider( diff --git a/lib/features/home/presentation/home_weather_controller.dart b/lib/features/home/presentation/home_weather_controller.dart index 0a6d4b36b..470dcfcaa 100644 --- a/lib/features/home/presentation/home_weather_controller.dart +++ b/lib/features/home/presentation/home_weather_controller.dart @@ -2,8 +2,12 @@ /// next-hour rain trend. library; +import 'dart:async'; + import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; @@ -18,13 +22,18 @@ import 'package:flutter/foundation.dart'; /// [RegionStore] township. 全國 has no point weather — [areaCode] is null and /// all feeds stay cleared. Reloads when the area changes; the last values are /// kept while a new fetch runs so the sheet never blanks. +/// +/// [gpsFix] supplies the live GPS coordinate (nullable when unavailable) for +/// the debug log — the realtime request itself goes to the township centre, +/// so the fix is logged alongside rather than used to fetch. class HomeWeatherController extends ChangeNotifier { HomeWeatherController( this._repository, this._hourTrendRepository, this._regions, - this._directory, - ) { + this._directory, { + this.gpsFix, + }) { _regions.addListener(_sync); _sync(); } @@ -34,7 +43,11 @@ class HomeWeatherController extends ChangeNotifier { final RegionStore _regions; final TownDirectory _directory; + /// Live GPS fix for the debug log; null when unavailable. + final Future Function()? gpsFix; + WeatherRealtime? _weather; + String? _weatherCode; WeatherForecast? _forecast; RainHourTrend? _hourTrend; bool _loading = false; @@ -46,6 +59,12 @@ class HomeWeatherController extends ChangeNotifier { /// The latest realtime observation, or null before the first load / at sea. WeatherRealtime? get weather => _weather; + /// The township code [weather] belongs to — null before the first load. The + /// header shows readings only when this matches the currently selected area, + /// so a stale (previous-area) observation never masquerades as the new one + /// while its fetch runs. + String? get weatherCode => _weatherCode; + /// The latest township 24h forecast, or null before the first load / no code. WeatherForecast? get forecast => _forecast; @@ -89,6 +108,7 @@ class HomeWeatherController extends ChangeNotifier { final town = code == null ? null : _directory.byCode(code); if (town == null || code == null) { _weather = null; + _weatherCode = null; _forecast = null; _hourTrend = null; _failure = null; @@ -107,6 +127,11 @@ class HomeWeatherController extends ChangeNotifier { _hourTrendFailure = null; notifyListeners(); + // The debug GPS read rides alongside the data fetches, never ahead of + // them: a fix without a fresh cache sits in its 10s timeout window, and it + // only annotates the log — awaiting it here would hold the sheet's new + // readings in limbo for nothing the user can see. + final gpsFuture = gpsFix?.call(); final realtimeFuture = _repository.realtime(lat, lng); final forecastFuture = _repository.forecast(code); final hourTrendFuture = _hourTrendRepository.hourTrend(code); @@ -118,10 +143,15 @@ class HomeWeatherController extends ChangeNotifier { _loading = false; realtime.when( - ok: (value) => _weather = value, + ok: (value) { + _weather = value; + _weatherCode = value == null ? null : code; + if (value != null) unawaited(_logRealtime(code, gpsFuture, value)); + }, err: (failure) { _failure = failure; _weather = null; + _weatherCode = null; }, ); forecast.when( @@ -141,6 +171,31 @@ class HomeWeatherController extends ChangeNotifier { notifyListeners(); } + /// Debug line: the GPS fix (3 decimals) plus the nearest-station realtime + /// result the fetch resolved, for verifying what the device saw. The fix may + /// still be inside its (up to 10s) timeout window — it only annotates the + /// log, so it's awaited here, after the sheet has already updated. + Future _logRealtime( + String code, + Future? gpsFuture, + WeatherRealtime value, + ) async { + final gps = await gpsFuture; + final coords = gps == null + ? 'no-fix' + : '${gps.lat.toStringAsFixed(3)},${gps.lng.toStringAsFixed(3)}'; + final data = value.data; + Log.debug( + // l10n-ignore: debug log line, not user-facing display text + '所在地 code=$code gps=$coords → ' + '${value.station.name}(${value.station.distance.toStringAsFixed(1)} km) ' + 'weather=${data.weather}(${data.weatherCode}) ' + 'temp=${data.temperature?.toStringAsFixed(1)}°C ' + 'rain=${data.rain?.toStringAsFixed(1)} mm ' + 'wind=${data.wind.speed?.toStringAsFixed(1)} m/s', + ); + } + @override void dispose() { _regions.removeListener(_sync); diff --git a/lib/features/home/presentation/widgets/home_sheet_header.dart b/lib/features/home/presentation/widgets/home_sheet_header.dart index 8acc755cb..fa840bb26 100644 --- a/lib/features/home/presentation/widgets/home_sheet_header.dart +++ b/lib/features/home/presentation/widgets/home_sheet_header.dart @@ -1,20 +1,34 @@ import 'package:dpip/app/theme/app_glass.dart'; 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/geo/town_directory.dart'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/weather_mode.dart'; -import 'package:dpip/features/home/presentation/home_weather_controller.dart'; import 'package:dpip/core/weather/weather_condition.dart'; +import 'package:dpip/features/home/presentation/home_weather_controller.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_station_handoff.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; /// The header at the top of the home sheet: the selected area name and, for a /// township, its current weather — condition icon + temperature on the left -/// (2/3), precipitation + humidity stacked on the right (1/3). 全國 shows the -/// name only (no point weather). +/// (2/3), precipitation over humidity on the right (1/3). The nearest-station +/// name + observation time (Taipei HH:mm) sit as small print under the name, +/// with a "view on map" link that opens the map on that station's temperature +/// layer. 全國 shows the name only (no point weather). +/// +/// Readings are shown only while they belong to the *selected* area — a +/// previous area's observation is held by the controller while the new fetch +/// runs, but a wrong-area temperature must never masquerade as the new one +/// (the sheet shows dashes instead until the new reading lands). /// /// When [expanded] (sheet flush full-screen), typography and layout step up to /// fill the hero band — same pattern as the station/typhoon chart sheets — so a @@ -73,7 +87,14 @@ class HomeSheetHeader extends StatelessWidget { SavedArea(:final code) => directory.byCode(code)?.fullName ?? '', }; - final data = context.watch().weather?.data; + final controller = context.watch(); + // A reading from a previous area is held while the new one loads (never + // blanking the sheet), but it must not be shown as the new area's — only + // data belonging to the currently selected township passes through here. + final realtime = controller.weatherCode == controller.areaCode + ? controller.weather + : null; + final data = realtime?.data; final temp = data?.temperature; final humidity = data?.humidity; final rain = data?.rain; @@ -125,6 +146,29 @@ class HomeSheetHeader extends StatelessWidget { style: nameStyle ?? const TextStyle(), child: Text(areaName), ), + if (realtime case final current?) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + l10n.weatherDataTime( + current.station.name, + DateFormat('HH:mm').format( + AppTime.taipei( + DateTime.fromMillisecondsSinceEpoch( + current.time * 1000, + isUtc: true, + ), + ), + ), + ), + style: + (expanded + ? theme.textTheme.labelMedium + : theme.textTheme.labelSmall) + ?.copyWith(color: secondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], if (!nationwide) ...[ SizedBox(height: expanded ? AppSpacing.xl : AppSpacing.lg), if (currentUnavailable) @@ -200,6 +244,39 @@ class HomeSheetHeader extends StatelessWidget { ), ], ), + if (realtime case final current?) ...[ + const SizedBox(height: AppSpacing.sm), + // Below the left-hand (precipitation) metric — a quiet + // path to the same station on the map, shown only when the + // sheet is flush so a collapsed header stays uncluttered. + InkWell( + borderRadius: BorderRadius.circular(AppRadius.sm), + onTap: () => _openNearestStationOnMap(context, current), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + vertical: AppSpacing.xs / 2, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.homeViewOnMap, + style: theme.textTheme.labelMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + Icon( + Icons.chevron_right, + size: 14, + color: foreground, + ), + ], + ), + ), + ), + ], ], ) else @@ -240,8 +317,8 @@ class HomeSheetHeader extends StatelessWidget { children: [ _Metric( label: l10n.weatherPrecipitation, - // A missing reading is a dash, never a fabricated 0.0 — - // "no rain" and "no data" must not look the same. + // A missing reading is a dash, never a fabricated + // 0.0 — "no rain" and "no data" must not match. value: rain == null ? '—' : '${rain.toStringAsFixed(1)} mm', @@ -309,3 +386,17 @@ class _Metric extends StatelessWidget { ); } } + +/// Opens the map tab on the temperature layer, focused on [realtime]'s station — +/// the same nearest station the header's reading came from (resolved against the +/// township centre / GPS fix by the repository). The one-shot station hand-off +/// switches the overlay, frames the station, and opens its sheet. +void _openNearestStationOnMap(BuildContext context, WeatherRealtime realtime) { + context.read().request( + layerId: 'temperature', + stationId: realtime.id, + latitude: realtime.station.latitude, + longitude: realtime.station.longitude, + ); + context.goNamed(AppRoutes.map); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fd92bf70d..00bb2fee8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -638,6 +638,22 @@ "@weatherHumidity": { "description": "Label for the humidity metric in the home weather header" }, + "weatherDataTime": "{station} · Data {time}", + "@weatherDataTime": { + "description": "Nearest-station name and observation time shown as small text under the home weather header name", + "placeholders": { + "station": { + "type": "String" + }, + "time": { + "type": "String" + } + } + }, + "homeViewOnMap": "View on map", + "@homeViewOnMap": { + "description": "Small home-header link that opens the map tab on the temperature layer at the nearest station" + }, "homeForecastTitle": "24-hour forecast", "@homeForecastTitle": { "description": "Section title for the home sheet township hourly forecast" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index af8a747a5..d27e01f64 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -140,6 +140,8 @@ "regionCurrentUnavailable": "Hindi makuha ang kasalukuyang lokasyon", "weatherPrecipitation": "Pag-ulan", "weatherHumidity": "Halumigmig", + "weatherDataTime": "{station} · Oras ng datos {time}", + "homeViewOnMap": "Tingnan sa mapa", "homeForecastTitle": "24-oras na forecast", "homeForecastHighLow": "T {high}° · B {low}°", "homeForecastPop": "{pop}%", @@ -232,7 +234,6 @@ "mapTimelineObserved": "Naobserbahan", "mapTimelineForecast": "Pagtaya", "mapTimelineDataTime": "Oras ng data {time}", - "mapTimelineDataTime": "Oras ng data {time}", "notifySettingsMenu": "Mga setting ng notipikasyon", "notifyTitle": "Mga Notipikasyon", "notifyUnavailable": "Hindi pa handa ang push notifications — subukan muli mamaya.", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 0184ae547..3f9b27d6d 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -140,6 +140,8 @@ "regionCurrentUnavailable": "Tidak dapat memperoleh lokasi saat ini", "weatherPrecipitation": "Curah hujan", "weatherHumidity": "Kelembapan", + "weatherDataTime": "{station} · Waktu data {time}", + "homeViewOnMap": "Lihat di peta", "homeForecastTitle": "Prakiraan 24 jam", "homeForecastHighLow": "T {high}° · R {low}°", "homeForecastPop": "{pop}%", @@ -232,7 +234,6 @@ "mapTimelineObserved": "Diamati", "mapTimelineForecast": "Prakiraan", "mapTimelineDataTime": "Waktu data {time}", - "mapTimelineDataTime": "Waktu data {time}", "notifySettingsMenu": "Pengaturan notifikasi", "notifyTitle": "Notifikasi", "notifyUnavailable": "Notifikasi push belum siap — coba lagi sebentar lagi.", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index c0b869e12..5b2405303 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -140,6 +140,8 @@ "regionCurrentUnavailable": "現在地を取得できません", "weatherPrecipitation": "降水量", "weatherHumidity": "湿度", + "weatherDataTime": "{station} · データ時刻 {time}", + "homeViewOnMap": "地図で見る", "homeForecastTitle": "24時間予報", "homeForecastHighLow": "高 {high}° · 低 {low}°", "homeForecastPop": "{pop}%", @@ -232,7 +234,6 @@ "mapTimelineObserved": "観測", "mapTimelineForecast": "予報", "mapTimelineDataTime": "データ時刻 {time}", - "mapTimelineDataTime": "データ時刻 {time}", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "プッシュ通知はまだ準備できていません。しばらくしてから再度お試しください。", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index d093ffbdc..9a291b1fb 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -140,6 +140,8 @@ "regionCurrentUnavailable": "현재 위치를 가져올 수 없습니다", "weatherPrecipitation": "강수량", "weatherHumidity": "습도", + "weatherDataTime": "{station} · 데이터 시간 {time}", + "homeViewOnMap": "지도에서 보기", "homeForecastTitle": "24시간 예보", "homeForecastHighLow": "최고 {high}° · 최저 {low}°", "homeForecastPop": "{pop}%", @@ -232,7 +234,6 @@ "mapTimelineObserved": "관측", "mapTimelineForecast": "예보", "mapTimelineDataTime": "데이터 시간 {time}", - "mapTimelineDataTime": "자료 시간 {time}", "notifySettingsMenu": "알림 설정", "notifyTitle": "알림", "notifyUnavailable": "푸시 알림이 아직 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 08c59c276..d2edf23c3 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -140,6 +140,8 @@ "regionCurrentUnavailable": "ไม่สามารถระบุตำแหน่งปัจจุบันได้", "weatherPrecipitation": "ปริมาณน้ำฝน", "weatherHumidity": "ความชื้น", + "weatherDataTime": "{station} · เวลาข้อมูล {time}", + "homeViewOnMap": "ดูบนแผนที่", "homeForecastTitle": "พยากรณ์ 24 ชั่วโมง", "homeForecastHighLow": "สูง {high}° · ต่ำ {low}°", "homeForecastPop": "{pop}%", @@ -232,7 +234,6 @@ "mapTimelineObserved": "เวลาตรวจวัด", "mapTimelineForecast": "พยากรณ์", "mapTimelineDataTime": "เวลาข้อมูล {time}", - "mapTimelineDataTime": "เวลาข้อมูล {time}", "notifySettingsMenu": "การตั้งค่าการแจ้งเตือน", "notifyTitle": "การแจ้งเตือน", "notifyUnavailable": "การแจ้งเตือนแบบพุชยังไม่พร้อม — โปรดลองอีกครั้งในภายหลัง", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index e7a2538b9..f55220ea3 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -140,6 +140,8 @@ "regionCurrentUnavailable": "Không thể lấy vị trí hiện tại", "weatherPrecipitation": "Lượng mưa", "weatherHumidity": "Độ ẩm", + "weatherDataTime": "{station} · Thời gian dữ liệu {time}", + "homeViewOnMap": "Xem trên bản đồ", "homeForecastTitle": "Dự báo 24 giờ", "homeForecastHighLow": "Cao {high}° · Thấp {low}°", "homeForecastPop": "{pop}%", @@ -232,7 +234,6 @@ "mapTimelineObserved": "Quan trắc", "mapTimelineForecast": "Dự báo", "mapTimelineDataTime": "Thời gian dữ liệu {time}", - "mapTimelineDataTime": "Thời điểm dữ liệu {time}", "notifySettingsMenu": "Cài đặt thông báo", "notifyTitle": "Thông báo", "notifyUnavailable": "Thông báo đẩy chưa sẵn sàng — vui lòng thử lại sau giây lát.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ab9566b17..a77406fcd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -140,6 +140,8 @@ "regionCurrentUnavailable": "無法取得所在地位置資訊", "weatherPrecipitation": "降水量", "weatherHumidity": "濕度", + "weatherDataTime": "{station} ∙ 資料時間 {time}", + "homeViewOnMap": "前往地圖察看", "homeForecastTitle": "24小時預報", "homeForecastHighLow": "高 {high}° · 低 {low}°", "homeForecastPop": "{pop}%", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 3b5661bb4..509f4ed2c 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -140,6 +140,8 @@ "regionCurrentUnavailable": "无法获取所在地位置信息", "weatherPrecipitation": "降水量", "weatherHumidity": "湿度", + "weatherDataTime": "{station} ∙ 资料时间 {time}", + "homeViewOnMap": "前往地图察看", "homeForecastTitle": "24小时预报", "homeForecastHighLow": "高 {high}° · 低 {low}°", "homeForecastPop": "{pop}%", @@ -232,7 +234,6 @@ "mapTimelineObserved": "观测", "mapTimelineForecast": "预报", "mapTimelineDataTime": "资料时间 {time}", - "mapTimelineDataTime": "资料时间 {time}", "notifySettingsMenu": "通知设置", "notifyTitle": "通知", "notifyUnavailable": "推送通知尚未就绪,请稍后再试。", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index f522ea7c8..741d9dd43 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -140,6 +140,8 @@ "regionCurrentUnavailable": "無法取得所在地位置資訊", "weatherPrecipitation": "降水量", "weatherHumidity": "濕度", + "weatherDataTime": "{station} ∙ 資料時間 {time}", + "homeViewOnMap": "前往地圖察看", "homeForecastTitle": "24小時預報", "homeForecastHighLow": "高 {high}° · 低 {low}°", "homeForecastPop": "{pop}%", @@ -232,7 +234,6 @@ "mapTimelineObserved": "觀測", "mapTimelineForecast": "預報", "mapTimelineDataTime": "資料時間 {time}", - "mapTimelineDataTime": "資料時間 {time}", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "推送尚未就緒,請稍後再試。", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7c2d7cc3e..6fcc7014d 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -140,6 +140,8 @@ "regionCurrentUnavailable": "無法取得所在地位置資訊", "weatherPrecipitation": "降水量", "weatherHumidity": "濕度", + "weatherDataTime": "{station} ∙ 資料時間 {time}", + "homeViewOnMap": "前往地圖察看", "homeForecastTitle": "24小時預報", "homeForecastHighLow": "高 {high}° · 低 {low}°", "homeForecastPop": "{pop}%", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 51afffd59..60bdfc8bf 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -957,6 +957,18 @@ abstract class AppLocalizations { /// **'Humidity'** String get weatherHumidity; + /// Nearest-station name and observation time shown as small text under the home weather header name + /// + /// In en, this message translates to: + /// **'{station} · Data {time}'** + String weatherDataTime(String station, String time); + + /// Small home-header link that opens the map tab on the temperature layer at the nearest station + /// + /// In en, this message translates to: + /// **'View on map'** + String get homeViewOnMap; + /// Section title for the home sheet township hourly forecast /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 93e9b7ff4..d36f7045a 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -463,6 +463,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get weatherHumidity => 'Humidity'; + @override + String weatherDataTime(String station, String time) { + return '$station · Data $time'; + } + + @override + String get homeViewOnMap => 'View on map'; + @override String get homeForecastTitle => '24-hour forecast'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 5597c6670..6e7fbc932 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -467,6 +467,14 @@ class AppLocalizationsFil extends AppLocalizations { @override String get weatherHumidity => 'Halumigmig'; + @override + String weatherDataTime(String station, String time) { + return '$station · Oras ng datos $time'; + } + + @override + String get homeViewOnMap => 'Tingnan sa mapa'; + @override String get homeForecastTitle => '24-oras na forecast'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index f7f66edec..369569415 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -464,6 +464,14 @@ class AppLocalizationsId extends AppLocalizations { @override String get weatherHumidity => 'Kelembapan'; + @override + String weatherDataTime(String station, String time) { + return '$station · Waktu data $time'; + } + + @override + String get homeViewOnMap => 'Lihat di peta'; + @override String get homeForecastTitle => 'Prakiraan 24 jam'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 1613102fd..e601c2baa 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -461,6 +461,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String get weatherHumidity => '湿度'; + @override + String weatherDataTime(String station, String time) { + return '$station · データ時刻 $time'; + } + + @override + String get homeViewOnMap => '地図で見る'; + @override String get homeForecastTitle => '24時間予報'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 92da0d8a1..e61bc1b8e 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -461,6 +461,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String get weatherHumidity => '습도'; + @override + String weatherDataTime(String station, String time) { + return '$station · 데이터 시간 $time'; + } + + @override + String get homeViewOnMap => '지도에서 보기'; + @override String get homeForecastTitle => '24시간 예보'; @@ -756,7 +764,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String mapTimelineDataTime(String time) { - return '자료 시간 $time'; + return '데이터 시간 $time'; } @override diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index ba21b5d13..739877eaf 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -462,6 +462,14 @@ class AppLocalizationsTh extends AppLocalizations { @override String get weatherHumidity => 'ความชื้น'; + @override + String weatherDataTime(String station, String time) { + return '$station · เวลาข้อมูล $time'; + } + + @override + String get homeViewOnMap => 'ดูบนแผนที่'; + @override String get homeForecastTitle => 'พยากรณ์ 24 ชั่วโมง'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index b0273d988..41b9cc952 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -463,6 +463,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String get weatherHumidity => 'Độ ẩm'; + @override + String weatherDataTime(String station, String time) { + return '$station · Thời gian dữ liệu $time'; + } + + @override + String get homeViewOnMap => 'Xem trên bản đồ'; + @override String get homeForecastTitle => 'Dự báo 24 giờ'; @@ -768,7 +776,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String mapTimelineDataTime(String time) { - return 'Thời điểm dữ liệu $time'; + return 'Thời gian dữ liệu $time'; } @override diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index d05c52ec0..49dc73e28 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -460,6 +460,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String get weatherHumidity => '濕度'; + @override + String weatherDataTime(String station, String time) { + return '$station ∙ 資料時間 $time'; + } + + @override + String get homeViewOnMap => '前往地圖察看'; + @override String get homeForecastTitle => '24小時預報'; @@ -2213,6 +2221,14 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get weatherHumidity => '湿度'; + @override + String weatherDataTime(String station, String time) { + return '$station ∙ 资料时间 $time'; + } + + @override + String get homeViewOnMap => '前往地图察看'; + @override String get homeForecastTitle => '24小时预报'; @@ -3966,6 +3982,14 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get weatherHumidity => '濕度'; + @override + String weatherDataTime(String station, String time) { + return '$station ∙ 資料時間 $time'; + } + + @override + String get homeViewOnMap => '前往地圖察看'; + @override String get homeForecastTitle => '24小時預報'; @@ -5719,6 +5743,14 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get weatherHumidity => '濕度'; + @override + String weatherDataTime(String station, String time) { + return '$station ∙ 資料時間 $time'; + } + + @override + String get homeViewOnMap => '前往地圖察看'; + @override String get homeForecastTitle => '24小時預報'; diff --git a/test/features/home/presentation/widgets/home_sheet_header_test.dart b/test/features/home/presentation/widgets/home_sheet_header_test.dart new file mode 100644 index 000000000..38ae61769 --- /dev/null +++ b/test/features/home/presentation/widgets/home_sheet_header_test.dart @@ -0,0 +1,254 @@ +/// Verifies the home header's weather-readout gating: readings (and the +/// "view on map" link + station data time) show only while they belong to the +/// *selected* area. The controller holds a previous area's observation while +/// the new fetch runs — this is what must never appear as the new area's. +library; + +import 'dart:async'; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/geo/town.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/settings/prefs.dart'; +import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/features/home/presentation/home_weather_controller.dart'; +import 'package:dpip/features/home/presentation/widgets/home_sheet_header.dart'; +import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend_repository.dart'; +import 'package:dpip/features/weather/domain/weather_forecast.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_station_handoff.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _directory = TownDirectory({ + '100': Town( + code: '100', + city: '臺北市', + town: '北區', + lat: 25.0, + lng: 121.5, + cityLevel: '市', + townLevel: '區', + ), + '200': Town( + code: '200', + city: '高雄市', + town: '南區', + lat: 24.0, + lng: 120.5, + cityLevel: '市', + townLevel: '區', + ), +}); + +/// A [MeteorWeatherRepository] whose `realtime` results are completed by hand, +/// so a test can hold a fetch in flight while the area switches underneath it. +class _GatedWeatherRepository implements MeteorWeatherRepository { + final Map>> _gates = {}; + + /// Resolves the realtime fetch for the township centred at ([lat], [lng]). + void complete(double lat, double lng, WeatherRealtime value) { + _gates.remove('$lat,$lng')?.complete(Ok(value)); + } + + @override + Future> realtime(double lat, double lng) { + final gate = Completer>(); + _gates['$lat,$lng'] = gate; + return gate.future; + } + + @override + Future> forecast(String code) async => + Ok(WeatherForecast(updateTime: 0, forecast: const [])); + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +class _FakeHourTrendRepository implements RainHourTrendRepository { + @override + Future> hourTrend(String code) async => + Ok(RainHourTrend(startSecond: 0, mm: List.filled(60, 0))); +} + +WeatherRealtime _realtime(String station, double temp) => WeatherRealtime( + id: '467410', + station: WeatherRealtimeStation( + name: station, + latitude: 25.0, + longitude: 121.5, + altitude: 10, + distance: 1.0, + ), + time: 0, + data: WeatherRealtimeData( + weather: '晴', + weatherCode: 100, + temperature: temp, + humidity: 50, + rain: 0, + wind: WeatherWind(), + gust: WeatherWind(), + ), +); + +Future _store() async { + SharedPreferences.setMockInitialValues({ + 'home.savedRegionCodes': ['100', '200'], + }); + return RegionStore(Prefs(await SharedPreferences.getInstance())); +} + +/// Pumps the header with a router (the view-on-map link navigates by name) and +/// the home weather providers it reads. The controller is built over [repo], +/// whose realtime results the test completes by hand. [gpsFix] is optional — +/// a test that wants a slow/hung GPS read injects it here. +Widget _wrap( + RegionStore store, + _GatedWeatherRepository repo, + MapStationHandoff handoff, { + Future<({double lat, double lng})?> Function()? gpsFix, + bool expanded = false, +}) { + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: store), + Provider.value(value: _directory), + ChangeNotifierProvider.value(value: handoff), + ChangeNotifierProvider( + create: (_) => HomeWeatherController( + repo, + _FakeHourTrendRepository(), + store, + _directory, + gpsFix: gpsFix, + ), + ), + ], + child: Scaffold( + body: SingleChildScrollView( + child: HomeSheetHeader(expanded: expanded), + ), + ), + ), + ), + // The view-on-map link lands here; the stub exists so `goNamed` resolves. + GoRoute( + path: '/map', + name: 'map', + builder: (_, _) => const Scaffold(body: SizedBox()), + ), + ], + ); + return MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('zh', 'TW'), + routerConfig: router, + ); +} + +void main() { + testWidgets('collapsed: station data time shows, view-on-map link does not', ( + tester, + ) async { + final store = await _store() + ..select(2); // the '100' saved township + final repo = _GatedWeatherRepository(); + final handoff = MapStationHandoff(); + await tester.pumpWidget(_wrap(store, repo, handoff)); + + repo.complete(25.0, 121.5, _realtime('信義', 28.7)); + await tester.pumpAndSettle(); + + final l10n = await AppLocalizations.delegate.load(const Locale('zh', 'TW')); + expect(find.textContaining('信義'), findsOneWidget); + expect(find.text(l10n.homeViewOnMap), findsNothing); + }); + + testWidgets( + 'switching area hides the previous area reading until the new one lands', + (tester) async { + final store = await _store() + ..select(2); // '100' + final repo = _GatedWeatherRepository(); + final handoff = MapStationHandoff(); + await tester.pumpWidget(_wrap(store, repo, handoff)); + + repo.complete(25.0, 121.5, _realtime('信義', 28.7)); + await tester.pumpAndSettle(); + + // Switch to '200' while its fetch is still in flight: the header must not + // show the previous area's station — dashes until the new reading arrives. + store.next(); + await tester.pump(); + + expect(find.textContaining('信義'), findsNothing); + + repo.complete(24.0, 120.5, _realtime('鳳山', 31.2)); + await tester.pumpAndSettle(); + + expect(find.textContaining('鳳山'), findsOneWidget); + }, + ); + + testWidgets( + 'expanded: view-on-map shows below the metrics and queues a hand-off', + (tester) async { + final store = await _store() + ..select(2); // '100' + final repo = _GatedWeatherRepository(); + final handoff = MapStationHandoff(); + await tester.pumpWidget(_wrap(store, repo, handoff, expanded: true)); + + repo.complete(25.0, 121.5, _realtime('信義', 28.7)); + await tester.pumpAndSettle(); + + final l10n = await AppLocalizations.delegate.load( + const Locale('zh', 'TW'), + ); + expect(find.text(l10n.homeViewOnMap), findsOneWidget); + + await tester.tap(find.text(l10n.homeViewOnMap)); + await tester.pump(); + + final pending = handoff.takePending(); + expect(pending, isNotNull); + expect(pending!.layerId, 'temperature'); + expect(pending.stationId, '467410'); + }, + ); + + testWidgets('a GPS fix that never resolves does not hold up the reading', ( + tester, + ) async { + final store = await _store() + ..select(2); // '100' + final repo = _GatedWeatherRepository(); + final handoff = MapStationHandoff(); + // A hung GPS read (the live fix can sit in its 10s timeout window) is a + // debug-log-only dependency and must never block the sheet's data. + final hungFix = Completer<({double lat, double lng})?>(); + await tester.pumpWidget( + _wrap(store, repo, handoff, gpsFix: () => hungFix.future), + ); + + // Real data resolves instantly; the hung GPS fix is still outstanding. + repo.complete(25.0, 121.5, _realtime('信義', 28.7)); + await tester.pumpAndSettle(); + + expect(find.textContaining('信義'), findsOneWidget); + }); +} From 1fb5fcace1050cd5a7d568432001f4d7862b1c96 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Wed, 12 Aug 2026 03:45:35 +0800 Subject: [PATCH 2/6] fix: pad realtime station id to the directory key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The realtime endpoint trims the trailing 0 from a station code (C0X16, not C0X160), so opening that station's map sheet failed — the directory and the trend endpoint both key by the 6-char form. Normalise the id on decode so every consumer addresses the same key space. --- .../weather/domain/weather_realtime.dart | 14 ++++- .../domain/weather_realtime.freezed.dart | 26 +++++---- .../weather/domain/weather_realtime.g.dart | 2 +- .../widgets/home_sheet_header_test.dart | 47 ++++++++-------- .../weather/domain/weather_realtime_test.dart | 54 +++++++++++++++++++ 5 files changed, 109 insertions(+), 34 deletions(-) create mode 100644 test/features/weather/domain/weather_realtime_test.dart diff --git a/lib/features/weather/domain/weather_realtime.dart b/lib/features/weather/domain/weather_realtime.dart index fb048054f..9c4517e79 100644 --- a/lib/features/weather/domain/weather_realtime.dart +++ b/lib/features/weather/domain/weather_realtime.dart @@ -17,8 +17,11 @@ part 'weather_realtime.g.dart'; @freezed abstract class WeatherRealtime with _$WeatherRealtime { const factory WeatherRealtime({ - /// Full 6-char station code (the `/station` directory key). - required String id, + /// Full 6-char station code — the `/station` directory key. The API returns + /// the 5-char form (e.g. `C0X16`); it is padded to the directory's 6-char + /// key (`C0X160`) so the id matches `WeatherStation`'s key space wherever a + /// station sheet or `trend/{id}` is addressed by it. + @JsonKey(fromJson: WeatherRealtime._stationKey) required String id, required WeatherRealtimeStation station, /// Observation time, Unix seconds. @@ -28,6 +31,13 @@ abstract class WeatherRealtime with _$WeatherRealtime { factory WeatherRealtime.fromJson(Map json) => _$WeatherRealtimeFromJson(json); + + /// `/station` keys are six chars; the realtime endpoint trims the trailing + /// `0`. Padding restores the directory key so both stay the same key space. + static String _stationKey(Object? id) { + final s = id as String; + return s.length == 5 ? '${s}0' : s; + } } /// The resolved station's identity and its [distance] from the query point. diff --git a/lib/features/weather/domain/weather_realtime.freezed.dart b/lib/features/weather/domain/weather_realtime.freezed.dart index 49e068fba..e74d0a91b 100644 --- a/lib/features/weather/domain/weather_realtime.freezed.dart +++ b/lib/features/weather/domain/weather_realtime.freezed.dart @@ -15,8 +15,11 @@ T _$identity(T value) => value; /// @nodoc mixin _$WeatherRealtime { -/// Full 6-char station code (the `/station` directory key). - String get id; WeatherRealtimeStation get station;/// Observation time, Unix seconds. +/// Full 6-char station code — the `/station` directory key. The API returns +/// the 5-char form (e.g. `C0X16`); it is padded to the directory's 6-char +/// key (`C0X160`) so the id matches `WeatherStation`'s key space wherever a +/// station sheet or `trend/{id}` is addressed by it. +@JsonKey(fromJson: WeatherRealtime._stationKey) String get id; WeatherRealtimeStation get station;/// Observation time, Unix seconds. int get time; WeatherRealtimeData get data; /// Create a copy of WeatherRealtime /// with the given fields replaced by the non-null parameter values. @@ -50,7 +53,7 @@ abstract mixin class $WeatherRealtimeCopyWith<$Res> { factory $WeatherRealtimeCopyWith(WeatherRealtime value, $Res Function(WeatherRealtime) _then) = _$WeatherRealtimeCopyWithImpl; @useResult $Res call({ - String id, WeatherRealtimeStation station, int time, WeatherRealtimeData data +@JsonKey(fromJson: WeatherRealtime._stationKey) String id, WeatherRealtimeStation station, int time, WeatherRealtimeData data }); @@ -176,7 +179,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, WeatherRealtimeStation station, int time, WeatherRealtimeData data)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: WeatherRealtime._stationKey) String id, WeatherRealtimeStation station, int time, WeatherRealtimeData data)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _WeatherRealtime() when $default != null: return $default(_that.id,_that.station,_that.time,_that.data);case _: @@ -197,7 +200,7 @@ return $default(_that.id,_that.station,_that.time,_that.data);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, WeatherRealtimeStation station, int time, WeatherRealtimeData data) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: WeatherRealtime._stationKey) String id, WeatherRealtimeStation station, int time, WeatherRealtimeData data) $default,) {final _that = this; switch (_that) { case _WeatherRealtime(): return $default(_that.id,_that.station,_that.time,_that.data);case _: @@ -217,7 +220,7 @@ return $default(_that.id,_that.station,_that.time,_that.data);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, WeatherRealtimeStation station, int time, WeatherRealtimeData data)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: WeatherRealtime._stationKey) String id, WeatherRealtimeStation station, int time, WeatherRealtimeData data)? $default,) {final _that = this; switch (_that) { case _WeatherRealtime() when $default != null: return $default(_that.id,_that.station,_that.time,_that.data);case _: @@ -232,11 +235,14 @@ return $default(_that.id,_that.station,_that.time,_that.data);case _: @JsonSerializable() class _WeatherRealtime implements WeatherRealtime { - const _WeatherRealtime({required this.id, required this.station, required this.time, required this.data}); + const _WeatherRealtime({@JsonKey(fromJson: WeatherRealtime._stationKey) required this.id, required this.station, required this.time, required this.data}); factory _WeatherRealtime.fromJson(Map json) => _$WeatherRealtimeFromJson(json); -/// Full 6-char station code (the `/station` directory key). -@override final String id; +/// Full 6-char station code — the `/station` directory key. The API returns +/// the 5-char form (e.g. `C0X16`); it is padded to the directory's 6-char +/// key (`C0X160`) so the id matches `WeatherStation`'s key space wherever a +/// station sheet or `trend/{id}` is addressed by it. +@override@JsonKey(fromJson: WeatherRealtime._stationKey) final String id; @override final WeatherRealtimeStation station; /// Observation time, Unix seconds. @override final int time; @@ -275,7 +281,7 @@ abstract mixin class _$WeatherRealtimeCopyWith<$Res> implements $WeatherRealtime factory _$WeatherRealtimeCopyWith(_WeatherRealtime value, $Res Function(_WeatherRealtime) _then) = __$WeatherRealtimeCopyWithImpl; @override @useResult $Res call({ - String id, WeatherRealtimeStation station, int time, WeatherRealtimeData data +@JsonKey(fromJson: WeatherRealtime._stationKey) String id, WeatherRealtimeStation station, int time, WeatherRealtimeData data }); diff --git a/lib/features/weather/domain/weather_realtime.g.dart b/lib/features/weather/domain/weather_realtime.g.dart index e8d15b422..d056755ee 100644 --- a/lib/features/weather/domain/weather_realtime.g.dart +++ b/lib/features/weather/domain/weather_realtime.g.dart @@ -8,7 +8,7 @@ part of 'weather_realtime.dart'; _WeatherRealtime _$WeatherRealtimeFromJson(Map json) => _WeatherRealtime( - id: json['id'] as String, + id: WeatherRealtime._stationKey(json['id']), station: WeatherRealtimeStation.fromJson( json['station'] as Map, ), diff --git a/test/features/home/presentation/widgets/home_sheet_header_test.dart b/test/features/home/presentation/widgets/home_sheet_header_test.dart index 38ae61769..4d2631bf1 100644 --- a/test/features/home/presentation/widgets/home_sheet_header_test.dart +++ b/test/features/home/presentation/widgets/home_sheet_header_test.dart @@ -78,26 +78,29 @@ class _FakeHourTrendRepository implements RainHourTrendRepository { Ok(RainHourTrend(startSecond: 0, mm: List.filled(60, 0))); } -WeatherRealtime _realtime(String station, double temp) => WeatherRealtime( - id: '467410', - station: WeatherRealtimeStation( - name: station, - latitude: 25.0, - longitude: 121.5, - altitude: 10, - distance: 1.0, - ), - time: 0, - data: WeatherRealtimeData( - weather: '晴', - weatherCode: 100, - temperature: temp, - humidity: 50, - rain: 0, - wind: WeatherWind(), - gust: WeatherWind(), - ), -); +/// Built through `fromJson` so the 5-char realtime id exercises the same +/// directory-key padding the live API payload goes through. +WeatherRealtime _realtime(String station, double temp) => + WeatherRealtime.fromJson({ + 'id': 'C0X16', + 'station': { + 'name': station, + 'lat': 25.0, + 'lon': 121.5, + 'altitude': 10, + 'distance': 1.0, + }, + 'time': 0, + 'data': { + 'weather': '晴', + 'weatherCode': 100, + 'temperature': temp, + 'humidity': 50, + 'rain': 0, + 'wind': {'speed': 0.0, 'beaufort': 0}, + 'gust': {'speed': -99, 'beaufort': -99}, + }, + }); Future _store() async { SharedPreferences.setMockInitialValues({ @@ -227,7 +230,9 @@ void main() { final pending = handoff.takePending(); expect(pending, isNotNull); expect(pending!.layerId, 'temperature'); - expect(pending.stationId, '467410'); + // The 5-char realtime id is padded to the directory's 6-char key on + // decode — this is the id the map layer's station sheet can resolve. + expect(pending.stationId, 'C0X160'); }, ); diff --git a/test/features/weather/domain/weather_realtime_test.dart b/test/features/weather/domain/weather_realtime_test.dart new file mode 100644 index 000000000..95e1ea404 --- /dev/null +++ b/test/features/weather/domain/weather_realtime_test.dart @@ -0,0 +1,54 @@ +/// Decoding tests for the nearest-station realtime observation — chiefly the +/// id normalisation: the API returns the 5-char station code, which must pad +/// to the `/station` directory's 6-char key so the station sheet / `trend` +/// lookups address the same key space as every other weather source. +library; + +import 'package:dpip/features/weather/domain/weather_realtime.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('a 5-char realtime id pads to the 6-char station directory key', () { + final realtime = WeatherRealtime.fromJson({ + 'id': 'C0X16', + 'station': { + 'name': '仁德', + 'lat': 22.9683, + 'lon': 120.2577, + 'altitude': 26, + 'distance': 0.81, + }, + 'time': 0, + 'data': { + 'weather': '陰', + 'weatherCode': 300, + 'wind': {'speed': -99, 'beaufort': -99}, + 'gust': {'speed': -99, 'beaufort': -99}, + }, + }); + + expect(realtime.id, 'C0X160'); + }); + + test('an already-6-char id is left untouched', () { + final realtime = WeatherRealtime.fromJson({ + 'id': '467410', + 'station': { + 'name': '臺南', + 'lat': 23.0, + 'lon': 120.2, + 'altitude': 40, + 'distance': 1.2, + }, + 'time': 0, + 'data': { + 'weather': '晴', + 'weatherCode': 100, + 'wind': {'speed': -99, 'beaufort': -99}, + 'gust': {'speed': -99, 'beaufort': -99}, + }, + }); + + expect(realtime.id, '467410'); + }); +} From e5a80bc1a3c9381148a58320bb294dae88a46c7f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Wed, 12 Aug 2026 12:26:04 +0800 Subject: [PATCH 3/6] Basemap over the static CDN; terrain relief from ExpTech's terrain-RGB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Basemap vector tiles now come from static.lb.exptech.dev (still served through the same three-tier tile cache), and the terrain tiles join the base map as a raster-dem source with a translucent hillshade layer between the land fills and the borders. The terrain PNGs are Mapbox.com terrain-RGB (height = num/10 - 10000), an encoding MapLibre Native can't read — both its terrarium and mapbox decoders would turn Taiwan into a -32 km pit. A small codec rewrites each tile to terrarium before MapLibre ever sees it: the Dio interceptor and MapTileCache convert on write, and MapTileCache fetches cache-missing terrain itself (a native download would keep the unconverted bytes). Also pins image (pure Dart PNG codec) as a direct dependency. --- api.md | 19 +++ lib/bootstrap.dart | 14 ++- lib/core/network/api_client.dart | 6 +- lib/core/network/api_paths.dart | 4 + lib/core/network/etag_interceptor.dart | 9 +- lib/core/network/terrain_tile_codec.dart | 69 ++++++++++ lib/shared/map/base_map.dart | 1 + lib/shared/map/map_style.dart | 40 ++++-- lib/shared/map/map_tile_cache.dart | 118 +++++++++++++++--- pubspec.lock | 2 +- pubspec.yaml | 1 + test/core/network/etag_binary_test.dart | 2 +- test/core/network/etag_cache_store_test.dart | 8 +- test/core/network/etag_interceptor_test.dart | 2 +- .../core/network/terrain_tile_codec_test.dart | 80 ++++++++++++ test/shared/map/map_style_test.dart | 40 ++++++ test/shared/map/map_tile_cache_test.dart | 114 ++++++++++++++++- 17 files changed, 491 insertions(+), 38 deletions(-) create mode 100644 lib/core/network/terrain_tile_codec.dart create mode 100644 test/core/network/terrain_tile_codec_test.dart diff --git a/api.md b/api.md index 30de24a39..51504595f 100644 --- a/api.md +++ b/api.md @@ -49,6 +49,25 @@ ## 沒多活備援 (single host, no failover) +### Basemap / Terrain(全域 static LB,無區域) + +Basemap 與 terrain 都由 MapLibre 直接抓(app 的 tile bridge 會以 URL 為鍵快取), +不經 `ApiClient` 的區域 failover。 + +| 用途 | 路徑 | 主機 | +|---|---|---| +| basemap | `/api/v1/map/tiles/{z}/{x}/{y}.pbf` | `static.lb.exptech.dev` | +| terrain | `/api/v1/map/terrain/{z}/{x}/{y}.png` | `static.lb.exptech.dev` | + +> **Terrain 是 Mapbox.com terrain-RGB(非 MapLibre 編碼)。** 每個像素編碼 +> `height = (R·65536 + G·256 + B)/10 − 10000` 公尺,MapLibre Native 的兩種 DEM +> encoding(`terrarium`、自家的 `mapbox`)都讀不出來。App 在 tile 進 SQLite 前 +> 用 `core/network/terrain_tile_codec.dart` 轉成 **terrarium**(16-bit,1 m 步進), +> style 的 `raster-dem` source 以 `encoding: terrarium` 使用它,底圖疊上半透明的 +> `hillshade` layer 呈現立體感。轉換發生在 `EtagInterceptor` 與 `MapTileCache` 的 +> 寫入路徑,且 `MapTileCache` 對快取缺失的 terrain tile 自己抓取轉換(MapLibre +> 直接下載會拿到未轉換的原始編碼)。 + ### 雷達(v2)—— `core-tnn1` 時間清單是差量編碼的 Unix 秒(`[baseSec, Δ, …]`),在 API 主機上帶 ETag/304; diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 120cfe21d..aba473471 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -88,14 +88,20 @@ Future bootstrap() async { final defaultMapLayer = DefaultMapLayerController(prefs); final mapLayerOrder = MapLayerOrderController(prefs); final cache = await _openCache(); + final dio = createDio(etagCache: cache?.etag, usage: cache?.usage); + final apiClient = ApiClient(dio, regions); // MapLibre asks Dart for every ExpTech tile before it asks the network, so - // this must be bound before the first map is built. + // this must be bound before the first map is built. The fetcher lets the + // cache download terrain tiles itself — MapLibre's own fetch would keep the + // unconverted Mapbox.com encoding (see `terrain_tile_codec.dart`). final mapTileCache = cache == null ? null - : MapTileCache(cache.etag, usage: cache.usage); + : MapTileCache( + cache.etag, + usage: cache.usage, + fetcher: (url) async => (await apiClient.getBytesAbsolute(url)).bytes, + ); await mapTileCache?.install(); - final dio = createDio(etagCache: cache?.etag, usage: cache?.usage); - final apiClient = ApiClient(dio, regions); // Calibrated clock: real SNTP (flutter_ntp, ExpTech primary / Apple backup) // anchored to a monotonic clock, exposed globally via `AppTime` and resynced diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 4fa301479..a146c5f20 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -71,9 +71,9 @@ class ApiClient { /// Absolute-URL GET as bytes with ETag (no region failover). /// - /// For hosts already baked into the MapLibre style (basemap `lb.exptech.dev`, - /// glyphs CDN) that are not an [ApiTier]. Prefer [getBytes] for region-pinned - /// ExpTech paths. + /// For hosts already baked into the MapLibre style (basemap + /// `static.lb.exptech.dev`, glyphs CDN) that are not an [ApiTier]. Prefer + /// [getBytes] for region-pinned ExpTech paths. Future getBytesAbsolute( String url, { CancelToken? cancelToken, diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index a37a836f9..4c16846a7 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -18,6 +18,10 @@ abstract final class ApiPaths { /// v1 basemap vector tiles (`/api/v1/map/tiles/…`). static const String mapTilesV1 = '/api/v1/map/tiles/'; + /// v1 terrain vector tiles (`/api/v1/map/terrain/…`) — the static CDN's + /// elevation mesh, same XYZ shape as [mapTilesV1]. + static const String mapTerrainV1 = '/api/v1/map/terrain/'; + /// Live EEW feed (with `?sse=1&compress=1` for the stream). static const String eew = '/api/v2/eq/eew'; diff --git a/lib/core/network/etag_interceptor.dart b/lib/core/network/etag_interceptor.dart index 49e098ecd..69017068e 100644 --- a/lib/core/network/etag_interceptor.dart +++ b/lib/core/network/etag_interceptor.dart @@ -6,6 +6,7 @@ import 'package:dio/dio.dart'; import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; +import 'package:dpip/core/network/terrain_tile_codec.dart'; /// Dio interceptor implementing HTTP ETag revalidation against an /// [EtagCacheStore]. @@ -59,7 +60,7 @@ class EtagInterceptor extends Interceptor { /// Bare-host basemap vector tiles (no server ETag). static bool isBasemapPbf(Uri uri) => - uri.host == 'lb.exptech.dev' && + uri.host == 'static.lb.exptech.dev' && uri.path.contains(ApiPaths.mapTilesV1) && uri.path.endsWith('.pbf'); @@ -78,6 +79,7 @@ class EtagInterceptor extends Interceptor { /// app's usage accounting. static const List immutableAssetMarkers = [ ApiPaths.mapTilesV1, // basemap vector tiles + ApiPaths.mapTerrainV1, // terrain vector tiles '${ApiPaths.tiles}/radar/', '${ApiPaths.tiles}/satellite/', '${ApiPaths.tiles}/wind/', @@ -235,11 +237,14 @@ class EtagInterceptor extends Interceptor { if (etag != null && response.data != null) { if (binary) { final bytes = _asBytes(response.data); + final converted = isTerrainPng(options.uri) + ? ensureTerrarium(bytes) + : null; unawaited( _store.writeBytes( url, etag: etag, - bytes: bytes, + bytes: converted ?? bytes, contentType: response.headers.value(Headers.contentTypeHeader), size: down, ), diff --git a/lib/core/network/terrain_tile_codec.dart b/lib/core/network/terrain_tile_codec.dart new file mode 100644 index 000000000..807eabe39 --- /dev/null +++ b/lib/core/network/terrain_tile_codec.dart @@ -0,0 +1,69 @@ +/// Bridge between ExpTech's terrain-RGB PNGs and MapLibre's DEM decoders. +library; + +import 'dart:typed_data'; + +import 'package:dpip/core/network/api_paths.dart'; +import 'package:image/image.dart' as img; + +/// The tile authority answers every ExpTech tile (see [MapTileCache]), so this +/// file's [isTerrainPng] / [ensureTerrarium] are the single conversion point +/// shared by the Dio interceptor and the tile cache — a PNG that ever reaches +/// MapLibre has been rewritten once, here, and never anywhere else. +/// +/// ## Why a rewrite is needed +/// The `/api/v1/map/terrain/` tiles are **Mapbox.com terrain-RGB**: an 8-bit +/// RGB pixel encodes +/// `height = (R·65536 + G·256 + B)/10 − 10000` metres (0 → sea level). +/// MapLibre Native only speaks two DEM encodings — `terrarium` +/// (`R·256 + G + B/256 − 32768`) and its own `mapbox` +/// (`num/256 − 32768`) — so it would read every Taiwan tile as a −32 km pit. +/// Rewriting to **terrarium** (16-bit, 1 m steps — a half-metre rounding on the +/// source's 0.1 m precision, invisible at render scale; Taiwan's 0–3952 m +/// survives losslessly) makes the tiles render as elevation without touching +/// native code. + +/// Whether [uri] names an ExpTech terrain tile this app rewrites. +/// +/// Host + path + extension, so radar/satellite/DPM PNGs are untouched. +bool isTerrainPng(Uri uri) => + uri.host == 'static.lb.exptech.dev' && + uri.path.contains(ApiPaths.mapTerrainV1) && + uri.path.endsWith('.png'); + +/// Rewrites an ExpTech terrain-RGB PNG into MapLibre's terrarium encoding. +/// +/// Returns the converted bytes, or `null` when [png] is already terrarium (or +/// not decodable) — callers store the returned value if non-null, the input +/// otherwise, so the store converges on converted bytes and re-encoding never +/// repeats on a warm cache. +Uint8List? ensureTerrarium(Uint8List png) { + final image = img.decodePng(png); + if (image == null) return null; + final raw = image.getBytes(order: img.ChannelOrder.rgb); + // Terrarium sea level is R=128; Mapbox.com terrain-RGB sea is R≈0-1, and + // every land pixel stays below R≈3 (Taiwan tops out at 3952 m). A mean R + // below 64 is unambiguous. + var sum = 0; + var n = 0; + for (var i = 0; i < raw.length; i += 48) { + sum += raw[i]; + n++; + } + if (sum ~/ n >= 64) return null; + for (var i = 0; i < raw.length; i += 3) { + final num = (raw[i] << 16) | (raw[i + 1] << 8) | raw[i + 2]; + // t = (height + 32768); height = num/10 − 10000. + final t = (num + 227680) ~/ 10; + raw[i] = t >> 8; + raw[i + 1] = t & 0xFF; + raw[i + 2] = 0; + } + final out = img.Image.fromBytes( + width: image.width, + height: image.height, + bytes: raw.buffer, + numChannels: 3, + ); + return Uint8List.fromList(img.encodePng(out)); +} diff --git a/lib/shared/map/base_map.dart b/lib/shared/map/base_map.dart index 4d97fb8e3..7f65baecb 100644 --- a/lib/shared/map/base_map.dart +++ b/lib/shared/map/base_map.dart @@ -218,6 +218,7 @@ class _BaseMapState extends State { palette, basemapTileUrl: basemapOriginTileUrl, glyphsUrl: glyphsOriginUrl, + terrainTileUrl: terrainOriginTileUrl, ), // A remount gets a fresh id, so a collided first attempt recovers (see // [_scheduleReadinessRetry]). diff --git a/lib/shared/map/map_style.dart b/lib/shared/map/map_style.dart index 36155bd9a..74f4de814 100644 --- a/lib/shared/map/map_style.dart +++ b/lib/shared/map/map_style.dart @@ -99,11 +99,20 @@ const String dpmRestroomPointsLayerId = 'dpm-restroom-points'; const String dpmShelterSourceId = 'dpm-shelter-src'; const String dpmShelterPointsLayerId = 'dpm-shelter-points'; -/// Origin basemap XYZ (LB). Fetched by MapLibre, served from the app's tile -/// store through the Dart bridge, and warmed by `MapTileWarmer` — the same -/// three tiers as every other ExpTech tile. +/// Origin basemap XYZ (static LB CDN). Fetched by MapLibre, served from the +/// app's tile store through the Dart bridge, and warmed by `MapTileWarmer` — +/// the same three tiers as every other ExpTech tile. const String basemapOriginTileUrl = - 'https://lb.exptech.dev${ApiPaths.mapTilesV1}{z}/{x}/{y}.pbf'; + 'https://static.lb.exptech.dev${ApiPaths.mapTilesV1}{z}/{x}/{y}.pbf'; + +/// Origin terrain XYZ (static LB CDN) — the elevation mesh backing the +/// base map's hillshade relief. +/// +/// The tiles are **Mapbox.com terrain-RGB PNGs** (see `terrain_tile_codec.dart` +/// for the encoding rewrite that lets MapLibre read them) — never point a +/// `raster-dem` source at the raw server bytes. +const String terrainOriginTileUrl = + 'https://static.lb.exptech.dev${ApiPaths.mapTerrainV1}{z}/{x}/{y}.png'; /// Origin glyph template — MapLibre HTTPS. const String glyphsOriginUrl = @@ -119,11 +128,15 @@ const String glyphsOriginUrl = /// borders stay legible. /// /// [basemapTileUrl] / [glyphsUrl] are origin HTTPS templates fetched by -/// MapLibre and served from the app's tile store through the Dart bridge. +/// MapLibre and served from the app's tile store through the Dart bridge. When +/// [terrainTileUrl] is given, a `raster-dem` source (terrarium encoding — see +/// `terrain_tile_codec.dart`) and a translucent hillshade layer sit between the +/// land fills and the borders, giving the base map a shaded-relief depth. String exptechVectorStyle( MapPalette palette, { required String basemapTileUrl, required String glyphsUrl, + String? terrainTileUrl, }) { final background = palette.background; final fill = palette.fill; @@ -131,18 +144,31 @@ String exptechVectorStyle( final townOutline = palette.townOutline; final label = palette.label; final labelHalo = palette.labelHalo; + final terrain = terrainTileUrl == null + ? '' + : ''' + ,"terrain": { "type": "raster-dem", "tiles": ["$terrainTileUrl"], "encoding": "terrarium", "tileSize": 512, "minzoom": 7, "maxzoom": 12 }'''; + final hillshade = terrainTileUrl == null + ? '' + : ''' + ,{ "id": "terrain-hillshade", "type": "hillshade", "source": "terrain", "paint": { + "hillshade-exaggeration": 0.6, + "hillshade-highlight-color": "#FFFFFF", + "hillshade-shadow-color": "rgba(0, 0, 0, 0.5)", + "hillshade-accent-color": "rgba(0, 0, 0, 0.35)" + } }'''; return ''' { "version": 8, "glyphs": "$glyphsUrl", "sources": { - "exptech": { "type": "vector", "tiles": ["$basemapTileUrl"], "maxzoom": 12 } + "exptech": { "type": "vector", "tiles": ["$basemapTileUrl"], "maxzoom": 12 }$terrain }, "layers": [ { "id": "bg", "type": "background", "paint": { "background-color": "$background" } }, { "id": "$landLayerId", "type": "fill", "source": "exptech", "source-layer": "global", "paint": { "fill-color": "$fill" } }, { "id": "county", "type": "fill", "source": "exptech", "source-layer": "city", "paint": { "fill-color": "$fill" } }, - { "id": "town", "type": "fill", "source": "exptech", "source-layer": "town", "paint": { "fill-color": "$fill" } }, + { "id": "town", "type": "fill", "source": "exptech", "source-layer": "town", "paint": { "fill-color": "$fill" } }$hillshade, { "id": "$townOutlineLayerId", "type": "line", "source": "exptech", "source-layer": "town", "paint": { "line-color": "$townOutline", "line-width": 0.4, "line-opacity": 0.7 } }, { "id": "$outlineLayerId", "type": "line", "source": "exptech", "source-layer": "city", "paint": { "line-color": "$outline", "line-width": 1.0 } }, { "id": "$townLabelLayerId", "type": "symbol", "source": "exptech", "source-layer": "town", "minzoom": $townLabelMinZoom, "layout": { diff --git a/lib/shared/map/map_tile_cache.dart b/lib/shared/map/map_tile_cache.dart index 96c48faa4..50dfb8f17 100644 --- a/lib/shared/map/map_tile_cache.dart +++ b/lib/shared/map/map_tile_cache.dart @@ -9,6 +9,7 @@ import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/etag_interceptor.dart'; import 'package:dpip/core/network/network_usage_store.dart'; +import 'package:dpip/core/network/terrain_tile_codec.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; /// Owns tile bytes for MapLibre: SQLite persistence, traffic metering, and the @@ -40,12 +41,27 @@ class MapTileCache { // // Not an initializing formal: Dart has no private *named* parameter, and the // field must stay private. - // ignore: prefer_initializing_formals - MapTileCache(this._store, {NetworkUsageStore? usage}) : _usage = usage; + MapTileCache( + this._store, { + NetworkUsageStore? usage, + Future Function(String url)? fetcher, + // ignore: prefer_initializing_formals + }) : _usage = usage, + // ignore: prefer_initializing_formals + _fetcher = fetcher; final EtagCacheStore _store; + // Not initializing formals: Dart has no private *named* parameter, and these + // fields must stay private. + // ignore: prefer_initializing_formals final NetworkUsageStore? _usage; + /// Fetches a tile body the store doesn't hold yet — the app must be the one + /// to download terrain tiles, because MapLibre's own fetch would keep the + /// unconverted Mapbox.com encoding (see [_terrainOr]). + // ignore: prefer_initializing_formals + final Future Function(String url)? _fetcher; + /// Native's in-process mirror budget. /// /// This tier is a staging buffer for the warm path, not a second copy of the @@ -81,21 +97,89 @@ class MapTileCache { } /// Native asked for tile bodies — answer the ones we hold. + /// + /// Terrain tiles get extra handling: stored bytes may still be the unconverted + /// Mapbox.com encoding (they arrived before [ensureTerrarium] could rewrite + /// them), and a tile the store doesn't hold yet is fetched by the app so + /// MapLibre never downloads — and renders — the wrong encoding. Every other + /// tile keeps the store-miss → native-download path. Future> _onGetBatch(List urls) async { final wanted = urls.where(_isTile).toList(growable: false); if (wanted.isEmpty) return const []; // Hit metering lives inside [EtagCacheStore.readBytesBatch] — never // double-count these serves here. final hits = await _store.readBytesBatch(wanted); - return [ - for (final entry in hits.entries) - MapLibreTile( - url: entry.key, - data: entry.value.bytes, - contentType: entry.value.contentType, - etag: entry.value.etag, + final served = {}; + for (final entry in hits.entries) { + final converted = _terrainOr(entry.key, entry.value.bytes); + served[entry.key] = MapLibreTile( + url: entry.key, + data: converted, + contentType: entry.value.contentType, + etag: entry.value.etag, + ); + } + await _fetchTerrainMisses(wanted, served); + return served.values.toList(); + } + + /// Rewrites [url]'s stored bytes to terrarium when they're a raw terrain PNG, + /// persisting the rewrite so the store converges on converted bytes. Returns + /// the bytes MapLibre should receive. + Uint8List _terrainOr(String url, Uint8List bytes) { + final uri = Uri.tryParse(url); + if (uri == null || !isTerrainPng(uri)) return bytes; + final converted = ensureTerrarium(bytes); + if (converted == null) return bytes; + unawaited( + _store.writeBytesBatch([ + ( + url: url, + etag: EtagInterceptor.etagFromUrl(uri), + bytes: converted, + contentType: 'image/png', + size: converted.length, ), - ]; + ]), + ); + return converted; + } + + /// Downloads terrain tiles [wanted] doesn't hold, converting them before + /// serving — the one place the app fetches a tile MapLibre asked about. + Future _fetchTerrainMisses( + List wanted, + Map served, + ) async { + final fetcher = _fetcher; + if (fetcher == null) return; + for (final url in wanted) { + if (served.containsKey(url)) continue; + final uri = Uri.tryParse(url); + if (uri == null || !isTerrainPng(uri)) continue; + try { + final bytes = await fetcher(url); + if (bytes == null || bytes.isEmpty) continue; + final converted = ensureTerrarium(bytes) ?? bytes; + await _store.writeBytesBatch([ + ( + url: url, + etag: EtagInterceptor.etagFromUrl(uri), + bytes: converted, + contentType: 'image/png', + size: converted.length, + ), + ]); + served[url] = MapLibreTile( + url: url, + data: converted, + contentType: 'image/png', + etag: EtagInterceptor.etagFromUrl(uri), + ); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'MapTileCache terrain fetch'); + } + } } /// Native downloaded tiles — persist and meter them. @@ -110,16 +194,19 @@ class MapTileCache { // else — a glyph range that momentarily failed, say — persisting // emptiness would serve a blank asset for the next seven days. if (tile.data.isEmpty && !EtagInterceptor.isBasemapPbf(uri)) continue; + final bytes = isTerrainPng(uri) + ? ensureTerrarium(tile.data) ?? tile.data + : tile.data; writes.add(( url: tile.url, // The URL is content-addressed, so the synthetic tag is the right key — // a new frame is a new URL, never a revalidation of this one. etag: EtagInterceptor.etagFromUrl(uri), - bytes: tile.data, + bytes: bytes, contentType: tile.contentType, - size: tile.data.length, + size: bytes.length, )); - downloaded += tile.data.length; + downloaded += bytes.length; } if (writes.isEmpty) return; await _store.writeBytesBatch(writes); @@ -183,13 +270,14 @@ class MapTileCache { Future put(String url, Uint8List bytes, {String? contentType}) async { final uri = Uri.tryParse(url); if (uri == null || !EtagInterceptor.isImmutableTile(uri)) return; + final stored = isTerrainPng(uri) ? ensureTerrarium(bytes) ?? bytes : bytes; await _store.writeBytesBatch([ ( url: url, etag: EtagInterceptor.etagFromUrl(uri), - bytes: bytes, + bytes: stored, contentType: contentType, - size: bytes.length, + size: stored.length, ), ]); } diff --git a/pubspec.lock b/pubspec.lock index 29e4f24ba..21978d396 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -518,7 +518,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" diff --git a/pubspec.yaml b/pubspec.yaml index b5aa9db13..def8401c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: sqflite: ^2.4.3 talker_flutter: ^5.1.9 url_launcher: ^6.3.2 + image: ^4.9.1 dev_dependencies: flutter_test: diff --git a/test/core/network/etag_binary_test.dart b/test/core/network/etag_binary_test.dart index 0f2f06d7c..c621938e1 100644 --- a/test/core/network/etag_binary_test.dart +++ b/test/core/network/etag_binary_test.dart @@ -175,7 +175,7 @@ void main() { final payload = Uint8List.fromList([0x1a, 0x2b, 0x3c]); final adapter = _BinaryAdapter(bytes: payload); // no server etag final dio = createDio(etagCache: store)..httpClientAdapter = adapter; - const url = 'https://lb.exptech.dev/api/v1/map/tiles/7/109/55.pbf'; + const url = 'https://static.lb.exptech.dev/api/v1/map/tiles/7/109/55.pbf'; final expectedEtag = EtagInterceptor.etagFromUrl(Uri.parse(url)); final first = await dio.get>( diff --git a/test/core/network/etag_cache_store_test.dart b/test/core/network/etag_cache_store_test.dart index 7832b1ea8..1db364a30 100644 --- a/test/core/network/etag_cache_store_test.dart +++ b/test/core/network/etag_cache_store_test.dart @@ -134,7 +134,7 @@ void main() { ...List.filled(600, 0x41), ]); await store.writeBytes( - 'https://lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf', + 'https://static.lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf', etag: 'W/"u1"', bytes: pbf, contentType: 'application/octet-stream', @@ -143,7 +143,9 @@ void main() { 'http_cache', columns: ['kind', 'body'], where: 'key = ?', - whereArgs: ['https://lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf'], + whereArgs: [ + 'https://static.lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf', + ], ); expect(pbfRows.first['kind'], EtagCacheStore.kindBinaryGzip); @@ -151,7 +153,7 @@ void main() { expect((await cold.readBytes('https://x/a.mvt'))!.bytes, mvt); expect( (await cold.readBytes( - 'https://lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf', + 'https://static.lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf', ))!.bytes, pbf, ); diff --git a/test/core/network/etag_interceptor_test.dart b/test/core/network/etag_interceptor_test.dart index 91e9f2693..c3c49f987 100644 --- a/test/core/network/etag_interceptor_test.dart +++ b/test/core/network/etag_interceptor_test.dart @@ -140,7 +140,7 @@ void main() { }); test('basemap PBF 404 is cached as empty and served locally', () async { - const url = 'https://lb.exptech.dev/api/v1/map/tiles/7/114/56.pbf'; + const url = 'https://static.lb.exptech.dev/api/v1/map/tiles/7/114/56.pbf'; final adapter = _StatusAdapter(404); final dio = dioWith(adapter); diff --git a/test/core/network/terrain_tile_codec_test.dart b/test/core/network/terrain_tile_codec_test.dart new file mode 100644 index 000000000..f3e07398f --- /dev/null +++ b/test/core/network/terrain_tile_codec_test.dart @@ -0,0 +1,80 @@ +import 'dart:typed_data'; + +import 'package:dpip/core/network/terrain_tile_codec.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; + +/// Builds a Mapbox.com terrain-RGB PNG from [heights] (row-major metres). +/// Encoding: `num = (h + 10000) * 10`, stored as R·65536 + G·256 + B. +Uint8List _mapboxComPng(List heights) { + final width = 4; + final image = img.Image(width: width, height: heights.length ~/ width); + for (var i = 0; i < heights.length; i++) { + final num = ((heights[i] + 10000) * 10).round(); + final x = i % width; + final y = i ~/ width; + image.setPixelRgb(x, y, (num >> 16) & 0xFF, (num >> 8) & 0xFF, num & 0xFF); + } + return Uint8List.fromList(img.encodePng(image)); +} + +double _decodeTerrarium(Uint8List png, int x, int y) { + final image = img.decodePng(png)!; + final pixel = image.getPixel(x, y); + return pixel.r.toDouble() * 256 + pixel.g - 32768; +} + +void main() { + test('isTerrainPng matches the terrain endpoint only', () { + expect( + isTerrainPng( + Uri.parse( + 'https://static.lb.exptech.dev/api/v1/map/terrain/7/107/55.png', + ), + ), + isTrue, + ); + expect( + isTerrainPng( + Uri.parse( + 'https://static.lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf', + ), + ), + isFalse, + reason: 'basemap vector tiles are not DEM data', + ); + expect( + isTerrainPng( + Uri.parse( + 'https://static.core-tnn1.exptech.dev/api/v2/tiles/radar/0/1/1.webp', + ), + ), + isFalse, + ); + }); + + test( + 'a Mapbox.com terrain-RGB PNG converts to terrarium with height preserved', + () { + const heights = [-100.0, 0.0, 500.5, 3952.0]; + final converted = ensureTerrarium(_mapboxComPng(heights)); + + expect(converted, isNotNull, reason: 'raw terrain-RGB must be rewritten'); + for (var i = 0; i < heights.length; i++) { + final got = _decodeTerrarium(converted!, i % 4, i ~/ 4); + // Terrarium is 16-bit (1 m steps) against the source's 0.1 m — a + // half-metre rounding is the encoding's floor, not a bug. + expect(got, closeTo(heights[i], 1.0), reason: 'pixel $i'); + } + }, + ); + + test('an already-terrarium PNG is left untouched (null)', () { + final once = ensureTerrarium(_mapboxComPng(const [0, 100, 1000, 3000]))!; + expect( + ensureTerrarium(once), + isNull, + reason: 'converted bytes must not re-encode', + ); + }); +} diff --git a/test/shared/map/map_style_test.dart b/test/shared/map/map_style_test.dart index f10e6b649..c72aabbc4 100644 --- a/test/shared/map/map_style_test.dart +++ b/test/shared/map/map_style_test.dart @@ -28,6 +28,46 @@ void main() { ]); }); + test( + 'terrain adds a terrarium raster-dem source and hillshade between fills and borders', + () { + final style = + jsonDecode( + exptechVectorStyle( + MapColors.dark, + basemapTileUrl: 'https://example.com/{z}/{x}/{y}.pbf', + glyphsUrl: 'https://example.com/{fontstack}/{range}.pbf', + terrainTileUrl: + 'https://static.lb.exptech.dev/api/v1/map/terrain/{z}/{x}/{y}.png', + ), + ) + as Map; + + final terrain = style['sources']['terrain'] as Map; + expect(terrain['type'], 'raster-dem'); + expect(terrain['encoding'], 'terrarium'); + expect(terrain['tileSize'], 512); + expect( + terrain['minzoom'], + 7, + reason: 'the server 204s below z7 — don\'t ask', + ); + + final layers = style['layers'] as List; + final ids = [for (final l in layers) (l as Map)['id']]; + expect(ids, [ + 'bg', + 'land', + 'county', + 'town', + 'terrain-hillshade', + 'town-outline', + 'county-outline', + 'town-label', + ]); + }, + ); + group('town-label layer', () { Map townLabel() { final style = diff --git a/test/shared/map/map_tile_cache_test.dart b/test/shared/map/map_tile_cache_test.dart index 930ea8878..6e87d2f59 100644 --- a/test/shared/map/map_tile_cache_test.dart +++ b/test/shared/map/map_tile_cache_test.dart @@ -1,11 +1,46 @@ import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/etag_interceptor.dart'; import 'package:dpip/core/network/network_usage_store.dart'; +import 'package:dpip/core/network/terrain_tile_codec.dart'; import 'package:dpip/shared/map/map_tile_cache.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +const terrainUrl = + 'https://static.lb.exptech.dev/api/v1/map/terrain/7/107/55.png'; + +/// A Mapbox.com terrain-RGB PNG: sea (0 m) and 1000 m pixels. +Uint8List _mapboxComPng() { + final image = img.Image(width: 2, height: 2); + for (var i = 0; i < 4; i++) { + final num = ((i < 2 ? 0 : 1000) + 10000) * 10; + image.setPixelRgb( + i % 2, + i ~/ 2, + (num >> 16) & 0xFF, + (num >> 8) & 0xFF, + num & 0xFF, + ); + } + return Uint8List.fromList(img.encodePng(image)); +} + +/// Mean R channel — Mapbox.com terrain-RGB sits near 1, terrarium ≥ 128. +int _meanR(Uint8List png) { + final image = img.decodePng(png)!; + var sum = 0; + var n = 0; + for (var y = 0; y < image.height; y++) { + for (var x = 0; x < image.width; x++) { + sum += image.getPixel(x, y).r.toInt(); + n++; + } + } + return sum ~/ n; +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -142,7 +177,7 @@ void main() { test('an empty body is kept for the basemap but dropped elsewhere', () async { await cache.install(); - const hole = 'https://lb.exptech.dev/api/v1/map/tiles/7/1/2.pbf'; + const hole = 'https://static.lb.exptech.dev/api/v1/map/tiles/7/1/2.pbf'; const glyph = 'https://cdn.jsdelivr.net/gh/exptechtw/map-assets/Noto/0-255.pbf'; @@ -169,4 +204,81 @@ void main() { reason: 'caching a momentary glyph failure would blank labels for a week', ); }); + + test( + 'a raw terrain tile from native is converted before it is stored', + () async { + await cache.install(); + final raw = _mapboxComPng(); + + await fromNative('putBatch', { + 'entries': [ + {'url': terrainUrl, 'data': raw, 'contentType': 'image/png'}, + ], + }); + + final stored = await store.readBytes(terrainUrl); + expect(stored, isNotNull); + expect( + _meanR(stored!.bytes), + greaterThanOrEqualTo(64), + reason: 'the store must hold terrarium, never the raw Mapbox.com bytes', + ); + }, + ); + + test( + 'a missed terrain tile is fetched by the app and converted before serving', + () async { + final raw = _mapboxComPng(); + final fetched = []; + final caching = MapTileCache( + store, + fetcher: (url) async { + fetched.add(url); + return raw; + }, + ); + await caching.install(); + + final served = + await fromNative('getBatch', { + 'urls': [terrainUrl], + }) + as Map; + expect(fetched, [ + terrainUrl, + ], reason: 'the app must fetch — not MapLibre'); + final data = (served[terrainUrl] as Map)['data'] as Uint8List; + expect( + _meanR(data), + greaterThanOrEqualTo(64), + reason: 'MapLibre must never render the unconverted encoding', + ); + final stored = await store.readBytes(terrainUrl); + expect(_meanR(stored!.bytes), greaterThanOrEqualTo(64)); + }, + ); + + test('already-converted terrain is served without re-encoding', () async { + await cache.install(); + final converted = ensureTerrarium(_mapboxComPng())!; + await store.writeBytes( + terrainUrl, + etag: EtagInterceptor.etagFromUrl(Uri.parse(terrainUrl)), + bytes: converted, + contentType: 'image/png', + ); + + final served = + await fromNative('getBatch', { + 'urls': [terrainUrl], + }) + as Map; + expect( + (served[terrainUrl] as Map)['data'], + converted, + reason: 'a warm cache must not pay a decode/re-encode round-trip', + ); + }); } From c44bf04ff4f9a574b45c399bde056029634aa0e1 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Wed, 12 Aug 2026 14:10:50 +0800 Subject: [PATCH 4/6] terrain: decode ExpTech terrain-RGB natively, drop the codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terrain tiles are Mapbox.com terrain-RGB — height = (R·65536 + G·256 + B)/10 − 10000 — which is exactly what MapLibre's `encoding: 'mapbox'` decodes, so the whole app-side rewrite to terrarium was wrong (see satellite-tiles-go/web). The raster-dem source now declares encoding mapbox, tileSize 512, zoom 0–12, and bounds [110, 10, 132, 35] — deliberately overshooting the DEM bbox so the hillshade edge never meets the plain background on screen. Hillshade follows the reference tuning (illumination 335°, exaggeration 0.3). Deleted terrain_tile_codec.dart and the conversion paths in EtagInterceptor and MapTileCache; MapTileCache no longer needs a fetcher. image is back to transitive. --- api.md | 15 ++- lib/bootstrap.dart | 10 +- lib/core/network/etag_interceptor.dart | 6 +- lib/core/network/terrain_tile_codec.dart | 69 ----------- lib/shared/map/map_style.dart | 29 +++-- lib/shared/map/map_tile_cache.dart | 93 ++------------ pubspec.lock | 2 +- pubspec.yaml | 1 - .../core/network/terrain_tile_codec_test.dart | 80 ------------ test/shared/map/map_style_test.dart | 29 ++++- test/shared/map/map_tile_cache_test.dart | 115 +++--------------- 11 files changed, 73 insertions(+), 376 deletions(-) delete mode 100644 lib/core/network/terrain_tile_codec.dart delete mode 100644 test/core/network/terrain_tile_codec_test.dart diff --git a/api.md b/api.md index 51504595f..63e2cb1cc 100644 --- a/api.md +++ b/api.md @@ -59,14 +59,13 @@ Basemap 與 terrain 都由 MapLibre 直接抓(app 的 tile bridge 會以 URL | basemap | `/api/v1/map/tiles/{z}/{x}/{y}.pbf` | `static.lb.exptech.dev` | | terrain | `/api/v1/map/terrain/{z}/{x}/{y}.png` | `static.lb.exptech.dev` | -> **Terrain 是 Mapbox.com terrain-RGB(非 MapLibre 編碼)。** 每個像素編碼 -> `height = (R·65536 + G·256 + B)/10 − 10000` 公尺,MapLibre Native 的兩種 DEM -> encoding(`terrarium`、自家的 `mapbox`)都讀不出來。App 在 tile 進 SQLite 前 -> 用 `core/network/terrain_tile_codec.dart` 轉成 **terrarium**(16-bit,1 m 步進), -> style 的 `raster-dem` source 以 `encoding: terrarium` 使用它,底圖疊上半透明的 -> `hillshade` layer 呈現立體感。轉換發生在 `EtagInterceptor` 與 `MapTileCache` 的 -> 寫入路徑,且 `MapTileCache` 對快取缺失的 terrain tile 自己抓取轉換(MapLibre -> 直接下載會拿到未轉換的原始編碼)。 +> **Terrain 是 Mapbox terrain-RGB,MapLibre 原生讀得懂。** 每個像素編碼 +> `height = (R·65536 + G·256 + B)/10 − 10000` 公尺,正是 MapLibre +> `raster-dem` 的 `encoding: 'mapbox'` —— style 直接以該 encoding 使用原始 +> PNG,**不需要任何 app 端轉換**(參照 `satellite-tiles-go/web` 的底圖處理)。 +> 底圖以 `encoding: 'mapbox'`、`tileSize: 512`、`bounds: [110, 10, 132, 35]` +> 註冊 `raster-dem` source,疊半透明 `hillshade` layer 呈現立體感;`bounds` +> 刻意大於真實 DEM bbox,讓 hillshade 邊緣永遠不會在畫面上碰到純背景。 ### 雷達(v2)—— `core-tnn1` diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index aba473471..e3b9415b7 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -91,16 +91,10 @@ Future bootstrap() async { final dio = createDio(etagCache: cache?.etag, usage: cache?.usage); final apiClient = ApiClient(dio, regions); // MapLibre asks Dart for every ExpTech tile before it asks the network, so - // this must be bound before the first map is built. The fetcher lets the - // cache download terrain tiles itself — MapLibre's own fetch would keep the - // unconverted Mapbox.com encoding (see `terrain_tile_codec.dart`). + // this must be bound before the first map is built. final mapTileCache = cache == null ? null - : MapTileCache( - cache.etag, - usage: cache.usage, - fetcher: (url) async => (await apiClient.getBytesAbsolute(url)).bytes, - ); + : MapTileCache(cache.etag, usage: cache.usage); await mapTileCache?.install(); // Calibrated clock: real SNTP (flutter_ntp, ExpTech primary / Apple backup) diff --git a/lib/core/network/etag_interceptor.dart b/lib/core/network/etag_interceptor.dart index 69017068e..eeb3fd83d 100644 --- a/lib/core/network/etag_interceptor.dart +++ b/lib/core/network/etag_interceptor.dart @@ -6,7 +6,6 @@ import 'package:dio/dio.dart'; import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; -import 'package:dpip/core/network/terrain_tile_codec.dart'; /// Dio interceptor implementing HTTP ETag revalidation against an /// [EtagCacheStore]. @@ -237,14 +236,11 @@ class EtagInterceptor extends Interceptor { if (etag != null && response.data != null) { if (binary) { final bytes = _asBytes(response.data); - final converted = isTerrainPng(options.uri) - ? ensureTerrarium(bytes) - : null; unawaited( _store.writeBytes( url, etag: etag, - bytes: converted ?? bytes, + bytes: bytes, contentType: response.headers.value(Headers.contentTypeHeader), size: down, ), diff --git a/lib/core/network/terrain_tile_codec.dart b/lib/core/network/terrain_tile_codec.dart deleted file mode 100644 index 807eabe39..000000000 --- a/lib/core/network/terrain_tile_codec.dart +++ /dev/null @@ -1,69 +0,0 @@ -/// Bridge between ExpTech's terrain-RGB PNGs and MapLibre's DEM decoders. -library; - -import 'dart:typed_data'; - -import 'package:dpip/core/network/api_paths.dart'; -import 'package:image/image.dart' as img; - -/// The tile authority answers every ExpTech tile (see [MapTileCache]), so this -/// file's [isTerrainPng] / [ensureTerrarium] are the single conversion point -/// shared by the Dio interceptor and the tile cache — a PNG that ever reaches -/// MapLibre has been rewritten once, here, and never anywhere else. -/// -/// ## Why a rewrite is needed -/// The `/api/v1/map/terrain/` tiles are **Mapbox.com terrain-RGB**: an 8-bit -/// RGB pixel encodes -/// `height = (R·65536 + G·256 + B)/10 − 10000` metres (0 → sea level). -/// MapLibre Native only speaks two DEM encodings — `terrarium` -/// (`R·256 + G + B/256 − 32768`) and its own `mapbox` -/// (`num/256 − 32768`) — so it would read every Taiwan tile as a −32 km pit. -/// Rewriting to **terrarium** (16-bit, 1 m steps — a half-metre rounding on the -/// source's 0.1 m precision, invisible at render scale; Taiwan's 0–3952 m -/// survives losslessly) makes the tiles render as elevation without touching -/// native code. - -/// Whether [uri] names an ExpTech terrain tile this app rewrites. -/// -/// Host + path + extension, so radar/satellite/DPM PNGs are untouched. -bool isTerrainPng(Uri uri) => - uri.host == 'static.lb.exptech.dev' && - uri.path.contains(ApiPaths.mapTerrainV1) && - uri.path.endsWith('.png'); - -/// Rewrites an ExpTech terrain-RGB PNG into MapLibre's terrarium encoding. -/// -/// Returns the converted bytes, or `null` when [png] is already terrarium (or -/// not decodable) — callers store the returned value if non-null, the input -/// otherwise, so the store converges on converted bytes and re-encoding never -/// repeats on a warm cache. -Uint8List? ensureTerrarium(Uint8List png) { - final image = img.decodePng(png); - if (image == null) return null; - final raw = image.getBytes(order: img.ChannelOrder.rgb); - // Terrarium sea level is R=128; Mapbox.com terrain-RGB sea is R≈0-1, and - // every land pixel stays below R≈3 (Taiwan tops out at 3952 m). A mean R - // below 64 is unambiguous. - var sum = 0; - var n = 0; - for (var i = 0; i < raw.length; i += 48) { - sum += raw[i]; - n++; - } - if (sum ~/ n >= 64) return null; - for (var i = 0; i < raw.length; i += 3) { - final num = (raw[i] << 16) | (raw[i + 1] << 8) | raw[i + 2]; - // t = (height + 32768); height = num/10 − 10000. - final t = (num + 227680) ~/ 10; - raw[i] = t >> 8; - raw[i + 1] = t & 0xFF; - raw[i + 2] = 0; - } - final out = img.Image.fromBytes( - width: image.width, - height: image.height, - bytes: raw.buffer, - numChannels: 3, - ); - return Uint8List.fromList(img.encodePng(out)); -} diff --git a/lib/shared/map/map_style.dart b/lib/shared/map/map_style.dart index 74f4de814..d08e16a49 100644 --- a/lib/shared/map/map_style.dart +++ b/lib/shared/map/map_style.dart @@ -108,9 +108,8 @@ const String basemapOriginTileUrl = /// Origin terrain XYZ (static LB CDN) — the elevation mesh backing the /// base map's hillshade relief. /// -/// The tiles are **Mapbox.com terrain-RGB PNGs** (see `terrain_tile_codec.dart` -/// for the encoding rewrite that lets MapLibre read them) — never point a -/// `raster-dem` source at the raw server bytes. +/// The tiles are **Mapbox.com terrain-RGB PNGs**, which MapLibre's +/// `encoding: 'mapbox'` decodes natively — no app-side rewrite. const String terrainOriginTileUrl = 'https://static.lb.exptech.dev${ApiPaths.mapTerrainV1}{z}/{x}/{y}.png'; @@ -118,6 +117,11 @@ const String terrainOriginTileUrl = const String glyphsOriginUrl = 'https://cdn.jsdelivr.net/gh/exptechtw/map-assets/{fontstack}/{range}.pbf'; +/// Id of the hillshade layer the base style bakes when [terrainTileUrl] is +/// given — [MapScaffold] toggles its visibility for the "terrain relief" +/// switch, so the id must be stable across style reloads. +const String terrainHillshadeLayerId = 'terrain-hillshade'; + /// Builds the ExpTech vector base-map style as a MapLibre style JSON string. /// /// Pass [MapColors.of] for the active brightness — never ad-hoc hexes. The base @@ -129,9 +133,12 @@ const String glyphsOriginUrl = /// /// [basemapTileUrl] / [glyphsUrl] are origin HTTPS templates fetched by /// MapLibre and served from the app's tile store through the Dart bridge. When -/// [terrainTileUrl] is given, a `raster-dem` source (terrarium encoding — see -/// `terrain_tile_codec.dart`) and a translucent hillshade layer sit between the -/// land fills and the borders, giving the base map a shaded-relief depth. +/// [terrainTileUrl] is given, a `raster-dem` source (`encoding: 'mapbox'` — the +/// tiles are natively Mapbox terrain-RGB) and a translucent hillshade layer sit +/// between the land fills and the borders, giving the base map a shaded-relief +/// depth. The `bounds` deliberately overshoot the DEM's own bbox (≈120–122°E, +/// 21.9–25.3°N): a hillshade edge on a plain background reads as a line, and +/// pushing the boundary past anywhere the user can pan hides it. String exptechVectorStyle( MapPalette palette, { required String basemapTileUrl, @@ -147,15 +154,13 @@ String exptechVectorStyle( final terrain = terrainTileUrl == null ? '' : ''' - ,"terrain": { "type": "raster-dem", "tiles": ["$terrainTileUrl"], "encoding": "terrarium", "tileSize": 512, "minzoom": 7, "maxzoom": 12 }'''; + ,"terrain": { "type": "raster-dem", "tiles": ["$terrainTileUrl"], "encoding": "mapbox", "tileSize": 512, "minzoom": 0, "maxzoom": 12, "bounds": [110, 10, 132, 35] }'''; final hillshade = terrainTileUrl == null ? '' : ''' - ,{ "id": "terrain-hillshade", "type": "hillshade", "source": "terrain", "paint": { - "hillshade-exaggeration": 0.6, - "hillshade-highlight-color": "#FFFFFF", - "hillshade-shadow-color": "rgba(0, 0, 0, 0.5)", - "hillshade-accent-color": "rgba(0, 0, 0, 0.35)" + ,{ "id": "$terrainHillshadeLayerId", "type": "hillshade", "source": "terrain", "paint": { + "hillshade-illumination-direction": 335, + "hillshade-exaggeration": 0.3 } }'''; return ''' { diff --git a/lib/shared/map/map_tile_cache.dart b/lib/shared/map/map_tile_cache.dart index 50dfb8f17..6215f1c46 100644 --- a/lib/shared/map/map_tile_cache.dart +++ b/lib/shared/map/map_tile_cache.dart @@ -9,7 +9,6 @@ import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/etag_interceptor.dart'; import 'package:dpip/core/network/network_usage_store.dart'; -import 'package:dpip/core/network/terrain_tile_codec.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; /// Owns tile bytes for MapLibre: SQLite persistence, traffic metering, and the @@ -44,11 +43,8 @@ class MapTileCache { MapTileCache( this._store, { NetworkUsageStore? usage, - Future Function(String url)? fetcher, // ignore: prefer_initializing_formals - }) : _usage = usage, - // ignore: prefer_initializing_formals - _fetcher = fetcher; + }) : _usage = usage; final EtagCacheStore _store; // Not initializing formals: Dart has no private *named* parameter, and these @@ -56,12 +52,6 @@ class MapTileCache { // ignore: prefer_initializing_formals final NetworkUsageStore? _usage; - /// Fetches a tile body the store doesn't hold yet — the app must be the one - /// to download terrain tiles, because MapLibre's own fetch would keep the - /// unconverted Mapbox.com encoding (see [_terrainOr]). - // ignore: prefer_initializing_formals - final Future Function(String url)? _fetcher; - /// Native's in-process mirror budget. /// /// This tier is a staging buffer for the warm path, not a second copy of the @@ -96,13 +86,8 @@ class MapTileCache { await setMapLibreTileMemoryLimit(memoryBytes); } - /// Native asked for tile bodies — answer the ones we hold. - /// - /// Terrain tiles get extra handling: stored bytes may still be the unconverted - /// Mapbox.com encoding (they arrived before [ensureTerrarium] could rewrite - /// them), and a tile the store doesn't hold yet is fetched by the app so - /// MapLibre never downloads — and renders — the wrong encoding. Every other - /// tile keeps the store-miss → native-download path. + /// Native asked for tile bodies — answer the ones we hold; a store miss + /// keeps the native-download path. Future> _onGetBatch(List urls) async { final wanted = urls.where(_isTile).toList(growable: false); if (wanted.isEmpty) return const []; @@ -111,77 +96,16 @@ class MapTileCache { final hits = await _store.readBytesBatch(wanted); final served = {}; for (final entry in hits.entries) { - final converted = _terrainOr(entry.key, entry.value.bytes); served[entry.key] = MapLibreTile( url: entry.key, - data: converted, + data: entry.value.bytes, contentType: entry.value.contentType, etag: entry.value.etag, ); } - await _fetchTerrainMisses(wanted, served); return served.values.toList(); } - /// Rewrites [url]'s stored bytes to terrarium when they're a raw terrain PNG, - /// persisting the rewrite so the store converges on converted bytes. Returns - /// the bytes MapLibre should receive. - Uint8List _terrainOr(String url, Uint8List bytes) { - final uri = Uri.tryParse(url); - if (uri == null || !isTerrainPng(uri)) return bytes; - final converted = ensureTerrarium(bytes); - if (converted == null) return bytes; - unawaited( - _store.writeBytesBatch([ - ( - url: url, - etag: EtagInterceptor.etagFromUrl(uri), - bytes: converted, - contentType: 'image/png', - size: converted.length, - ), - ]), - ); - return converted; - } - - /// Downloads terrain tiles [wanted] doesn't hold, converting them before - /// serving — the one place the app fetches a tile MapLibre asked about. - Future _fetchTerrainMisses( - List wanted, - Map served, - ) async { - final fetcher = _fetcher; - if (fetcher == null) return; - for (final url in wanted) { - if (served.containsKey(url)) continue; - final uri = Uri.tryParse(url); - if (uri == null || !isTerrainPng(uri)) continue; - try { - final bytes = await fetcher(url); - if (bytes == null || bytes.isEmpty) continue; - final converted = ensureTerrarium(bytes) ?? bytes; - await _store.writeBytesBatch([ - ( - url: url, - etag: EtagInterceptor.etagFromUrl(uri), - bytes: converted, - contentType: 'image/png', - size: converted.length, - ), - ]); - served[url] = MapLibreTile( - url: url, - data: converted, - contentType: 'image/png', - etag: EtagInterceptor.etagFromUrl(uri), - ); - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'MapTileCache terrain fetch'); - } - } - } - /// Native downloaded tiles — persist and meter them. Future _onPutBatch(List tiles) async { final writes = []; @@ -194,9 +118,7 @@ class MapTileCache { // else — a glyph range that momentarily failed, say — persisting // emptiness would serve a blank asset for the next seven days. if (tile.data.isEmpty && !EtagInterceptor.isBasemapPbf(uri)) continue; - final bytes = isTerrainPng(uri) - ? ensureTerrarium(tile.data) ?? tile.data - : tile.data; + final bytes = tile.data; writes.add(( url: tile.url, // The URL is content-addressed, so the synthetic tag is the right key — @@ -270,14 +192,13 @@ class MapTileCache { Future put(String url, Uint8List bytes, {String? contentType}) async { final uri = Uri.tryParse(url); if (uri == null || !EtagInterceptor.isImmutableTile(uri)) return; - final stored = isTerrainPng(uri) ? ensureTerrarium(bytes) ?? bytes : bytes; await _store.writeBytesBatch([ ( url: url, etag: EtagInterceptor.etagFromUrl(uri), - bytes: stored, + bytes: bytes, contentType: contentType, - size: stored.length, + size: bytes.length, ), ]); } diff --git a/pubspec.lock b/pubspec.lock index 21978d396..29e4f24ba 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -518,7 +518,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: "direct main" + dependency: transitive description: name: image sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" diff --git a/pubspec.yaml b/pubspec.yaml index def8401c2..b5aa9db13 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,6 @@ dependencies: sqflite: ^2.4.3 talker_flutter: ^5.1.9 url_launcher: ^6.3.2 - image: ^4.9.1 dev_dependencies: flutter_test: diff --git a/test/core/network/terrain_tile_codec_test.dart b/test/core/network/terrain_tile_codec_test.dart deleted file mode 100644 index f3e07398f..000000000 --- a/test/core/network/terrain_tile_codec_test.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:typed_data'; - -import 'package:dpip/core/network/terrain_tile_codec.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:image/image.dart' as img; - -/// Builds a Mapbox.com terrain-RGB PNG from [heights] (row-major metres). -/// Encoding: `num = (h + 10000) * 10`, stored as R·65536 + G·256 + B. -Uint8List _mapboxComPng(List heights) { - final width = 4; - final image = img.Image(width: width, height: heights.length ~/ width); - for (var i = 0; i < heights.length; i++) { - final num = ((heights[i] + 10000) * 10).round(); - final x = i % width; - final y = i ~/ width; - image.setPixelRgb(x, y, (num >> 16) & 0xFF, (num >> 8) & 0xFF, num & 0xFF); - } - return Uint8List.fromList(img.encodePng(image)); -} - -double _decodeTerrarium(Uint8List png, int x, int y) { - final image = img.decodePng(png)!; - final pixel = image.getPixel(x, y); - return pixel.r.toDouble() * 256 + pixel.g - 32768; -} - -void main() { - test('isTerrainPng matches the terrain endpoint only', () { - expect( - isTerrainPng( - Uri.parse( - 'https://static.lb.exptech.dev/api/v1/map/terrain/7/107/55.png', - ), - ), - isTrue, - ); - expect( - isTerrainPng( - Uri.parse( - 'https://static.lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf', - ), - ), - isFalse, - reason: 'basemap vector tiles are not DEM data', - ); - expect( - isTerrainPng( - Uri.parse( - 'https://static.core-tnn1.exptech.dev/api/v2/tiles/radar/0/1/1.webp', - ), - ), - isFalse, - ); - }); - - test( - 'a Mapbox.com terrain-RGB PNG converts to terrarium with height preserved', - () { - const heights = [-100.0, 0.0, 500.5, 3952.0]; - final converted = ensureTerrarium(_mapboxComPng(heights)); - - expect(converted, isNotNull, reason: 'raw terrain-RGB must be rewritten'); - for (var i = 0; i < heights.length; i++) { - final got = _decodeTerrarium(converted!, i % 4, i ~/ 4); - // Terrarium is 16-bit (1 m steps) against the source's 0.1 m — a - // half-metre rounding is the encoding's floor, not a bug. - expect(got, closeTo(heights[i], 1.0), reason: 'pixel $i'); - } - }, - ); - - test('an already-terrarium PNG is left untouched (null)', () { - final once = ensureTerrarium(_mapboxComPng(const [0, 100, 1000, 3000]))!; - expect( - ensureTerrarium(once), - isNull, - reason: 'converted bytes must not re-encode', - ); - }); -} diff --git a/test/shared/map/map_style_test.dart b/test/shared/map/map_style_test.dart index c72aabbc4..4ae8baa63 100644 --- a/test/shared/map/map_style_test.dart +++ b/test/shared/map/map_style_test.dart @@ -29,7 +29,7 @@ void main() { }); test( - 'terrain adds a terrarium raster-dem source and hillshade between fills and borders', + 'terrain adds a mapbox-encoded raster-dem source and hillshade between fills and borders', () { final style = jsonDecode( @@ -45,14 +45,31 @@ void main() { final terrain = style['sources']['terrain'] as Map; expect(terrain['type'], 'raster-dem'); - expect(terrain['encoding'], 'terrarium'); + expect( + terrain['encoding'], + 'mapbox', + reason: + 'the server tiles are Mapbox terrain-RGB — MapLibre decodes them ' + 'natively, no app-side rewrite (see satellite-tiles-go/web)', + ); expect(terrain['tileSize'], 512); + expect(terrain['minzoom'], 0); + expect(terrain['maxzoom'], 12); expect( - terrain['minzoom'], - 7, - reason: 'the server 204s below z7 — don\'t ask', + terrain['bounds'], + [110, 10, 132, 35], + reason: + 'the bounds must overshoot the DEM bbox so the hillshade edge ' + 'never meets the plain background on screen', ); + final hillshade = (style['layers'] as List) + .cast>() + .firstWhere((l) => l['id'] == terrainHillshadeLayerId); + final paint = hillshade['paint'] as Map; + expect(paint['hillshade-illumination-direction'], 335); + expect(paint['hillshade-exaggeration'], 0.3); + final layers = style['layers'] as List; final ids = [for (final l in layers) (l as Map)['id']]; expect(ids, [ @@ -60,7 +77,7 @@ void main() { 'land', 'county', 'town', - 'terrain-hillshade', + terrainHillshadeLayerId, 'town-outline', 'county-outline', 'town-label', diff --git a/test/shared/map/map_tile_cache_test.dart b/test/shared/map/map_tile_cache_test.dart index 6e87d2f59..e8dba2140 100644 --- a/test/shared/map/map_tile_cache_test.dart +++ b/test/shared/map/map_tile_cache_test.dart @@ -1,46 +1,14 @@ import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/etag_interceptor.dart'; import 'package:dpip/core/network/network_usage_store.dart'; -import 'package:dpip/core/network/terrain_tile_codec.dart'; import 'package:dpip/shared/map/map_tile_cache.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:image/image.dart' as img; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const terrainUrl = 'https://static.lb.exptech.dev/api/v1/map/terrain/7/107/55.png'; -/// A Mapbox.com terrain-RGB PNG: sea (0 m) and 1000 m pixels. -Uint8List _mapboxComPng() { - final image = img.Image(width: 2, height: 2); - for (var i = 0; i < 4; i++) { - final num = ((i < 2 ? 0 : 1000) + 10000) * 10; - image.setPixelRgb( - i % 2, - i ~/ 2, - (num >> 16) & 0xFF, - (num >> 8) & 0xFF, - num & 0xFF, - ); - } - return Uint8List.fromList(img.encodePng(image)); -} - -/// Mean R channel — Mapbox.com terrain-RGB sits near 1, terrarium ≥ 128. -int _meanR(Uint8List png) { - final image = img.decodePng(png)!; - var sum = 0; - var n = 0; - for (var y = 0; y < image.height; y++) { - for (var x = 0; x < image.width; x++) { - sum += image.getPixel(x, y).r.toInt(); - n++; - } - } - return sum ~/ n; -} - void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -205,80 +173,27 @@ void main() { ); }); - test( - 'a raw terrain tile from native is converted before it is stored', - () async { - await cache.install(); - final raw = _mapboxComPng(); - - await fromNative('putBatch', { - 'entries': [ - {'url': terrainUrl, 'data': raw, 'contentType': 'image/png'}, - ], - }); - - final stored = await store.readBytes(terrainUrl); - expect(stored, isNotNull); - expect( - _meanR(stored!.bytes), - greaterThanOrEqualTo(64), - reason: 'the store must hold terrarium, never the raw Mapbox.com bytes', - ); - }, - ); - - test( - 'a missed terrain tile is fetched by the app and converted before serving', - () async { - final raw = _mapboxComPng(); - final fetched = []; - final caching = MapTileCache( - store, - fetcher: (url) async { - fetched.add(url); - return raw; - }, - ); - await caching.install(); - - final served = - await fromNative('getBatch', { - 'urls': [terrainUrl], - }) - as Map; - expect(fetched, [ - terrainUrl, - ], reason: 'the app must fetch — not MapLibre'); - final data = (served[terrainUrl] as Map)['data'] as Uint8List; - expect( - _meanR(data), - greaterThanOrEqualTo(64), - reason: 'MapLibre must never render the unconverted encoding', - ); - final stored = await store.readBytes(terrainUrl); - expect(_meanR(stored!.bytes), greaterThanOrEqualTo(64)); - }, - ); - - test('already-converted terrain is served without re-encoding', () async { + test('a terrain tile is stored and served byte-for-byte', () async { await cache.install(); - final converted = ensureTerrarium(_mapboxComPng())!; - await store.writeBytes( - terrainUrl, - etag: EtagInterceptor.etagFromUrl(Uri.parse(terrainUrl)), - bytes: converted, - contentType: 'image/png', - ); + // Arbitrary PNG bytes — the encoding is MapLibre's job now (`encoding: + // 'mapbox'` decodes the server's terrain-RGB natively), so the store must + // never rewrite them. + final bytes = Uint8List.fromList([9, 8, 7, 6, 5]); + + await fromNative('putBatch', { + 'entries': [ + {'url': terrainUrl, 'data': bytes, 'contentType': 'image/png'}, + ], + }); + final stored = await store.readBytes(terrainUrl); + expect(stored, isNotNull); + expect(stored!.bytes, bytes); final served = await fromNative('getBatch', { 'urls': [terrainUrl], }) as Map; - expect( - (served[terrainUrl] as Map)['data'], - converted, - reason: 'a warm cache must not pay a decode/re-encode round-trip', - ); + expect((served[terrainUrl] as Map)['data'], bytes); }); } From 37230e11efef8ac9a8858763b1811cf81ffdd8fa Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Wed, 12 Aug 2026 14:11:03 +0800 Subject: [PATCH 5/6] map: terrain-relief toggle in every layer's settings menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a shared MapTerrainRow (relief checkbox) that slots into every overlay menu alongside the township-label row. MapTownLabelsMenu becomes MapBasemapMenu holding both toggles, and the chip turns active when either is on. MapScaffold owns the ValueNotifier and calls setLayerVisibility on the stable terrain-hillshade id — re-applied after style reloads so the preference survives them. MapLayer.buildTopTrailingChrome threads the toggle through each layer so the state lives in exactly one place. --- .../layers/disaster_map_layer.dart | 4 + .../presentation/layers/qpesums_layer.dart | 4 + .../map/presentation/layers/radar_layer.dart | 4 + .../map/presentation/layers/rain_layer.dart | 9 +- .../presentation/layers/satellite_layer.dart | 6 ++ .../presentation/layers/typhoon_layer.dart | 4 + .../layers/wind_forecast_layer.dart | 4 + .../widgets/disaster_map_overlay_menu.dart | 14 +++- .../widgets/forecast_overlay_menu.dart | 19 ++++- .../widgets/radar_overlay_menu.dart | 7 ++ .../widgets/satellite_style_menu.dart | 35 +++++++- .../widgets/scan_range_overlay_menu.dart | 20 ++++- .../widgets/typhoon_overlay_menu.dart | 13 +++ lib/l10n/app_en.arb | 9 ++ lib/l10n/app_fil.arb | 3 + lib/l10n/app_id.arb | 3 + lib/l10n/app_ja.arb | 3 + lib/l10n/app_ko.arb | 3 + lib/l10n/app_th.arb | 3 + lib/l10n/app_vi.arb | 3 + lib/l10n/app_zh.arb | 3 + lib/l10n/app_zh_Hans.arb | 3 + lib/l10n/app_zh_Hant_HK.arb | 3 + lib/l10n/app_zh_TW.arb | 3 + lib/l10n/gen/app_localizations.dart | 12 +++ lib/l10n/gen/app_localizations_en.dart | 7 ++ lib/l10n/gen/app_localizations_fil.dart | 6 ++ lib/l10n/gen/app_localizations_id.dart | 6 ++ lib/l10n/gen/app_localizations_ja.dart | 6 ++ lib/l10n/gen/app_localizations_ko.dart | 6 ++ lib/l10n/gen/app_localizations_th.dart | 6 ++ lib/l10n/gen/app_localizations_vi.dart | 6 ++ lib/l10n/gen/app_localizations_zh.dart | 24 ++++++ lib/shared/map/map_layer.dart | 13 ++- lib/shared/map/map_scaffold.dart | 37 ++++++++- lib/shared/map/map_terrain_toggle.dart | 46 +++++++++++ lib/shared/map/map_town_labels.dart | 26 ++++-- lib/shared/map/raster_timeline_layer.dart | 2 + .../features/map/radar_overlay_menu_test.dart | 34 +++++++- .../map/satellite_style_menu_test.dart | 10 +++ .../map/typhoon_overlay_menu_test.dart | 2 + .../map/wind_forecast_layer_test.dart | 2 + test/shared/map/map_town_labels_test.dart | 82 ++++++++++++++----- 43 files changed, 471 insertions(+), 44 deletions(-) create mode 100644 lib/shared/map/map_terrain_toggle.dart diff --git a/lib/features/map/presentation/layers/disaster_map_layer.dart b/lib/features/map/presentation/layers/disaster_map_layer.dart index 6ff2ef4ee..743d7bdc8 100644 --- a/lib/features/map/presentation/layers/disaster_map_layer.dart +++ b/lib/features/map/presentation/layers/disaster_map_layer.dart @@ -611,11 +611,15 @@ class DisasterMapLayer with MapLayerDefaults implements MapLayer { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required ValueListenable showTerrain, + required ValueChanged onShowTerrainChanged, required Future Function() onReloadActive, }) => DisasterMapOverlayMenu( layer: this, showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, ); @override diff --git a/lib/features/map/presentation/layers/qpesums_layer.dart b/lib/features/map/presentation/layers/qpesums_layer.dart index 4ed332edb..b18b6101b 100644 --- a/lib/features/map/presentation/layers/qpesums_layer.dart +++ b/lib/features/map/presentation/layers/qpesums_layer.dart @@ -70,12 +70,16 @@ class QpesumsMapLayer extends RasterTimelineLayer BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required ValueListenable showTerrain, + required ValueChanged onShowTerrainChanged, required Future Function() onReloadActive, }) => ScanRangeOverlayMenu( layer: this, tooltip: AppLocalizations.of(context).qpesumsOverlayMenuTooltip, showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, ); @override diff --git a/lib/features/map/presentation/layers/radar_layer.dart b/lib/features/map/presentation/layers/radar_layer.dart index 62c9c6db2..7b641198b 100644 --- a/lib/features/map/presentation/layers/radar_layer.dart +++ b/lib/features/map/presentation/layers/radar_layer.dart @@ -49,11 +49,15 @@ class RadarMapLayer extends RasterTimelineLayer BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required ValueListenable showTerrain, + required ValueChanged onShowTerrainChanged, required Future Function() onReloadActive, }) => RadarOverlayMenu( layer: this, showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, ); @override diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index e9f7fe6f8..4c43e9020 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -7,6 +7,7 @@ import 'package:dpip/features/weather/domain/rain_interval.dart'; import 'package:dpip/features/weather/domain/rain_snapshot.dart'; import 'package:dpip/features/weather/domain/rain_trend.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_terrain_toggle.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:dpip/shared/widgets/section_header.dart'; @@ -138,12 +139,14 @@ class RainMapLayer BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required ValueListenable showTerrain, + required ValueChanged onShowTerrainChanged, required Future Function() onReloadActive, }) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).colorScheme; return ListenableBuilder( - listenable: Listenable.merge([interval, showTownLabels]), + listenable: Listenable.merge([interval, showTownLabels, showTerrain]), builder: (context, _) { final current = interval.value; return MenuAnchor( @@ -176,6 +179,10 @@ class RainMapLayer showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, ), + MapTerrainRow( + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, + ), ], ), ], diff --git a/lib/features/map/presentation/layers/satellite_layer.dart b/lib/features/map/presentation/layers/satellite_layer.dart index 2670317e6..da6888410 100644 --- a/lib/features/map/presentation/layers/satellite_layer.dart +++ b/lib/features/map/presentation/layers/satellite_layer.dart @@ -133,6 +133,8 @@ class SatelliteMapLayer extends RasterTimelineLayer { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required ValueListenable showTerrain, + required ValueChanged onShowTerrainChanged, required Future Function() onReloadActive, }) { if (channel.isBand && channel.isThermal) { @@ -141,12 +143,16 @@ class SatelliteMapLayer extends RasterTimelineLayer { onReloadActive: onReloadActive, showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, ); } return SatelliteReferenceMenu( layer: this, showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, ); } diff --git a/lib/features/map/presentation/layers/typhoon_layer.dart b/lib/features/map/presentation/layers/typhoon_layer.dart index 8d1f98a76..bcf72e5a6 100644 --- a/lib/features/map/presentation/layers/typhoon_layer.dart +++ b/lib/features/map/presentation/layers/typhoon_layer.dart @@ -1124,11 +1124,15 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required ValueListenable showTerrain, + required ValueChanged onShowTerrainChanged, required Future Function() onReloadActive, }) => TyphoonOverlayMenu( layer: this, showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, ); @override diff --git a/lib/features/map/presentation/layers/wind_forecast_layer.dart b/lib/features/map/presentation/layers/wind_forecast_layer.dart index 3cd6576df..5364b931c 100644 --- a/lib/features/map/presentation/layers/wind_forecast_layer.dart +++ b/lib/features/map/presentation/layers/wind_forecast_layer.dart @@ -151,11 +151,15 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required ValueListenable showTerrain, + required ValueChanged onShowTerrainChanged, required Future Function() onReloadActive, }) => ForecastOverlayMenu( layer: this, showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, ); @override diff --git a/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart b/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart index 701b27a6b..93565da52 100644 --- a/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart @@ -6,6 +6,7 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/features/map/presentation/layers/disaster_map_layer.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/color_hex.dart'; +import 'package:dpip/shared/map/map_terrain_toggle.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:dpip/shared/widgets/section_header.dart'; @@ -19,12 +20,17 @@ class DisasterMapOverlayMenu extends StatelessWidget { required this.layer, required this.showTownLabels, required this.onShowTownLabelsChanged, + required this.showTerrain, + required this.onShowTerrainChanged, }); final DisasterMapLayer layer; final ValueListenable showTownLabels; final ValueChanged onShowTownLabelsChanged; + final ValueListenable showTerrain; + final ValueChanged onShowTerrainChanged; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -32,12 +38,14 @@ class DisasterMapOverlayMenu extends StatelessWidget { listenable: Listenable.merge([ for (final s in layer.subLayers) s.visible, showTownLabels, + showTerrain, ]), builder: (context, _) { // Highlight the chip when the default set is altered (a layer off). final active = layer.subLayers.any((s) => !s.visible.value) || - !showTownLabels.value; + !showTownLabels.value || + !showTerrain.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), style: MapChipButton.menuStyle(context), @@ -75,6 +83,10 @@ class DisasterMapOverlayMenu extends StatelessWidget { showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, ), + MapTerrainRow( + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, + ), ], ), ], diff --git a/lib/features/map/presentation/widgets/forecast_overlay_menu.dart b/lib/features/map/presentation/widgets/forecast_overlay_menu.dart index 14951208b..0662d21dc 100644 --- a/lib/features/map/presentation/widgets/forecast_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/forecast_overlay_menu.dart @@ -5,6 +5,7 @@ library; import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_terrain_toggle.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; @@ -28,6 +29,8 @@ class ForecastOverlayMenu extends StatelessWidget { required this.layer, required this.showTownLabels, required this.onShowTownLabelsChanged, + required this.showTerrain, + required this.onShowTerrainChanged, }); final AdminOutlineChrome layer; @@ -35,6 +38,9 @@ class ForecastOverlayMenu extends StatelessWidget { final ValueListenable showTownLabels; final ValueChanged onShowTownLabelsChanged; + final ValueListenable showTerrain; + final ValueChanged onShowTerrainChanged; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -42,12 +48,14 @@ class ForecastOverlayMenu extends StatelessWidget { listenable: Listenable.merge([ layer.adminChromeListenable, showTownLabels, + showTerrain, ]), builder: (context, _) { final showGlobal = layer.showGlobalOutline.value; final showCounty = layer.showCountyOutline.value; final showTown = layer.showTownOutline.value; final showLabels = showTownLabels.value; + final showRelief = showTerrain.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), style: MapChipButton.menuStyle(context), @@ -56,7 +64,12 @@ class ForecastOverlayMenu extends StatelessWidget { tooltip: l10n.windForecastOverlayMenuTooltip, // The dot marks "not the defaults". County and town ship on, 國界 // and labels off, so it lights up when one has moved. - active: showGlobal || !showCounty || !showTown || !showLabels, + active: + showGlobal || + !showCounty || + !showTown || + !showLabels || + !showRelief, onTap: () => controller.isOpen ? controller.close() : controller.open(), ), @@ -94,6 +107,10 @@ class ForecastOverlayMenu extends StatelessWidget { showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, ), + MapTerrainRow( + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, + ), ], ), ], diff --git a/lib/features/map/presentation/widgets/radar_overlay_menu.dart b/lib/features/map/presentation/widgets/radar_overlay_menu.dart index 1448ff06c..1e4fdd28a 100644 --- a/lib/features/map/presentation/widgets/radar_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/radar_overlay_menu.dart @@ -16,17 +16,24 @@ class RadarOverlayMenu extends StatelessWidget { required this.layer, required this.showTownLabels, required this.onShowTownLabelsChanged, + required this.showTerrain, + required this.onShowTerrainChanged, }); final RadarMapLayer layer; final ValueListenable showTownLabels; final ValueChanged onShowTownLabelsChanged; + final ValueListenable showTerrain; + final ValueChanged onShowTerrainChanged; + @override Widget build(BuildContext context) => ScanRangeOverlayMenu( layer: layer, tooltip: AppLocalizations.of(context).radarOverlayMenuTooltip, showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, ); } diff --git a/lib/features/map/presentation/widgets/satellite_style_menu.dart b/lib/features/map/presentation/widgets/satellite_style_menu.dart index 93b15fdff..224c160c2 100644 --- a/lib/features/map/presentation/widgets/satellite_style_menu.dart +++ b/lib/features/map/presentation/widgets/satellite_style_menu.dart @@ -6,6 +6,7 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/features/map/presentation/layers/satellite_layer.dart'; import 'package:dpip/features/weather/domain/satellite_channel.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_terrain_toggle.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; @@ -25,6 +26,8 @@ class SatelliteStyleMenu extends StatelessWidget { required this.onReloadActive, required this.showTownLabels, required this.onShowTownLabelsChanged, + required this.showTerrain, + required this.onShowTerrainChanged, }); final SatelliteMapLayer layer; @@ -32,6 +35,9 @@ class SatelliteStyleMenu extends StatelessWidget { final ValueListenable showTownLabels; final ValueChanged onShowTownLabelsChanged; + final ValueListenable showTerrain; + final ValueChanged onShowTerrainChanged; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -40,13 +46,18 @@ class SatelliteStyleMenu extends StatelessWidget { layer.style, layer.showGlobalOutline, showTownLabels, + showTerrain, ]), builder: (context, _) { final style = layer.style.value; final showGlobal = layer.showGlobalOutline.value; final showLabels = showTownLabels.value; + final showRelief = showTerrain.value; final active = - style != SatelliteStyle.gray || showGlobal || !showLabels; + style != SatelliteStyle.gray || + showGlobal || + !showLabels || + !showRelief; return MenuAnchor( alignmentOffset: const Offset(0, 4), style: MapChipButton.menuStyle(context), @@ -114,6 +125,10 @@ class SatelliteStyleMenu extends StatelessWidget { showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, ), + MapTerrainRow( + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, + ), ], ), ], @@ -134,20 +149,30 @@ class SatelliteReferenceMenu extends StatelessWidget { required this.layer, required this.showTownLabels, required this.onShowTownLabelsChanged, + required this.showTerrain, + required this.onShowTerrainChanged, }); final SatelliteMapLayer layer; final ValueListenable showTownLabels; final ValueChanged onShowTownLabelsChanged; + final ValueListenable showTerrain; + final ValueChanged onShowTerrainChanged; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return ListenableBuilder( - listenable: Listenable.merge([layer.showGlobalOutline, showTownLabels]), + listenable: Listenable.merge([ + layer.showGlobalOutline, + showTownLabels, + showTerrain, + ]), builder: (context, _) { final showGlobal = layer.showGlobalOutline.value; final showLabels = showTownLabels.value; + final showRelief = showTerrain.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), style: MapChipButton.menuStyle(context), @@ -156,7 +181,7 @@ class SatelliteReferenceMenu extends StatelessWidget { tooltip: l10n.mapOverlaySectionReference, // The dot marks "not the defaults". 國界 ships off and labels on, // so it lights up when either has moved. - active: showGlobal || !showLabels, + active: showGlobal || !showLabels || !showRelief, onTap: () => controller.isOpen ? controller.close() : controller.open(), ), @@ -178,6 +203,10 @@ class SatelliteReferenceMenu extends StatelessWidget { showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, ), + MapTerrainRow( + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, + ), ], ), ], diff --git a/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart index dd2dcf59d..d5edfb3fc 100644 --- a/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart @@ -5,6 +5,7 @@ library; import 'package:dpip/features/map/presentation/layers/scan_range_overlay_chrome.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_terrain_toggle.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; @@ -25,6 +26,8 @@ class ScanRangeOverlayMenu extends StatelessWidget { required this.tooltip, required this.showTownLabels, required this.onShowTownLabelsChanged, + required this.showTerrain, + required this.onShowTerrainChanged, }); final ScanRangeOverlayChrome layer; @@ -36,17 +39,25 @@ class ScanRangeOverlayMenu extends StatelessWidget { final ValueListenable showTownLabels; final ValueChanged onShowTownLabelsChanged; + final ValueListenable showTerrain; + final ValueChanged onShowTerrainChanged; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return ListenableBuilder( - listenable: Listenable.merge([layer.chromeListenable, showTownLabels]), + listenable: Listenable.merge([ + layer.chromeListenable, + showTownLabels, + showTerrain, + ]), builder: (context, _) { final showRange = layer.showScanRange.value; final showGlobal = layer.showGlobalOutline.value; final showCounty = layer.showCountyOutline.value; final showTown = layer.showTownOutline.value; final showLabels = showTownLabels.value; + final showRelief = showTerrain.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), style: MapChipButton.menuStyle(context), @@ -61,7 +72,8 @@ class ScanRangeOverlayMenu extends StatelessWidget { showGlobal || !showCounty || !showTown || - !showLabels, + !showLabels || + !showRelief, onTap: () => controller.isOpen ? controller.close() : controller.open(), ), @@ -107,6 +119,10 @@ class ScanRangeOverlayMenu extends StatelessWidget { showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, ), + MapTerrainRow( + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, + ), ], ), ], diff --git a/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart index 293ef7259..d608be23b 100644 --- a/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart @@ -7,6 +7,7 @@ import 'package:dpip/features/map/presentation/layers/typhoon_layer.dart'; import 'package:dpip/features/map/presentation/layers/typhoon_storm_band.dart'; import 'package:dpip/features/map/presentation/layers/typhoon_weather_overlay.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_terrain_toggle.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; @@ -21,12 +22,17 @@ class TyphoonOverlayMenu extends StatelessWidget { required this.layer, required this.showTownLabels, required this.onShowTownLabelsChanged, + required this.showTerrain, + required this.onShowTerrainChanged, }); final TyphoonMapLayer layer; final ValueListenable showTownLabels; final ValueChanged onShowTownLabelsChanged; + final ValueListenable showTerrain; + final ValueChanged onShowTerrainChanged; + static const Color _l7 = Color(0xFF9C27B0); static const Color _l10 = Color(0xFFFFC107); @@ -44,6 +50,7 @@ class TyphoonOverlayMenu extends StatelessWidget { layer.showCountyOutline, layer.showTownOutline, showTownLabels, + showTerrain, ]), builder: (context, _) { final band = layer.stormBand.value; @@ -56,11 +63,13 @@ class TyphoonOverlayMenu extends StatelessWidget { final showCounty = layer.showCountyOutline.value; final showTown = layer.showTownOutline.value; final showLabels = showTownLabels.value; + final showRelief = showTerrain.value; final active = showProb || !showCallouts || showWarn || !showLabels || + !showRelief || band != TyphoonStormBand.level7 || // Any underlay at all is already a deviation, and the radar chrome // only exists while there is one — so the marker is on for every @@ -192,6 +201,10 @@ class TyphoonOverlayMenu extends StatelessWidget { showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, ), + MapTerrainRow( + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, + ), ], ), ], diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 00bb2fee8..572e4ec3c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1854,6 +1854,15 @@ "description": "Hint under the township-names setting" }, + "mapTerrainRelief": "Terrain relief", + "@mapTerrainRelief": { + "description": "Map setting: show the base map's hillshade relief" + }, + "mapTerrainReliefHint": "Show shaded terrain relief on the base map", + "@mapTerrainReliefHint": { + "description": "Hint under the terrain-relief setting" + }, + "dpmSheetEmpty": "Tap a marker on the map for details", "@dpmSheetEmpty": { "description": "Hint in the disaster-map detail sheet when nothing is selected" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index d27e01f64..4b32c030d 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -424,6 +424,9 @@ "mapTownLabels": "Mga pangalan ng bayan", "mapTownLabelsHint": "Ipakita ang mga pangalan ng bayan kapag naka-zoom", + "mapTerrainRelief": "Rehiyebo ng terrain", + "mapTerrainReliefHint": "Ipakita ang anino ng terrain sa base map", + "dpmSheetEmpty": "I-tap ang marker sa mapa para sa detalye", "dpmAddress": "Address", "restroomTypeLabel": "Uri", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 3f9b27d6d..4bf3f7fa0 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -424,6 +424,9 @@ "mapTownLabels": "Nama kecamatan", "mapTownLabelsHint": "Tampilkan nama kecamatan saat diperbesar", + "mapTerrainRelief": "Relief terrain", + "mapTerrainReliefHint": "Tampilkan relief terrain di peta dasar", + "dpmSheetEmpty": "Ketuk penanda di peta untuk detail", "dpmAddress": "Alamat", "restroomTypeLabel": "Jenis", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 5b2405303..99d64fe59 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -424,6 +424,9 @@ "mapTownLabels": "郷鎮名", "mapTownLabelsHint": "拡大すると郷鎮名を表示", + "mapTerrainRelief": "地形の立体感", + "mapTerrainReliefHint": "ベースマップに地形の陰影を表示", + "dpmSheetEmpty": "地図上のマーカーをタップして詳細を表示", "dpmAddress": "住所", "restroomTypeLabel": "種別", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9a291b1fb..23a9cf905 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -424,6 +424,9 @@ "mapTownLabels": "읍면동 이름", "mapTownLabelsHint": "확대하면 읍면동 이름 표시", + "mapTerrainRelief": "지형 입체감", + "mapTerrainReliefHint": "기본 지도에 지형 음영 표시", + "dpmSheetEmpty": "지도에서 마커를 눌러 상세 보기", "dpmAddress": "주소", "restroomTypeLabel": "유형", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index d2edf23c3..2e0405c10 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -424,6 +424,9 @@ "mapTownLabels": "ชื่อตำบล", "mapTownLabelsHint": "แสดงชื่อตำบลเมื่อขยายแผนที่", + "mapTerrainRelief": "ความนูนของภูมิประเทศ", + "mapTerrainReliefHint": "แสดงความนูนของภูมิประเทศบนแผนที่ฐาน", + "dpmSheetEmpty": "แตะเครื่องหมายบนแผนที่เพื่อดูรายละเอียด", "dpmAddress": "ที่อยู่", "restroomTypeLabel": "ประเภท", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index f55220ea3..151a87c63 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -424,6 +424,9 @@ "mapTownLabels": "Tên hương trấn", "mapTownLabelsHint": "Hiển thị tên hương trấn khi phóng to", + "mapTerrainRelief": "Độ nổi địa hình", + "mapTerrainReliefHint": "Hiển thị địa hình nổi trên bản đồ nền", + "dpmSheetEmpty": "Chạm vào điểm đánh dấu trên bản đồ để xem chi tiết", "dpmAddress": "Địa chỉ", "restroomTypeLabel": "Loại", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a77406fcd..7ff104b4c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -444,6 +444,9 @@ "mapTownLabels": "鄉鎮名稱", "mapTownLabelsHint": "放大時顯示鄉鎮名稱", + "mapTerrainRelief": "地形立體感", + "mapTerrainReliefHint": "在底圖上顯示立體地形陰影", + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 509f4ed2c..a0ba85ee4 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -424,6 +424,9 @@ "mapTownLabels": "乡镇名称", "mapTownLabelsHint": "放大时显示乡镇名称", + "mapTerrainRelief": "地形立体感", + "mapTerrainReliefHint": "在底图上显示立体地形阴影", + "dpmSheetEmpty": "点击地图上的标记查看详情", "dpmAddress": "地址", "restroomTypeLabel": "厕所类型", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 741d9dd43..580ec62c0 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -424,6 +424,9 @@ "mapTownLabels": "鄉鎮名稱", "mapTownLabelsHint": "放大時顯示鄉鎮名稱", + "mapTerrainRelief": "地形立體感", + "mapTerrainReliefHint": "在底圖上顯示立體地形陰影", + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 6fcc7014d..5636ef8f2 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -444,6 +444,9 @@ "mapTownLabels": "鄉鎮名稱", "mapTownLabelsHint": "放大時顯示鄉鎮名稱", + "mapTerrainRelief": "地形立體感", + "mapTerrainReliefHint": "在底圖上顯示立體地形陰影", + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 60bdfc8bf..81e53e65f 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2655,6 +2655,18 @@ abstract class AppLocalizations { /// **'Show township names when zoomed in'** String get mapTownLabelsHint; + /// Map setting: show the base map's hillshade relief + /// + /// In en, this message translates to: + /// **'Terrain relief'** + String get mapTerrainRelief; + + /// Hint under the terrain-relief setting + /// + /// In en, this message translates to: + /// **'Show shaded terrain relief on the base map'** + String get mapTerrainReliefHint; + /// Hint in the disaster-map detail sheet when nothing is selected /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index d36f7045a..9949f25b8 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1391,6 +1391,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapTownLabelsHint => 'Show township names when zoomed in'; + @override + String get mapTerrainRelief => 'Terrain relief'; + + @override + String get mapTerrainReliefHint => + 'Show shaded terrain relief on the base map'; + @override String get dpmSheetEmpty => 'Tap a marker on the map for details'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 6e7fbc932..88e03af47 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1399,6 +1399,12 @@ class AppLocalizationsFil extends AppLocalizations { String get mapTownLabelsHint => 'Ipakita ang mga pangalan ng bayan kapag naka-zoom'; + @override + String get mapTerrainRelief => 'Rehiyebo ng terrain'; + + @override + String get mapTerrainReliefHint => 'Ipakita ang anino ng terrain sa base map'; + @override String get dpmSheetEmpty => 'I-tap ang marker sa mapa para sa detalye'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 369569415..11617c8c7 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1395,6 +1395,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapTownLabelsHint => 'Tampilkan nama kecamatan saat diperbesar'; + @override + String get mapTerrainRelief => 'Relief terrain'; + + @override + String get mapTerrainReliefHint => 'Tampilkan relief terrain di peta dasar'; + @override String get dpmSheetEmpty => 'Ketuk penanda di peta untuk detail'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index e601c2baa..efdda72c0 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1369,6 +1369,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapTownLabelsHint => '拡大すると郷鎮名を表示'; + @override + String get mapTerrainRelief => '地形の立体感'; + + @override + String get mapTerrainReliefHint => 'ベースマップに地形の陰影を表示'; + @override String get dpmSheetEmpty => '地図上のマーカーをタップして詳細を表示'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index e61bc1b8e..bdc45fe2b 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1370,6 +1370,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapTownLabelsHint => '확대하면 읍면동 이름 표시'; + @override + String get mapTerrainRelief => '지형 입체감'; + + @override + String get mapTerrainReliefHint => '기본 지도에 지형 음영 표시'; + @override String get dpmSheetEmpty => '지도에서 마커를 눌러 상세 보기'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 739877eaf..4128cdec5 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1389,6 +1389,12 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapTownLabelsHint => 'แสดงชื่อตำบลเมื่อขยายแผนที่'; + @override + String get mapTerrainRelief => 'ความนูนของภูมิประเทศ'; + + @override + String get mapTerrainReliefHint => 'แสดงความนูนของภูมิประเทศบนแผนที่ฐาน'; + @override String get dpmSheetEmpty => 'แตะเครื่องหมายบนแผนที่เพื่อดูรายละเอียด'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 41b9cc952..7d5cdfade 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1391,6 +1391,12 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapTownLabelsHint => 'Hiển thị tên hương trấn khi phóng to'; + @override + String get mapTerrainRelief => 'Độ nổi địa hình'; + + @override + String get mapTerrainReliefHint => 'Hiển thị địa hình nổi trên bản đồ nền'; + @override String get dpmSheetEmpty => 'Chạm vào điểm đánh dấu trên bản đồ để xem chi tiết'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 49dc73e28..3fb71d9bb 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1363,6 +1363,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + @override + String get mapTerrainRelief => '地形立體感'; + + @override + String get mapTerrainReliefHint => '在底圖上顯示立體地形陰影'; + @override String get dpmSheetEmpty => '點選地圖上的標記查看詳情'; @@ -3124,6 +3130,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapTownLabelsHint => '放大时显示乡镇名称'; + @override + String get mapTerrainRelief => '地形立体感'; + + @override + String get mapTerrainReliefHint => '在底图上显示立体地形阴影'; + @override String get dpmSheetEmpty => '点击地图上的标记查看详情'; @@ -4885,6 +4897,12 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + @override + String get mapTerrainRelief => '地形立體感'; + + @override + String get mapTerrainReliefHint => '在底圖上顯示立體地形陰影'; + @override String get dpmSheetEmpty => '點選地圖上的標記查看詳情'; @@ -6646,6 +6664,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + @override + String get mapTerrainRelief => '地形立體感'; + + @override + String get mapTerrainReliefHint => '在底圖上顯示立體地形陰影'; + @override String get dpmSheetEmpty => '點選地圖上的標記查看詳情'; diff --git a/lib/shared/map/map_layer.dart b/lib/shared/map/map_layer.dart index 48fcdda40..8e256b7f4 100644 --- a/lib/shared/map/map_layer.dart +++ b/lib/shared/map/map_layer.dart @@ -138,10 +138,11 @@ abstract interface class MapLayer { /// empty — most layers only need the shared switcher. /// /// [showTownLabels] / [onShowTownLabelsChanged] expose the base map's shared - /// township-label setting, so a layer's menu can carry it alongside its own - /// options (one affordance, not a second chip). Layers whose chrome is not a - /// settings menu may ignore them — [MapScaffold] shows a standalone - /// township-label menu for layers that return no chrome. + /// township-label setting, and [showTerrain] / [onShowTerrainChanged] the + /// shared terrain-relief setting, so a layer's menu can carry them alongside + /// its own options (one affordance, not extra chips). Layers whose chrome is + /// not a settings menu may ignore them — [MapScaffold] shows a standalone + /// base-map menu for layers that return no chrome. /// /// [onReloadActive] re-loads this layer from scratch — a chrome option that /// changes what the layer renders (e.g. the satellite colour style) calls it @@ -150,6 +151,8 @@ abstract interface class MapLayer { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required ValueListenable showTerrain, + required ValueChanged onShowTerrainChanged, required Future Function() onReloadActive, }); @@ -254,6 +257,8 @@ mixin MapLayerDefaults implements MapLayer { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required ValueListenable showTerrain, + required ValueChanged onShowTerrainChanged, required Future Function() onReloadActive, }) => const SizedBox.shrink(); diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index a4c309cb7..0651b1095 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -123,6 +123,11 @@ class _MapScaffoldState extends State { /// instead of on one chrome mixin. Defaults on, per the layer docs. final ValueNotifier _showTownLabels = ValueNotifier(true); + /// Whether the base map's terrain-relief (hillshade) is shown. Also a + /// base-map property, so it lives beside [_showTownLabels]. Defaults on — + /// the relief is the terrain feature's whole point. + final ValueNotifier _showTerrain = ValueNotifier(true); + /// The geography the map is framed on, kept across layer switches so each /// layer re-frames the *same* place into its own visible band. LatLngBounds? _target; @@ -158,6 +163,7 @@ class _MapScaffoldState extends State { void dispose() { _bearing.dispose(); _showTownLabels.dispose(); + _showTerrain.dispose(); _basemapWarmer?.cancel(); _handoff?.removeListener(_onHandoff); _stationHandoff?.removeListener(_onStationHandoff); @@ -361,6 +367,29 @@ class _MapScaffoldState extends State { _applyTownLabelVisibility(); } + void _setShowTerrain(bool value) { + if (_showTerrain.value == value) return; + _showTerrain.value = value; + _applyTerrainVisibility(); + } + + /// Pushes the terrain-relief setting onto a live map. Like the township + /// labels, the base style's hillshade layer survives style reloads (which + /// reset it to visible), so this also runs after every [_onStyleLoaded]. + void _applyTerrainVisibility() { + final controller = _controller; + if (controller == null) return; + unawaited( + controller + .setLayerVisibility(terrainHillshadeLayerId, _showTerrain.value) + .catchError((Object e, StackTrace st) { + // The layer only exists in styles built with terrainTileUrl; a + // surface that never baked it has nothing to hide. + Log.handle(e, st, 'Failed to sync the terrain relief'); + }), + ); + } + /// Pushes the township-label setting onto a live map. The base style's /// `town-label` layer survives style reloads, which reset it to visible, so /// this also runs after every [_onStyleLoaded] to re-assert the choice. @@ -431,6 +460,8 @@ class _MapScaffoldState extends State { unawaited(_applyCameraHandoff()); // A reload resets the base style's township-label layer to visible. _applyTownLabelVisibility(); + // …and so does the hillshade layer — re-assert the user's choice. + _applyTerrainVisibility(); } /// Forwards a map tap to the active (sheet) layer — it selects the nearest @@ -705,6 +736,8 @@ class _MapScaffoldState extends State { context, showTownLabels: _showTownLabels, onShowTownLabelsChanged: _setShowTownLabels, + showTerrain: _showTerrain, + onShowTerrainChanged: _setShowTerrain, onReloadActive: _reloadActive, ); final hasChrome = chrome is! SizedBox; @@ -715,9 +748,11 @@ class _MapScaffoldState extends State { chrome, const SizedBox(width: AppSpacing.sm), ] else ...[ - MapTownLabelsMenu( + MapBasemapMenu( showTownLabels: _showTownLabels, onShowTownLabelsChanged: _setShowTownLabels, + showTerrain: _showTerrain, + onShowTerrainChanged: _setShowTerrain, ), const SizedBox(width: AppSpacing.sm), ], diff --git a/lib/shared/map/map_terrain_toggle.dart b/lib/shared/map/map_terrain_toggle.dart new file mode 100644 index 000000000..2cbfb61cb --- /dev/null +++ b/lib/shared/map/map_terrain_toggle.dart @@ -0,0 +1,46 @@ +/// The base map's terrain-relief (hillshade) toggle, shared by every layer's +/// settings menu. +/// +/// [MapTerrainRow] slots into an existing overlay menu alongside +/// [MapTownLabelsRow] so the map-level toggle never needs its own chip; the +/// standalone dropdown for layers that ship no other chrome lives in +/// [MapTownLabelsMenu] (the state lives in [MapScaffold], this just renders it). +library; + +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_style.dart'; +import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// One checkbox row for the terrain-relief setting — drops into any overlay +/// menu's children. +class MapTerrainRow extends StatelessWidget { + const MapTerrainRow({ + super.key, + required this.showTerrain, + required this.onShowTerrainChanged, + }); + + /// Whether the base map's hillshade relief is on (see + /// [terrainHillshadeLayerId]). + final ValueListenable showTerrain; + + final ValueChanged onShowTerrainChanged; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ListenableBuilder( + listenable: showTerrain, + builder: (context, _) => MapMenuToggleRow( + selected: showTerrain.value, + icon: Icons.terrain_outlined, + title: l10n.mapTerrainRelief, + subtitle: l10n.mapTerrainReliefHint, + tooltip: l10n.mapTerrainReliefHint, + onTap: () => onShowTerrainChanged(!showTerrain.value), + ), + ); + } +} diff --git a/lib/shared/map/map_town_labels.dart b/lib/shared/map/map_town_labels.dart index 4fc9a489a..2f2278a9e 100644 --- a/lib/shared/map/map_town_labels.dart +++ b/lib/shared/map/map_town_labels.dart @@ -8,6 +8,7 @@ library; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_style.dart'; +import 'package:dpip/shared/map/map_terrain_toggle.dart'; import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; import 'package:flutter/foundation.dart'; @@ -44,31 +45,37 @@ class MapTownLabelsRow extends StatelessWidget { } } -/// Standalone township-label dropdown for layers that ship no other chrome — -/// same chip affordance as the layer menus, so a map without a tune button -/// still exposes the map's one optional setting. -class MapTownLabelsMenu extends StatelessWidget { - const MapTownLabelsMenu({ +/// Standalone base-map dropdown for layers that ship no other chrome — same +/// chip affordance as the layer menus, so a map without a tune button still +/// exposes the map's optional settings (township names + terrain relief). The +/// state lives in [MapScaffold]; this just renders it. +class MapBasemapMenu extends StatelessWidget { + const MapBasemapMenu({ super.key, required this.showTownLabels, required this.onShowTownLabelsChanged, + required this.showTerrain, + required this.onShowTerrainChanged, }); final ValueListenable showTownLabels; final ValueChanged onShowTownLabelsChanged; + final ValueListenable showTerrain; + final ValueChanged onShowTerrainChanged; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return ListenableBuilder( - listenable: showTownLabels, + listenable: Listenable.merge([showTownLabels, showTerrain]), builder: (context, _) => MenuAnchor( alignmentOffset: const Offset(0, 4), style: MapChipButton.menuStyle(context), builder: (context, controller, _) => MapChipButton( icon: Icons.tune, tooltip: l10n.mapTownLabels, - active: !showTownLabels.value, + active: !showTownLabels.value || !showTerrain.value, onTap: () => controller.isOpen ? controller.close() : controller.open(), ), @@ -79,6 +86,11 @@ class MapTownLabelsMenu extends StatelessWidget { showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, ), + const MapMenuDivider(), + MapTerrainRow( + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, + ), ], ), ], diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index a12173ea5..4d748e9e6 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -176,6 +176,8 @@ abstract class RasterTimelineLayer implements MapLayer { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required ValueListenable showTerrain, + required ValueChanged onShowTerrainChanged, required Future Function() onReloadActive, }) => const SizedBox.shrink(); diff --git a/test/features/map/radar_overlay_menu_test.dart b/test/features/map/radar_overlay_menu_test.dart index 7d3890f37..e6d3f6d70 100644 --- a/test/features/map/radar_overlay_menu_test.dart +++ b/test/features/map/radar_overlay_menu_test.dart @@ -21,6 +21,8 @@ Widget _wrap( RadarMapLayer layer, { ValueListenable? showTownLabels, ValueChanged? onShowTownLabelsChanged, + ValueListenable? showTerrain, + ValueChanged? onShowTerrainChanged, }) => MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -33,6 +35,8 @@ Widget _wrap( layer: layer, showTownLabels: showTownLabels ?? ValueNotifier(true), onShowTownLabelsChanged: onShowTownLabelsChanged ?? (_) {}, + showTerrain: showTerrain ?? ValueNotifier(true), + onShowTerrainChanged: onShowTerrainChanged ?? (_) {}, ), ), ), @@ -52,7 +56,7 @@ void _useTallSurface(WidgetTester tester) { } void main() { - testWidgets('the chip opens a menu carrying all five overlay toggles', ( + testWidgets('the chip opens a menu carrying all six overlay toggles', ( tester, ) async { _useTallSurface(tester); @@ -71,16 +75,38 @@ void main() { expect(find.text(l10n.radarCountyOutline), findsOneWidget); expect(find.text(l10n.radarTownOutline), findsOneWidget); expect(find.text(l10n.mapTownLabels), findsOneWidget); + expect(find.text(l10n.mapTerrainRelief), findsOneWidget); // The menu is sectioned like the typhoon one: the raster's reference // chrome first, then the base-map settings. expect(find.text(l10n.mapOverlaySectionReference), findsOneWidget); expect(find.text(l10n.mapOverlaySectionMap), findsOneWidget); - // Reference chrome (scan range, county, town) and the name toggle ship - // on; the national border ships off. - expect(find.byIcon(Icons.check_box), findsNWidgets(4)); + // Reference chrome (scan range, county, town), the name toggle, and the + // relief toggle ship on; the national border ships off. + expect(find.byIcon(Icons.check_box), findsNWidgets(5)); expect(find.byIcon(Icons.check_box_outline_blank), findsOneWidget); }); + testWidgets('tapping the terrain-relief row reports the flip upward', ( + tester, + ) async { + _useTallSurface(tester); + final layer = RadarMapLayer(_FakeRadarRepository()); + final terrain = ValueNotifier(true); + final flipped = []; + await tester.pumpWidget( + _wrap(layer, showTerrain: terrain, onShowTerrainChanged: flipped.add), + ); + + final l10n = await _l10n(); + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.mapTerrainRelief)); + await tester.pumpAndSettle(); + + // Same contract as the name toggle: the value lives on the scaffold. + expect(flipped, [false]); + }); + testWidgets('tapping the national-border row turns it on', (tester) async { _useTallSurface(tester); final layer = RadarMapLayer(_FakeRadarRepository()); diff --git a/test/features/map/satellite_style_menu_test.dart b/test/features/map/satellite_style_menu_test.dart index d9bc2ad35..30a4b2aa1 100644 --- a/test/features/map/satellite_style_menu_test.dart +++ b/test/features/map/satellite_style_menu_test.dart @@ -61,6 +61,8 @@ void main() { onReloadActive: () async {}, showTownLabels: ValueNotifier(true), onShowTownLabelsChanged: (_) {}, + showTerrain: ValueNotifier(true), + onShowTerrainChanged: (_) {}, ), ), ); @@ -94,6 +96,8 @@ void main() { onReloadActive: () async => reloads++, showTownLabels: ValueNotifier(true), onShowTownLabelsChanged: (_) {}, + showTerrain: ValueNotifier(true), + onShowTerrainChanged: (_) {}, ), ), ); @@ -125,6 +129,8 @@ void main() { tester.element(find.byType(Scaffold)), showTownLabels: ValueNotifier(true), onShowTownLabelsChanged: (_) {}, + showTerrain: ValueNotifier(true), + onShowTerrainChanged: (_) {}, onReloadActive: () async {}, ); expect(chrome, isA()); @@ -143,6 +149,8 @@ void main() { tester.element(find.byType(Scaffold)), showTownLabels: ValueNotifier(true), onShowTownLabelsChanged: (_) {}, + showTerrain: ValueNotifier(true), + onShowTerrainChanged: (_) {}, onReloadActive: () async {}, ); expect(chrome, isA()); @@ -160,6 +168,8 @@ void main() { tester.element(find.byType(Scaffold)), showTownLabels: ValueNotifier(true), onShowTownLabelsChanged: (_) {}, + showTerrain: ValueNotifier(true), + onShowTerrainChanged: (_) {}, onReloadActive: () async {}, ); expect(chrome, isNot(isA())); diff --git a/test/features/map/typhoon_overlay_menu_test.dart b/test/features/map/typhoon_overlay_menu_test.dart index 9c2604036..a6fd6841c 100644 --- a/test/features/map/typhoon_overlay_menu_test.dart +++ b/test/features/map/typhoon_overlay_menu_test.dart @@ -55,6 +55,8 @@ Widget _wrap(TyphoonMapLayer layer) => MaterialApp( layer: layer, showTownLabels: ValueNotifier(true), onShowTownLabelsChanged: (_) {}, + showTerrain: ValueNotifier(true), + onShowTerrainChanged: (_) {}, ), ), ), diff --git a/test/features/map/wind_forecast_layer_test.dart b/test/features/map/wind_forecast_layer_test.dart index 9aaf90e2c..6002bdac6 100644 --- a/test/features/map/wind_forecast_layer_test.dart +++ b/test/features/map/wind_forecast_layer_test.dart @@ -451,6 +451,8 @@ void main() { tester.element(find.byType(Scaffold)), showTownLabels: ValueNotifier(true), onShowTownLabelsChanged: (_) {}, + showTerrain: ValueNotifier(true), + onShowTerrainChanged: (_) {}, onReloadActive: () async {}, ); await tester.pumpWidget( diff --git a/test/shared/map/map_town_labels_test.dart b/test/shared/map/map_town_labels_test.dart index c9ecb0f02..edfd09363 100644 --- a/test/shared/map/map_town_labels_test.dart +++ b/test/shared/map/map_town_labels_test.dart @@ -4,23 +4,29 @@ import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -/// Standalone township-label dropdown, as [MapScaffold] shows it for layers -/// with no settings menu of their own. -Widget _wrap(ValueNotifier labels, {ValueChanged? onChanged}) => - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - locale: const Locale('en'), - home: Scaffold( - body: Align( - alignment: Alignment.topRight, - child: MapTownLabelsMenu( - showTownLabels: labels, - onShowTownLabelsChanged: onChanged ?? (_) {}, - ), - ), +/// Standalone base-map dropdown, as [MapScaffold] shows it for layers with no +/// settings menu of their own. +Widget _wrap( + ValueNotifier labels, { + ValueChanged? onLabelsChanged, + ValueNotifier? terrain, + ValueChanged? onTerrainChanged, +}) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: Align( + alignment: Alignment.topRight, + child: MapBasemapMenu( + showTownLabels: labels, + onShowTownLabelsChanged: onLabelsChanged ?? (_) {}, + showTerrain: terrain ?? ValueNotifier(true), + onShowTerrainChanged: onTerrainChanged ?? (_) {}, ), - ); + ), + ), +); Future _l10n() => AppLocalizations.delegate.load(const Locale('en')); @@ -38,7 +44,7 @@ void main() { ); }); - testWidgets('the dropdown carries the township-name toggle', (tester) async { + testWidgets('the dropdown carries the base-map toggles', (tester) async { final labels = ValueNotifier(true); await tester.pumpWidget(_wrap(labels)); @@ -49,14 +55,15 @@ void main() { await tester.pumpAndSettle(); expect(find.text(l10n.mapTownLabels), findsOneWidget); - // On by default: the box starts ticked. - expect(find.byIcon(Icons.check_box), findsOneWidget); + expect(find.text(l10n.mapTerrainRelief), findsOneWidget); + // Both ship on by default: two ticked boxes. + expect(find.byIcon(Icons.check_box), findsNWidgets(2)); }); testWidgets('tapping the row flips the shared setting', (tester) async { final labels = ValueNotifier(true); final flipped = []; - await tester.pumpWidget(_wrap(labels, onChanged: flipped.add)); + await tester.pumpWidget(_wrap(labels, onLabelsChanged: flipped.add)); final l10n = await _l10n(); await tester.tap(find.byType(MapChipButton)); @@ -68,6 +75,41 @@ void main() { expect(flipped, [false]); }); + testWidgets('tapping the terrain-relief row flips the shared setting', ( + tester, + ) async { + final terrain = ValueNotifier(true); + final flipped = []; + await tester.pumpWidget( + _wrap( + ValueNotifier(true), + terrain: terrain, + onTerrainChanged: flipped.add, + ), + ); + + final l10n = await _l10n(); + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.mapTerrainRelief)); + await tester.pumpAndSettle(); + + expect(flipped, [false]); + }); + + testWidgets('the chip marks itself once the relief is switched off', ( + tester, + ) async { + final terrain = ValueNotifier(false); + await tester.pumpWidget(_wrap(ValueNotifier(true), terrain: terrain)); + + // Off is a deviation from the default, so the chip carries the dot. + expect( + tester.widget(find.byType(MapChipButton)).active, + isTrue, + ); + }); + testWidgets('the chip marks itself once the labels are switched off', ( tester, ) async { From 02ec941141d96a0ab19e6c184b6b4b96464be8df Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Wed, 12 Aug 2026 14:19:36 +0800 Subject: [PATCH 6/6] fix: map crash --- lib/shared/map/map_tile_warmer.dart | 11 ++++++++ test/shared/map/xyz_tiles_test.dart | 41 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/lib/shared/map/map_tile_warmer.dart b/lib/shared/map/map_tile_warmer.dart index 57a90f422..0f27e83ca 100644 --- a/lib/shared/map/map_tile_warmer.dart +++ b/lib/shared/map/map_tile_warmer.dart @@ -170,6 +170,17 @@ List viewportTiles({ int pad = 1, int maxTiles = 48, }) { + // A camera that hasn't settled can report NaN/∞ for zoom and bounds — + // MapLibre returns these mid-init or during a transition. Nothing to warm + // until it's finite; bailing here (instead of letting `zoom.floor()` throw) + // makes every warm path a no-op instead of a crash. + if (!south.isFinite || + !west.isFinite || + !north.isFinite || + !east.isFinite || + !zoom.isFinite) { + return const []; + } final z = math.min(zoom.floor(), maxZoom); final tiles = tilesCovering( south: south, diff --git a/test/shared/map/xyz_tiles_test.dart b/test/shared/map/xyz_tiles_test.dart index fc1278ec9..23034b3d8 100644 --- a/test/shared/map/xyz_tiles_test.dart +++ b/test/shared/map/xyz_tiles_test.dart @@ -1,3 +1,4 @@ +import 'package:dpip/shared/map/map_tile_warmer.dart'; import 'package:dpip/shared/map/xyz_tiles.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -57,4 +58,44 @@ void main() { // z=1: eastern hemisphere expect(lngToTileX(90, 1), 1); }); + + test('viewportTiles bails on a camera that has not settled', () { + // MapLibre reports NaN/∞ mid-init or during a transition — a warm must + // no-op, not throw on `zoom.floor()`. + expect( + viewportTiles( + south: double.nan, + west: 121.5, + north: 25.1, + east: 121.6, + zoom: 10, + maxZoom: 12, + ), + isEmpty, + reason: 'a NaN bound means the camera box is not real yet', + ); + expect( + viewportTiles( + south: 25.0, + west: 121.5, + north: 25.1, + east: 121.6, + zoom: double.infinity, + maxZoom: 12, + ), + isEmpty, + reason: 'an ∞ zoom must not reach floor()', + ); + expect( + viewportTiles( + south: 25.0, + west: 121.5, + north: 25.1, + east: 121.6, + zoom: 10, + maxZoom: 12, + ), + isNotEmpty, + ); + }); }