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
18 changes: 18 additions & 0 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@

## 沒多活備援 (single host, no failover)

### Basemap / Terrain(全域 static LB,無區域)

Basemap 與 terrain 都由 MapLibre 直接抓(app 的 tile bridge 會以 URL 為鍵快取),
不經 `ApiClient` 的區域 failover。

| 用途 | 路徑 | 主機 |
|---|---|---|
| basemap | `/api/v1/map/tiles/{z}/{x}/{y}.pbf` | `static.lb.exptech.dev` |
| terrain | `/api/v1/map/terrain/{z}/{x}/{y}.png` | `static.lb.exptech.dev` |

> **Terrain 是 Mapbox terrain-RGB,MapLibre 原生讀得懂。** 每個像素編碼
> `height = (R·65536 + G·256 + B)/10 − 10000` 公尺,正是 MapLibre
> `raster-dem` 的 `encoding: 'mapbox'` —— style 直接以該 encoding 使用原始
> PNG,**不需要任何 app 端轉換**(參照 `satellite-tiles-go/web` 的底圖處理)。
> 底圖以 `encoding: 'mapbox'`、`tileSize: 512`、`bounds: [110, 10, 132, 35]`
> 註冊 `raster-dem` source,疊半透明 `hillshade` layer 呈現立體感;`bounds`
> 刻意大於真實 DEM bbox,讓 hillshade 邊緣永遠不會在畫面上碰到純背景。

### 雷達(v2)—— `core-tnn1`

時間清單是差量編碼的 Unix 秒(`[baseSec, Δ, …]`),在 API 主機上帶 ETag/304;
Expand Down
4 changes: 2 additions & 2 deletions lib/bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,14 @@ Future<void> bootstrap() async {
final defaultMapLayer = DefaultMapLayerController(prefs);
final mapLayerOrder = MapLayerOrderController(prefs);
final cache = await _openCache();
final dio = createDio(etagCache: cache?.etag, usage: cache?.usage);
final apiClient = ApiClient(dio, regions);
// MapLibre asks Dart for every ExpTech tile before it asks the network, so
// this must be bound before the first map is built.
final mapTileCache = cache == null
? null
: MapTileCache(cache.etag, usage: cache.usage);
await mapTileCache?.install();
final dio = createDio(etagCache: cache?.etag, usage: cache?.usage);
final apiClient = ApiClient(dio, regions);

// Calibrated clock: real SNTP (flutter_ntp, ExpTech primary / Apple backup)
// anchored to a monotonic clock, exposed globally via `AppTime` and resynced
Expand Down
6 changes: 3 additions & 3 deletions lib/core/network/api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ class ApiClient {

/// Absolute-URL GET as bytes with ETag (no region failover).
///
/// For hosts already baked into the MapLibre style (basemap `lb.exptech.dev`,
/// glyphs CDN) that are not an [ApiTier]. Prefer [getBytes] for region-pinned
/// ExpTech paths.
/// For hosts already baked into the MapLibre style (basemap
/// `static.lb.exptech.dev`, glyphs CDN) that are not an [ApiTier]. Prefer
/// [getBytes] for region-pinned ExpTech paths.
Future<BytePayload> getBytesAbsolute(
String url, {
CancelToken? cancelToken,
Expand Down
4 changes: 4 additions & 0 deletions lib/core/network/api_paths.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ abstract final class ApiPaths {
/// v1 basemap vector tiles (`/api/v1/map/tiles/…`).
static const String mapTilesV1 = '/api/v1/map/tiles/';

/// v1 terrain vector tiles (`/api/v1/map/terrain/…`) — the static CDN's
/// elevation mesh, same XYZ shape as [mapTilesV1].
static const String mapTerrainV1 = '/api/v1/map/terrain/';

/// Live EEW feed (with `?sse=1&compress=1` for the stream).
static const String eew = '/api/v2/eq/eew';

Expand Down
3 changes: 2 additions & 1 deletion lib/core/network/etag_interceptor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ class EtagInterceptor extends Interceptor {

/// Bare-host basemap vector tiles (no server ETag).
static bool isBasemapPbf(Uri uri) =>
uri.host == 'lb.exptech.dev' &&
uri.host == 'static.lb.exptech.dev' &&
uri.path.contains(ApiPaths.mapTilesV1) &&
uri.path.endsWith('.pbf');

Expand All @@ -78,6 +78,7 @@ class EtagInterceptor extends Interceptor {
/// app's usage accounting.
static const List<String> immutableAssetMarkers = [
ApiPaths.mapTilesV1, // basemap vector tiles
ApiPaths.mapTerrainV1, // terrain vector tiles
'${ApiPaths.tiles}/radar/',
'${ApiPaths.tiles}/satellite/',
'${ApiPaths.tiles}/wind/',
Expand Down
2 changes: 2 additions & 0 deletions lib/features/home/home_providers.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:dpip/core/geo/location_service.dart';
import 'package:dpip/core/geo/town_directory.dart';
import 'package:dpip/core/settings/region_store.dart';
import 'package:dpip/features/events/domain/event_repository.dart';
Expand Down Expand Up @@ -26,6 +27,7 @@ List<SingleChildWidget> homeProviders() => [
context.read<RainHourTrendRepository>(),
context.read<RegionStore>(),
context.read<TownDirectory>(),
gpsFix: context.read<LocationService>().currentFix,
),
),
ChangeNotifierProvider<HomeActiveEventsController>(
Expand Down
61 changes: 58 additions & 3 deletions lib/features/home/presentation/home_weather_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
/// next-hour rain trend.
library;

import 'dart:async';

import 'package:dpip/core/error/failure.dart';
import 'package:dpip/core/geo/location_service.dart';
import 'package:dpip/core/geo/town_directory.dart';
import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/settings/home_area.dart';
import 'package:dpip/core/settings/region_store.dart';
import 'package:dpip/features/weather/domain/meteor_weather_repository.dart';
Expand All @@ -18,13 +22,18 @@ import 'package:flutter/foundation.dart';
/// [RegionStore] township. 全國 has no point weather — [areaCode] is null and
/// all feeds stay cleared. Reloads when the area changes; the last values are
/// kept while a new fetch runs so the sheet never blanks.
///
/// [gpsFix] supplies the live GPS coordinate (nullable when unavailable) for
/// the debug log — the realtime request itself goes to the township centre,
/// so the fix is logged alongside rather than used to fetch.
class HomeWeatherController extends ChangeNotifier {
HomeWeatherController(
this._repository,
this._hourTrendRepository,
this._regions,
this._directory,
) {
this._directory, {
this.gpsFix,
}) {
_regions.addListener(_sync);
_sync();
}
Expand All @@ -34,7 +43,11 @@ class HomeWeatherController extends ChangeNotifier {
final RegionStore _regions;
final TownDirectory _directory;

/// Live GPS fix for the debug log; null when unavailable.
final Future<GpsFix?> Function()? gpsFix;

WeatherRealtime? _weather;
String? _weatherCode;
WeatherForecast? _forecast;
RainHourTrend? _hourTrend;
bool _loading = false;
Expand All @@ -46,6 +59,12 @@ class HomeWeatherController extends ChangeNotifier {
/// The latest realtime observation, or null before the first load / at sea.
WeatherRealtime? get weather => _weather;

/// The township code [weather] belongs to — null before the first load. The
/// header shows readings only when this matches the currently selected area,
/// so a stale (previous-area) observation never masquerades as the new one
/// while its fetch runs.
String? get weatherCode => _weatherCode;

/// The latest township 24h forecast, or null before the first load / no code.
WeatherForecast? get forecast => _forecast;

Expand Down Expand Up @@ -89,6 +108,7 @@ class HomeWeatherController extends ChangeNotifier {
final town = code == null ? null : _directory.byCode(code);
if (town == null || code == null) {
_weather = null;
_weatherCode = null;
_forecast = null;
_hourTrend = null;
_failure = null;
Expand All @@ -107,6 +127,11 @@ class HomeWeatherController extends ChangeNotifier {
_hourTrendFailure = null;
notifyListeners();

// The debug GPS read rides alongside the data fetches, never ahead of
// them: a fix without a fresh cache sits in its 10s timeout window, and it
// only annotates the log — awaiting it here would hold the sheet's new
// readings in limbo for nothing the user can see.
final gpsFuture = gpsFix?.call();
final realtimeFuture = _repository.realtime(lat, lng);
final forecastFuture = _repository.forecast(code);
final hourTrendFuture = _hourTrendRepository.hourTrend(code);
Expand All @@ -118,10 +143,15 @@ class HomeWeatherController extends ChangeNotifier {

_loading = false;
realtime.when(
ok: (value) => _weather = value,
ok: (value) {
_weather = value;
_weatherCode = value == null ? null : code;
if (value != null) unawaited(_logRealtime(code, gpsFuture, value));
},
err: (failure) {
_failure = failure;
_weather = null;
_weatherCode = null;
},
);
forecast.when(
Expand All @@ -141,6 +171,31 @@ class HomeWeatherController extends ChangeNotifier {
notifyListeners();
}

/// Debug line: the GPS fix (3 decimals) plus the nearest-station realtime
/// result the fetch resolved, for verifying what the device saw. The fix may
/// still be inside its (up to 10s) timeout window — it only annotates the
/// log, so it's awaited here, after the sheet has already updated.
Future<void> _logRealtime(
String code,
Future<GpsFix?>? gpsFuture,
WeatherRealtime value,
) async {
final gps = await gpsFuture;
final coords = gps == null
? 'no-fix'
: '${gps.lat.toStringAsFixed(3)},${gps.lng.toStringAsFixed(3)}';
final data = value.data;
Log.debug(
// l10n-ignore: debug log line, not user-facing display text
'所在地 code=$code gps=$coords → '
'${value.station.name}(${value.station.distance.toStringAsFixed(1)} km) '
'weather=${data.weather}(${data.weatherCode}) '
'temp=${data.temperature?.toStringAsFixed(1)}°C '
'rain=${data.rain?.toStringAsFixed(1)} mm '
'wind=${data.wind.speed?.toStringAsFixed(1)} m/s',
);
}

@override
void dispose() {
_regions.removeListener(_sync);
Expand Down
103 changes: 97 additions & 6 deletions lib/features/home/presentation/widgets/home_sheet_header.dart
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import 'package:dpip/app/theme/app_glass.dart';
import 'package:dpip/app/theme/app_motion.dart';
import 'package:dpip/app/theme/app_radius.dart';
import 'package:dpip/app/theme/app_spacing.dart';
import 'package:dpip/core/geo/town_directory.dart';
import 'package:dpip/core/realtime/app_time.dart';
import 'package:dpip/core/settings/home_area.dart';
import 'package:dpip/core/settings/region_store.dart';
import 'package:dpip/core/settings/weather_mode.dart';
import 'package:dpip/features/home/presentation/home_weather_controller.dart';
import 'package:dpip/core/weather/weather_condition.dart';
import 'package:dpip/features/home/presentation/home_weather_controller.dart';
import 'package:dpip/features/weather/domain/weather_realtime.dart';
import 'package:dpip/l10n/gen/app_localizations.dart';
import 'package:dpip/shared/map/map_station_handoff.dart';
import 'package:dpip/shared/navigation/app_routes.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';

/// The header at the top of the home sheet: the selected area name and, for a
/// township, its current weather — condition icon + temperature on the left
/// (2/3), precipitation + humidity stacked on the right (1/3). 全國 shows the
/// name only (no point weather).
/// (2/3), precipitation over humidity on the right (1/3). The nearest-station
/// name + observation time (Taipei HH:mm) sit as small print under the name,
/// with a "view on map" link that opens the map on that station's temperature
/// layer. 全國 shows the name only (no point weather).
///
/// Readings are shown only while they belong to the *selected* area — a
/// previous area's observation is held by the controller while the new fetch
/// runs, but a wrong-area temperature must never masquerade as the new one
/// (the sheet shows dashes instead until the new reading lands).
///
/// When [expanded] (sheet flush full-screen), typography and layout step up to
/// fill the hero band — same pattern as the station/typhoon chart sheets — so a
Expand Down Expand Up @@ -73,7 +87,14 @@ class HomeSheetHeader extends StatelessWidget {
SavedArea(:final code) => directory.byCode(code)?.fullName ?? '',
};

final data = context.watch<HomeWeatherController>().weather?.data;
final controller = context.watch<HomeWeatherController>();
// A reading from a previous area is held while the new one loads (never
// blanking the sheet), but it must not be shown as the new area's — only
// data belonging to the currently selected township passes through here.
final realtime = controller.weatherCode == controller.areaCode
? controller.weather
: null;
final data = realtime?.data;
final temp = data?.temperature;
final humidity = data?.humidity;
final rain = data?.rain;
Expand Down Expand Up @@ -125,6 +146,29 @@ class HomeSheetHeader extends StatelessWidget {
style: nameStyle ?? const TextStyle(),
child: Text(areaName),
),
if (realtime case final current?) ...[
const SizedBox(height: AppSpacing.xs),
Text(
l10n.weatherDataTime(
current.station.name,
DateFormat('HH:mm').format(
AppTime.taipei(
DateTime.fromMillisecondsSinceEpoch(
current.time * 1000,
isUtc: true,
),
),
),
),
style:
(expanded
? theme.textTheme.labelMedium
: theme.textTheme.labelSmall)
?.copyWith(color: secondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (!nationwide) ...[
SizedBox(height: expanded ? AppSpacing.xl : AppSpacing.lg),
if (currentUnavailable)
Expand Down Expand Up @@ -200,6 +244,39 @@ class HomeSheetHeader extends StatelessWidget {
),
],
),
if (realtime case final current?) ...[
const SizedBox(height: AppSpacing.sm),
// Below the left-hand (precipitation) metric — a quiet
// path to the same station on the map, shown only when the
// sheet is flush so a collapsed header stays uncluttered.
InkWell(
borderRadius: BorderRadius.circular(AppRadius.sm),
onTap: () => _openNearestStationOnMap(context, current),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
vertical: AppSpacing.xs / 2,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.homeViewOnMap,
style: theme.textTheme.labelMedium?.copyWith(
color: foreground,
fontWeight: FontWeight.w700,
),
),
Icon(
Icons.chevron_right,
size: 14,
color: foreground,
),
],
),
),
),
],
],
)
else
Expand Down Expand Up @@ -240,8 +317,8 @@ class HomeSheetHeader extends StatelessWidget {
children: [
_Metric(
label: l10n.weatherPrecipitation,
// A missing reading is a dash, never a fabricated 0.0 —
// "no rain" and "no data" must not look the same.
// A missing reading is a dash, never a fabricated
// 0.0 — "no rain" and "no data" must not match.
value: rain == null
? '—'
: '${rain.toStringAsFixed(1)} mm',
Expand Down Expand Up @@ -309,3 +386,17 @@ class _Metric extends StatelessWidget {
);
}
}

/// Opens the map tab on the temperature layer, focused on [realtime]'s station —
/// the same nearest station the header's reading came from (resolved against the
/// township centre / GPS fix by the repository). The one-shot station hand-off
/// switches the overlay, frames the station, and opens its sheet.
void _openNearestStationOnMap(BuildContext context, WeatherRealtime realtime) {
context.read<MapStationHandoff>().request(
layerId: 'temperature',
stationId: realtime.id,
latitude: realtime.station.latitude,
longitude: realtime.station.longitude,
);
context.goNamed(AppRoutes.map);
}
4 changes: 4 additions & 0 deletions lib/features/map/presentation/layers/disaster_map_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -611,11 +611,15 @@ class DisasterMapLayer with MapLayerDefaults implements MapLayer {
BuildContext context, {
required ValueListenable<bool> showTownLabels,
required ValueChanged<bool> onShowTownLabelsChanged,
required ValueListenable<bool> showTerrain,
required ValueChanged<bool> onShowTerrainChanged,
required Future<void> Function() onReloadActive,
}) => DisasterMapOverlayMenu(
layer: this,
showTownLabels: showTownLabels,
onShowTownLabelsChanged: onShowTownLabelsChanged,
showTerrain: showTerrain,
onShowTerrainChanged: onShowTerrainChanged,
);

@override
Expand Down
Loading
Loading