Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

| 類別.方法 | 路徑 | 層級 |
Expand Down
17 changes: 13 additions & 4 deletions lib/app/router/notification_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,20 @@ const Map<String, String> 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
/// a feature's presentation layer; `notification_routes_test` pins the two
/// 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';

Expand Down Expand Up @@ -216,10 +223,12 @@ const Map<String, String> 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,
Expand Down
2 changes: 2 additions & 0 deletions lib/bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -460,6 +461,7 @@ Future<void> bootstrap() async {
...weatherProviders(deps),
...disasterMapProviders(deps),
...typhoonProviders(deps),
...tsunamiProviders(deps),
...eventsProviders(deps),
...changelogProviders(deps),
...notificationProviders(deps),
Expand Down
3 changes: 3 additions & 0 deletions lib/core/network/api_paths.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
1 change: 1 addition & 0 deletions lib/core/settings/default_map_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum DefaultMapLayer {
lightning,
typhoon,
monitor,
tsunami,
temperature,
humidity,
pressure,
Expand Down
83 changes: 48 additions & 35 deletions lib/features/earthquake/presentation/pages/report_replay_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -297,42 +297,55 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
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, _) {
Expand Down
1 change: 1 addition & 0 deletions lib/features/map/presentation/layers/mesh_node_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lib/features/map/presentation/layers/rain_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading