diff --git a/api.md b/api.md index 44001bef8..9fcb93ad4 100644 --- a/api.md +++ b/api.md @@ -259,6 +259,33 @@ lightning 沒有測站,也沒有 `trend`)。`{kind}` = `track` \| `potential > 乃至完全不帶參數,回應一律是 `"range":"24h"` 且為 24 筆逐時資料。App 送出的參數 > 是對的,是後端尚未實作 —— 在後端補上之前,「7 天」等同 24 小時。 +### 海嘯 CWA tsunami —— core 雙區 + +兩支端點都是**索引 + 詳情**:索引只有事件摘要與 `id`,詳情才是內文與 +`data.area`。**時間單位是 Unix 毫秒**(`sent`、`validUntil`、以及 area 內各自的 +時間),與氣象家族的秒不同、也不經差量編碼 —— 直接當 13 位數毫秒用。 + +**`data.area` 是位置式陣列,欄位數隨 `data.type` 改變**:`predict` 五欄 +`[地區, 沿岸範圍, 預估浪高, 抵達時間, 顏色]`、`observe` 六欄 +`[測站代碼, 測站名, 觀測浪高, 觀測時間, 經度, 緯度]`。用錯分支不會拋錯,只會把 +海岸範圍當成測站名;`observe` 的經緯度**可以整組是 `null`**(2024-04-03 的 +臺中港就是),所以地圖只畫有座標的那幾筆。解碼與判斷都在 +`TsunamiReport.decode`。 + +**索引 `reports` 是最新在前,且會混入往年事件** —— `no`(`113003`)才是同一個 +事件的鍵,`rep`(`第3報`)是該事件內的第幾報。事件的**最後一報通常是「海嘯警報 +解除」**(`observe`、只有實測),所以「只读最新一報」等於看不到預估;列表與報告 +選擇器因此都以**最新事件的整個事件**為單位(`TsunamiBulletin.newestEvent`)。 + +| 類別.方法 | 路徑 | 層級 | +|---|---|---| +| `TsunamiApi.getReports` | `/api/v1/cwa/tsunami` | `coreApi` | +| `TsunamiApi.getReport` | `/api/v1/cwa/tsunami/{id}` | `coreApi` | + +> `coreApi`(多活)而非 `coreExclusiveApi`:`core-tyo1` 與 `core-tnn1` 實測都回 +> 200(索引與詳情皆然,2026-10-05),所以它跟著區域容錯,沒有理由只釘在 tnn1。 +> 沒有 ETag,因此每次都由 App 重新抓取 —— 只在圖層開啟時抓,索引與內文都小。 + ### 裝置與通知 —— `core-tnn1` | 類別.方法 | 路徑 | 層級 | diff --git a/lib/app/router/notification_routes.dart b/lib/app/router/notification_routes.dart index 3edad806f..3a574bbbe 100644 --- a/lib/app/router/notification_routes.dart +++ b/lib/app/router/notification_routes.dart @@ -108,6 +108,9 @@ const Map notificationChannelMapLayers = { 'eew-general-v2': monitorMapLayerId, 'eew-silence-v2': monitorMapLayerId, 'eq-v2': monitorMapLayerId, + 'tsunami-important-v2': tsunamiMapLayerId, + 'tsunami-general-v2': tsunamiMapLayerId, + 'tsunami-silent-v2': tsunamiMapLayerId, }; /// `RtsMapLayer.id` — 強震監視器. A literal because `app/router` must not import @@ -115,6 +118,10 @@ const Map notificationChannelMapLayers = { /// together. const String monitorMapLayerId = 'monitor'; +/// `TsunamiMapLayer.id` — 海嘯警報. Literal for the same reason as +/// [monitorMapLayerId]. +const String tsunamiMapLayerId = 'tsunami'; + /// The payload key naming the item a tap should open. const String notificationTargetKey = 'reportId'; @@ -216,10 +223,12 @@ const Map notificationChannelRoutes = { 'weather_minor-general-v2': AppRoutes.home, // 需要 ID 'evacuation_major-important-v2': AppRoutes.home, // 需要 ID 'evacuation_minor-general-v2': AppRoutes.home, // 需要 ID - // 海嘯 — same, until a tsunami screen exists. - 'tsunami-important-v2': AppRoutes.home, // 需要 ID - 'tsunami-general-v2': AppRoutes.home, // 需要 ID - 'tsunami-silent-v2': AppRoutes.home, // 需要 ID + // 海嘯 — the tsunami overlay, where the bulletin and its coastal readings + // are. Same reasoning as the 地震速報 row above: the map tab keeps whichever + // overlay the session last used, so the route alone is not a destination. + 'tsunami-important-v2': AppRoutes.map, + 'tsunami-general-v2': AppRoutes.map, + 'tsunami-silent-v2': AppRoutes.map, // LoRa 網狀網路 'mesh_message': AppRoutes.meshtastic, 'mesh_node': AppRoutes.meshtastic, diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 85f18e110..aad19c992 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -76,6 +76,7 @@ import 'package:dpip/features/notification/notification_providers.dart'; import 'package:dpip/features/sponsor/sponsor_providers.dart'; import 'package:dpip/features/bug_tracker/bug_tracker_providers.dart'; import 'package:dpip/features/status/status_providers.dart'; +import 'package:dpip/features/tsunami/tsunami_providers.dart'; import 'package:dpip/features/typhoon/typhoon_providers.dart'; import 'package:dpip/features/weather/weather_providers.dart'; import 'package:dpip/firebase_options.dart'; @@ -460,6 +461,7 @@ Future bootstrap() async { ...weatherProviders(deps), ...disasterMapProviders(deps), ...typhoonProviders(deps), + ...tsunamiProviders(deps), ...eventsProviders(deps), ...changelogProviders(deps), ...notificationProviders(deps), diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index 924e076cd..862600cf1 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -45,4 +45,7 @@ abstract final class ApiPaths { /// Push notification config — `/api/v2/notify/{token}/…`. static const String notify = '/api/v2/notify/'; + + /// CWA tsunami bulletins — the index, and `/{id}` for one bulletin. + static const String tsunami = '/api/v1/cwa/tsunami'; } diff --git a/lib/core/settings/default_map_layer.dart b/lib/core/settings/default_map_layer.dart index ed88b5210..21f184958 100644 --- a/lib/core/settings/default_map_layer.dart +++ b/lib/core/settings/default_map_layer.dart @@ -10,6 +10,7 @@ enum DefaultMapLayer { lightning, typhoon, monitor, + tsunami, temperature, humidity, pressure, diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 7896debe8..77a2320dc 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -63,7 +63,7 @@ import 'package:dpip/shared/map/map_style.dart' show MapColors, countyFillLayerId, townFillLayerId, townLabelLayerId; import 'package:dpip/shared/map/monitor_map_stack.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; -import 'package:dpip/shared/widgets/frosted_surface.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:dpip/shared/widgets/collapsible_map_legend.dart'; import 'package:dpip/shared/widgets/intensity_legend.dart'; import 'package:dpip/shared/widgets/intensity_ranking_card.dart'; @@ -297,42 +297,55 @@ class _ReportReplayPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FrostedSurface( - borderRadius: AppRadius.large, - child: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.pop(), - ), + // Back and legend share one row, and both are the + // shared [MapChipButton] so the whole top edge — back, + // legend, and the base-map chip on the right — is one + // height. A bare [IconButton] back was the tallest thing + // on screen (its padded tap target is 48px) and read as + // a different app's control sitting next to ours. + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MapChipButton( + icon: Icons.arrow_back, + tooltip: MaterialLocalizations.of(context) + .backButtonTooltip, + active: false, + onTap: () => context.pop(), + ), + const SizedBox(width: AppSpacing.sm), + // The replay surface is the 強震監視器 frozen in time — + // the same intensity legend the live monitor carries, + // switching to the EEW felt-scale while an alert is up + // (the legacy monitor did exactly this on active EEW). + // + // Collapsible, and collapsed to a chip to start with, + // exactly as [MapScaffold] mounts every layer's legend: + // this page is a full-screen map too, and an + // eleven-row scale pinned open covers the north-west + // corner of the island for the whole replay. The wrap + // sits *outside* the mode swap on purpose — an alert + // arriving mid-replay changes the scale being shown, + // not whether the user asked to see it. + CollapsibleMapLegend( + legend: ListenableBuilder( + listenable: _session.eew, + builder: (context, _) { + final hasEew = _session.eew.alerts.isNotEmpty; + return MapLegendCard( + child: IntensityLegend( + mode: hasEew + ? IntensityLegendMode.eew + : IntensityLegendMode.rts, + ), + ); + }, + ), + ), + ], ), const SizedBox(height: AppSpacing.md), - // The replay surface is the 強震監視器 frozen in time — - // the same intensity legend the live monitor carries, - // switching to the EEW felt-scale while an alert is up - // (the legacy monitor did exactly this on active EEW). - // - // Collapsible, and collapsed to a chip to start with, - // exactly as [MapScaffold] mounts every layer's legend: - // this page is a full-screen map too, and an eleven-row - // scale pinned open covers the north-west corner of the - // island for the whole replay. The wrap sits *outside* - // the mode swap on purpose — an alert arriving mid-replay - // changes the scale being shown, not whether the user - // asked to see it. - CollapsibleMapLegend( - legend: ListenableBuilder( - listenable: _session.eew, - builder: (context, _) { - final hasEew = _session.eew.alerts.isNotEmpty; - return MapLegendCard( - child: IntensityLegend( - mode: hasEew - ? IntensityLegendMode.eew - : IntensityLegendMode.rts, - ), - ); - }, - ), - ), ListenableBuilder( listenable: _session.rts, builder: (context, _) { diff --git a/lib/features/map/presentation/layers/mesh_node_layer.dart b/lib/features/map/presentation/layers/mesh_node_layer.dart index b24343c1a..cbac74419 100644 --- a/lib/features/map/presentation/layers/mesh_node_layer.dart +++ b/lib/features/map/presentation/layers/mesh_node_layer.dart @@ -897,6 +897,7 @@ class _MeshNodeMenu extends StatelessWidget { !excludeMqtt || !showTownLabels.value || !showTerrain.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), + animated: true, style: MapChipButton.menuStyle(context), builder: (context, controller, _) => MapChipButton( icon: Icons.tune, diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index 7044830ca..34fb6d3c8 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -203,6 +203,7 @@ class RainMapLayer final scale = colorScale.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), + animated: true, style: MapChipButton.menuStyle(context), builder: (context, controller, _) => MapChipButton( icon: Icons.timelapse_outlined, diff --git a/lib/features/map/presentation/layers/tsunami_layer.dart b/lib/features/map/presentation/layers/tsunami_layer.dart new file mode 100644 index 000000000..f4272b720 --- /dev/null +++ b/lib/features/map/presentation/layers/tsunami_layer.dart @@ -0,0 +1,527 @@ +/// CWA tsunami bulletin map layer: the source epicentre and every observed +/// coastal reading, banded by wave height, with the bulletin itself in a sheet. +library; + +import 'dart:async'; + +import 'package:dpip/core/a11y/color_vision.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/features/map/presentation/widgets/tsunami_panel.dart'; +import 'package:dpip/features/tsunami/domain/tsunami_report.dart'; +import 'package:dpip/features/tsunami/domain/tsunami_repository.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_layer.dart'; +import 'package:dpip/shared/map/map_station_labels.dart'; +import 'package:dpip/shared/map/map_style.dart'; +import 'package:dpip/shared/seismic/intensity_icon_renderer.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// A sheet layer: one event, no frames and no timeline. +/// +/// The map carries the two things a map is for — where the earthquake was, and +/// where the wave was measured — while the sheet carries the bulletin text and +/// the per-area heights. +/// +/// **Predicted coasts.** As in the legacy page, the base style's own `tsunami` +/// vector layer (CWA's coast, one line per forecast region, keyed by +/// `AREANAME`) is recoloured by predicted height and blinked. +class TsunamiMapLayer with MapLayerDefaults implements MapLayer { + TsunamiMapLayer(this.repository); + + /// Exposed so the sheet's retry and its report picker can drive the fetch + /// they render. + final TsunamiRepository repository; + + /// Latest fetch. `null` is "nothing fetched yet" (the panel shows its + /// loading state and the map is empty); `Ok(null)` is **CWA has issued + /// nothing** — an all-clear, which is not a failure and must not be shown as + /// one; `Ok(report)` is the picked bulletin; `Err` is a failed fetch. + final ValueNotifier?> state = ValueNotifier(null); + + /// The newest event's reports, newest first — the sheet's 第N報 picker. + final ValueNotifier> bulletins = ValueNotifier( + const [], + ); + + /// The report currently shown, or null when there is none to show. A notifier + /// rather than a plain field because the picker's pills read it. + final ValueNotifier selectedId = ValueNotifier(null); + + /// The bulletin currently on the map, or null. + TsunamiReport? get report => state.value?.valueOrNull; + + /// Wire id, also the id a notification tap asks the map for + /// (`notificationChannelMapLayers`). + static const String layerId = 'tsunami'; + + static const String _sourceId = 'tsunami-source'; + static const String _epicenterLayerId = 'tsunami-epicenter'; + static const String _epicenterIconId = 'tsunami-epicenter-icon'; + static const String _observationLayerId = 'tsunami-observations'; + static const String _labelLayerId = 'tsunami-labels'; + + /// The base style's vector source and the layer in its tiles. + static const String _areaSourceId = 'exptech'; + static const String _areaSourceLayer = 'tsunami'; + static const String _areaLayerId = 'tsunami-areas'; + + /// Coast line width by zoom. A fixed pixel width is a hairline once the map + /// is close enough to read a district name, so it widens with the zoom. + static const List _areaLineWidth = [ + Expressions.interpolate, + ['linear'], + [Expressions.zoom], + 5, + 6.0, + 8, + 20.0, + 11, + 24.0, + 14, + 36.0, + ]; + + /// The legacy cadence: a half-second tick, line shown for six ticks of eight. + static const Duration _blinkTick = Duration(milliseconds: 500); + static const int _blinkCycle = 8; + static const int _blinkVisible = 6; + + /// The legacy palette, kept because CWA's own bands did not change: over 3 m + /// is the alert colour, under 30 cm the quiet one. Getters rather than + /// `static const` because the colour-vision transform is not a compile-time + /// constant; the legend's swatches convert these same strings, so key and map + /// cannot drift. + static String get bandOver3mColor => '#E543FF'.vision; + static String get bandFrom1mColor => '#C90000'.vision; + static String get bandFrom30cmColor => '#FFC900'.vision; + static String get bandUnder30cmColor => '#00AAFF'.vision; + + /// A reading the height text would not parse. Neutral on purpose: it is not a + /// band, and painting it the quiet blue would state a wave height CWA never + /// reported. + static String get _unknownBandColor => '#9E9E9E'.vision; + + static String get _strokeColor => '#FFFFFF'.vision; + + MapLibreMapController? _controller; + + /// Serial op lane, the same shape [DisasterMapLayer] uses: two report pills + /// tapped in quick succession must not interleave their `setGeoJsonSource` + /// calls, and the last tap wins because the fetch reads [_selectedId] when it + /// starts rather than when it was queued. + Future _ops = Future.value(); + + @override + String get id => layerId; + + @override + IconData get icon => Icons.tsunami_outlined; + + @override + String label(BuildContext context) => + AppLocalizations.of(context).mapLayerTsunami; + + @override + bool get usesTimeline => false; + + @override + double get bottomChromeFraction => TsunamiPanel.peekExtent; + + @override + Future render(MapLibreMapController controller) async { + _controller = controller; + await load(); + } + + /// Loads the event's report list and shows its newest report. Called on + /// activation and by the sheet's retry button. + Future load() => _enqueue(_loadIndex); + + /// Shows another report of the same event — the sheet's 第N報 picker. + Future selectBulletin(String id) { + selectedId.value = id; + return _enqueue(_loadSelected); + } + + Future _loadIndex() async { + final result = await repository.bulletins(); + final list = result.valueOrNull; + if (list == null) { + bulletins.value = const []; + state.value = Err(result.failureOrNull!); + return; + } + bulletins.value = list; + if (list.isEmpty) { + selectedId.value = null; + state.value = const Ok(null); + return; + } + // A reload (a style rebuild, a retry) keeps the report the user picked when + // it is still part of the event; only a report that is gone falls back to + // the newest, so a refresh mid-event never yanks the reader back to 最新報. + final keep = list.any((bulletin) => bulletin.id == selectedId.value) + ? selectedId.value! + : list.first.id; + selectedId.value = keep; + await _loadSelected(); + } + + Future _loadSelected() async { + final id = selectedId.value; + if (id == null) return; + final result = await repository.report(id); + // A newer tap landed while this fetch was in the air — its own run is + // queued behind this one and will win; showing this result first would only + // flash the wrong bulletin. + if (id != selectedId.value) return; + state.value = result; + final controller = _controller; + if (controller == null) return; + await _draw(controller, result.valueOrNull); + } + + Future _draw( + MapLibreMapController controller, + TsunamiReport? report, + ) async { + await _remove(controller); + // `Ok(null)` — CWA has issued nothing, so there is nothing to draw and the + // map simply has no overlay. + if (report == null) return; + // The epicentre cross is drawn in code, like every other map icon, and the + // legacy tsunami page used this same artwork for the same purpose. + await controller.addImage( + _epicenterIconId, + await IntensityIconRenderer.render('cross'), + ); + // Underneath the points, so a station dot is never hidden by the coast. + await _drawAreas(controller, report); + await controller.addSource( + _sourceId, + GeojsonSourceProperties(data: tsunamiGeoJson(report)), + ); + await controller.addCircleLayer( + _sourceId, + _observationLayerId, + CircleLayerProperties( + circleRadius: const [ + Expressions.interpolate, + ['linear'], + [Expressions.zoom], + 6, + 5.0, + 12, + 9.0, + ], + circleColor: _bandColorExpression(), + circleStrokeColor: _strokeColor, + circleStrokeWidth: 1.5, + ), + filter: _kindIs(_observationKind), + enableInteraction: false, + ); + // Readings only appear once the map is close enough to attribute a label to + // the dot it belongs to; nationwide view is the distribution. + await controller.addSymbolLayer( + _sourceId, + _labelLayerId, + stationLabelProps(textField: const ['get', 'label']), + filter: _kindIs(_observationKind), + minzoom: 7, + enableInteraction: false, + ); + await controller.addSymbolLayer( + _sourceId, + _epicenterLayerId, + SymbolLayerProperties( + iconImage: _epicenterIconId, + iconSize: 1, + iconAllowOverlap: true, + iconIgnorePlacement: true, + ), + filter: _kindIs(_epicenterKind), + enableInteraction: false, + ); + } + + Timer? _blink; + int _blinkStep = 0; + + /// Paints each predicted region's coast in its warning band, blinking. + /// + /// A region the asset does not know, or one whose colour has no band, is + /// simply not drawn — it is logged, because CWA adding a region is the one + /// way this goes quietly out of date. + Future _drawAreas( + MapLibreMapController controller, + TsunamiReport report, + ) async { + final bands = tsunamiAreaBands(report); + if (bands.isEmpty) return; + // The base style's own vector source: its `tsunami` layer is CWA's coast, + // one line per forecast region, named by `AREANAME`. + await controller.addLineLayer( + _areaSourceId, + _areaLayerId, + LineLayerProperties( + lineColor: _areaColorExpression(bands), + lineWidth: _areaLineWidth, + lineOpacity: 1, + ), + belowLayerId: landLayerId, + sourceLayer: _areaSourceLayer, + enableInteraction: false, + ); + _startBlink(bands); + } + + /// A `match` on the `area` property, as the legacy `AREANAME` one was. + List _areaColorExpression(Map bands) => [ + Expressions.match, + [Expressions.get, 'AREANAME'], + for (final entry in bands.entries) ...[entry.key, bandColor(entry.value)], + '#000000', + ]; + + void _startBlink(Map bands) { + _blink?.cancel(); + _blinkStep = 0; + // Colour, width and opacity every tick, as the legacy page re-sent its + // colour with each opacity: a partial update is not guaranteed to leave the + // properties it omits alone, and a line reset to defaults reads as gone. + final color = _areaColorExpression(bands); + var reported = false; + _blink = Timer.periodic(_blinkTick, (_) { + _blinkStep = (_blinkStep + 1) % _blinkCycle; + final controller = _controller; + if (controller == null) return; + controller + .setLayerProperties( + _areaLayerId, + LineLayerProperties( + lineColor: color, + lineWidth: _areaLineWidth, + lineOpacity: _blinkStep < _blinkVisible ? 1 : 0, + ), + ) + .catchError((Object error, StackTrace stackTrace) { + // Once per layer, not once per tick. + if (reported) return; + reported = true; + Log.handle(error, stackTrace, 'tsunami area blink'); + }); + }); + } + + Future _removeAreas(MapLibreMapController controller) async { + _blink?.cancel(); + _blink = null; + try { + await controller.removeLayer(_areaLayerId); + } catch (_) { + // Already absent — the style may have been replaced under us. + } + } + + /// A `match` on each feature's band name, defaulting to the neutral colour. + List _bandColorExpression() => [ + Expressions.match, + [Expressions.get, 'band'], + TsunamiWaveBand.over3m.name, + bandOver3mColor, + TsunamiWaveBand.from1m.name, + bandFrom1mColor, + TsunamiWaveBand.from30cm.name, + bandFrom30cmColor, + TsunamiWaveBand.under30cm.name, + bandUnder30cmColor, + _unknownBandColor, + ]; + + /// The band palette, for the legend and the sheet's chips. + static String bandColor(TsunamiWaveBand band) => switch (band) { + TsunamiWaveBand.over3m => bandOver3mColor, + TsunamiWaveBand.from1m => bandFrom1mColor, + TsunamiWaveBand.from30cm => bandFrom30cmColor, + TsunamiWaveBand.under30cm => bandUnder30cmColor, + }; + + @override + Widget buildSheet(BuildContext context) => TsunamiPanel(layer: this); + + /// The height scale, plus the epicentre mark. + /// + /// The four rows are the point of the layer: without them the colours on the + /// map are decoration. The epicentre keeps a row because a cross is not + /// obviously an earthquake to somebody who has never seen one. + @override + Widget buildLegend(BuildContext context) { + final l10n = AppLocalizations.of(context); + return MapLegendCard( + child: SymbolLegend( + items: [ + SymbolLegendItem( + swatch: LegendDot(color: const Color(0xFFE543FF).vision), + label: l10n.tsunamiBandOver3m, + ), + SymbolLegendItem( + swatch: LegendDot(color: const Color(0xFFC90000).vision), + label: l10n.tsunamiBand1To3m, + ), + SymbolLegendItem( + swatch: LegendDot(color: const Color(0xFFFFC900).vision), + label: l10n.tsunamiBand30cmTo1m, + ), + SymbolLegendItem( + swatch: LegendDot(color: const Color(0xFF00AAFF).vision), + label: l10n.tsunamiBandUnder30cm, + ), + SymbolLegendItem( + swatch: Icon( + Icons.close, + size: 13, + // The epicentre artwork's own red (`IntensityIconRenderer`), so + // the key names the mark that is actually on the map. + color: const Color(0xFFFF0000), + ), + label: l10n.tsunamiLegendEpicenter, + ), + ], + ), + ); + } + + @override + Future clear(MapLibreMapController controller) async { + await _remove(controller); + _controller = null; + state.value = null; + bulletins.value = const []; + selectedId.value = null; + } + + @override + void onStyleReset() { + _blink?.cancel(); + _blink = null; + // The style reload dropped every runtime source and image; the bulletin + // itself is untouched, and the scaffold's re-render re-adds them. Only the + // controller is stale. + _controller = null; + } + + /// Queues [op] behind any load in flight, logging a failure instead of + /// throwing it: a failed overlay op degrades the map, and the panel already + /// has the error to show. Returning the chain (not the op) keeps a caller + /// from having to handle an error that has nowhere useful to go. + Future _enqueue(Future Function() op) { + _ops = _ops.then((_) => op()).catchError(( + Object error, + StackTrace stackTrace, + ) { + Log.handle(error, stackTrace, 'tsunami layer op'); + }); + return _ops; + } + + Future _remove(MapLibreMapController controller) async { + await _removeAreas(controller); + for (final id in [_epicenterLayerId, _labelLayerId, _observationLayerId]) { + try { + await controller.removeLayer(id); + } catch (_) { + // Already absent — the style may have been replaced under us. + } + } + try { + await controller.removeSource(_sourceId); + } catch (_) { + // Same. + } + } + + static List _kindIs(String kind) => [ + '==', + ['get', 'kind'], + kind, + ]; +} + +/// The warning band of every predicted region [report] carries, by the region +/// name the bundled coastline is keyed on. +/// +/// Pure so it is unit-testable. A prediction whose colour has no band is left +/// out rather than defaulted — see [TsunamiWaveBand.ofWarningColor]. Two rows +/// for one region keep the **higher** band: a coast is as dangerous as its +/// worst forecast. +Map tsunamiAreaBands(TsunamiReport report) { + final bands = {}; + for (final prediction in report.predictions) { + final band = TsunamiWaveBand.ofWarningColor(prediction.color); + if (band == null) continue; + final known = bands[prediction.area]; + if (known == null || band.index > known.index) { + bands[prediction.area] = band; + } + } + return bands; +} + +/// GeoJSON property naming which of the two marks a feature is. +const String _epicenterKind = 'epicenter'; +const String _observationKind = 'observation'; + +/// The bulletin as map geometry: the source earthquake, plus every observed +/// station that reported a position. +/// +/// Pure (no controller, no widgets) so the shape is unit-testable — and so the +/// one row with `null` coordinates is dropped here, in one place, rather than +/// becoming a `LatLng(null, null)` inside the style. +/// +/// Each reading carries its band name in `band` (not a colour), so the circle +/// layer colours every dot by the one scale the legend keys, and a height the +/// text would not parse stays unbanded instead of silently defaulting to a +/// claim about the sea. +Map tsunamiGeoJson(TsunamiReport report) => { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [ + report.earthquake.longitude, + report.earthquake.latitude, + ], + }, + 'properties': {'kind': _epicenterKind}, + }, + for (final observation in report.observations) + if (observation.longitude != null && observation.latitude != null) + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [observation.longitude, observation.latitude], + }, + 'properties': { + 'kind': _observationKind, + if (observedBand(observation) case final band?) 'band': band.name, + // Name over its reading, the same two-line shape every station + // label on this map uses (`stationLabelProps`). + 'label': '${observation.name}\n${observation.height}', + }, + }, + ], +}; + +/// The wave band an observation's height text reports, or null when it holds no +/// number to place on the scale. +TsunamiWaveBand? observedBand(TsunamiObservation observation) { + final centimeters = parseObservedCentimeters(observation.height); + return centimeters == null ? null : TsunamiWaveBand.of(centimeters); +} diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index a9ceba453..4cb97a6c1 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -30,8 +30,10 @@ import 'package:dpip/features/map/presentation/layers/rts_layer.dart'; import 'package:dpip/features/map/presentation/layers/satellite_layer.dart'; import 'package:dpip/features/map/presentation/layers/temperature_layer.dart'; import 'package:dpip/features/map/presentation/layers/typhoon_layer.dart'; +import 'package:dpip/features/map/presentation/layers/tsunami_layer.dart'; import 'package:dpip/features/map/presentation/layers/wind_forecast_layer.dart'; import 'package:dpip/features/map/presentation/layers/wind_layer.dart'; +import 'package:dpip/features/tsunami/domain/tsunami_repository.dart'; import 'package:dpip/features/typhoon/domain/meteor_typhoon_repository.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; import 'package:dpip/features/weather/domain/meteor_rain_repository.dart'; @@ -130,6 +132,7 @@ class _MapPageState extends State { PressureMapLayer(context.read()), WindMapLayer(context.read()), RainMapLayer(context.read()), + TsunamiMapLayer(context.read()), DisasterMapLayer(context.read()), MeshNodeMapLayer( context.read(), 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 0687c9a3f..1eee1ba1c 100644 --- a/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart @@ -47,6 +47,7 @@ class DisasterMapOverlayMenu extends StatelessWidget { !showTerrain.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), + animated: true, style: MapChipButton.menuStyle(context), builder: (context, controller, _) { return MapChipButton( diff --git a/lib/features/map/presentation/widgets/forecast_overlay_menu.dart b/lib/features/map/presentation/widgets/forecast_overlay_menu.dart index 4290fe413..2fd1b17bb 100644 --- a/lib/features/map/presentation/widgets/forecast_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/forecast_overlay_menu.dart @@ -57,6 +57,7 @@ class ForecastOverlayMenu extends StatelessWidget { final showRelief = showTerrain.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), + animated: true, style: MapChipButton.menuStyle(context), builder: (context, controller, _) => MapChipButton( icon: Icons.tune, diff --git a/lib/features/map/presentation/widgets/satellite_style_menu.dart b/lib/features/map/presentation/widgets/satellite_style_menu.dart index 4af1acf53..7cf5e2599 100644 --- a/lib/features/map/presentation/widgets/satellite_style_menu.dart +++ b/lib/features/map/presentation/widgets/satellite_style_menu.dart @@ -59,6 +59,7 @@ class SatelliteStyleMenu extends StatelessWidget { !showRelief; return MenuAnchor( alignmentOffset: const Offset(0, 4), + animated: true, style: MapChipButton.menuStyle(context), builder: (context, controller, _) { return MapChipButton( @@ -171,6 +172,7 @@ class SatelliteReferenceMenu extends StatelessWidget { final showRelief = showTerrain.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), + animated: true, style: MapChipButton.menuStyle(context), builder: (context, controller, _) => MapChipButton( icon: Icons.tune, 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 4a577e992..9ba5e1353 100644 --- a/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart @@ -72,6 +72,7 @@ class ScanRangeOverlayMenu extends StatelessWidget { final showRelief = showTerrain.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), + animated: true, style: MapChipButton.menuStyle(context), builder: (context, controller, _) => MapChipButton( icon: Icons.tune, diff --git a/lib/features/map/presentation/widgets/tsunami_panel.dart b/lib/features/map/presentation/widgets/tsunami_panel.dart new file mode 100644 index 000000000..ce42c3d18 --- /dev/null +++ b/lib/features/map/presentation/widgets/tsunami_panel.dart @@ -0,0 +1,433 @@ +/// Bottom sheet for a CWA tsunami bulletin: the report picker, the bulletin +/// text, the source earthquake, and the predicted / observed coastal heights. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/core/settings/locale_config.dart'; +import 'package:dpip/features/map/presentation/layers/tsunami_layer.dart'; +import 'package:dpip/features/tsunami/domain/tsunami_report.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/color_hex.dart'; +import 'package:dpip/shared/widgets/empty_view.dart'; +import 'package:dpip/shared/widgets/error_view.dart'; +import 'package:dpip/shared/widgets/loading_view.dart'; +import 'package:dpip/shared/widgets/selectable_pill.dart'; +import 'package:dpip/shared/widgets/sheet_extent.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// Collapsible tsunami bulletin sheet — the text half of the layer, on the same +/// peek / rest / full skeleton as the station, DPM and typhoon sheets. +/// +/// It renders [TsunamiMapLayer.state] rather than fetching again: the layer +/// already asked for exactly this bulletin to draw, and a second request would +/// let the map and the panel show two different reports. +class TsunamiPanel extends StatefulWidget { + const TsunamiPanel({super.key, required this.layer}); + + final TsunamiMapLayer layer; + + /// Collapsed peek height — also what the scaffold frames around. + static const double peekExtent = 0.22; + static const double _rest = 0.58; + static const double _expanded = 1; + + @override + State createState() => _TsunamiPanelState(); +} + +class _TsunamiPanelState extends State { + final ValueNotifier _extent = ValueNotifier(TsunamiPanel.peekExtent); + + @override + void dispose() { + _extent.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ExtentSheetChrome( + extent: _extent, + keyValue: 'tsunami', + initial: TsunamiPanel.peekExtent, + min: TsunamiPanel.peekExtent, + max: TsunamiPanel._expanded, + snapSizes: const [TsunamiPanel._rest], + content: (context, scrollController) => ListView( + controller: scrollController, + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom + AppSpacing.xl, + ), + children: [ + SheetGrip(extent: _extent), + // The picker sits above the report it picks, so it is reachable at the + // sheet's resting height — switching 第1報 / 第3報 must not require + // dragging the sheet open first. + _ReportPicker(layer: widget.layer), + ValueListenableBuilder?>( + valueListenable: widget.layer.state, + builder: (context, state, _) => switch (state) { + null => const _Pending(), + // `Ok(null)` is CWA having issued nothing: an all-clear, not an + // error, so it gets the calm empty state rather than a retry button + // that would only fetch the same nothing. + Ok(value: null) => Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xl), + child: EmptyView( + icon: Icons.check_circle_outline, + message: AppLocalizations.of(context).tsunamiEmpty, + ), + ), + Err(:final failure) => ErrorView( + headline: AppLocalizations.of(context).commonFetchFailed, + detail: failure.message, + onRetry: widget.layer.load, + ), + Ok(:final value?) => _Bulletin(report: value), + }, + ), + ], + ), + ); +} + +/// The fetch is still in flight. +class _Pending extends StatelessWidget { + const _Pending(); + + @override + Widget build(BuildContext context) => const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.xl), + child: LoadingView(), + ); +} + +/// 第N報 pills for the newest event's reports, newest first. +/// +/// One request per report is what CWA's index is for (`reports` carries the +/// summaries), and it is the only way to read the predictions at all: an event +/// ends with a 海嘯警報解除 report, which carries observations and no predicted +/// heights, so "the newest report only" would hide the forecast exactly when a +/// reader wants to compare it with what happened. +class _ReportPicker extends StatelessWidget { + const _ReportPicker({required this.layer}); + + final TsunamiMapLayer layer; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ListenableBuilder( + listenable: Listenable.merge([layer.bulletins, layer.selectedId]), + builder: (context, _) { + final bulletins = layer.bulletins.value; + if (bulletins.length < 2) return const SizedBox.shrink(); + final selected = layer.selectedId.value; + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.sm, + ), + child: Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.xs, + children: [ + for (final bulletin in bulletins) + SelectablePill( + label: reportLabel(l10n, bulletin), + selected: bulletin.id == selected, + onTap: () => layer.selectBulletin(bulletin.id), + ), + ], + ), + ); + }, + ); + } +} + +/// The pill's label for one report: CWA's own `第3報` re-numbered through the +/// catalogue (`Report 3` in English), or the raw text when it holds no number. +String reportLabel(AppLocalizations l10n, TsunamiBulletin bulletin) { + final digits = RegExp(r'\d+').firstMatch(bulletin.report)?.group(0); + final number = digits == null ? null : int.tryParse(digits); + return number == null ? bulletin.report : l10n.tsunamiReportNumber(number); +} + +class _Bulletin extends StatelessWidget { + const _Bulletin({required this.report}); + + final TsunamiReport report; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final stamp = TsunamiStamp(Localizations.localeOf(context)); + final cancelled = report.msgType == 'Cancel'; + final earthquake = report.earthquake; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + cancelled ? Icons.check_circle : Icons.tsunami, + color: cancelled ? colors.primary : colors.error, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(report.type, style: theme.textTheme.titleLarge), + const SizedBox(height: AppSpacing.xs), + Text( + '${report.report} · ${stamp.of(report.sent)}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + Text(report.content, style: theme.textTheme.bodyMedium), + const SizedBox(height: AppSpacing.xl), + Text(l10n.tsunamiEarthquake, style: theme.textTheme.titleMedium), + const SizedBox(height: AppSpacing.sm), + _FactRow( + label: l10n.reportDetailEpicenter, + value: earthquake.location, + ), + _FactRow( + label: l10n.reportDetailMagnitude, + value: l10n.reportListMagnitude( + earthquake.magnitude.toStringAsFixed(1), + ), + ), + _FactRow( + label: l10n.reportDetailDepth, + value: l10n.reportFilterDepthKm( + earthquake.depth.toStringAsFixed(1), + ), + ), + _FactRow( + label: l10n.reportDetailOriginTime, + value: stamp.of(earthquake.time), + ), + if (report.predictions.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.xl), + Text(l10n.tsunamiPredictions, style: theme.textTheme.titleMedium), + for (final prediction in report.predictions) + _CoastalRow( + title: prediction.area, + subtitle: prediction.coast, + // CWA's own text (`<1`), not a band: the predicted height is a + // range the agency chose, and bucketing `<1` onto one colour + // would state a metre figure CWA did not. + chip: prediction.height, + trailing: stamp.of(prediction.arrivalTime), + ), + ], + if (report.observations.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.xl), + Text(l10n.tsunamiObservations, style: theme.textTheme.titleMedium), + for (final observation in report.observations) + _CoastalRow( + title: observation.name, + chip: _observedLabel(l10n, observation), + band: observedBand(observation), + trailing: stamp.of(observation.time), + ), + ], + ], + ), + ); + } + + /// The observed reading, in centimetres where CWA's text holds a number so + /// the unit localises (`27公分` → `27 cm`), and verbatim where it does not. + static String _observedLabel( + AppLocalizations l10n, + TsunamiObservation observation, + ) { + final centimeters = parseObservedCentimeters(observation.height); + return centimeters == null + ? observation.height + : l10n.tsunamiWaveHeightCm(centimeters); + } +} + +/// `label ————— value`, the label/value row both the earthquake facts and the +/// bulletin body already read as. +class _FactRow extends StatelessWidget { + const _FactRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = theme.textTheme.bodyMedium; + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + value, + textAlign: TextAlign.end, + style: style?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +/// One coastal entry: the area or station, its height chip, and the time. +class _CoastalRow extends StatelessWidget { + const _CoastalRow({ + required this.title, + required this.chip, + required this.trailing, + this.subtitle, + this.band, + }); + + final String title; + final String? subtitle; + + /// The chip's text — a height, in CWA's words or localised. + final String chip; + + /// The band the chip is coloured by; null keeps it neutral. + final TsunamiWaveBand? band; + final String trailing; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.bodyLarge), + if (subtitle != null) + Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + trailing, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.md), + _HeightChip(text: chip, band: band), + ], + ), + ); + } +} + +/// The height chip: the band's colour, or a neutral surface when the height +/// could not be placed on the scale. +/// +/// Ink follows the fill — white reads on every band except the yellow one, +/// which is why the legacy app darkened it there and this does the same. +class _HeightChip extends StatelessWidget { + const _HeightChip({required this.text, this.band}); + + final String text; + final TsunamiWaveBand? band; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final fill = switch (band) { + final band? => + colorFromHexRgb(TsunamiMapLayer.bandColor(band)) ?? + colors.surfaceContainerHighest, + null => colors.surfaceContainerHighest, + }; + final ink = switch (band) { + TsunamiWaveBand.from30cm => const Color(0xFF202020), + null => colors.onSurfaceVariant, + _ => Colors.white, + }; + return Container( + constraints: const BoxConstraints(minWidth: 68), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: fill, + borderRadius: BorderRadius.circular(AppSpacing.sm), + ), + child: Text( + text, + textAlign: TextAlign.center, + style: theme.textTheme.labelLarge?.copyWith( + color: ink, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} + +/// Formats the feed's Unix-millisecond stamps as Taipei wall time, via +/// [AppTime.taipei] — the same fixed +8 the rest of the app reads server stamps +/// with. `toLocal()` would relabel a CWA coastal arrival time with whatever +/// timezone the reader happens to be in. +/// +/// The locale goes through [intlDateLocale] first: `intl` carries its own locale +/// data, separate from `flutter_localizations`, and throws "Invalid locale" on a +/// tag it does not know (`yue`, which this app ships). +class TsunamiStamp { + TsunamiStamp(Locale locale) + : _format = DateFormat('MM/dd HH:mm', intlDateLocale(locale)); + + final DateFormat _format; + + String of(int milliseconds) => _format.format( + AppTime.taipei( + DateTime.fromMillisecondsSinceEpoch(milliseconds, isUtc: true), + ), + ); +} diff --git a/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart index 754ec2a98..bc440d07f 100644 --- a/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart @@ -77,6 +77,7 @@ class TyphoonOverlayMenu extends StatelessWidget { weather != TyphoonWeatherOverlay.none; return MenuAnchor( alignmentOffset: const Offset(0, 4), + animated: true, style: MapChipButton.menuStyle(context), builder: (context, controller, _) { return MapChipButton( diff --git a/lib/features/map/presentation/widgets/typhoon_panel.dart b/lib/features/map/presentation/widgets/typhoon_panel.dart index 948ef7f41..2424d563d 100644 --- a/lib/features/map/presentation/widgets/typhoon_panel.dart +++ b/lib/features/map/presentation/widgets/typhoon_panel.dart @@ -16,6 +16,7 @@ import 'package:dpip/features/typhoon/domain/storm_circle.dart'; import 'package:dpip/features/typhoon/domain/typhoon_intensity.dart'; import 'package:dpip/features/typhoon/domain/typhoon_track.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/selectable_pill.dart'; import 'package:dpip/shared/widgets/sheet_extent.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -374,7 +375,7 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { runSpacing: AppSpacing.xs, children: [ for (final o in options) - _CyclonePill( + SelectablePill( label: o.label, selected: o.key == selectedKey, onTap: () => layer.selectCyclone(o.key), @@ -504,62 +505,6 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { ); } -/// One storm in the sheet's cyclone selector — a quiet pill that marks the -/// focused storm and switches on tap. Only rendered when more than one storm -/// is active, so a single-storm season shows no chrome at all. -class _CyclonePill extends StatelessWidget { - const _CyclonePill({ - required this.label, - required this.selected, - required this.onTap, - }); - - final String label; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppSpacing.lg), - child: DecoratedBox( - decoration: BoxDecoration( - color: selected - ? colors.primaryContainer.withValues(alpha: 0.55) - : Colors.transparent, - borderRadius: BorderRadius.circular(AppSpacing.lg), - border: Border.all( - color: selected - ? Colors.transparent - : colors.outlineVariant.withValues(alpha: 0.5), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.xs + 1, - ), - child: Text( - label, - style: theme.textTheme.labelMedium?.copyWith( - color: selected - ? colors.onPrimaryContainer - : colors.onSurfaceVariant, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - ), - ), - ), - ), - ), - ); - } -} - /// Low-key CWA serial badge (TY 4 / TD 14) on the sheet hero — the storm's /// catalogue number is secondary to its name, so it renders as a quiet pill /// rather than part of the display typography. diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 2e4e2a854..acbdf4c20 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -241,6 +241,29 @@ class MorePage extends StatelessWidget { host: 'youtube.com/@exptechtw', url: 'https://www.youtube.com/@exptechtw', ), + // Social accounts, right under YouTube — the same 官方/粉專 + // distinction the accounts make against fan pages. Material has + // no Threads or Instagram glyph (and the house rule is built-in + // Icons only), so the @ stands in for the handle and the camera + // for the photo feed. + _MoreLinkTile( + icon: Icons.alternate_email_outlined, + title: l10n.moreThreads, + host: 'threads.com/@dpip_tw', + url: 'https://www.threads.com/@dpip_tw', + ), + _MoreLinkTile( + icon: Icons.photo_camera_outlined, + title: l10n.moreInstagram, + host: 'instagram.com/dpip_tw', + url: 'https://www.instagram.com/dpip_tw/', + ), + _MoreLinkTile( + icon: Icons.facebook_outlined, + title: l10n.moreFacebook, + host: 'exptech.com.tw/dpip_tw_FB', + url: 'https://exptech.com.tw/dpip_tw_FB', + ), _MoreLinkTile( icon: Icons.groups_outlined, title: l10n.moreGithub, diff --git a/lib/features/tsunami/data/tsunami_api.dart b/lib/features/tsunami/data/tsunami_api.dart new file mode 100644 index 000000000..0e92ec365 --- /dev/null +++ b/lib/features/tsunami/data/tsunami_api.dart @@ -0,0 +1,29 @@ +/// CWA tsunami endpoints on the region-aware API client. +library; + +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_paths.dart'; +import 'package:dpip/core/network/api_region.dart'; + +/// `/api/v1/cwa/tsunami` — the bulletin index, and `/…/{id}` for one bulletin. +/// +/// **[ApiTier.coreApi], not `coreExclusiveApi`**: both core regions (`tyo1` +/// and `tnn1`) answer `/api/v1/cwa/tsunami` (verified 2026-10-05, index and +/// detail alike), so this is one of the multi-active endpoints and gets the +/// failover rather than being pinned to tpe1's neighbour for no reason. +class TsunamiApi { + const TsunamiApi(this._client); + + final ApiClient _client; + + /// The index — `{ statistics, count, reports: [...] }`, newest first. + Future> getReports() async => + (await _client.get(ApiTier.coreApi, ApiPaths.tsunami)) + as Map; + + /// One bulletin in full. The index rows only carry the event summary; this is + /// where `content` and the `data.area` tuples are. + Future> getReport(String id) async => + (await _client.get(ApiTier.coreApi, '${ApiPaths.tsunami}/$id')) + as Map; +} diff --git a/lib/features/tsunami/data/tsunami_repository_impl.dart b/lib/features/tsunami/data/tsunami_repository_impl.dart new file mode 100644 index 000000000..9ea22c630 --- /dev/null +++ b/lib/features/tsunami/data/tsunami_repository_impl.dart @@ -0,0 +1,27 @@ +/// [TsunamiRepository] backed by the CWA tsunami API. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/network/api_exception.dart'; +import 'package:dpip/features/tsunami/data/tsunami_api.dart'; +import 'package:dpip/features/tsunami/domain/tsunami_report.dart'; +import 'package:dpip/features/tsunami/domain/tsunami_repository.dart'; + +/// Maps the index rows to [TsunamiBulletin]s and the detail payload to a +/// [TsunamiReport], converting transport/decode errors to typed failures via +/// [guardResult]. +class TsunamiRepositoryImpl implements TsunamiRepository { + const TsunamiRepositoryImpl(this._api); + + final TsunamiApi _api; + + @override + Future>> bulletins() => guardResult(() async { + final index = await _api.getReports(); + return TsunamiBulletin.newestEvent((index['reports'] as List?) ?? const []); + }); + + @override + Future> report(String id) => + guardResult(() async => TsunamiReport.decode(await _api.getReport(id))); +} diff --git a/lib/features/tsunami/domain/tsunami_report.dart b/lib/features/tsunami/domain/tsunami_report.dart new file mode 100644 index 000000000..2a5e08268 --- /dev/null +++ b/lib/features/tsunami/domain/tsunami_report.dart @@ -0,0 +1,244 @@ +/// CWA tsunami bulletins, their coastal entries, and the wave-height scale the +/// map and the sheet both colour by. +library; + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'tsunami_report.freezed.dart'; +part 'tsunami_report.g.dart'; + +/// Earthquake a tsunami bulletin was issued for. +@freezed +abstract class TsunamiEarthquake with _$TsunamiEarthquake { + const factory TsunamiEarthquake({ + /// Origin time (Unix milliseconds). + @JsonKey(name: 't') required int time, + @JsonKey(name: 'lon') required double longitude, + @JsonKey(name: 'lat') required double latitude, + @JsonKey(name: 'loc') required String location, + + /// Focal depth (km). + @JsonKey(name: 'dep') required double depth, + + /// CWA magnitude. + @JsonKey(name: 'mag') required double magnitude, + }) = _TsunamiEarthquake; + + factory TsunamiEarthquake.fromJson(Map json) => + _$TsunamiEarthquakeFromJson(json); +} + +/// One predicted coastal impact area (a `data.type == 'predict'` row). +/// +/// [height] is CWA's own text (`<1`, and on other bulletins a bare number), and +/// it stays text on purpose: unlike an observation it is not a measurement, so +/// there is no centimetre value to bucket — see [TsunamiWaveBand]. +@freezed +abstract class TsunamiPrediction with _$TsunamiPrediction { + const factory TsunamiPrediction({ + required String area, + required String coast, + required String height, + + /// Predicted arrival time (Unix milliseconds). + required int arrivalTime, + + /// CWA's own warning colour for the area (`黃色`), the fifth field of the + /// tuple. Null when the tuple is shorter or the field is empty: the map + /// then leaves the area unpainted rather than reading a band out of + /// [height]. + String? color, + }) = _TsunamiPrediction; +} + +/// One observed reading from a tide-gauge station (a `data.type == 'observe'` +/// row). +/// +/// [longitude] / [latitude] are **nullable** — several stations in the +/// 2024-04-03 花蓮 bulletin ship `null` for both, so an absent position is a +/// real shape rather than a reason to reject the bulletin. +@freezed +abstract class TsunamiObservation with _$TsunamiObservation { + const factory TsunamiObservation({ + required String name, + + /// CWA's observed height text (`27公分`). + required String height, + + /// Observation time (Unix milliseconds). + required int time, + double? longitude, + double? latitude, + }) = _TsunamiObservation; +} + +/// One report of one event, as the index lists it (`GET /api/v1/cwa/tsunami`). +/// +/// The index rows carry the summary only — no text, no `data.area` — which is +/// exactly what the sheet's report picker needs: enough to label a pill (第3報) +/// and fetch the full bulletin behind it. +@freezed +abstract class TsunamiBulletin with _$TsunamiBulletin { + const factory TsunamiBulletin({ + required String id, + + /// CWA event number, shared by every report of the same earthquake + /// (`113003`) — the key that makes "this event's reports" a list. + @JsonKey(name: 'no') required int number, + + /// Report within the event, CWA's own wording (`第3報`). + @JsonKey(name: 'rep') required String report, + + /// `海嘯警報` / `海嘯警報解除`. + required String type, + + /// Send time (Unix milliseconds). + required int sent, + }) = _TsunamiBulletin; + + factory TsunamiBulletin.fromJson(Map json) => + _$TsunamiBulletinFromJson(json); + + /// The **newest event's** reports from the raw index rows, newest first. + /// + /// An event, not the whole index: the index mixes past years in (the only + /// rows CWA serves outside an event are old ones), so "the current event" is + /// the newest row's `no` and its reports are the rows sharing it. Filtering + /// by `no` rather than by an id prefix is deliberate — the id carries the + /// event *and* the report (`…11300303-2024-0403-111000`), so a prefix match + /// would re-derive a serial CWA already gives us. An index with nothing in it + /// is an empty list, which is the calm case rather than a failure. + static List newestEvent(List rows) { + if (rows.isEmpty) return const []; + final event = (rows.first as Map)['no']; + return [ + for (final row in rows) + if ((row as Map)['no'] == event) + TsunamiBulletin.fromJson(row), + ]; + } +} + +/// One complete CWA tsunami bulletin. +@freezed +abstract class TsunamiReport with _$TsunamiReport { + const factory TsunamiReport({ + required String id, + + /// Send time (Unix milliseconds — the tsunami feed, unlike the meteor + /// families, is not in seconds). + required int sent, + + /// CAP message type: `Issue` / `Update` / `Cancel`. `Cancel` means the + /// threat was lifted, which the sheet states rather than hiding. + required String msgType, + + /// Report within the event, CWA's own wording (`第3報`). + required String report, + + /// `海嘯警報` / `海嘯警報解除`. + required String type, + required String content, + required TsunamiEarthquake earthquake, + required List predictions, + required List observations, + }) = _TsunamiReport; + + /// Decodes the payload, whose `data.area` is a positional tuple — five + /// fields when `data.type` is `predict`, six when it is `observe`. + factory TsunamiReport.decode(Map json) { + final data = json['data'] as Map?; + final areas = (data?['area'] as List?) ?? const []; + final predicted = data?['type'] == 'predict'; + return TsunamiReport( + id: json['id'] as String, + sent: (json['sent'] as num).toInt(), + msgType: json['msgType'] as String, + report: json['rep'] as String, + type: json['type'] as String, + content: json['content'] as String, + earthquake: TsunamiEarthquake.fromJson( + json['eq'] as Map, + ), + predictions: predicted + ? [ + for (final raw in areas) + TsunamiPrediction( + area: (raw as List)[0] as String, + coast: raw[1] as String, + height: raw[2] as String, + arrivalTime: (raw[3] as num).toInt(), + color: raw.length > 4 ? raw[4] as String? : null, + ), + ] + : const [], + observations: predicted + ? const [] + : [ + for (final raw in areas) + TsunamiObservation( + name: (raw as List)[1] as String, + height: raw[2] as String, + time: (raw[3] as num).toInt(), + longitude: (raw[4] as num?)?.toDouble(), + latitude: (raw[5] as num?)?.toDouble(), + ), + ], + ); + } +} + +/// How tall a wave was, in the four steps CWA and the legacy app both used. +/// +/// The boundaries are CWA's own (0.3 m / 1 m / 3 m) and the labels read in +/// metres even though an **observed** height arrives in centimetres, because +/// the step a reader cares about is "was it over a metre", not the exact number. +enum TsunamiWaveBand { + /// Under 0.3 m. + under30cm, + + /// 0.3–1 m. + from30cm, + + /// 1–3 m. + from1m, + + /// Over 3 m. + over3m; + + /// The band CWA's warning colour for a predicted area stands for, or null for + /// a colour this map has no step for. + /// + /// Yellow is CWA's "under 1 m" and is the legacy app's 0.3–1 m step; red and + /// purple are 1–3 m and over 3 m. There is no predicted colour for the quiet + /// blue step, so an unknown colour is left unpainted rather than defaulted. + static TsunamiWaveBand? ofWarningColor(String? color) => switch (color) { + '黃色' => from30cm, + '紅色' => from1m, + '紫色' => over3m, + _ => null, + }; + + /// The band a [centimeters] reading falls in. + static TsunamiWaveBand of(int centimeters) => switch (centimeters) { + >= 300 => over3m, + >= 100 => from1m, + >= 30 => from30cm, + _ => under30cm, + }; +} + +/// The centimetre reading inside CWA's observed-height text (`27公分`, `1.5 公尺`), +/// or null when the text holds no number at all. +/// +/// Returns null rather than 0 for text it cannot read: "no height I understand" +/// and "a zero-centimetre wave" must not paint the same colour, and the map's +/// quietest band is a claim about the sea. +int? parseObservedCentimeters(String text) { + final match = RegExp(r'\d+(?:\.\d+)?').firstMatch(text); + if (match == null) return null; + final value = double.tryParse(match.group(0)!); + if (value == null) return null; + // A reading CWA wrote in metres (`1.5 公尺`) is not 1.5 cm. + return text.contains('公尺') ? (value * 100).round() : value.round(); +} diff --git a/lib/features/tsunami/domain/tsunami_report.freezed.dart b/lib/features/tsunami/domain/tsunami_report.freezed.dart new file mode 100644 index 000000000..40d5bdb93 --- /dev/null +++ b/lib/features/tsunami/domain/tsunami_report.freezed.dart @@ -0,0 +1,1459 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'tsunami_report.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$TsunamiEarthquake { + +/// Origin time (Unix milliseconds). +@JsonKey(name: 't') int get time;@JsonKey(name: 'lon') double get longitude;@JsonKey(name: 'lat') double get latitude;@JsonKey(name: 'loc') String get location;/// Focal depth (km). +@JsonKey(name: 'dep') double get depth;/// CWA magnitude. +@JsonKey(name: 'mag') double get magnitude; +/// Create a copy of TsunamiEarthquake +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TsunamiEarthquakeCopyWith get copyWith => _$TsunamiEarthquakeCopyWithImpl(this as TsunamiEarthquake, _$identity); + + /// Serializes this TsunamiEarthquake to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TsunamiEarthquake&&(identical(other.time, time) || other.time == time)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.location, location) || other.location == location)&&(identical(other.depth, depth) || other.depth == depth)&&(identical(other.magnitude, magnitude) || other.magnitude == magnitude)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,time,longitude,latitude,location,depth,magnitude); + +@override +String toString() { + return 'TsunamiEarthquake(time: $time, longitude: $longitude, latitude: $latitude, location: $location, depth: $depth, magnitude: $magnitude)'; +} + + +} + +/// @nodoc +abstract mixin class $TsunamiEarthquakeCopyWith<$Res> { + factory $TsunamiEarthquakeCopyWith(TsunamiEarthquake value, $Res Function(TsunamiEarthquake) _then) = _$TsunamiEarthquakeCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 't') int time,@JsonKey(name: 'lon') double longitude,@JsonKey(name: 'lat') double latitude,@JsonKey(name: 'loc') String location,@JsonKey(name: 'dep') double depth,@JsonKey(name: 'mag') double magnitude +}); + + + + +} +/// @nodoc +class _$TsunamiEarthquakeCopyWithImpl<$Res> + implements $TsunamiEarthquakeCopyWith<$Res> { + _$TsunamiEarthquakeCopyWithImpl(this._self, this._then); + + final TsunamiEarthquake _self; + final $Res Function(TsunamiEarthquake) _then; + +/// Create a copy of TsunamiEarthquake +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? time = null,Object? longitude = null,Object? latitude = null,Object? location = null,Object? depth = null,Object? magnitude = null,}) { + return _then(TsunamiEarthquake( +time: null == time ? _self.time : time // ignore: cast_nullable_to_non_nullable +as int,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String,depth: null == depth ? _self.depth : depth // ignore: cast_nullable_to_non_nullable +as double,magnitude: null == magnitude ? _self.magnitude : magnitude // ignore: cast_nullable_to_non_nullable +as double, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TsunamiEarthquake]. +extension TsunamiEarthquakePatterns on TsunamiEarthquake { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TsunamiEarthquake value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TsunamiEarthquake() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TsunamiEarthquake value) $default,){ +final _that = this; +switch (_that) { +case _TsunamiEarthquake(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TsunamiEarthquake value)? $default,){ +final _that = this; +switch (_that) { +case _TsunamiEarthquake() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 't') int time, @JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'loc') String location, @JsonKey(name: 'dep') double depth, @JsonKey(name: 'mag') double magnitude)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TsunamiEarthquake() when $default != null: +return $default(_that.time,_that.longitude,_that.latitude,_that.location,_that.depth,_that.magnitude);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 't') int time, @JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'loc') String location, @JsonKey(name: 'dep') double depth, @JsonKey(name: 'mag') double magnitude) $default,) {final _that = this; +switch (_that) { +case _TsunamiEarthquake(): +return $default(_that.time,_that.longitude,_that.latitude,_that.location,_that.depth,_that.magnitude);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 't') int time, @JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'loc') String location, @JsonKey(name: 'dep') double depth, @JsonKey(name: 'mag') double magnitude)? $default,) {final _that = this; +switch (_that) { +case _TsunamiEarthquake() when $default != null: +return $default(_that.time,_that.longitude,_that.latitude,_that.location,_that.depth,_that.magnitude);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TsunamiEarthquake implements TsunamiEarthquake { + const _TsunamiEarthquake({@JsonKey(name: 't') required this.time, @JsonKey(name: 'lon') required this.longitude, @JsonKey(name: 'lat') required this.latitude, @JsonKey(name: 'loc') required this.location, @JsonKey(name: 'dep') required this.depth, @JsonKey(name: 'mag') required this.magnitude}); + factory _TsunamiEarthquake.fromJson(Map json) => _$TsunamiEarthquakeFromJson(json); + +/// Origin time (Unix milliseconds). +@override@JsonKey(name: 't') final int time; +@override@JsonKey(name: 'lon') final double longitude; +@override@JsonKey(name: 'lat') final double latitude; +@override@JsonKey(name: 'loc') final String location; +/// Focal depth (km). +@override@JsonKey(name: 'dep') final double depth; +/// CWA magnitude. +@override@JsonKey(name: 'mag') final double magnitude; + +/// Create a copy of TsunamiEarthquake +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TsunamiEarthquakeCopyWith<_TsunamiEarthquake> get copyWith => __$TsunamiEarthquakeCopyWithImpl<_TsunamiEarthquake>(this, _$identity); + +@override +Map toJson() { + return _$TsunamiEarthquakeToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TsunamiEarthquake&&(identical(other.time, time) || other.time == time)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.location, location) || other.location == location)&&(identical(other.depth, depth) || other.depth == depth)&&(identical(other.magnitude, magnitude) || other.magnitude == magnitude)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,time,longitude,latitude,location,depth,magnitude); + +@override +String toString() { + return 'TsunamiEarthquake(time: $time, longitude: $longitude, latitude: $latitude, location: $location, depth: $depth, magnitude: $magnitude)'; +} + + +} + +/// @nodoc +abstract mixin class _$TsunamiEarthquakeCopyWith<$Res> implements $TsunamiEarthquakeCopyWith<$Res> { + factory _$TsunamiEarthquakeCopyWith(_TsunamiEarthquake value, $Res Function(_TsunamiEarthquake) _then) = __$TsunamiEarthquakeCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 't') int time,@JsonKey(name: 'lon') double longitude,@JsonKey(name: 'lat') double latitude,@JsonKey(name: 'loc') String location,@JsonKey(name: 'dep') double depth,@JsonKey(name: 'mag') double magnitude +}); + + + + +} +/// @nodoc +class __$TsunamiEarthquakeCopyWithImpl<$Res> + implements _$TsunamiEarthquakeCopyWith<$Res> { + __$TsunamiEarthquakeCopyWithImpl(this._self, this._then); + + final _TsunamiEarthquake _self; + final $Res Function(_TsunamiEarthquake) _then; + +/// Create a copy of TsunamiEarthquake +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? time = null,Object? longitude = null,Object? latitude = null,Object? location = null,Object? depth = null,Object? magnitude = null,}) { + return _then(_TsunamiEarthquake( +time: null == time ? _self.time : time // ignore: cast_nullable_to_non_nullable +as int,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String,depth: null == depth ? _self.depth : depth // ignore: cast_nullable_to_non_nullable +as double,magnitude: null == magnitude ? _self.magnitude : magnitude // ignore: cast_nullable_to_non_nullable +as double, + )); +} + + +} + +/// @nodoc +mixin _$TsunamiPrediction { + + String get area; String get coast; String get height;/// Predicted arrival time (Unix milliseconds). + int get arrivalTime;/// CWA's own warning colour for the area (`黃色`), the fifth field of the +/// tuple. Null when the tuple is shorter or the field is empty: the map +/// then leaves the area unpainted rather than reading a band out of +/// [height]. + String? get color; +/// Create a copy of TsunamiPrediction +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TsunamiPredictionCopyWith get copyWith => _$TsunamiPredictionCopyWithImpl(this as TsunamiPrediction, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TsunamiPrediction&&(identical(other.area, area) || other.area == area)&&(identical(other.coast, coast) || other.coast == coast)&&(identical(other.height, height) || other.height == height)&&(identical(other.arrivalTime, arrivalTime) || other.arrivalTime == arrivalTime)&&(identical(other.color, color) || other.color == color)); +} + + +@override +int get hashCode => Object.hash(runtimeType,area,coast,height,arrivalTime,color); + +@override +String toString() { + return 'TsunamiPrediction(area: $area, coast: $coast, height: $height, arrivalTime: $arrivalTime, color: $color)'; +} + + +} + +/// @nodoc +abstract mixin class $TsunamiPredictionCopyWith<$Res> { + factory $TsunamiPredictionCopyWith(TsunamiPrediction value, $Res Function(TsunamiPrediction) _then) = _$TsunamiPredictionCopyWithImpl; +@useResult +$Res call({ + String area, String coast, String height, int arrivalTime, String? color +}); + + + + +} +/// @nodoc +class _$TsunamiPredictionCopyWithImpl<$Res> + implements $TsunamiPredictionCopyWith<$Res> { + _$TsunamiPredictionCopyWithImpl(this._self, this._then); + + final TsunamiPrediction _self; + final $Res Function(TsunamiPrediction) _then; + +/// Create a copy of TsunamiPrediction +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? area = null,Object? coast = null,Object? height = null,Object? arrivalTime = null,Object? color = freezed,}) { + return _then(TsunamiPrediction( +area: null == area ? _self.area : area // ignore: cast_nullable_to_non_nullable +as String,coast: null == coast ? _self.coast : coast // ignore: cast_nullable_to_non_nullable +as String,height: null == height ? _self.height : height // ignore: cast_nullable_to_non_nullable +as String,arrivalTime: null == arrivalTime ? _self.arrivalTime : arrivalTime // ignore: cast_nullable_to_non_nullable +as int,color: freezed == color ? _self.color : color // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TsunamiPrediction]. +extension TsunamiPredictionPatterns on TsunamiPrediction { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TsunamiPrediction value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TsunamiPrediction() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TsunamiPrediction value) $default,){ +final _that = this; +switch (_that) { +case _TsunamiPrediction(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TsunamiPrediction value)? $default,){ +final _that = this; +switch (_that) { +case _TsunamiPrediction() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String area, String coast, String height, int arrivalTime, String? color)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TsunamiPrediction() when $default != null: +return $default(_that.area,_that.coast,_that.height,_that.arrivalTime,_that.color);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String area, String coast, String height, int arrivalTime, String? color) $default,) {final _that = this; +switch (_that) { +case _TsunamiPrediction(): +return $default(_that.area,_that.coast,_that.height,_that.arrivalTime,_that.color);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String area, String coast, String height, int arrivalTime, String? color)? $default,) {final _that = this; +switch (_that) { +case _TsunamiPrediction() when $default != null: +return $default(_that.area,_that.coast,_that.height,_that.arrivalTime,_that.color);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _TsunamiPrediction implements TsunamiPrediction { + const _TsunamiPrediction({required this.area, required this.coast, required this.height, required this.arrivalTime, this.color}); + + +@override final String area; +@override final String coast; +@override final String height; +/// Predicted arrival time (Unix milliseconds). +@override final int arrivalTime; +/// CWA's own warning colour for the area (`黃色`), the fifth field of the +/// tuple. Null when the tuple is shorter or the field is empty: the map +/// then leaves the area unpainted rather than reading a band out of +/// [height]. +@override final String? color; + +/// Create a copy of TsunamiPrediction +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TsunamiPredictionCopyWith<_TsunamiPrediction> get copyWith => __$TsunamiPredictionCopyWithImpl<_TsunamiPrediction>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TsunamiPrediction&&(identical(other.area, area) || other.area == area)&&(identical(other.coast, coast) || other.coast == coast)&&(identical(other.height, height) || other.height == height)&&(identical(other.arrivalTime, arrivalTime) || other.arrivalTime == arrivalTime)&&(identical(other.color, color) || other.color == color)); +} + + +@override +int get hashCode => Object.hash(runtimeType,area,coast,height,arrivalTime,color); + +@override +String toString() { + return 'TsunamiPrediction(area: $area, coast: $coast, height: $height, arrivalTime: $arrivalTime, color: $color)'; +} + + +} + +/// @nodoc +abstract mixin class _$TsunamiPredictionCopyWith<$Res> implements $TsunamiPredictionCopyWith<$Res> { + factory _$TsunamiPredictionCopyWith(_TsunamiPrediction value, $Res Function(_TsunamiPrediction) _then) = __$TsunamiPredictionCopyWithImpl; +@override @useResult +$Res call({ + String area, String coast, String height, int arrivalTime, String? color +}); + + + + +} +/// @nodoc +class __$TsunamiPredictionCopyWithImpl<$Res> + implements _$TsunamiPredictionCopyWith<$Res> { + __$TsunamiPredictionCopyWithImpl(this._self, this._then); + + final _TsunamiPrediction _self; + final $Res Function(_TsunamiPrediction) _then; + +/// Create a copy of TsunamiPrediction +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? area = null,Object? coast = null,Object? height = null,Object? arrivalTime = null,Object? color = freezed,}) { + return _then(_TsunamiPrediction( +area: null == area ? _self.area : area // ignore: cast_nullable_to_non_nullable +as String,coast: null == coast ? _self.coast : coast // ignore: cast_nullable_to_non_nullable +as String,height: null == height ? _self.height : height // ignore: cast_nullable_to_non_nullable +as String,arrivalTime: null == arrivalTime ? _self.arrivalTime : arrivalTime // ignore: cast_nullable_to_non_nullable +as int,color: freezed == color ? _self.color : color // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +mixin _$TsunamiObservation { + + String get name;/// CWA's observed height text (`27公分`). + String get height;/// Observation time (Unix milliseconds). + int get time; double? get longitude; double? get latitude; +/// Create a copy of TsunamiObservation +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TsunamiObservationCopyWith get copyWith => _$TsunamiObservationCopyWithImpl(this as TsunamiObservation, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TsunamiObservation&&(identical(other.name, name) || other.name == name)&&(identical(other.height, height) || other.height == height)&&(identical(other.time, time) || other.time == time)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name,height,time,longitude,latitude); + +@override +String toString() { + return 'TsunamiObservation(name: $name, height: $height, time: $time, longitude: $longitude, latitude: $latitude)'; +} + + +} + +/// @nodoc +abstract mixin class $TsunamiObservationCopyWith<$Res> { + factory $TsunamiObservationCopyWith(TsunamiObservation value, $Res Function(TsunamiObservation) _then) = _$TsunamiObservationCopyWithImpl; +@useResult +$Res call({ + String name, String height, int time, double? longitude, double? latitude +}); + + + + +} +/// @nodoc +class _$TsunamiObservationCopyWithImpl<$Res> + implements $TsunamiObservationCopyWith<$Res> { + _$TsunamiObservationCopyWithImpl(this._self, this._then); + + final TsunamiObservation _self; + final $Res Function(TsunamiObservation) _then; + +/// Create a copy of TsunamiObservation +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? height = null,Object? time = null,Object? longitude = freezed,Object? latitude = freezed,}) { + return _then(TsunamiObservation( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,height: null == height ? _self.height : height // ignore: cast_nullable_to_non_nullable +as String,time: null == time ? _self.time : time // ignore: cast_nullable_to_non_nullable +as int,longitude: freezed == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double?,latitude: freezed == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TsunamiObservation]. +extension TsunamiObservationPatterns on TsunamiObservation { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TsunamiObservation value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TsunamiObservation() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TsunamiObservation value) $default,){ +final _that = this; +switch (_that) { +case _TsunamiObservation(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TsunamiObservation value)? $default,){ +final _that = this; +switch (_that) { +case _TsunamiObservation() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String height, int time, double? longitude, double? latitude)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TsunamiObservation() when $default != null: +return $default(_that.name,_that.height,_that.time,_that.longitude,_that.latitude);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, String height, int time, double? longitude, double? latitude) $default,) {final _that = this; +switch (_that) { +case _TsunamiObservation(): +return $default(_that.name,_that.height,_that.time,_that.longitude,_that.latitude);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String height, int time, double? longitude, double? latitude)? $default,) {final _that = this; +switch (_that) { +case _TsunamiObservation() when $default != null: +return $default(_that.name,_that.height,_that.time,_that.longitude,_that.latitude);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _TsunamiObservation implements TsunamiObservation { + const _TsunamiObservation({required this.name, required this.height, required this.time, this.longitude, this.latitude}); + + +@override final String name; +/// CWA's observed height text (`27公分`). +@override final String height; +/// Observation time (Unix milliseconds). +@override final int time; +@override final double? longitude; +@override final double? latitude; + +/// Create a copy of TsunamiObservation +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TsunamiObservationCopyWith<_TsunamiObservation> get copyWith => __$TsunamiObservationCopyWithImpl<_TsunamiObservation>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TsunamiObservation&&(identical(other.name, name) || other.name == name)&&(identical(other.height, height) || other.height == height)&&(identical(other.time, time) || other.time == time)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name,height,time,longitude,latitude); + +@override +String toString() { + return 'TsunamiObservation(name: $name, height: $height, time: $time, longitude: $longitude, latitude: $latitude)'; +} + + +} + +/// @nodoc +abstract mixin class _$TsunamiObservationCopyWith<$Res> implements $TsunamiObservationCopyWith<$Res> { + factory _$TsunamiObservationCopyWith(_TsunamiObservation value, $Res Function(_TsunamiObservation) _then) = __$TsunamiObservationCopyWithImpl; +@override @useResult +$Res call({ + String name, String height, int time, double? longitude, double? latitude +}); + + + + +} +/// @nodoc +class __$TsunamiObservationCopyWithImpl<$Res> + implements _$TsunamiObservationCopyWith<$Res> { + __$TsunamiObservationCopyWithImpl(this._self, this._then); + + final _TsunamiObservation _self; + final $Res Function(_TsunamiObservation) _then; + +/// Create a copy of TsunamiObservation +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? height = null,Object? time = null,Object? longitude = freezed,Object? latitude = freezed,}) { + return _then(_TsunamiObservation( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,height: null == height ? _self.height : height // ignore: cast_nullable_to_non_nullable +as String,time: null == time ? _self.time : time // ignore: cast_nullable_to_non_nullable +as int,longitude: freezed == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double?,latitude: freezed == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + + +} + + +/// @nodoc +mixin _$TsunamiBulletin { + + String get id;/// CWA event number, shared by every report of the same earthquake +/// (`113003`) — the key that makes "this event's reports" a list. +@JsonKey(name: 'no') int get number;/// Report within the event, CWA's own wording (`第3報`). +@JsonKey(name: 'rep') String get report;/// `海嘯警報` / `海嘯警報解除`. + String get type;/// Send time (Unix milliseconds). + int get sent; +/// Create a copy of TsunamiBulletin +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TsunamiBulletinCopyWith get copyWith => _$TsunamiBulletinCopyWithImpl(this as TsunamiBulletin, _$identity); + + /// Serializes this TsunamiBulletin to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TsunamiBulletin&&(identical(other.id, id) || other.id == id)&&(identical(other.number, number) || other.number == number)&&(identical(other.report, report) || other.report == report)&&(identical(other.type, type) || other.type == type)&&(identical(other.sent, sent) || other.sent == sent)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,number,report,type,sent); + +@override +String toString() { + return 'TsunamiBulletin(id: $id, number: $number, report: $report, type: $type, sent: $sent)'; +} + + +} + +/// @nodoc +abstract mixin class $TsunamiBulletinCopyWith<$Res> { + factory $TsunamiBulletinCopyWith(TsunamiBulletin value, $Res Function(TsunamiBulletin) _then) = _$TsunamiBulletinCopyWithImpl; +@useResult +$Res call({ + String id,@JsonKey(name: 'no') int number,@JsonKey(name: 'rep') String report, String type, int sent +}); + + + + +} +/// @nodoc +class _$TsunamiBulletinCopyWithImpl<$Res> + implements $TsunamiBulletinCopyWith<$Res> { + _$TsunamiBulletinCopyWithImpl(this._self, this._then); + + final TsunamiBulletin _self; + final $Res Function(TsunamiBulletin) _then; + +/// Create a copy of TsunamiBulletin +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? number = null,Object? report = null,Object? type = null,Object? sent = null,}) { + return _then(TsunamiBulletin( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,number: null == number ? _self.number : number // ignore: cast_nullable_to_non_nullable +as int,report: null == report ? _self.report : report // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,sent: null == sent ? _self.sent : sent // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TsunamiBulletin]. +extension TsunamiBulletinPatterns on TsunamiBulletin { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TsunamiBulletin value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TsunamiBulletin() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TsunamiBulletin value) $default,){ +final _that = this; +switch (_that) { +case _TsunamiBulletin(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TsunamiBulletin value)? $default,){ +final _that = this; +switch (_that) { +case _TsunamiBulletin() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, @JsonKey(name: 'no') int number, @JsonKey(name: 'rep') String report, String type, int sent)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TsunamiBulletin() when $default != null: +return $default(_that.id,_that.number,_that.report,_that.type,_that.sent);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, @JsonKey(name: 'no') int number, @JsonKey(name: 'rep') String report, String type, int sent) $default,) {final _that = this; +switch (_that) { +case _TsunamiBulletin(): +return $default(_that.id,_that.number,_that.report,_that.type,_that.sent);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, @JsonKey(name: 'no') int number, @JsonKey(name: 'rep') String report, String type, int sent)? $default,) {final _that = this; +switch (_that) { +case _TsunamiBulletin() when $default != null: +return $default(_that.id,_that.number,_that.report,_that.type,_that.sent);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TsunamiBulletin implements TsunamiBulletin { + const _TsunamiBulletin({required this.id, @JsonKey(name: 'no') required this.number, @JsonKey(name: 'rep') required this.report, required this.type, required this.sent}); + factory _TsunamiBulletin.fromJson(Map json) => _$TsunamiBulletinFromJson(json); + +@override final String id; +/// CWA event number, shared by every report of the same earthquake +/// (`113003`) — the key that makes "this event's reports" a list. +@override@JsonKey(name: 'no') final int number; +/// Report within the event, CWA's own wording (`第3報`). +@override@JsonKey(name: 'rep') final String report; +/// `海嘯警報` / `海嘯警報解除`. +@override final String type; +/// Send time (Unix milliseconds). +@override final int sent; + +/// Create a copy of TsunamiBulletin +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TsunamiBulletinCopyWith<_TsunamiBulletin> get copyWith => __$TsunamiBulletinCopyWithImpl<_TsunamiBulletin>(this, _$identity); + +@override +Map toJson() { + return _$TsunamiBulletinToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TsunamiBulletin&&(identical(other.id, id) || other.id == id)&&(identical(other.number, number) || other.number == number)&&(identical(other.report, report) || other.report == report)&&(identical(other.type, type) || other.type == type)&&(identical(other.sent, sent) || other.sent == sent)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,number,report,type,sent); + +@override +String toString() { + return 'TsunamiBulletin(id: $id, number: $number, report: $report, type: $type, sent: $sent)'; +} + + +} + +/// @nodoc +abstract mixin class _$TsunamiBulletinCopyWith<$Res> implements $TsunamiBulletinCopyWith<$Res> { + factory _$TsunamiBulletinCopyWith(_TsunamiBulletin value, $Res Function(_TsunamiBulletin) _then) = __$TsunamiBulletinCopyWithImpl; +@override @useResult +$Res call({ + String id,@JsonKey(name: 'no') int number,@JsonKey(name: 'rep') String report, String type, int sent +}); + + + + +} +/// @nodoc +class __$TsunamiBulletinCopyWithImpl<$Res> + implements _$TsunamiBulletinCopyWith<$Res> { + __$TsunamiBulletinCopyWithImpl(this._self, this._then); + + final _TsunamiBulletin _self; + final $Res Function(_TsunamiBulletin) _then; + +/// Create a copy of TsunamiBulletin +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? number = null,Object? report = null,Object? type = null,Object? sent = null,}) { + return _then(_TsunamiBulletin( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,number: null == number ? _self.number : number // ignore: cast_nullable_to_non_nullable +as int,report: null == report ? _self.report : report // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,sent: null == sent ? _self.sent : sent // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc +mixin _$TsunamiReport { + + String get id;/// Send time (Unix milliseconds — the tsunami feed, unlike the meteor +/// families, is not in seconds). + int get sent;/// CAP message type: `Issue` / `Update` / `Cancel`. `Cancel` means the +/// threat was lifted, which the sheet states rather than hiding. + String get msgType;/// Report within the event, CWA's own wording (`第3報`). + String get report;/// `海嘯警報` / `海嘯警報解除`. + String get type; String get content; TsunamiEarthquake get earthquake; List get predictions; List get observations; +/// Create a copy of TsunamiReport +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TsunamiReportCopyWith get copyWith => _$TsunamiReportCopyWithImpl(this as TsunamiReport, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TsunamiReport&&(identical(other.id, id) || other.id == id)&&(identical(other.sent, sent) || other.sent == sent)&&(identical(other.msgType, msgType) || other.msgType == msgType)&&(identical(other.report, report) || other.report == report)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.earthquake, earthquake) || other.earthquake == earthquake)&&const DeepCollectionEquality().equals(other.predictions, predictions)&&const DeepCollectionEquality().equals(other.observations, observations)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,sent,msgType,report,type,content,earthquake,const DeepCollectionEquality().hash(predictions),const DeepCollectionEquality().hash(observations)); + +@override +String toString() { + return 'TsunamiReport(id: $id, sent: $sent, msgType: $msgType, report: $report, type: $type, content: $content, earthquake: $earthquake, predictions: $predictions, observations: $observations)'; +} + + +} + +/// @nodoc +abstract mixin class $TsunamiReportCopyWith<$Res> { + factory $TsunamiReportCopyWith(TsunamiReport value, $Res Function(TsunamiReport) _then) = _$TsunamiReportCopyWithImpl; +@useResult +$Res call({ + String id, int sent, String msgType, String report, String type, String content, TsunamiEarthquake earthquake, List predictions, List observations +}); + + +$TsunamiEarthquakeCopyWith<$Res> get earthquake; + +} +/// @nodoc +class _$TsunamiReportCopyWithImpl<$Res> + implements $TsunamiReportCopyWith<$Res> { + _$TsunamiReportCopyWithImpl(this._self, this._then); + + final TsunamiReport _self; + final $Res Function(TsunamiReport) _then; + +/// Create a copy of TsunamiReport +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? sent = null,Object? msgType = null,Object? report = null,Object? type = null,Object? content = null,Object? earthquake = null,Object? predictions = null,Object? observations = null,}) { + return _then(TsunamiReport( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,sent: null == sent ? _self.sent : sent // ignore: cast_nullable_to_non_nullable +as int,msgType: null == msgType ? _self.msgType : msgType // ignore: cast_nullable_to_non_nullable +as String,report: null == report ? _self.report : report // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String,earthquake: null == earthquake ? _self.earthquake : earthquake // ignore: cast_nullable_to_non_nullable +as TsunamiEarthquake,predictions: null == predictions ? _self.predictions : predictions // ignore: cast_nullable_to_non_nullable +as List,observations: null == observations ? _self.observations : observations // ignore: cast_nullable_to_non_nullable +as List, + )); +} +/// Create a copy of TsunamiReport +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TsunamiEarthquakeCopyWith<$Res> get earthquake { + + return $TsunamiEarthquakeCopyWith<$Res>(_self.earthquake, (value) { + return _then(_self.copyWith(earthquake: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [TsunamiReport]. +extension TsunamiReportPatterns on TsunamiReport { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TsunamiReport value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TsunamiReport() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TsunamiReport value) $default,){ +final _that = this; +switch (_that) { +case _TsunamiReport(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TsunamiReport value)? $default,){ +final _that = this; +switch (_that) { +case _TsunamiReport() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, int sent, String msgType, String report, String type, String content, TsunamiEarthquake earthquake, List predictions, List observations)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TsunamiReport() when $default != null: +return $default(_that.id,_that.sent,_that.msgType,_that.report,_that.type,_that.content,_that.earthquake,_that.predictions,_that.observations);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, int sent, String msgType, String report, String type, String content, TsunamiEarthquake earthquake, List predictions, List observations) $default,) {final _that = this; +switch (_that) { +case _TsunamiReport(): +return $default(_that.id,_that.sent,_that.msgType,_that.report,_that.type,_that.content,_that.earthquake,_that.predictions,_that.observations);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, int sent, String msgType, String report, String type, String content, TsunamiEarthquake earthquake, List predictions, List observations)? $default,) {final _that = this; +switch (_that) { +case _TsunamiReport() when $default != null: +return $default(_that.id,_that.sent,_that.msgType,_that.report,_that.type,_that.content,_that.earthquake,_that.predictions,_that.observations);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _TsunamiReport implements TsunamiReport { + const _TsunamiReport({required this.id, required this.sent, required this.msgType, required this.report, required this.type, required this.content, required this.earthquake, required List predictions, required List observations}): _predictions = predictions,_observations = observations; + + +@override final String id; +/// Send time (Unix milliseconds — the tsunami feed, unlike the meteor +/// families, is not in seconds). +@override final int sent; +/// CAP message type: `Issue` / `Update` / `Cancel`. `Cancel` means the +/// threat was lifted, which the sheet states rather than hiding. +@override final String msgType; +/// Report within the event, CWA's own wording (`第3報`). +@override final String report; +/// `海嘯警報` / `海嘯警報解除`. +@override final String type; +@override final String content; +@override final TsunamiEarthquake earthquake; + final List _predictions; +@override List get predictions { + if (_predictions is EqualUnmodifiableListView) return _predictions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_predictions); +} + + final List _observations; +@override List get observations { + if (_observations is EqualUnmodifiableListView) return _observations; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_observations); +} + + +/// Create a copy of TsunamiReport +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TsunamiReportCopyWith<_TsunamiReport> get copyWith => __$TsunamiReportCopyWithImpl<_TsunamiReport>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TsunamiReport&&(identical(other.id, id) || other.id == id)&&(identical(other.sent, sent) || other.sent == sent)&&(identical(other.msgType, msgType) || other.msgType == msgType)&&(identical(other.report, report) || other.report == report)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.earthquake, earthquake) || other.earthquake == earthquake)&&const DeepCollectionEquality().equals(other._predictions, _predictions)&&const DeepCollectionEquality().equals(other._observations, _observations)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,sent,msgType,report,type,content,earthquake,const DeepCollectionEquality().hash(_predictions),const DeepCollectionEquality().hash(_observations)); + +@override +String toString() { + return 'TsunamiReport(id: $id, sent: $sent, msgType: $msgType, report: $report, type: $type, content: $content, earthquake: $earthquake, predictions: $predictions, observations: $observations)'; +} + + +} + +/// @nodoc +abstract mixin class _$TsunamiReportCopyWith<$Res> implements $TsunamiReportCopyWith<$Res> { + factory _$TsunamiReportCopyWith(_TsunamiReport value, $Res Function(_TsunamiReport) _then) = __$TsunamiReportCopyWithImpl; +@override @useResult +$Res call({ + String id, int sent, String msgType, String report, String type, String content, TsunamiEarthquake earthquake, List predictions, List observations +}); + + +@override $TsunamiEarthquakeCopyWith<$Res> get earthquake; + +} +/// @nodoc +class __$TsunamiReportCopyWithImpl<$Res> + implements _$TsunamiReportCopyWith<$Res> { + __$TsunamiReportCopyWithImpl(this._self, this._then); + + final _TsunamiReport _self; + final $Res Function(_TsunamiReport) _then; + +/// Create a copy of TsunamiReport +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? sent = null,Object? msgType = null,Object? report = null,Object? type = null,Object? content = null,Object? earthquake = null,Object? predictions = null,Object? observations = null,}) { + return _then(_TsunamiReport( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,sent: null == sent ? _self.sent : sent // ignore: cast_nullable_to_non_nullable +as int,msgType: null == msgType ? _self.msgType : msgType // ignore: cast_nullable_to_non_nullable +as String,report: null == report ? _self.report : report // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String,earthquake: null == earthquake ? _self.earthquake : earthquake // ignore: cast_nullable_to_non_nullable +as TsunamiEarthquake,predictions: null == predictions ? _self._predictions : predictions // ignore: cast_nullable_to_non_nullable +as List,observations: null == observations ? _self._observations : observations // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +/// Create a copy of TsunamiReport +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TsunamiEarthquakeCopyWith<$Res> get earthquake { + + return $TsunamiEarthquakeCopyWith<$Res>(_self.earthquake, (value) { + return _then(_self.copyWith(earthquake: value)); + }); +} +} + +// dart format on diff --git a/lib/features/tsunami/domain/tsunami_report.g.dart b/lib/features/tsunami/domain/tsunami_report.g.dart new file mode 100644 index 000000000..19a25ca81 --- /dev/null +++ b/lib/features/tsunami/domain/tsunami_report.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// coverage:ignore-file + +part of 'tsunami_report.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_TsunamiEarthquake _$TsunamiEarthquakeFromJson(Map json) => + _TsunamiEarthquake( + time: (json['t'] as num).toInt(), + longitude: (json['lon'] as num).toDouble(), + latitude: (json['lat'] as num).toDouble(), + location: json['loc'] as String, + depth: (json['dep'] as num).toDouble(), + magnitude: (json['mag'] as num).toDouble(), + ); + +Map _$TsunamiEarthquakeToJson(_TsunamiEarthquake instance) => + { + 't': instance.time, + 'lon': instance.longitude, + 'lat': instance.latitude, + 'loc': instance.location, + 'dep': instance.depth, + 'mag': instance.magnitude, + }; + +_TsunamiBulletin _$TsunamiBulletinFromJson(Map json) => + _TsunamiBulletin( + id: json['id'] as String, + number: (json['no'] as num).toInt(), + report: json['rep'] as String, + type: json['type'] as String, + sent: (json['sent'] as num).toInt(), + ); + +Map _$TsunamiBulletinToJson(_TsunamiBulletin instance) => + { + 'id': instance.id, + 'no': instance.number, + 'rep': instance.report, + 'type': instance.type, + 'sent': instance.sent, + }; diff --git a/lib/features/tsunami/domain/tsunami_repository.dart b/lib/features/tsunami/domain/tsunami_repository.dart new file mode 100644 index 000000000..b6b1a03e4 --- /dev/null +++ b/lib/features/tsunami/domain/tsunami_repository.dart @@ -0,0 +1,20 @@ +/// Access to CWA tsunami bulletins. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/tsunami/domain/tsunami_report.dart'; + +/// Reads the tsunami index (the event's reports) and one full bulletin. +abstract interface class TsunamiRepository { + /// Every report of the **newest event**, newest first; `Ok([])` when CWA has + /// issued nothing at all. + /// + /// An event, not the whole index: a bulletin is re-issued as the event + /// develops (第1報 predicted areas, 第3報 the cancellation with the observed + /// heights), so a reader needs the event's reports together — and only the + /// newest event, since a 2024 bulletin must not be listed under this year's. + Future>> bulletins(); + + /// One report in full, by its [TsunamiBulletin.id]. + Future> report(String id); +} diff --git a/lib/features/tsunami/tsunami_providers.dart b/lib/features/tsunami/tsunami_providers.dart new file mode 100644 index 000000000..358b49d0e --- /dev/null +++ b/lib/features/tsunami/tsunami_providers.dart @@ -0,0 +1,16 @@ +/// Provider wiring for CWA tsunami bulletins. +library; + +import 'package:dpip/core/di/shared_deps.dart'; +import 'package:dpip/features/tsunami/data/tsunami_api.dart'; +import 'package:dpip/features/tsunami/data/tsunami_repository_impl.dart'; +import 'package:dpip/features/tsunami/domain/tsunami_repository.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; + +/// Exposes tsunami data to the map layer. +List tsunamiProviders(SharedDeps deps) => [ + Provider.value( + value: TsunamiRepositoryImpl(TsunamiApi(deps.apiClient)), + ), +]; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7d31056b3..b9736eec8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -307,6 +307,18 @@ }, "mapLayerMonitor": "Seismic Monitor", "moreYoutube": "YouTube", + "moreThreads": "DPIP Threads", + "moreInstagram": "DPIP Instagram", + "moreFacebook": "DPIP Facebook", + "@moreThreads": { + "description": "More-menu link to the DPIP Threads account" + }, + "@moreInstagram": { + "description": "More-menu link to the DPIP Instagram account" + }, + "@moreFacebook": { + "description": "More-menu link to the DPIP Facebook page" + }, "sponsorSubscriptions": "Subscriptions", "@homeForecastWind": { "description": "Wind direction string and Beaufort force for the selected hour", @@ -375,7 +387,7 @@ "description": "Section title in map overlay lists: everyday-life facility overlays" }, "meshtasticSilent": "Silent", - "mapLayerCategoryEarthquake": "Earthquake", + "mapLayerCategoryEarthquake": "Earthquakes & tsunami", "mapLayerSatelliteB12": "Himawari Ozone (B12)", "restroomCategoryOther": "Other", "@typhoonLegendWarningAreas": { @@ -2352,15 +2364,25 @@ "reportFilterTitle": "Filters", "onboardingPermCritical": "Critical alerts", "onboardingPermUrgentAndroid": "Major alerts in Do Not Disturb", - "@onboardingPermUrgentAndroid": {"description": "Android permission-check row for urgent notification channels"}, + "@onboardingPermUrgentAndroid": { + "description": "Android permission-check row for urgent notification channels" + }, "onboardingPermUrgentAndroidDesc": "Choose which major-alert channels Android may allow during Do Not Disturb. Silent mode, volume, and the active Mode still apply.", - "@onboardingPermUrgentAndroidDesc": {"description": "Limitations of Android urgent notification channel settings"}, + "@onboardingPermUrgentAndroidDesc": { + "description": "Limitations of Android urgent notification channel settings" + }, "permissionGuideUrgentAndroid": "In the channel settings, choose whether this notification may interrupt Do Not Disturb, then return to DPIP.", - "@permissionGuideUrgentAndroid": {"description": "Instruction before opening one Android notification channel"}, + "@permissionGuideUrgentAndroid": { + "description": "Instruction before opening one Android notification channel" + }, "urgentNotificationBypassesDnd": "Allowed during Do Not Disturb", - "@urgentNotificationBypassesDnd": {"description": "Android notification channel currently bypasses Do Not Disturb"}, + "@urgentNotificationBypassesDnd": { + "description": "Android notification channel currently bypasses Do Not Disturb" + }, "urgentNotificationFollowsDnd": "Follows Do Not Disturb", - "@urgentNotificationFollowsDnd": {"description": "Android notification channel currently follows Do Not Disturb"}, + "@urgentNotificationFollowsDnd": { + "description": "Android notification channel currently follows Do Not Disturb" + }, "trendCumulativeTotal": "Cumulative {total} mm", "languageName": "English", "@mapLayerStyleJma": { @@ -4048,5 +4070,63 @@ }, "@eewSpokenAnnouncementOff": { "description": "Option label: no announcement, warning sound only" + }, + "mapLayerTsunami": "Tsunami", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "Earthquake epicentre", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "Source earthquake", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "Predicted arrival time and wave height", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "Observed tsunami by station", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "Over 3 m", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1-3 m", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3-1 m", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "Under 0.3 m", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "No tsunami bulletin has been issued", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "Report {n}", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} cm", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 6c95015bd..b99fdcd6b 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -102,6 +102,9 @@ "weatherRankingMergeTown": "Bayan", "mapLayerMonitor": "Seismic Monitor", "moreYoutube": "YouTube", + "moreThreads": "Opisyal na DPIP Threads", + "moreInstagram": "Opisyal na DPIP Instagram", + "moreFacebook": "Opisyal na DPIP Facebook page", "sponsorSubscriptions": "Mga subscription", "typhoonValueLon": "{lon}°E", "skyTime": "Oras ng langit", @@ -117,7 +120,7 @@ "reportFilterDateEndNote": "End day: through 24:00(Taipei)", "meshtasticSilent": "Tahimik", "reportFilterSortMagnitude": "Magnitude", - "mapLayerCategoryEarthquake": "Lindol", + "mapLayerCategoryEarthquake": "Lindol at tsunami", "mapLayerSatelliteB12": "Himawari Ozone (B12)", "restroomCategoryOther": "Iba pa", "@meshtasticRegionConfirm": { @@ -2006,5 +2009,63 @@ "moreSectionAccessibility": "Accessibility", "eewSpokenAnnouncementOn": "Naka-on", "eewSpokenAnnouncementOnDescription": "Babasahin muna nang malakas ang tinatayang intensidad bago tumunog ang babala — kaya naaantala ito nang kasinghaba ng pangungusap.", - "eewSpokenAnnouncementOff": "Naka-off" + "eewSpokenAnnouncementOff": "Naka-off", + "mapLayerTsunami": "Babala sa tsunami", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "Episentro ng lindol", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "Lindol na pinagmulan", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "Tinatayang oras ng pagdating at taas ng alon", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "Tsunami na naobserbahan sa bawat istasyon", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "Higit sa 3 m", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1-3 m", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3-1 m", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "Mas mababa sa 0.3 m", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "Walang inilabas na bulletin ng tsunami sa ngayon", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "Ulat {n}", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} cm", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 7ef35fba6..82e4f8436 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -102,6 +102,9 @@ "weatherRankingMergeTown": "Kecamatan", "mapLayerMonitor": "Monitor Seismik", "moreYoutube": "YouTube", + "moreThreads": "Threads resmi DPIP", + "moreInstagram": "Instagram resmi DPIP", + "moreFacebook": "Halaman Facebook resmi DPIP", "sponsorSubscriptions": "Langganan", "typhoonValueLon": "{lon}°E", "skyTime": "Waktu langit", @@ -117,7 +120,7 @@ "reportFilterDateEndNote": "Hari akhir: hingga 24:00(Taipei)", "meshtasticSilent": "Senyap", "reportFilterSortMagnitude": "Magnitudo", - "mapLayerCategoryEarthquake": "Gempa", + "mapLayerCategoryEarthquake": "Gempa dan tsunami", "mapLayerSatelliteB12": "Himawari Ozone (B12)", "restroomCategoryOther": "Lainnya", "@meshtasticRegionConfirm": { @@ -2006,5 +2009,63 @@ "moreSectionAccessibility": "Aksesibilitas", "eewSpokenAnnouncementOn": "Aktif", "eewSpokenAnnouncementOnDescription": "Intensitas perkiraan dibacakan lebih dulu, lalu suara peringatan diputar — sehingga suara itu tertunda selama kalimatnya.", - "eewSpokenAnnouncementOff": "Nonaktif" + "eewSpokenAnnouncementOff": "Nonaktif", + "mapLayerTsunami": "Peringatan tsunami", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "Episentrum gempa", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "Gempa sumber", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "Perkiraan waktu tiba dan tinggi gelombang", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "Tsunami teramati per stasiun", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "Lebih dari 3 m", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1-3 m", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3-1 m", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "Kurang dari 0.3 m", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "Belum ada buletin tsunami yang diterbitkan", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "Laporan {n}", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} cm", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 55d345912..d59546ebd 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -102,6 +102,9 @@ "weatherRankingMergeTown": "町村", "mapLayerMonitor": "強震モニタ", "moreYoutube": "YouTube", + "moreThreads": "DPIP 公式 Threads", + "moreInstagram": "DPIP 公式 Instagram", + "moreFacebook": "DPIP 公式 Facebook ページ", "sponsorSubscriptions": "サブスクリプション", "typhoonValueLon": "東経 {lon} 度", "skyTime": "空の時刻", @@ -117,7 +120,7 @@ "reportFilterDateEndNote": "終了日:当日 24:00(台北時間)", "meshtasticSilent": "サイレント", "reportFilterSortMagnitude": "規模", - "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryEarthquake": "地震と津波", "mapLayerSatelliteB12": "ひまわり オゾン(B12)", "restroomCategoryOther": "その他", "@meshtasticRegionConfirm": { @@ -2006,5 +2009,63 @@ "moreSectionAccessibility": "アクセシビリティ", "eewSpokenAnnouncementOn": "オン", "eewSpokenAnnouncementOnDescription": "予想震度を音声で読み上げてから警報音を鳴らします。その分、警報音は読み上げの長さだけ遅れます。", - "eewSpokenAnnouncementOff": "オフ" + "eewSpokenAnnouncementOff": "オフ", + "mapLayerTsunami": "津波警報", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "震央", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "地震情報", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "予想される津波の到達時刻と高さ", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "各地で観測された津波", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "3 m 超", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1〜3 m", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3〜1 m", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "0.3 m 未満", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "現在、津波に関する情報はありません", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "第 {n} 報", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} cm", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 29ff471d4..6bc7e56a8 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -102,6 +102,9 @@ "weatherRankingMergeTown": "향진", "mapLayerMonitor": "실시간 지진 모니터", "moreYoutube": "YouTube", + "moreThreads": "DPIP 공식 Threads", + "moreInstagram": "DPIP 공식 Instagram", + "moreFacebook": "DPIP 공식 Facebook 페이지", "sponsorSubscriptions": "구독", "typhoonValueLon": "{lon}°E", "skyTime": "하늘 시각", @@ -117,7 +120,7 @@ "reportFilterDateEndNote": "종료일: 당일 24:00(타이베이)", "meshtasticSilent": "무음", "reportFilterSortMagnitude": "규모", - "mapLayerCategoryEarthquake": "지진", + "mapLayerCategoryEarthquake": "지진과 쓰나미", "mapLayerSatelliteB12": "히마와리 오존(B12)", "restroomCategoryOther": "기타", "@meshtasticRegionConfirm": { @@ -2006,5 +2009,63 @@ "moreSectionAccessibility": "접근성", "eewSpokenAnnouncementOn": "켜기", "eewSpokenAnnouncementOnDescription": "예상 진도를 음성으로 먼저 안내한 뒤 경보음이 울립니다. 그만큼 경보음이 늦어집니다.", - "eewSpokenAnnouncementOff": "끄기" + "eewSpokenAnnouncementOff": "끄기", + "mapLayerTsunami": "지진해일 경보", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "지진 진앙", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "발생 지진", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "예상 도달 시각 및 파고", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "관측소별 관측된 지진해일", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "3 m 초과", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1~3 m", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3~1 m", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "0.3 m 미만", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "현재 발표된 지진해일 정보가 없습니다", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "제 {n} 보", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} cm", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index a7327382a..c88714726 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -102,6 +102,9 @@ "weatherRankingMergeTown": "ตำบล", "mapLayerMonitor": "เครื่องตรวจแผ่นดินไหว", "moreYoutube": "YouTube", + "moreThreads": "Threads ทางการของ DPIP", + "moreInstagram": "Instagram ทางการของ DPIP", + "moreFacebook": "เพจ Facebook ทางการของ DPIP", "sponsorSubscriptions": "แบบสมัครสมาชิก", "typhoonValueLon": "{lon}°E", "skyTime": "เวลาท้องฟ้า", @@ -117,7 +120,7 @@ "reportFilterDateEndNote": "วันสิ้นสุด: 24:00 ของวันนั้น(ไทเป)", "meshtasticSilent": "เงียบ", "reportFilterSortMagnitude": "ขนาด", - "mapLayerCategoryEarthquake": "แผ่นดินไหว", + "mapLayerCategoryEarthquake": "แผ่นดินไหวและสึนามิ", "mapLayerSatelliteB12": "Himawari Ozone (B12)", "restroomCategoryOther": "อื่น ๆ", "@meshtasticRegionConfirm": { @@ -2006,5 +2009,63 @@ "moreSectionAccessibility": "การช่วยการเข้าถึง", "eewSpokenAnnouncementOn": "เปิด", "eewSpokenAnnouncementOnDescription": "อ่านออกเสียงความรุนแรงที่คาดการณ์ก่อน แล้วจึงส่งเสียงเตือน ซึ่งทำให้เสียงเตือนช้าลงตามความยาวของประโยค", - "eewSpokenAnnouncementOff": "ปิด" + "eewSpokenAnnouncementOff": "ปิด", + "mapLayerTsunami": "เตือนภัยสึนามิ", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "ศูนย์กลางแผ่นดินไหว", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "แผ่นดินไหวต้นเหตุ", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "เวลาที่คาดว่าจะมาถึงและความสูงของคลื่น", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "สึนามิที่ตรวจวัดได้ตามสถานี", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "มากกว่า 3 m", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1-3 m", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3-1 m", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "น้อยกว่า 0.3 m", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "ยังไม่มีการออกประกาศสึนามิ", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "รายงาน {n}", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} cm", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 18fef7a2a..7bb7e82cf 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -102,6 +102,9 @@ "weatherRankingMergeTown": "Xã/trấn", "mapLayerMonitor": "Giám sát địa chấn", "moreYoutube": "YouTube", + "moreThreads": "Threads chính thức của DPIP", + "moreInstagram": "Instagram chính thức của DPIP", + "moreFacebook": "Trang Facebook chính thức của DPIP", "sponsorSubscriptions": "Gói đăng ký", "typhoonValueLon": "{lon}°E", "skyTime": "Thời gian bầu trời", @@ -117,7 +120,7 @@ "reportFilterDateEndNote": "Ngày kết thúc: đến 24:00(Đài Bắc)", "meshtasticSilent": "Im lặng", "reportFilterSortMagnitude": "Độ lớn", - "mapLayerCategoryEarthquake": "Động đất", + "mapLayerCategoryEarthquake": "Động đất và sóng thần", "mapLayerSatelliteB12": "Himawari Ozone (B12)", "restroomCategoryOther": "Khác", "@meshtasticRegionConfirm": { @@ -2006,5 +2009,63 @@ "moreSectionAccessibility": "Trợ năng", "eewSpokenAnnouncementOn": "Bật", "eewSpokenAnnouncementOnDescription": "Cường độ dự kiến được đọc lên trước, sau đó mới phát âm báo động — nên âm báo động chậm lại đúng bằng thời lượng câu đọc.", - "eewSpokenAnnouncementOff": "Tắt" + "eewSpokenAnnouncementOff": "Tắt", + "mapLayerTsunami": "Cảnh báo sóng thần", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "Tâm chấn động đất", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "Trận động đất gây ra", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "Thời gian dự kiến sóng đến và chiều cao sóng", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "Sóng thần quan trắc theo trạm", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "Trên 3 m", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1-3 m", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3-1 m", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "Dưới 0.3 m", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "Hiện chưa có bản tin sóng thần nào được phát hành", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "Bản tin {n}", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} cm", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index cc08779d0..fa5ede9c7 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -104,6 +104,9 @@ "weatherRankingMergeTown": "鄉鎮", "mapLayerMonitor": "強震監視器", "moreYoutube": "YouTube", + "moreThreads": "DPIP 官方 Threads", + "moreInstagram": "DPIP 官方 Instagram", + "moreFacebook": "DPIP Facebook 粉專", "sponsorSubscriptions": "訂閱制", "typhoonValueLon": "東經 {lon} 度", "skyTime": "天空時間", @@ -119,7 +122,7 @@ "reportFilterDateEndNote": "結束日:當日 24:00(台北時間)", "meshtasticSilent": "已靜默", "reportFilterSortMagnitude": "規模", - "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryEarthquake": "地震與海嘯", "mapLayerSatelliteB12": "ひまわり 臭氧(B12)", "restroomCategoryOther": "其他", "@meshtasticRegionConfirm": { @@ -2006,5 +2009,63 @@ "moreSectionAccessibility": "無障礙", "eewSpokenAnnouncementOn": "開啟", "eewSpokenAnnouncementOnDescription": "會先讀出預估震度,之後先播警示音——即係警示音會遲咗一句嘢咁耐。", - "eewSpokenAnnouncementOff": "關閉" + "eewSpokenAnnouncementOff": "關閉", + "mapLayerTsunami": "海嘯警報", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "地震震央", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "地震資訊", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "預估海嘯到達時間及波高", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "各地觀測到的海嘯", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "超過 3 公尺", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1~3 公尺", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3~1 公尺", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "小於 0.3 公尺", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "目前沒有海嘯警報", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "第 {n} 報", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} 公分", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 182c2fd3a..bfc77482e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -102,6 +102,9 @@ "weatherRankingMergeTown": "鄉鎮", "mapLayerMonitor": "強震監視器", "moreYoutube": "YouTube", + "moreThreads": "DPIP 官方 Threads", + "moreInstagram": "DPIP 官方 Instagram", + "moreFacebook": "DPIP Facebook 粉專", "sponsorSubscriptions": "訂閱制", "typhoonValueLon": "東經 {lon} 度", "skyTime": "天空時間", @@ -117,7 +120,7 @@ "reportFilterDateEndNote": "結束日:當日 24:00(臺北時間)", "meshtasticSilent": "已靜默", "reportFilterSortMagnitude": "規模", - "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryEarthquake": "地震與海嘯", "mapLayerSatelliteB12": "ひまわり 臭氧(B12)", "restroomCategoryOther": "其他", "@meshtasticRegionConfirm": { @@ -1998,5 +2001,63 @@ "moreSectionAccessibility": "无障碍", "eewSpokenAnnouncementOn": "开启", "eewSpokenAnnouncementOnDescription": "先用语音朗读预估烈度,再播放警示音——警示音会因此延后一句话的长度。", - "eewSpokenAnnouncementOff": "关闭" + "eewSpokenAnnouncementOff": "关闭", + "mapLayerTsunami": "海嘯警報", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "地震震央", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "地震資訊", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "預估海嘯到達時間及波高", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "各地觀測到的海嘯", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "超過 3 公尺", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1~3 公尺", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3~1 公尺", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "小於 0.3 公尺", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "目前沒有海嘯警報", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "第 {n} 報", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} 公分", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index e8c6a98ad..78a171fb5 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -102,6 +102,9 @@ "weatherRankingMergeTown": "乡镇", "mapLayerMonitor": "强震监视器", "moreYoutube": "YouTube", + "moreThreads": "DPIP 官方 Threads", + "moreInstagram": "DPIP 官方 Instagram", + "moreFacebook": "DPIP Facebook 粉丝专页", "sponsorSubscriptions": "订阅制", "typhoonValueLon": "东经 {lon} 度", "skyTime": "天空时间", @@ -117,7 +120,7 @@ "reportFilterDateEndNote": "结束日:当日 24:00(台北时间)", "meshtasticSilent": "已静默", "reportFilterSortMagnitude": "规模", - "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryEarthquake": "地震与海啸", "mapLayerSatelliteB12": "ひまわり 臭氧(B12)", "restroomCategoryOther": "其他", "@meshtasticRegionConfirm": { @@ -2006,5 +2009,63 @@ "moreSectionAccessibility": "无障碍", "eewSpokenAnnouncementOn": "开启", "eewSpokenAnnouncementOnDescription": "先用语音朗读预估烈度,再播放警示音——警示音会因此延后一句话的长度。", - "eewSpokenAnnouncementOff": "关闭" + "eewSpokenAnnouncementOff": "关闭", + "mapLayerTsunami": "Tsunami", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "Earthquake epicentre", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "Source earthquake", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "Predicted arrival time and wave height", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "Observed tsunami by station", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "Over 3 m", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1-3 m", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3-1 m", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "Under 0.3 m", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "No tsunami bulletin has been issued", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "Report {n}", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} cm", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 18fd215e2..868d497df 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -102,6 +102,9 @@ "weatherRankingMergeTown": "鄉鎮", "mapLayerMonitor": "強震監視器", "moreYoutube": "YouTube", + "moreThreads": "DPIP 官方 Threads", + "moreInstagram": "DPIP 官方 Instagram", + "moreFacebook": "DPIP Facebook 粉專", "sponsorSubscriptions": "訂閱制", "typhoonValueLon": "東經 {lon} 度", "skyTime": "天空時間", @@ -117,7 +120,7 @@ "reportFilterDateEndNote": "結束日:當日 24:00(台北時間)", "meshtasticSilent": "已靜默", "reportFilterSortMagnitude": "規模", - "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryEarthquake": "地震與海嘯", "mapLayerSatelliteB12": "ひまわり 臭氧(B12)", "restroomCategoryOther": "其他", "@meshtasticRegionConfirm": { @@ -2006,5 +2009,63 @@ "moreSectionAccessibility": "無障礙", "eewSpokenAnnouncementOn": "開啟", "eewSpokenAnnouncementOnDescription": "先以語音朗讀預估震度,再播放警示音——警示音會因此延後一句話的長度。", - "eewSpokenAnnouncementOff": "關閉" + "eewSpokenAnnouncementOff": "關閉", + "mapLayerTsunami": "海嘯警報", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "地震震央", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "地震資訊", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "預估海嘯到達時間及波高", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "各地觀測到的海嘯", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "超過 3 公尺", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1~3 公尺", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3~1 公尺", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "小於 0.3 公尺", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "目前沒有海嘯警報", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "第 {n} 報", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} 公分", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 3c0b8db2f..9bde57cce 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -102,6 +102,9 @@ "weatherRankingMergeTown": "鄉鎮", "mapLayerMonitor": "強震監視器", "moreYoutube": "YouTube", + "moreThreads": "DPIP 官方 Threads", + "moreInstagram": "DPIP 官方 Instagram", + "moreFacebook": "DPIP Facebook 粉專", "sponsorSubscriptions": "訂閱制", "typhoonValueLon": "東經 {lon} 度", "skyTime": "天空時間", @@ -117,7 +120,7 @@ "reportFilterDateEndNote": "結束日:當日 24:00(臺北時間)", "meshtasticSilent": "已靜默", "reportFilterSortMagnitude": "規模", - "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryEarthquake": "地震與海嘯", "mapLayerSatelliteB12": "ひまわり 臭氧(B12)", "restroomCategoryOther": "其他", "@meshtasticRegionConfirm": { @@ -2006,5 +2009,63 @@ "moreSectionAccessibility": "無障礙", "eewSpokenAnnouncementOn": "開啟", "eewSpokenAnnouncementOnDescription": "先以語音朗讀預估震度,再播放警示音——警示音會因此延後一句話的長度。", - "eewSpokenAnnouncementOff": "關閉" + "eewSpokenAnnouncementOff": "關閉", + "mapLayerTsunami": "海嘯警報", + "@mapLayerTsunami": { + "description": "Tsunami feature string: mapLayerTsunami" + }, + "tsunamiLegendEpicenter": "地震震央", + "@tsunamiLegendEpicenter": { + "description": "Tsunami feature string: tsunamiLegendEpicenter" + }, + "tsunamiEarthquake": "地震資訊", + "@tsunamiEarthquake": { + "description": "Tsunami feature string: tsunamiEarthquake" + }, + "tsunamiPredictions": "預估海嘯到達時間及波高", + "@tsunamiPredictions": { + "description": "Tsunami feature string: tsunamiPredictions" + }, + "tsunamiObservations": "各地觀測到的海嘯", + "@tsunamiObservations": { + "description": "Tsunami feature string: tsunamiObservations" + }, + "tsunamiBandOver3m": "超過 3 公尺", + "@tsunamiBandOver3m": { + "description": "Tsunami feature string: tsunamiBandOver3m" + }, + "tsunamiBand1To3m": "1~3 公尺", + "@tsunamiBand1To3m": { + "description": "Tsunami feature string: tsunamiBand1To3m" + }, + "tsunamiBand30cmTo1m": "0.3~1 公尺", + "@tsunamiBand30cmTo1m": { + "description": "Tsunami feature string: tsunamiBand30cmTo1m" + }, + "tsunamiBandUnder30cm": "小於 0.3 公尺", + "@tsunamiBandUnder30cm": { + "description": "Tsunami feature string: tsunamiBandUnder30cm" + }, + "tsunamiEmpty": "目前沒有海嘯警報", + "@tsunamiEmpty": { + "description": "Tsunami feature string: tsunamiEmpty" + }, + "tsunamiReportNumber": "第 {n} 報", + "@tsunamiReportNumber": { + "description": "Tsunami feature string: tsunamiReportNumber", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "tsunamiWaveHeightCm": "{cm} 公分", + "@tsunamiWaveHeightCm": { + "description": "Tsunami feature string: tsunamiWaveHeightCm", + "placeholders": { + "cm": { + "type": "int" + } + } + } } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 24e424a62..be23c3257 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -606,6 +606,24 @@ abstract class AppLocalizations { /// **'YouTube'** String get moreYoutube; + /// More-menu link to the DPIP Threads account + /// + /// In en, this message translates to: + /// **'DPIP Threads'** + String get moreThreads; + + /// More-menu link to the DPIP Instagram account + /// + /// In en, this message translates to: + /// **'DPIP Instagram'** + String get moreInstagram; + + /// More-menu link to the DPIP Facebook page + /// + /// In en, this message translates to: + /// **'DPIP Facebook'** + String get moreFacebook; + /// Support page section header for recurring subscription tiers /// /// In en, this message translates to: @@ -663,7 +681,7 @@ abstract class AppLocalizations { /// Section title in map overlay lists: the seismic-monitor overlays /// /// In en, this message translates to: - /// **'Earthquake'** + /// **'Earthquakes & tsunami'** String get mapLayerCategoryEarthquake; /// Himawari ozone-band channel (B12, 9.6 µm) layer name @@ -6419,6 +6437,78 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Off'** String get eewSpokenAnnouncementOff; + + /// Tsunami feature string: mapLayerTsunami + /// + /// In en, this message translates to: + /// **'Tsunami'** + String get mapLayerTsunami; + + /// Tsunami feature string: tsunamiLegendEpicenter + /// + /// In en, this message translates to: + /// **'Earthquake epicentre'** + String get tsunamiLegendEpicenter; + + /// Tsunami feature string: tsunamiEarthquake + /// + /// In en, this message translates to: + /// **'Source earthquake'** + String get tsunamiEarthquake; + + /// Tsunami feature string: tsunamiPredictions + /// + /// In en, this message translates to: + /// **'Predicted arrival time and wave height'** + String get tsunamiPredictions; + + /// Tsunami feature string: tsunamiObservations + /// + /// In en, this message translates to: + /// **'Observed tsunami by station'** + String get tsunamiObservations; + + /// Tsunami feature string: tsunamiBandOver3m + /// + /// In en, this message translates to: + /// **'Over 3 m'** + String get tsunamiBandOver3m; + + /// Tsunami feature string: tsunamiBand1To3m + /// + /// In en, this message translates to: + /// **'1-3 m'** + String get tsunamiBand1To3m; + + /// Tsunami feature string: tsunamiBand30cmTo1m + /// + /// In en, this message translates to: + /// **'0.3-1 m'** + String get tsunamiBand30cmTo1m; + + /// Tsunami feature string: tsunamiBandUnder30cm + /// + /// In en, this message translates to: + /// **'Under 0.3 m'** + String get tsunamiBandUnder30cm; + + /// Tsunami feature string: tsunamiEmpty + /// + /// In en, this message translates to: + /// **'No tsunami bulletin has been issued'** + String get tsunamiEmpty; + + /// Tsunami feature string: tsunamiReportNumber + /// + /// In en, this message translates to: + /// **'Report {n}'** + String tsunamiReportNumber(int n); + + /// Tsunami feature string: tsunamiWaveHeightCm + /// + /// In en, this message translates to: + /// **'{cm} cm'** + String tsunamiWaveHeightCm(int cm); } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 305a62715..345d71a6e 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -276,6 +276,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'DPIP Threads'; + + @override + String get moreInstagram => 'DPIP Instagram'; + + @override + String get moreFacebook => 'DPIP Facebook'; + @override String get sponsorSubscriptions => 'Subscriptions'; @@ -306,7 +315,7 @@ class AppLocalizationsEn extends AppLocalizations { String get meshtasticSilent => 'Silent'; @override - String get mapLayerCategoryEarthquake => 'Earthquake'; + String get mapLayerCategoryEarthquake => 'Earthquakes & tsunami'; @override String get mapLayerSatelliteB12 => 'Himawari Ozone (B12)'; @@ -3384,4 +3393,44 @@ class AppLocalizationsEn extends AppLocalizations { @override String get eewSpokenAnnouncementOff => 'Off'; + + @override + String get mapLayerTsunami => 'Tsunami'; + + @override + String get tsunamiLegendEpicenter => 'Earthquake epicentre'; + + @override + String get tsunamiEarthquake => 'Source earthquake'; + + @override + String get tsunamiPredictions => 'Predicted arrival time and wave height'; + + @override + String get tsunamiObservations => 'Observed tsunami by station'; + + @override + String get tsunamiBandOver3m => 'Over 3 m'; + + @override + String get tsunamiBand1To3m => '1-3 m'; + + @override + String get tsunamiBand30cmTo1m => '0.3-1 m'; + + @override + String get tsunamiBandUnder30cm => 'Under 0.3 m'; + + @override + String get tsunamiEmpty => 'No tsunami bulletin has been issued'; + + @override + String tsunamiReportNumber(int n) { + return 'Report $n'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm cm'; + } } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 43db21e66..62a2a3afe 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -277,6 +277,15 @@ class AppLocalizationsFil extends AppLocalizations { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'Opisyal na DPIP Threads'; + + @override + String get moreInstagram => 'Opisyal na DPIP Instagram'; + + @override + String get moreFacebook => 'Opisyal na DPIP Facebook page'; + @override String get sponsorSubscriptions => 'Mga subscription'; @@ -307,7 +316,7 @@ class AppLocalizationsFil extends AppLocalizations { String get meshtasticSilent => 'Tahimik'; @override - String get mapLayerCategoryEarthquake => 'Lindol'; + String get mapLayerCategoryEarthquake => 'Lindol at tsunami'; @override String get mapLayerSatelliteB12 => 'Himawari Ozone (B12)'; @@ -3404,4 +3413,45 @@ class AppLocalizationsFil extends AppLocalizations { @override String get eewSpokenAnnouncementOff => 'Naka-off'; + + @override + String get mapLayerTsunami => 'Babala sa tsunami'; + + @override + String get tsunamiLegendEpicenter => 'Episentro ng lindol'; + + @override + String get tsunamiEarthquake => 'Lindol na pinagmulan'; + + @override + String get tsunamiPredictions => + 'Tinatayang oras ng pagdating at taas ng alon'; + + @override + String get tsunamiObservations => 'Tsunami na naobserbahan sa bawat istasyon'; + + @override + String get tsunamiBandOver3m => 'Higit sa 3 m'; + + @override + String get tsunamiBand1To3m => '1-3 m'; + + @override + String get tsunamiBand30cmTo1m => '0.3-1 m'; + + @override + String get tsunamiBandUnder30cm => 'Mas mababa sa 0.3 m'; + + @override + String get tsunamiEmpty => 'Walang inilabas na bulletin ng tsunami sa ngayon'; + + @override + String tsunamiReportNumber(int n) { + return 'Ulat $n'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm cm'; + } } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 81f14b939..b82910956 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -275,6 +275,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'Threads resmi DPIP'; + + @override + String get moreInstagram => 'Instagram resmi DPIP'; + + @override + String get moreFacebook => 'Halaman Facebook resmi DPIP'; + @override String get sponsorSubscriptions => 'Langganan'; @@ -305,7 +314,7 @@ class AppLocalizationsId extends AppLocalizations { String get meshtasticSilent => 'Senyap'; @override - String get mapLayerCategoryEarthquake => 'Gempa'; + String get mapLayerCategoryEarthquake => 'Gempa dan tsunami'; @override String get mapLayerSatelliteB12 => 'Himawari Ozone (B12)'; @@ -3398,4 +3407,44 @@ class AppLocalizationsId extends AppLocalizations { @override String get eewSpokenAnnouncementOff => 'Nonaktif'; + + @override + String get mapLayerTsunami => 'Peringatan tsunami'; + + @override + String get tsunamiLegendEpicenter => 'Episentrum gempa'; + + @override + String get tsunamiEarthquake => 'Gempa sumber'; + + @override + String get tsunamiPredictions => 'Perkiraan waktu tiba dan tinggi gelombang'; + + @override + String get tsunamiObservations => 'Tsunami teramati per stasiun'; + + @override + String get tsunamiBandOver3m => 'Lebih dari 3 m'; + + @override + String get tsunamiBand1To3m => '1-3 m'; + + @override + String get tsunamiBand30cmTo1m => '0.3-1 m'; + + @override + String get tsunamiBandUnder30cm => 'Kurang dari 0.3 m'; + + @override + String get tsunamiEmpty => 'Belum ada buletin tsunami yang diterbitkan'; + + @override + String tsunamiReportNumber(int n) { + return 'Laporan $n'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm cm'; + } } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index e7f41e012..056743726 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -272,6 +272,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'DPIP 公式 Threads'; + + @override + String get moreInstagram => 'DPIP 公式 Instagram'; + + @override + String get moreFacebook => 'DPIP 公式 Facebook ページ'; + @override String get sponsorSubscriptions => 'サブスクリプション'; @@ -302,7 +311,7 @@ class AppLocalizationsJa extends AppLocalizations { String get meshtasticSilent => 'サイレント'; @override - String get mapLayerCategoryEarthquake => '地震'; + String get mapLayerCategoryEarthquake => '地震と津波'; @override String get mapLayerSatelliteB12 => 'ひまわり オゾン(B12)'; @@ -3321,4 +3330,44 @@ class AppLocalizationsJa extends AppLocalizations { @override String get eewSpokenAnnouncementOff => 'オフ'; + + @override + String get mapLayerTsunami => '津波警報'; + + @override + String get tsunamiLegendEpicenter => '震央'; + + @override + String get tsunamiEarthquake => '地震情報'; + + @override + String get tsunamiPredictions => '予想される津波の到達時刻と高さ'; + + @override + String get tsunamiObservations => '各地で観測された津波'; + + @override + String get tsunamiBandOver3m => '3 m 超'; + + @override + String get tsunamiBand1To3m => '1〜3 m'; + + @override + String get tsunamiBand30cmTo1m => '0.3〜1 m'; + + @override + String get tsunamiBandUnder30cm => '0.3 m 未満'; + + @override + String get tsunamiEmpty => '現在、津波に関する情報はありません'; + + @override + String tsunamiReportNumber(int n) { + return '第 $n 報'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm cm'; + } } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 24b18954f..f3150cac2 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -271,6 +271,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'DPIP 공식 Threads'; + + @override + String get moreInstagram => 'DPIP 공식 Instagram'; + + @override + String get moreFacebook => 'DPIP 공식 Facebook 페이지'; + @override String get sponsorSubscriptions => '구독'; @@ -301,7 +310,7 @@ class AppLocalizationsKo extends AppLocalizations { String get meshtasticSilent => '무음'; @override - String get mapLayerCategoryEarthquake => '지진'; + String get mapLayerCategoryEarthquake => '지진과 쓰나미'; @override String get mapLayerSatelliteB12 => '히마와리 오존(B12)'; @@ -3323,4 +3332,44 @@ class AppLocalizationsKo extends AppLocalizations { @override String get eewSpokenAnnouncementOff => '끄기'; + + @override + String get mapLayerTsunami => '지진해일 경보'; + + @override + String get tsunamiLegendEpicenter => '지진 진앙'; + + @override + String get tsunamiEarthquake => '발생 지진'; + + @override + String get tsunamiPredictions => '예상 도달 시각 및 파고'; + + @override + String get tsunamiObservations => '관측소별 관측된 지진해일'; + + @override + String get tsunamiBandOver3m => '3 m 초과'; + + @override + String get tsunamiBand1To3m => '1~3 m'; + + @override + String get tsunamiBand30cmTo1m => '0.3~1 m'; + + @override + String get tsunamiBandUnder30cm => '0.3 m 미만'; + + @override + String get tsunamiEmpty => '현재 발표된 지진해일 정보가 없습니다'; + + @override + String tsunamiReportNumber(int n) { + return '제 $n 보'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm cm'; + } } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 34d7f5af4..e0bf25627 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -275,6 +275,15 @@ class AppLocalizationsTh extends AppLocalizations { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'Threads ทางการของ DPIP'; + + @override + String get moreInstagram => 'Instagram ทางการของ DPIP'; + + @override + String get moreFacebook => 'เพจ Facebook ทางการของ DPIP'; + @override String get sponsorSubscriptions => 'แบบสมัครสมาชิก'; @@ -305,7 +314,7 @@ class AppLocalizationsTh extends AppLocalizations { String get meshtasticSilent => 'เงียบ'; @override - String get mapLayerCategoryEarthquake => 'แผ่นดินไหว'; + String get mapLayerCategoryEarthquake => 'แผ่นดินไหวและสึนามิ'; @override String get mapLayerSatelliteB12 => 'Himawari Ozone (B12)'; @@ -3377,4 +3386,44 @@ class AppLocalizationsTh extends AppLocalizations { @override String get eewSpokenAnnouncementOff => 'ปิด'; + + @override + String get mapLayerTsunami => 'เตือนภัยสึนามิ'; + + @override + String get tsunamiLegendEpicenter => 'ศูนย์กลางแผ่นดินไหว'; + + @override + String get tsunamiEarthquake => 'แผ่นดินไหวต้นเหตุ'; + + @override + String get tsunamiPredictions => 'เวลาที่คาดว่าจะมาถึงและความสูงของคลื่น'; + + @override + String get tsunamiObservations => 'สึนามิที่ตรวจวัดได้ตามสถานี'; + + @override + String get tsunamiBandOver3m => 'มากกว่า 3 m'; + + @override + String get tsunamiBand1To3m => '1-3 m'; + + @override + String get tsunamiBand30cmTo1m => '0.3-1 m'; + + @override + String get tsunamiBandUnder30cm => 'น้อยกว่า 0.3 m'; + + @override + String get tsunamiEmpty => 'ยังไม่มีการออกประกาศสึนามิ'; + + @override + String tsunamiReportNumber(int n) { + return 'รายงาน $n'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm cm'; + } } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 8e0ed12ba..4b3482a80 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -275,6 +275,15 @@ class AppLocalizationsVi extends AppLocalizations { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'Threads chính thức của DPIP'; + + @override + String get moreInstagram => 'Instagram chính thức của DPIP'; + + @override + String get moreFacebook => 'Trang Facebook chính thức của DPIP'; + @override String get sponsorSubscriptions => 'Gói đăng ký'; @@ -305,7 +314,7 @@ class AppLocalizationsVi extends AppLocalizations { String get meshtasticSilent => 'Im lặng'; @override - String get mapLayerCategoryEarthquake => 'Động đất'; + String get mapLayerCategoryEarthquake => 'Động đất và sóng thần'; @override String get mapLayerSatelliteB12 => 'Himawari Ozone (B12)'; @@ -3386,4 +3395,46 @@ class AppLocalizationsVi extends AppLocalizations { @override String get eewSpokenAnnouncementOff => 'Tắt'; + + @override + String get mapLayerTsunami => 'Cảnh báo sóng thần'; + + @override + String get tsunamiLegendEpicenter => 'Tâm chấn động đất'; + + @override + String get tsunamiEarthquake => 'Trận động đất gây ra'; + + @override + String get tsunamiPredictions => + 'Thời gian dự kiến sóng đến và chiều cao sóng'; + + @override + String get tsunamiObservations => 'Sóng thần quan trắc theo trạm'; + + @override + String get tsunamiBandOver3m => 'Trên 3 m'; + + @override + String get tsunamiBand1To3m => '1-3 m'; + + @override + String get tsunamiBand30cmTo1m => '0.3-1 m'; + + @override + String get tsunamiBandUnder30cm => 'Dưới 0.3 m'; + + @override + String get tsunamiEmpty => + 'Hiện chưa có bản tin sóng thần nào được phát hành'; + + @override + String tsunamiReportNumber(int n) { + return 'Bản tin $n'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm cm'; + } } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index eba2ef4fa..b0313cd30 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -268,6 +268,15 @@ class AppLocalizationsYue extends AppLocalizations { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'DPIP 官方 Threads'; + + @override + String get moreInstagram => 'DPIP 官方 Instagram'; + + @override + String get moreFacebook => 'DPIP Facebook 粉專'; + @override String get sponsorSubscriptions => '訂閱制'; @@ -298,7 +307,7 @@ class AppLocalizationsYue extends AppLocalizations { String get meshtasticSilent => '已靜默'; @override - String get mapLayerCategoryEarthquake => '地震'; + String get mapLayerCategoryEarthquake => '地震與海嘯'; @override String get mapLayerSatelliteB12 => 'ひまわり 臭氧(B12)'; @@ -3302,4 +3311,44 @@ class AppLocalizationsYue extends AppLocalizations { @override String get eewSpokenAnnouncementOff => '關閉'; + + @override + String get mapLayerTsunami => '海嘯警報'; + + @override + String get tsunamiLegendEpicenter => '地震震央'; + + @override + String get tsunamiEarthquake => '地震資訊'; + + @override + String get tsunamiPredictions => '預估海嘯到達時間及波高'; + + @override + String get tsunamiObservations => '各地觀測到的海嘯'; + + @override + String get tsunamiBandOver3m => '超過 3 公尺'; + + @override + String get tsunamiBand1To3m => '1~3 公尺'; + + @override + String get tsunamiBand30cmTo1m => '0.3~1 公尺'; + + @override + String get tsunamiBandUnder30cm => '小於 0.3 公尺'; + + @override + String get tsunamiEmpty => '目前沒有海嘯警報'; + + @override + String tsunamiReportNumber(int n) { + return '第 $n 報'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm 公分'; + } } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index d81ec5b47..9092a4204 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -268,6 +268,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'DPIP 官方 Threads'; + + @override + String get moreInstagram => 'DPIP 官方 Instagram'; + + @override + String get moreFacebook => 'DPIP Facebook 粉專'; + @override String get sponsorSubscriptions => '訂閱制'; @@ -298,7 +307,7 @@ class AppLocalizationsZh extends AppLocalizations { String get meshtasticSilent => '已靜默'; @override - String get mapLayerCategoryEarthquake => '地震'; + String get mapLayerCategoryEarthquake => '地震與海嘯'; @override String get mapLayerSatelliteB12 => 'ひまわり 臭氧(B12)'; @@ -3302,6 +3311,46 @@ class AppLocalizationsZh extends AppLocalizations { @override String get eewSpokenAnnouncementOff => '关闭'; + + @override + String get mapLayerTsunami => '海嘯警報'; + + @override + String get tsunamiLegendEpicenter => '地震震央'; + + @override + String get tsunamiEarthquake => '地震資訊'; + + @override + String get tsunamiPredictions => '預估海嘯到達時間及波高'; + + @override + String get tsunamiObservations => '各地觀測到的海嘯'; + + @override + String get tsunamiBandOver3m => '超過 3 公尺'; + + @override + String get tsunamiBand1To3m => '1~3 公尺'; + + @override + String get tsunamiBand30cmTo1m => '0.3~1 公尺'; + + @override + String get tsunamiBandUnder30cm => '小於 0.3 公尺'; + + @override + String get tsunamiEmpty => '目前沒有海嘯警報'; + + @override + String tsunamiReportNumber(int n) { + return '第 $n 報'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm 公分'; + } } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -3565,6 +3614,15 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'DPIP 官方 Threads'; + + @override + String get moreInstagram => 'DPIP 官方 Instagram'; + + @override + String get moreFacebook => 'DPIP Facebook 粉丝专页'; + @override String get sponsorSubscriptions => '订阅制'; @@ -3595,7 +3653,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get meshtasticSilent => '已静默'; @override - String get mapLayerCategoryEarthquake => '地震'; + String get mapLayerCategoryEarthquake => '地震与海啸'; @override String get mapLayerSatelliteB12 => 'ひまわり 臭氧(B12)'; @@ -6599,6 +6657,46 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get eewSpokenAnnouncementOff => '关闭'; + + @override + String get mapLayerTsunami => 'Tsunami'; + + @override + String get tsunamiLegendEpicenter => 'Earthquake epicentre'; + + @override + String get tsunamiEarthquake => 'Source earthquake'; + + @override + String get tsunamiPredictions => 'Predicted arrival time and wave height'; + + @override + String get tsunamiObservations => 'Observed tsunami by station'; + + @override + String get tsunamiBandOver3m => 'Over 3 m'; + + @override + String get tsunamiBand1To3m => '1-3 m'; + + @override + String get tsunamiBand30cmTo1m => '0.3-1 m'; + + @override + String get tsunamiBandUnder30cm => 'Under 0.3 m'; + + @override + String get tsunamiEmpty => 'No tsunami bulletin has been issued'; + + @override + String tsunamiReportNumber(int n) { + return 'Report $n'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm cm'; + } } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -6862,6 +6960,15 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'DPIP 官方 Threads'; + + @override + String get moreInstagram => 'DPIP 官方 Instagram'; + + @override + String get moreFacebook => 'DPIP Facebook 粉專'; + @override String get sponsorSubscriptions => '訂閱制'; @@ -6892,7 +6999,7 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get meshtasticSilent => '已靜默'; @override - String get mapLayerCategoryEarthquake => '地震'; + String get mapLayerCategoryEarthquake => '地震與海嘯'; @override String get mapLayerSatelliteB12 => 'ひまわり 臭氧(B12)'; @@ -9896,6 +10003,46 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get eewSpokenAnnouncementOff => '關閉'; + + @override + String get mapLayerTsunami => '海嘯警報'; + + @override + String get tsunamiLegendEpicenter => '地震震央'; + + @override + String get tsunamiEarthquake => '地震資訊'; + + @override + String get tsunamiPredictions => '預估海嘯到達時間及波高'; + + @override + String get tsunamiObservations => '各地觀測到的海嘯'; + + @override + String get tsunamiBandOver3m => '超過 3 公尺'; + + @override + String get tsunamiBand1To3m => '1~3 公尺'; + + @override + String get tsunamiBand30cmTo1m => '0.3~1 公尺'; + + @override + String get tsunamiBandUnder30cm => '小於 0.3 公尺'; + + @override + String get tsunamiEmpty => '目前沒有海嘯警報'; + + @override + String tsunamiReportNumber(int n) { + return '第 $n 報'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm 公分'; + } } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -10159,6 +10306,15 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get moreYoutube => 'YouTube'; + @override + String get moreThreads => 'DPIP 官方 Threads'; + + @override + String get moreInstagram => 'DPIP 官方 Instagram'; + + @override + String get moreFacebook => 'DPIP Facebook 粉專'; + @override String get sponsorSubscriptions => '訂閱制'; @@ -10189,7 +10345,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get meshtasticSilent => '已靜默'; @override - String get mapLayerCategoryEarthquake => '地震'; + String get mapLayerCategoryEarthquake => '地震與海嘯'; @override String get mapLayerSatelliteB12 => 'ひまわり 臭氧(B12)'; @@ -13193,4 +13349,44 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get eewSpokenAnnouncementOff => '關閉'; + + @override + String get mapLayerTsunami => '海嘯警報'; + + @override + String get tsunamiLegendEpicenter => '地震震央'; + + @override + String get tsunamiEarthquake => '地震資訊'; + + @override + String get tsunamiPredictions => '預估海嘯到達時間及波高'; + + @override + String get tsunamiObservations => '各地觀測到的海嘯'; + + @override + String get tsunamiBandOver3m => '超過 3 公尺'; + + @override + String get tsunamiBand1To3m => '1~3 公尺'; + + @override + String get tsunamiBand30cmTo1m => '0.3~1 公尺'; + + @override + String get tsunamiBandUnder30cm => '小於 0.3 公尺'; + + @override + String get tsunamiEmpty => '目前沒有海嘯警報'; + + @override + String tsunamiReportNumber(int n) { + return '第 $n 報'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm 公分'; + } } diff --git a/lib/shared/map/default_map_layer_ui.dart b/lib/shared/map/default_map_layer_ui.dart index 0d7e64426..8786062b8 100644 --- a/lib/shared/map/default_map_layer_ui.dart +++ b/lib/shared/map/default_map_layer_ui.dart @@ -19,6 +19,7 @@ extension DefaultMapLayerUi on DefaultMapLayer { DefaultMapLayer.lightning => Icons.bolt_outlined, DefaultMapLayer.typhoon => Icons.cyclone_outlined, DefaultMapLayer.monitor => Icons.sensors_outlined, + DefaultMapLayer.tsunami => Icons.tsunami_outlined, DefaultMapLayer.temperature => Icons.thermostat_outlined, DefaultMapLayer.humidity => Icons.water_drop_outlined, DefaultMapLayer.pressure => Icons.compress, @@ -35,6 +36,7 @@ extension DefaultMapLayerUi on DefaultMapLayer { DefaultMapLayer.lightning => Icons.bolt, DefaultMapLayer.typhoon => Icons.cyclone, DefaultMapLayer.monitor => Icons.sensors, + DefaultMapLayer.tsunami => Icons.tsunami, DefaultMapLayer.temperature => Icons.thermostat, DefaultMapLayer.humidity => Icons.water_drop, DefaultMapLayer.pressure => Icons.compress, @@ -51,6 +53,9 @@ extension DefaultMapLayerUi on DefaultMapLayer { DefaultMapLayer.lightning => l10n.mapNavLightning, DefaultMapLayer.typhoon => l10n.mapNavTyphoon, DefaultMapLayer.monitor => l10n.mapNavEarthquake, + // The map layer's own name, not a new mapNav key: the picker shows the + // same row the in-map switcher does. + DefaultMapLayer.tsunami => l10n.mapLayerTsunami, DefaultMapLayer.temperature => l10n.mapNavTemperature, DefaultMapLayer.humidity => l10n.mapNavHumidity, DefaultMapLayer.pressure => l10n.mapNavPressure, diff --git a/lib/shared/map/map_layer_category.dart b/lib/shared/map/map_layer_category.dart index 6058b6eae..bc837ddbd 100644 --- a/lib/shared/map/map_layer_category.dart +++ b/lib/shared/map/map_layer_category.dart @@ -6,7 +6,10 @@ import 'package:dpip/l10n/gen/app_localizations.dart'; /// The seven groups the overlays fall into, in display order. enum MapLayerCategory { - /// Seismic-monitor events (RTS). + /// Seismic-monitor events and the CWA tsunami bulletins — both come from the + /// seismology side of CWA, and a tsunami bulletin is triggered by an + /// earthquake, so the two share a group rather than giving one row its own + /// header. earthquake, /// Ground-radar imagery. @@ -48,7 +51,7 @@ MapLayerCategory categoryOf(String layerId) { return MapLayerCategory.forecast; } return switch (layerId) { - 'monitor' => MapLayerCategory.earthquake, + 'monitor' || 'tsunami' => MapLayerCategory.earthquake, 'typhoon' => MapLayerCategory.typhoon, 'temperature' || 'humidity' || diff --git a/lib/shared/map/map_town_labels.dart b/lib/shared/map/map_town_labels.dart index dfb56e73e..6f2ec8db6 100644 --- a/lib/shared/map/map_town_labels.dart +++ b/lib/shared/map/map_town_labels.dart @@ -114,6 +114,7 @@ class MapBasemapMenu extends StatelessWidget { listenable: Listenable.merge([showTownLabels, showTerrain, ?gsi]), builder: (context, _) => MenuAnchor( alignmentOffset: const Offset(0, 4), + animated: true, style: MapChipButton.menuStyle(context), builder: (context, controller, _) => MapChipButton( icon: Icons.tune, diff --git a/lib/shared/widgets/collapsible_map_legend.dart b/lib/shared/widgets/collapsible_map_legend.dart index 8663e121e..81a50035e 100644 --- a/lib/shared/widgets/collapsible_map_legend.dart +++ b/lib/shared/widgets/collapsible_map_legend.dart @@ -7,6 +7,7 @@ import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/widgets/frosted_surface.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:flutter/material.dart'; /// Wraps a layer's [legend] body. Remount with a new [Key] (e.g. layer id) to @@ -46,6 +47,12 @@ class _CollapsibleMapLegendState extends State { } /// Compact frosted control that reveals the legend. +/// +/// Literally the shared [MapChipButton]: on the replay page this chip sits in +/// the same top row as that page's back button and the base-map chip, and a +/// legend of its own chrome was 4px shorter than the chips beside it — a +/// difference small enough to look like a mistake rather than a choice. One +/// widget means the row cannot drift apart again. class _Chip extends StatelessWidget { const _Chip({required this.tooltip, required this.onTap}); @@ -54,43 +61,12 @@ class _Chip extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return Tooltip( - message: tooltip, - child: FrostedSurface( - borderRadius: AppRadius.small, - child: Material( - type: MaterialType.transparency, - child: InkWell( - borderRadius: AppRadius.small, - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.legend_toggle, - size: 18, - color: colors.onSurfaceVariant, - ), - const SizedBox(width: AppSpacing.sm), - Text( - tooltip, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ), - ), + return MapChipButton( + icon: Icons.legend_toggle, + label: tooltip, + tooltip: tooltip, + active: false, + onTap: onTap, ); } } diff --git a/lib/shared/widgets/map_chip_button.dart b/lib/shared/widgets/map_chip_button.dart index 44a87d856..507679ffa 100644 --- a/lib/shared/widgets/map_chip_button.dart +++ b/lib/shared/widgets/map_chip_button.dart @@ -45,6 +45,13 @@ class MapChipButton extends StatelessWidget { /// Shared dropdown chrome for overlay menus: rounded card on /// [ColorScheme.surfaceContainerHigh], hairline border, soft shadow, and /// inset rows ([AppSpacing.sm] gutters) so hover highlights stay rounded. + /// + /// Every caller pairs this with `MenuAnchor(animated: true)`. That flag + /// defaults to **false** in the framework, which is why the overlay menus + /// used to pop in with no transition at all while the layer switcher's + /// `showModalBottomSheet` slid up — the same tap felt like it belonged to + /// two different apps. With it on, the panel grows out of the chip and fades + /// in over the framework's 500ms opening curve. static MenuStyle menuStyle(BuildContext context) { final colors = Theme.of(context).colorScheme; return MenuStyle( diff --git a/lib/shared/widgets/selectable_pill.dart b/lib/shared/widgets/selectable_pill.dart new file mode 100644 index 000000000..0de704f4f --- /dev/null +++ b/lib/shared/widgets/selectable_pill.dart @@ -0,0 +1,68 @@ +/// The small selectable pill both map sheets use to pick which item of a set +/// they are showing — one storm out of several, one 報 out of an event. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:flutter/material.dart'; + +/// A labelled pill that reads as pressed when [selected]. +/// +/// Extracted from the typhoon sheet's cyclone picker so the tsunami sheet's +/// report picker (第1報 / 第2報 / 第3報) is the same affordance rather than a +/// second, slightly different one. Deliberately a pill and not a dropdown: the +/// sets it chooses between are small, and every option stays visible — a reader +/// comparing 第1報 with 第3報 should not have to open a menu twice. +class SelectablePill extends StatelessWidget { + const SelectablePill({ + super.key, + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final radius = BorderRadius.circular(AppSpacing.lg); + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: DecoratedBox( + decoration: BoxDecoration( + color: selected + ? colors.primaryContainer.withValues(alpha: 0.55) + : Colors.transparent, + borderRadius: radius, + border: Border.all( + color: selected + ? Colors.transparent + : colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs + 1, + ), + child: Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: selected + ? colors.onPrimaryContainer + : colors.onSurfaceVariant, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ), + ); + } +} diff --git a/test/app/router/notification_routes_test.dart b/test/app/router/notification_routes_test.dart index 947aa716f..e936549b2 100644 --- a/test/app/router/notification_routes_test.dart +++ b/test/app/router/notification_routes_test.dart @@ -2,6 +2,7 @@ import 'package:dpip/app/router/notification_routes.dart'; import 'package:dpip/core/notifications/notification_channels.dart'; import 'package:dpip/core/notifications/notification_tap.dart'; import 'package:dpip/features/map/presentation/layers/rts_layer.dart'; +import 'package:dpip/features/map/presentation/layers/tsunami_layer.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -79,7 +80,7 @@ void main() { routeForNotificationChannel('report-general-v2'), AppRoutes.earthquake, ); - expect(routeForNotificationChannel('tsunami-important-v2'), AppRoutes.home); + expect(routeForNotificationChannel('tsunami-important-v2'), AppRoutes.map); expect( routeForNotificationChannel('announcement-general-v2'), AppRoutes.home, @@ -384,8 +385,8 @@ void main() { }); // Half of an EEW tap's destination is the route; the other half is which of - // the map's fourteen overlays it lands on. Routing alone would drop the user - // on whatever the session was last looking at. + // the map's overlays it lands on. Routing alone would drop the user on + // whatever the session was last looking at. test('an EEW tap asks the map for 強震監視器, before it navigates', () { final order = []; void record( @@ -407,6 +408,25 @@ void main() { expect(order, ['layer:$monitorMapLayerId', 'go:${AppRoutes.map}']); }); + test('a tsunami tap asks the map for the tsunami overlay', () { + final order = []; + void record( + String name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + String? fragment, + Object? extra, + }) => order.add('go:$name'); + + routeNotificationTap( + const NotificationTap(channelKey: 'tsunami-important-v2'), + navigate: record, + focusMapLayer: (layerId) => order.add('layer:$layerId'), + ); + + expect(order, ['layer:$tsunamiMapLayerId', 'go:${AppRoutes.map}']); + }); + test('a channel with no overlay of its own leaves the map alone', () { final focused = []; routeNotificationTap( @@ -423,12 +443,14 @@ void main() { expect(focused, isEmpty); }); - // The id is a literal in `app/router` because that layer must not import a - // feature's presentation code. This is what keeps the literal honest. + // The ids are literals in `app/router` because that layer must not import a + // feature's presentation code. This is what keeps the literals honest. test('every mapped overlay id is one the map actually offers', () { expect(RtsMapLayer.layerId, monitorMapLayerId); - for (final id in notificationChannelMapLayers.values) { - expect(id, monitorMapLayerId); - } + expect(TsunamiMapLayer.layerId, tsunamiMapLayerId); + expect(notificationChannelMapLayers.values.toSet(), { + monitorMapLayerId, + tsunamiMapLayerId, + }); }); } diff --git a/test/core/settings/default_map_layer_test.dart b/test/core/settings/default_map_layer_test.dart index 006177a13..4645e7583 100644 --- a/test/core/settings/default_map_layer_test.dart +++ b/test/core/settings/default_map_layer_test.dart @@ -13,7 +13,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('id is exactly the member name for every layer', () { - expect(DefaultMapLayer.values, hasLength(12)); + expect(DefaultMapLayer.values, hasLength(13)); for (final layer in DefaultMapLayer.values) { expect(layer.id, layer.name, reason: layer.name); } diff --git a/test/features/earthquake/presentation/pages/report_detail_page_test.dart b/test/features/earthquake/presentation/pages/report_detail_page_test.dart index 7fc923adf..d0e57d242 100644 --- a/test/features/earthquake/presentation/pages/report_detail_page_test.dart +++ b/test/features/earthquake/presentation/pages/report_detail_page_test.dart @@ -584,19 +584,21 @@ void main() { expect(find.byType(Image), findsOneWidget); expect(client.urls.single, contains('scweb.cwa.gov.tw')); + // Settle, never a bare pump: the menu opens on an animation, so one frame + // leaves every row mid-flight and a tap lands wherever it was passing. await tester.tap(find.byTooltip('Township names')); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(find.text('Terrain relief')); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(find.text('Detailed map')); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(find.text('Terrain relief')); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(find.text('Township names')); - await tester.pump(); + await tester.pumpAndSettle(); // The menu stays open over the compass; close it before tapping the needle. await tester.tap(find.byTooltip('Township names')); - await tester.pump(); + await tester.pumpAndSettle(); platform.failNextDetailSource = true; platform.failNextVisibility = true; diff --git a/test/features/earthquake/presentation/pages/report_replay_page_test.dart b/test/features/earthquake/presentation/pages/report_replay_page_test.dart index 26d7d1315..af8e83967 100644 --- a/test/features/earthquake/presentation/pages/report_replay_page_test.dart +++ b/test/features/earthquake/presentation/pages/report_replay_page_test.dart @@ -598,16 +598,19 @@ void main() { await tester.pump(const Duration(seconds: 1)); expect(find.text('花蓮縣'), findsNothing); + // Settle, never a bare pump: the menu opens on an animation, so one + // frame leaves every row mid-flight and a tap lands wherever it was + // passing. await tester.tap(find.byTooltip('Township names')); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(find.text('Detailed map')); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(find.text('Terrain relief')); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(find.text('Township names')); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Township names')); - await tester.pump(); + await tester.pumpAndSettle(); platform.lookEast(); await tester.pump(); diff --git a/test/features/map/presentation/pages/map_page_test.dart b/test/features/map/presentation/pages/map_page_test.dart index 8a0a413b5..3b68add8f 100644 --- a/test/features/map/presentation/pages/map_page_test.dart +++ b/test/features/map/presentation/pages/map_page_test.dart @@ -29,6 +29,7 @@ import 'package:dpip/features/earthquake/domain/rts_live_demand.dart'; import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/features/earthquake/domain/trem_station_repository.dart'; import 'package:dpip/features/map/presentation/pages/map_page.dart'; +import 'package:dpip/features/tsunami/domain/tsunami_repository.dart'; import 'package:dpip/features/typhoon/domain/meteor_typhoon_repository.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; import 'package:dpip/features/weather/domain/meteor_rain_repository.dart'; @@ -103,6 +104,8 @@ class _DisasterRepo extends _Silent implements DisasterMapRepository { void cancelTilePrefetch() {} } +class _TsunamiRepo extends _Silent implements TsunamiRepository {} + class _TremRepo extends _Silent implements TremStationRepository {} class _RainRepo extends _Silent implements MeteorRainRepository {} @@ -223,6 +226,7 @@ void main() { Provider.value(value: _RainRepo()), Provider.value(value: _TyphoonRepo()), Provider.value(value: _DisasterRepo()), + Provider.value(value: _TsunamiRepo()), Provider.value(value: _TremRepo()), ChangeNotifierProvider>.value(value: rts), ChangeNotifierProvider>>.value(value: eew), @@ -301,6 +305,7 @@ void main() { 'rain', 'dpm', 'meshtastic', + 'tsunami', }, ); expect(find.text(l10n.mapLayerRadar), findsWidgets); diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index fad93f63b..0c8c8f834 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -782,6 +782,19 @@ void main() { await tester.pump(); expect(launcher.launched, contains('https://exptech.com.tw/tos')); + // The social accounts carry their own handles, not the org landing page. + for (final (label, url) in <(String, String)>[ + ('DPIP Threads', 'https://www.threads.com/@dpip_tw'), + ('DPIP Instagram', 'https://www.instagram.com/dpip_tw/'), + ('DPIP Facebook', 'https://exptech.com.tw/dpip_tw_FB'), + ]) { + launcher.launched.clear(); + await tester.ensureVisible(find.text(label)); + await tester.tap(find.text(label)); + await tester.pump(); + expect(launcher.launched, [url], reason: label); + } + launcher.fail = true; await tester.ensureVisible(find.text('Discord community')); await tester.tap(find.text('Discord community')); diff --git a/test/features/tsunami/domain/tsunami_report_test.dart b/test/features/tsunami/domain/tsunami_report_test.dart new file mode 100644 index 000000000..5dba71030 --- /dev/null +++ b/test/features/tsunami/domain/tsunami_report_test.dart @@ -0,0 +1,326 @@ +/// The `data.area` tuples CWA sends, the wave-height scale both the map and the +/// sheet colour by, and the event grouping behind the sheet's 第N報 picker. +/// +/// Both tuple shapes matter because they decode without complaint under the +/// wrong branch — a prediction read as an observation would take its name from +/// a coast description and its reading from a predicted height, and the only +/// thing visibly wrong would be the panel. The fixtures are the real +/// 2024-04-03 花蓮 event's payloads (`…300301-…` 第1報 `predict`; `…300302-…` +/// 第2報 `predict`; `…300303-…` 第3報 `observe`), including the 臺中港 entry +/// whose coordinates are `null` — the one row `tsunamiGeoJson` has to skip. +library; + +import 'package:dpip/core/a11y/color_vision.dart'; +import 'package:dpip/features/map/presentation/layers/tsunami_layer.dart'; +import 'package:dpip/features/tsunami/domain/tsunami_report.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const Map _predict = { + 'id': 'CWA-TSU11300301-2024-0403-081100', + 'sent': 1712103060000, + 'msgType': 'Issue', + 'no': 113003, + 'rep': '第1報', + 'type': '海嘯警報', + 'content': '…', + 'eq': { + 't': 1712102280000, + 'lon': 121.67, + 'lat': 23.77, + 'loc': '臺灣東部海域', + 'dep': 15.5, + 'mag': 7.3, + }, + 'data': { + 'type': 'predict', + 'area': [ + ['東部沿海地區', '宜蘭縣南澳鄉至臺東縣長濱鄉沿岸', '<1', 1712102340000, '黃色'], + ['海峽沿海地區', '桃園市至嘉義縣沿岸', '<1', 1712109480000, '黃色'], + ], + }, +}; + +const Map _observe = { + 'id': 'CWA-TSU11300303-2024-0403-111000', + 'sent': 1712113800000, + 'msgType': 'Cancel', + 'no': 113003, + 'rep': '第3報', + 'type': '海嘯警報解除', + 'content': '…', + 'eq': { + 't': 1712102280000, + 'lon': 121.67, + 'lat': 23.77, + 'loc': '臺灣', + 'dep': 15.5, + 'mag': 7.2, + }, + 'data': { + 'type': 'observe', + 'area': [ + ['HL', '花蓮', '27公分', 1712104200000, 121.62, 23.98], + ['CK', '台東成功', '54公分', 1712105520000, 121.38, 23.09], + ['TO', '宜蘭烏石', '82公分', 1712106720000, 121.84, 24.87], + ['臺中港臺中港', '臺中港臺中港', '27公分', 1712108160000, null, null], + ], + }, +}; + +void main() { + group('TsunamiReport.decode', () { + test('a predict bulletin decodes its five-field area tuples', () { + final report = TsunamiReport.decode(_predict); + + expect(report.report, '第1報'); + expect(report.msgType, 'Issue'); + expect(report.type, '海嘯警報'); + // `eq` is a plain object on the wire, so it goes through the generated + // `fromJson` — the one place a `@JsonKey` typo would silently null a + // field instead of throwing. + expect(report.earthquake.location, '臺灣東部海域'); + expect(report.earthquake.magnitude, 7.3); + expect(report.earthquake.depth, 15.5); + expect(report.earthquake.longitude, 121.67); + expect( + report.earthquake.time, + 1712102280000, + reason: 'earthquake origin time is a @JsonKey rename of `t`', + ); + expect(report.observations, isEmpty); + expect(report.predictions, hasLength(2)); + final first = report.predictions.first; + expect(first.area, '東部沿海地區'); + expect(first.coast, '宜蘭縣南澳鄉至臺東縣長濱鄉沿岸'); + expect(first.height, '<1'); + expect(first.arrivalTime, 1712102340000); + }); + + test('an observe bulletin decodes its six-field area tuples', () { + final report = TsunamiReport.decode(_observe); + + expect(report.predictions, isEmpty); + expect(report.observations, hasLength(4)); + final first = report.observations.first; + expect(first.name, '花蓮'); + expect(first.height, '27公分'); + expect(first.time, 1712104200000); + expect(first.longitude, 121.62); + expect(first.latitude, 23.98); + }); + + test('a station that reported no position keeps a null one', () { + final station = TsunamiReport.decode(_observe).observations.last; + expect(station.name, '臺中港臺中港'); + expect(station.longitude, isNull); + expect(station.latitude, isNull); + }); + + test('a bulletin whose data block is empty decodes to neither list', () { + final report = TsunamiReport.decode({ + ..._predict, + 'data': const {'type': 'predict'}, + }); + expect(report.predictions, isEmpty); + expect(report.observations, isEmpty); + }); + }); + + group('predicted coast', () { + Map predictBulletin(List> rows) => { + ..._predict, + 'data': {'type': 'predict', 'area': rows}, + }; + + test('a prediction keeps CWA\'s own warning colour', () { + final first = TsunamiReport.decode(_predict).predictions.first; + expect(first.color, '黃色'); + }); + + test('a four-field tuple has no colour rather than failing', () { + final report = TsunamiReport.decode( + predictBulletin([ + ['東部沿海地區', '宜蘭縣南澳鄉至臺東縣長濱鄉沿岸', '<1', 1712102340000], + ]), + ); + expect(report.predictions.single.color, isNull); + }); + + test('CWA colours map to the legacy steps, and nothing is invented', () { + expect(TsunamiWaveBand.ofWarningColor('黃色'), TsunamiWaveBand.from30cm); + expect(TsunamiWaveBand.ofWarningColor('紅色'), TsunamiWaveBand.from1m); + expect(TsunamiWaveBand.ofWarningColor('紫色'), TsunamiWaveBand.over3m); + // An unknown colour must not be defaulted to the quiet blue. + expect(TsunamiWaveBand.ofWarningColor('綠色'), isNull); + expect(TsunamiWaveBand.ofWarningColor(null), isNull); + }); + + test('each region is banded by its own colour', () { + final bands = tsunamiAreaBands(TsunamiReport.decode(_predict)); + expect(bands, { + '東部沿海地區': TsunamiWaveBand.from30cm, + '海峽沿海地區': TsunamiWaveBand.from30cm, + }); + }); + + test('a region listed twice keeps its higher band', () { + final bands = tsunamiAreaBands( + TsunamiReport.decode( + predictBulletin([ + ['東部沿海地區', 'a', '<1', 1, '紅色'], + ['東部沿海地區', 'b', '<1', 1, '黃色'], + ]), + ), + ); + expect(bands, {'東部沿海地區': TsunamiWaveBand.from1m}); + }); + + test('an observe bulletin paints no coast', () { + expect(tsunamiAreaBands(TsunamiReport.decode(_observe)), isEmpty); + }); + }); + + group('TsunamiBulletin', () { + test('fromJson reads no/rep, toJson restores the wire keys', () { + final json = { + 'id': 'CWA-TSU11300303-2024-0403-111000', + 'no': 113003, + 'rep': '第3報', + 'type': '海嘯警報解除', + 'sent': 1712113800000, + }; + + final bulletin = TsunamiBulletin.fromJson(json); + + expect(bulletin.number, 113003); + expect(bulletin.report, '第3報'); + expect(bulletin.type, '海嘯警報解除'); + expect(bulletin.sent, 1712113800000); + expect(bulletin.toJson(), json); + }); + + Map row(String id, int no, String rep, int sent) => { + 'id': id, + 'no': no, + 'rep': rep, + 'type': '海嘯警報', + 'sent': sent, + }; + + test('newestEvent keeps the newest event\'s reports, newest first', () { + final bulletins = TsunamiBulletin.newestEvent([ + row('b-3', 113004, '第1報', 300), + row('a-3', 113003, '第3報', 200), + row('a-2', 113003, '第2報', 100), + row('a-1', 113003, '第1報', 50), + ]); + + // 113004 is this year's event and 113003 last year's: the picker must + // offer this year's three reports and never the older event's, or a + // reader reopening the layer during a quiet season would be shown a + // bulletin from the previous year as though it were current. + expect([for (final b in bulletins) b.id], ['b-3']); + }); + + test('newestEvent keeps every report of one event, in index order', () { + final bulletins = TsunamiBulletin.newestEvent([ + row('a-3', 113003, '第3報', 200), + row('a-2', 113003, '第2報', 100), + row('a-1', 113003, '第1報', 50), + ]); + + expect([for (final b in bulletins) b.report], ['第3報', '第2報', '第1報']); + }); + + test('newestEvent over an empty index is empty, not an error', () { + expect(TsunamiBulletin.newestEvent(const []), isEmpty); + }); + }); + + group('wave-height scale', () { + test('a reading lands in the band CWA defines', () { + // CWA's boundaries, and both sides of each: 30 cm, 100 cm, 300 cm. + expect(TsunamiWaveBand.of(0), TsunamiWaveBand.under30cm); + expect(TsunamiWaveBand.of(29), TsunamiWaveBand.under30cm); + expect(TsunamiWaveBand.of(30), TsunamiWaveBand.from30cm); + expect(TsunamiWaveBand.of(99), TsunamiWaveBand.from30cm); + expect(TsunamiWaveBand.of(100), TsunamiWaveBand.from1m); + expect(TsunamiWaveBand.of(299), TsunamiWaveBand.from1m); + expect(TsunamiWaveBand.of(300), TsunamiWaveBand.over3m); + expect(TsunamiWaveBand.of(1000), TsunamiWaveBand.over3m); + }); + + test('the observed text becomes centimetres', () { + expect(parseObservedCentimeters('27公分'), 27); + expect(parseObservedCentimeters('82公分'), 82); + expect(parseObservedCentimeters('1.5 公尺'), 150); + expect( + parseObservedCentimeters('不明'), + isNull, + reason: 'no number is not a zero-centimetre wave', + ); + }); + + test('the four band colours are the legacy palette', () { + // Pinned because the map, the legend and the chips all read them: a + // changed hex here silently repaints a safety scale. + expect( + TsunamiMapLayer.bandColor(TsunamiWaveBand.over3m), + '#E543FF'.vision, + ); + expect( + TsunamiMapLayer.bandColor(TsunamiWaveBand.from1m), + '#C90000'.vision, + ); + expect( + TsunamiMapLayer.bandColor(TsunamiWaveBand.from30cm), + '#FFC900'.vision, + ); + expect( + TsunamiMapLayer.bandColor(TsunamiWaveBand.under30cm), + '#00AAFF'.vision, + ); + }); + }); + + group('map geometry', () { + test('the map skips an observation with no coordinates', () { + final features = + tsunamiGeoJson(TsunamiReport.decode(_observe))['features'] as List; + + // Epicentre + three positioned stations; 臺中港 is dropped. A station with + // no position is not "somewhere off the coast" — putting `null` into a + // Point is a crash, not a marker. + expect(features, hasLength(4)); + expect((features.first as Map)['properties'], {'kind': 'epicenter'}); + expect((features.first as Map)['geometry'], { + 'type': 'Point', + 'coordinates': [121.67, 23.77], + }); + expect(features[1], { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [121.62, 23.98], + }, + 'properties': { + 'kind': 'observation', + 'band': 'under30cm', + 'label': '花蓮\n27公分', + }, + }); + }); + + test('each reading carries its band, not a colour', () { + final features = + tsunamiGeoJson(TsunamiReport.decode(_observe))['features'] as List; + final bands = [ + for (final feature in features.skip(1)) + (feature as Map)['properties']['band'], + ]; + // 27 cm and 54 cm read quiet on the scale, 82 cm reaches 0.3-1 m — the + // heights the legacy palette was chosen for. + expect(bands, ['under30cm', 'from30cm', 'from30cm']); + }); + }); +} diff --git a/test/shared/map/map_layer_category_test.dart b/test/shared/map/map_layer_category_test.dart index c9bc50b0c..5f0725921 100644 --- a/test/shared/map/map_layer_category_test.dart +++ b/test/shared/map/map_layer_category_test.dart @@ -10,6 +10,14 @@ void main() { expect(categoryOf('monitor'), MapLayerCategory.earthquake); }); + test('tsunami bulletins share the seismology group', () { + // The group header reads 「地震與海嘯」 for exactly this reason: a tsunami + // bulletin is issued off an earthquake, and the two are one product family + // at CWA. A tsunami row under a header saying only 「地震」 would read as a + // misfiled alert. + expect(categoryOf('tsunami'), MapLayerCategory.earthquake); + }); + test('typhoon is its own group', () { expect(categoryOf('typhoon'), MapLayerCategory.typhoon); });