diff --git a/api.md b/api.md index 30de24a39..63e2cb1cc 100644 --- a/api.md +++ b/api.md @@ -49,6 +49,24 @@ ## 沒多活備援 (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 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` 時間清單是差量編碼的 Unix 秒(`[baseSec, Δ, …]`),在 API 主機上帶 ETag/304; diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 120cfe21d..e3b9415b7 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -88,14 +88,14 @@ 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. final mapTileCache = cache == null ? null : MapTileCache(cache.etag, usage: cache.usage); 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..eeb3fd83d 100644 --- a/lib/core/network/etag_interceptor.dart +++ b/lib/core/network/etag_interceptor.dart @@ -59,7 +59,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 +78,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/', 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/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/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/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fd92bf70d..572e4ec3c 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" @@ -1838,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 af8a747a5..4b32c030d 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.", @@ -423,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 0184ae547..4bf3f7fa0 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.", @@ -423,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 c0b869e12..99d64fe59 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": "プッシュ通知はまだ準備できていません。しばらくしてから再度お試しください。", @@ -423,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 d093ffbdc..23a9cf905 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": "푸시 알림이 아직 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.", @@ -423,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 08c59c276..2e0405c10 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": "การแจ้งเตือนแบบพุชยังไม่พร้อม — โปรดลองอีกครั้งในภายหลัง", @@ -423,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 e7a2538b9..151a87c63 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.", @@ -423,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 ab9566b17..7ff104b4c 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}%", @@ -442,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 3b5661bb4..a0ba85ee4 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": "推送通知尚未就绪,请稍后再试。", @@ -423,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 f522ea7c8..580ec62c0 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": "推送尚未就緒,請稍後再試。", @@ -423,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 7c2d7cc3e..5636ef8f2 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}%", @@ -442,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 51afffd59..81e53e65f 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: @@ -2643,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 93e9b7ff4..9949f25b8 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'; @@ -1383,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 5597c6670..88e03af47 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'; @@ -1391,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 f7f66edec..11617c8c7 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'; @@ -1387,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 1613102fd..efdda72c0 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時間予報'; @@ -1361,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 92da0d8a1..bdc45fe2b 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 @@ -1362,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 ba21b5d13..4128cdec5 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 ชั่วโมง'; @@ -1381,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 b0273d988..7d5cdfade 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 @@ -1383,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 d05c52ec0..3fb71d9bb 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小時預報'; @@ -1355,6 +1363,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + @override + String get mapTerrainRelief => '地形立體感'; + + @override + String get mapTerrainReliefHint => '在底圖上顯示立體地形陰影'; + @override String get dpmSheetEmpty => '點選地圖上的標記查看詳情'; @@ -2213,6 +2227,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小时预报'; @@ -3108,6 +3130,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapTownLabelsHint => '放大时显示乡镇名称'; + @override + String get mapTerrainRelief => '地形立体感'; + + @override + String get mapTerrainReliefHint => '在底图上显示立体地形阴影'; + @override String get dpmSheetEmpty => '点击地图上的标记查看详情'; @@ -3966,6 +3994,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小時預報'; @@ -4861,6 +4897,12 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + @override + String get mapTerrainRelief => '地形立體感'; + + @override + String get mapTerrainReliefHint => '在底圖上顯示立體地形陰影'; + @override String get dpmSheetEmpty => '點選地圖上的標記查看詳情'; @@ -5719,6 +5761,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小時預報'; @@ -6614,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/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_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_style.dart b/lib/shared/map/map_style.dart index 36155bd9a..d08e16a49 100644 --- a/lib/shared/map/map_style.dart +++ b/lib/shared/map/map_style.dart @@ -99,16 +99,29 @@ 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**, 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'; /// Origin glyph template — MapLibre HTTPS. 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 @@ -119,11 +132,18 @@ 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 (`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, required String glyphsUrl, + String? terrainTileUrl, }) { final background = palette.background; final fill = palette.fill; @@ -131,18 +151,29 @@ 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": "mapbox", "tileSize": 512, "minzoom": 0, "maxzoom": 12, "bounds": [110, 10, 132, 35] }'''; + final hillshade = terrainTileUrl == null + ? '' + : ''' + ,{ "id": "$terrainHillshadeLayerId", "type": "hillshade", "source": "terrain", "paint": { + "hillshade-illumination-direction": 335, + "hillshade-exaggeration": 0.3 + } }'''; 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_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_tile_cache.dart b/lib/shared/map/map_tile_cache.dart index 96c48faa4..6215f1c46 100644 --- a/lib/shared/map/map_tile_cache.dart +++ b/lib/shared/map/map_tile_cache.dart @@ -40,10 +40,16 @@ 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, + // ignore: prefer_initializing_formals + }) : _usage = usage; 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; /// Native's in-process mirror budget. @@ -80,22 +86,24 @@ class MapTileCache { await setMapLibreTileMemoryLimit(memoryBytes); } - /// Native asked for tile bodies — answer the ones we hold. + /// 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 []; // 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) { + served[entry.key] = MapLibreTile( + url: entry.key, + data: entry.value.bytes, + contentType: entry.value.contentType, + etag: entry.value.etag, + ); + } + return served.values.toList(); } /// Native downloaded tiles — persist and meter them. @@ -110,16 +118,17 @@ 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 = 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); 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/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/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/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..4d2631bf1 --- /dev/null +++ b/test/features/home/presentation/widgets/home_sheet_header_test.dart @@ -0,0 +1,259 @@ +/// 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))); +} + +/// 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({ + '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'); + // 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'); + }, + ); + + 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); + }); +} 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/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'); + }); +} diff --git a/test/shared/map/map_style_test.dart b/test/shared/map/map_style_test.dart index f10e6b649..4ae8baa63 100644 --- a/test/shared/map/map_style_test.dart +++ b/test/shared/map/map_style_test.dart @@ -28,6 +28,63 @@ void main() { ]); }); + test( + 'terrain adds a mapbox-encoded 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'], + '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['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, [ + 'bg', + 'land', + 'county', + 'town', + terrainHillshadeLayerId, + '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..e8dba2140 100644 --- a/test/shared/map/map_tile_cache_test.dart +++ b/test/shared/map/map_tile_cache_test.dart @@ -6,6 +6,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +const terrainUrl = + 'https://static.lb.exptech.dev/api/v1/map/terrain/7/107/55.png'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -142,7 +145,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 +172,28 @@ void main() { reason: 'caching a momentary glyph failure would blank labels for a week', ); }); + + test('a terrain tile is stored and served byte-for-byte', () async { + await cache.install(); + // 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'], bytes); + }); } 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 { 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, + ); + }); }