Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
e9a67b3
feat: assets
whes1015 Aug 9, 2026
39e7521
fix: background
whes1015 Aug 9, 2026
01c64f7
fix: lang
whes1015 Aug 9, 2026
1b56b42
fix: background
whes1015 Aug 9, 2026
01ec52a
fix: android crash
whes1015 Aug 9, 2026
869b511
fix: background
whes1015 Aug 9, 2026
47dea66
fix: pin maplibre_gl to gzip fix
whes1015 Aug 9, 2026
ee5ec55
fix: developer mode
whes1015 Aug 10, 2026
91bf45c
fix: home page weather card
whes1015 Aug 10, 2026
3246c08
fix: home page weather icon
whes1015 Aug 10, 2026
42589dc
fix: home page weather background
whes1015 Aug 10, 2026
1e1eeec
feat: home sheet next-hour rain trend from rainforecast API
whes1015 Aug 10, 2026
fd8db7d
fix: home background blur/dim flicker
whes1015 Aug 10, 2026
8776ea3
feat: weather phenomenon icons finer than the backdrop modes
whes1015 Aug 10, 2026
46a9245
feat: beautify data page
whes1015 Aug 10, 2026
b777bf1
feat: home rain trend summary + minute axis
whes1015 Aug 10, 2026
3bce5ad
feat: wind map arrows with baked-in outline, speed-coloured
whes1015 Aug 10, 2026
30e38f8
feat: wind chart segments coloured by speed bucket
whes1015 Aug 10, 2026
6f73fb3
fix: wind chart tooltip crash on segmented bars
whes1015 Aug 10, 2026
9f97889
feat: 24M tile cache with fill-style warm injection
whes1015 Aug 10, 2026
1fab8d7
feat: QPESUMS forecast overlay with scan/county/town chrome
whes1015 Aug 10, 2026
b9493ad
fix: self-heal map platform view on iOS hot restart
whes1015 Aug 10, 2026
163c289
fix: compass above all overlays
whes1015 Aug 10, 2026
d740ca8
fix: space the QPESUMS layer label
whes1015 Aug 10, 2026
7e3c228
refactor: extract notification tap routing into its own file
whes1015 Aug 10, 2026
2d1a290
feat: open DPM points in an external map app
whes1015 Aug 10, 2026
5187dca
style: reformat the notification route navigator typedef
whes1015 Aug 10, 2026
e51250d
feat: show rain-trend update time and a no-data pane
whes1015 Aug 10, 2026
cd54102
feat: outline lightning markers for visibility over pale tiles
whes1015 Aug 10, 2026
e9126d4
refactor: bake lightning colours in so the outline survives tinting
whes1015 Aug 10, 2026
42b18ea
style: thicken the lightning outline to match the wind arrows
whes1015 Aug 10, 2026
087140d
feat: expose the township-name toggle on every map layer
whes1015 Aug 10, 2026
96e3793
fix: settle the compass needle on reset and park it lower
whes1015 Aug 10, 2026
0906bf9
feat: use the Flutter MapCompass on the report and replay maps
whes1015 Aug 10, 2026
a430cc4
feat: treat a dry rain-hour forecast as no rain
whes1015 Aug 10, 2026
a0d69dd
feat: grow the hero forecast card on a dry hour
whes1015 Aug 10, 2026
70a4d8e
feat: centre the home backdrop on GPS in current-location mode
whes1015 Aug 10, 2026
f006195
perf: square the radar scan-range terms by multiplication
whes1015 Aug 10, 2026
6f74601
perf: binary-search the wind chart's nearest sample
whes1015 Aug 10, 2026
82f34cb
refactor: share the wind arrow outline directions
whes1015 Aug 10, 2026
d6dc1bd
refactor: drop the dead string sort in the lightning cache eviction
whes1015 Aug 10, 2026
cf5b42c
refactor: tidy the tile-cache inject chunk boundary
whes1015 Aug 10, 2026
1348358
perf: cut scroll-frame cost on the home sheet
whes1015 Aug 10, 2026
55199aa
perf: cut the home backdrop's per-frame cost and fix forced-look rain
whes1015 Aug 10, 2026
e9c750d
feat: custom layer order in the map layer picker
whes1015 Aug 10, 2026
d918991
feat: add QPESUMS as a default map layer choice
whes1015 Aug 10, 2026
a451a02
feat: group map layers into six ordered categories
whes1015 Aug 10, 2026
0bf2b48
feat: let the layer order editor reorder categories too
whes1015 Aug 10, 2026
a934af4
refactor: split the layer order editor into two levels
whes1015 Aug 10, 2026
00cbb55
feat: reorder default map-layer categories
whes1015 Aug 10, 2026
c0bf2e1
chore: bump flutter-maplibre-gl to 7c2e249
whes1015 Aug 10, 2026
1e5f950
perf: render weather/cloud shaders at mediump precision
whes1015 Aug 10, 2026
6354040
refactor: merge the three v2 tile APIs and repositories
whes1015 Aug 10, 2026
82ac382
refactor: share degToRad, rampColor, and drop dead ramp code
whes1015 Aug 10, 2026
9ff5ab0
refactor: give MapLayer a defaults mixin for unused members
whes1015 Aug 10, 2026
589ff91
refactor: share meteor repo decodes, bounds, and calibrated time
whes1015 Aug 10, 2026
d08cde6
refactor: fold RainMapLayer into a generic station layer
whes1015 Aug 11, 2026
c9d68ce
refactor: generalize the meteor snapshot API and repositories
whes1015 Aug 11, 2026
17199cf
refactor: share typhoon payload decoding
whes1015 Aug 11, 2026
0f749f1
refactor: route Taipei wall-clock conversions through AppTime.taipei
whes1015 Aug 11, 2026
2c73243
refactor: share small inline UI building blocks
whes1015 Aug 11, 2026
3ed307d
refactor: share the map sheets' extent plumbing and chrome
whes1015 Aug 11, 2026
bdcefa0
refactor: centralize API path constants and drop the dead v1 report e…
whes1015 Aug 11, 2026
5fd2d02
feat: more satellite channel
whes1015 Aug 11, 2026
7459bc8
feat: expand satellite channels to all Himawari bands and products
whes1015 Aug 11, 2026
3ec3845
feat: bright-yellow admin frame over satellite layers
whes1015 Aug 11, 2026
eb3a22b
feat: satellite layer styles, legends and switcher subtitles
whes1015 Aug 11, 2026
165eb53
fix: degrade 所在地 to nationwide when GPS is unavailable
whes1015 Aug 11, 2026
9650b6d
chore: pin maplibre_gl git dependency in pubspec.lock
whes1015 Aug 11, 2026
bac1a7f
fix: clear the stale township frame when 所在地 loses GPS
whes1015 Aug 11, 2026
2c8ee7a
fix: weather icon
whes1015 Aug 11, 2026
c109471
feat: ECMWF/GFS wind-forecast overlays with particle animation
whes1015 Aug 11, 2026
0453678
feat: 國界 borders default-off on every reference overlay
whes1015 Aug 11, 2026
1c5d122
refactor: legend units always shown under the scale
whes1015 Aug 11, 2026
19de96d
feat: cache wind-forecast tiles in the immutable ETag store
whes1015 Aug 11, 2026
0e307e5
perf: equivalent simplification of the wind particle hot path
whes1015 Aug 11, 2026
b08405d
feat: per-hour usage history chart on the Debug page
whes1015 Aug 11, 2026
ddcaf56
feat: colour-coded 現在/歷史/未來 eras on the map timeline
whes1015 Aug 11, 2026
a26bb82
refactor: extract Debug network chart into a tested public widget
whes1015 Aug 11, 2026
c3a94ab
style: reformat drifted map timeline and wind files
whes1015 Aug 11, 2026
2996ce6
feat: red/green network chart with mixed or single-series toggle
whes1015 Aug 11, 2026
a83a1d1
feat: 24h/7d window toggle on the network usage chart
whes1015 Aug 11, 2026
eed7c4d
fix: space out x-axis labels on the network usage chart
whes1015 Aug 11, 2026
358382c
fix: keep the network chart tooltip inside the screen
whes1015 Aug 11, 2026
fa2474c
fix: a future forecast step is never labelled 現在
whes1015 Aug 11, 2026
d7b535b
feat: surface a snackbar when the chosen map app cannot open
whes1015 Aug 11, 2026
55bda87
feat: map timeline shows a forecast's issue time and valid span
whes1015 Aug 11, 2026
36f36ad
refactor: DPM sheet accents per sub-layer, drop category filters
whes1015 Aug 11, 2026
4c473ef
style: polish typhoon warning and waypoint cards
whes1015 Aug 11, 2026
8c072ae
fix: restore maplibre_gl git dependency in pubspec.lock
whes1015 Aug 11, 2026
4b6e76a
fix: typhoon cards
whes1015 Aug 11, 2026
37220f1
fix: typhoon cards
whes1015 Aug 11, 2026
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
2 changes: 1 addition & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ android {
applicationId = "com.exptech.dpip"
minSdk = 26
targetSdk = flutter.targetSdkVersion
versionCode = 300909001
versionCode = 300909009
versionName = flutter.versionName
}

Expand Down
12 changes: 12 additions & 0 deletions android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,24 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import org.maplibre.android.MapLibre

/**
* Hosts the app's native platform channels. Each capability that replaces a
* third-party plugin is registered here against the engine's binary messenger.
*/
class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: android.os.Bundle?) {
// The maplibre_gl fork's HttpRequestImpl class-init calls
// MapLibre.getApplicationContext(), which throws unless MapLibre was
// initialized first — and it runs during automatic plugin registration
// (inside super.onCreate). Init before the engine attaches or startup
// dies with ExceptionInInitializerError and stalls on the splash.
// getInstance is idempotent; MapSnapshotChannel re-inits later.
MapLibre.getInstance(applicationContext)
super.onCreate(savedInstanceState)
}

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger
Expand Down
29 changes: 23 additions & 6 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
| `openRtsSse` | `/api/v2/trem/rts?sse=1&compress=1` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` |
| `getRtsRealtime` | `/api/v2/trem/rts` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` |
| `getEewRealtime` | `/api/v2/eq/eew` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` |
| `getLatestReports` | `/api/v1/eq/report` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |
| `getReportList` | `/api/v2/eq/report` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |
| `getReport` | `/api/v2/eq/report/{id}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |

Expand Down Expand Up @@ -63,14 +62,31 @@ max-age=300`)。`{sec}` 就是解出清單後的 10 位數秒,直接使用

### 衛星雲圖(v2)—— `core-tnn1`

Himawari Band-13 IR XYZ WebP。時間清單是差量編碼的 Unix 秒(`[baseSec, Δ, …]`),
在 API 主機上帶 ETag/304;tile 在 **static** 主機。`{sec}` 就是解出清單後的
10 分鐘秒,直接使用。
Himawari-9 AHI 的 XYZ WebP,預設是 Band-13 IR。時間清單是差量編碼的
Unix 秒(`[baseSec, Δ, …]`),在 API 主機上帶 ETag/304;tile 在 **static**
主機。`{sec}` 就是解出清單後的 10 分鐘秒,直接使用。

`?channel=` 選取渲染的頻道或產品 —— 單一頻道用數字(`13`)、命名產品用名稱
(`btd_wvirw`、`cloudtop`…,即 `satellite-tiles-go/docs.md` 的產品目錄)。
帶 channel 時時間清單為該 channel 的交集(產品需要的頻道缺一就不可渲染,
`list` 只列齊全的時刻)。App 的圖層選擇器為每個 channel 註冊一個獨立圖層
(`satellite` 保留給 B13,其餘為 `satellite-<channel>`)。

| 方法 | 路徑 | 層級 | 主機 |
|---|---|---|---|
| `getFrames` | `/api/v2/tiles/satellite/list[?channel=…]` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` |
| `tileUrl` | `/api/v2/tiles/satellite/{sec}/{z}/{x}/{y}.webp[?channel=…]` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |

### 未來1小時降水預報 QPESUMS(v2)—— `core-tnn1`

QPESUMS 定量降水預報 XYZ WebP。時間清單是差量編碼的 Unix **毫秒**
(`[baseMs, Δ, …]`);tile 在 **static** 主機。`{ms}` 就是解出清單後的 13 位數
毫秒,直接使用(時間軸解析已同時支援秒與毫秒)。

| 方法 | 路徑 | 層級 | 主機 |
|---|---|---|---|
| `getFrames` | `/api/v2/tiles/satellite/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` |
| `tileUrl` | `/api/v2/tiles/satellite/{sec}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
| `getFrames` | `/api/v2/tiles/qpesums/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` |
| `tileUrl` | `/api/v2/tiles/qpesums/{ms}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |

### 防災地圖 DPM(v2)—— `core-tnn1`

Expand Down Expand Up @@ -172,6 +188,7 @@ typhoon)共用同一組形狀:`/api/v5/meteor/{family}` 是最新快照、`/
|---|---|
| `getLocalizationProgress` | `https://exptech.dev/api/v1/dpip/locale` |
| `getReleases` | `https://api.github.com/repos/ExpTechTW/DPIP/releases`(ETag;`per_page=30`) |
| `getRainHourForecast` | `https://exptech.dingbot.tw/api/weather/rainforecast/{code}`(`{code}` = 鄉鎮 3 碼;回應為單 series 信封 `{"<系列名>": [{"start": 秒, "rain": [60 × mm]}]}`;空 series `[]` = 該小時無雨,卡片隱藏) |

## curl 可用性(2026-08-02,HTTP 狀態碼)

Expand Down
Binary file added assets/weather/clouds/00.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/clouds/01.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/clouds/02.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/clouds/03.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/clouds/04.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/clouds/05.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/clouds/06.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/clouds/07.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/clouds/08.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/clouds/09.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/clouds/10.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/clouds/11.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/particles/drop_normal.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/particles/particle_blurred.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/particles/rain_drop.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/particles/snow_flake.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/sky/annulus.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/sky/starmap.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/sky/sun_profile.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/weather/sky/sun_rays.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions dart_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Development-only previews (they write PNGs); excluded from the default run.
tags:
preview:
skip: "development preview — run with: flutter test --tags preview"
14 changes: 3 additions & 11 deletions lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import 'package:dpip/core/geo/device_location_reporter.dart';
import 'package:dpip/core/geo/location_monitor.dart';
import 'package:dpip/core/geo/location_service.dart';
import 'package:dpip/core/notifications/notification_service.dart';
import 'package:dpip/core/notifications/notification_tap.dart';
import 'package:dpip/core/notifications/notification_taps.dart';
import 'package:dpip/core/platform/background_location.dart';
import 'package:dpip/core/realtime/realtime_lifecycle.dart';
Expand Down Expand Up @@ -75,8 +74,8 @@ class DpipApp extends StatelessWidget {

/// Owns app-level service wiring that needs a [State]/lifecycle:
/// - the realtime spine's start/pause/resume (via [RealtimeLifecycleObserver]);
/// - routing a tapped notification to the right tab (the channel-key → route
/// mapping lives here because this layer owns the router);
/// - registering the notification tap → route handler ([routeNotificationTap],
/// whose channel→screen table lives in `notification_routes.dart`);
/// - requesting notification permission once, after the first frame;
/// - resolving the current GPS township into the [RegionStore] so 所在地 shows
/// the real location (and the "can't get location" state when GPS is off).
Expand Down Expand Up @@ -117,7 +116,7 @@ class _AppServicesHostState extends State<_AppServicesHost>
super.initState();
_observer = RealtimeLifecycleObserver(widget.realtimeService);
WidgetsBinding.instance.addObserver(this);
NotificationTaps.onTap = _routeNotificationTap;
NotificationTaps.onTap = routeNotificationTap;
widget.onboarding.addListener(_onOnboardingChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.realtimeService.startAll();
Expand Down Expand Up @@ -188,13 +187,6 @@ class _AppServicesHostState extends State<_AppServicesHost>
super.dispose();
}

/// Sends a tapped notification to a screen. The destination is resolved by the
/// declarative channel→route table; the tap's [NotificationTap.id] is carried
/// for the per-item detail routes to come.
void _routeNotificationTap(NotificationTap tap) {
appRouter.goNamed(routeForNotificationChannel(tap.channelKey));
}

@override
Widget build(BuildContext context) => widget.child;
}
29 changes: 29 additions & 0 deletions lib/app/router/notification_routes.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,36 @@
import 'package:dpip/app/router/app_router.dart';
import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/notifications/notification_channels.dart';
import 'package:dpip/core/notifications/notification_tap.dart';
import 'package:dpip/shared/navigation/app_routes.dart';

/// The slice of the router a notification tap needs — [GoRouter.goNamed].
typedef NotificationRouteNavigator =
void Function(
String name, {
Map<String, String> pathParameters,
Map<String, dynamic> queryParameters,
String? fragment,
Object? extra,
});

/// Single owner of notification → destination, mirroring the legacy
/// `notify.dart` tap table in one file: [NotificationTaps] carries the tap
/// intent and calls [routeNotificationTap] once the router is live (replaying a
/// cold-start tap through [NotificationTaps.drainPending]). The channel
/// resolves to a route name via the declarative group table below, then the
/// router navigates — no widget hosts this logic, so adding an alert family is
/// one row in the table and nothing else.
///
/// [navigate] is injectable for tests; it defaults to the app router's
/// [GoRouter.goNamed].
void routeNotificationTap(
NotificationTap tap, {
NotificationRouteNavigator? navigate,
}) {
(navigate ?? appRouter.goNamed)(routeForNotificationChannel(tap.channelKey));
}

/// Resolves a tapped notification's channel to a destination route.
///
/// Declarative and group-driven (via [NotificationChannels.groupOf]) instead of
Expand Down
108 changes: 98 additions & 10 deletions lib/app/theme/app_glass.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:dpip/core/realtime/app_time.dart';
import 'package:dpip/core/settings/weather_mode.dart';
import 'package:flutter/material.dart';

Expand All @@ -10,30 +11,117 @@ import 'package:flutter/material.dart';
/// — because dark-theme [ColorScheme.onSurface] is white and vanishes on clear
/// daylight.

/// The card fill the reference paints once the weather is showing: **the sky's own
/// colour at 20 % alpha**, with only its HSL lightness moved.
///
/// The reference's `D()` converts the sky-gradient colour to HSL, shifts `L`
/// alone — −0.30 in the morning bucket, −0.40 around midday, +0.20 otherwise —
/// converts back, and returns `Color.argb(51, …)`. Hue and saturation are never
/// touched, which is why the card reads as a pane of the sky rather than a grey
/// plate laid over it, and 51/255 = 0.20 is a literal in that method rather
/// than anything device- or theme-dependent.
///
/// [hour] is the local hour that picks the bucket (the reference
/// buckets 4–9 / 10–14 / rest); it is a parameter so this stays pure.
Color skyCardTint(Color sky, {required int hour}) {
final shift = hour >= 4 && hour <= 9
? -0.30
: hour >= 10 && hour <= 14
? -0.40
: 0.20;
final hsl = HSLColor.fromColor(sky);
return hsl
.withLightness((hsl.lightness + shift).clamp(0.0, 1.0))
.toColor()
.withValues(alpha: glassRevealedAlpha);
}

/// The reference's card alpha once the weather backdrop is showing (`Color.argb(51,…)`).
///
/// It is this low on purpose: the falling rain has to be visible *through* the
/// card. A card opaque enough to hide it stops the rain dead at its edge, which
/// is the single thing that most gives a port away.
const double glassRevealedAlpha = 0.20;

/// The translucent card/surface tint: resting
/// [ColorScheme.surfaceContainerHighest] → near-opaque theme surface as
/// [ColorScheme.surfaceContainerHighest] → the reference's sky-tinted 20 % fill as
/// [reveal] rises.
Color glassSurface(ColorScheme colors, double reveal) => Color.lerp(
///
/// Without a [sky] colour (no backdrop running, or before the first LUT bake)
/// it falls back to a near-opaque theme surface, because 20 % of nothing is an
/// unreadable card.
Color glassSurface(
ColorScheme colors,
double reveal, {
Color? sky,
int? hour,
}) => Color.lerp(
colors.surfaceContainerHighest.withValues(alpha: 0.55),
colors.surface.withValues(alpha: 0.92),
sky == null
? colors.surface.withValues(alpha: 0.92)
: skyCardTint(sky, hour: hour ?? AppTime.utc8.hour),
reveal,
)!;

/// Ink for content **inside** a [glassSurface] card — always the theme
/// on-surface roles. Cards carry their own plate; do not wash these toward
/// white or black for the sky.
Color glassOnSurface(ColorScheme colors) => colors.onSurface;
/// Ink for content **inside** a [glassSurface] card.
///
/// At rest the card is its own plate and the theme's on-surface roles are
/// right. Once [reveal] dissolves it to the 20 % fill above, the card is no
/// longer a plate — it is a pane of the sky — so its ink has to follow the sky
/// exactly the way [inkOverWeather] does. Leaving it on [ColorScheme.onSurface]
/// puts near-black text on a rain sky in a light theme.
Color glassOnSurface(
ColorScheme colors, {
double reveal = 0,
bool skyIsLight = false,
}) => inkOverWeather(colors, reveal, skyIsLight: skyIsLight);

/// Secondary ink for content inside a [glassSurface] card.
Color glassOnSurfaceVariant(ColorScheme colors) => colors.onSurfaceVariant;
Color glassOnSurfaceVariant(
ColorScheme colors, {
double reveal = 0,
bool skyIsLight = false,
}) => inkOverWeatherVariant(colors, reveal, skyIsLight: skyIsLight);

/// Whether [mode]'s sky is light enough that dark foregrounds read better than
/// white once the weather backdrop is showing.
///
/// A **fallback only**, for the brief window before the sky has actually
/// baked (`SkyLutCache.panelAmbient` is still null): the table only knows the
/// weather *type*, not the time of day, so `clear`/`cloudy`/`snow`/`fog`/`auto`
/// all resolve to "light" even at night — a clear night sky is the darkest
/// thing on screen, and this alone would pick near-black ink for it. Once a
/// sky colour exists, [skyIsLightFrom] must be used instead.
bool weatherSkyIsLight(WeatherMode mode) => switch (mode) {
WeatherMode.rain || WeatherMode.thunderstorm => false,
WeatherMode.clear || WeatherMode.fog || WeatherMode.auto => true,
WeatherMode.rain ||
WeatherMode.thunderstorm ||
WeatherMode.overcast ||
WeatherMode.sand => false,
WeatherMode.clear ||
WeatherMode.cloudy ||
WeatherMode.snow ||
WeatherMode.fog ||
WeatherMode.auto => true,
};

/// Whether [sky] — the actual rendered sky colour (`SkyLutCache.panelAmbient`)
/// — is light enough that dark foregrounds read better than white.
///
/// This is what every ink-on-sky decision should call: [weatherSkyIsLight]
/// alone can only guess from the weather *type*, and the same "clear" sky is
/// bright blue at noon and near-black past sunset. Falls back to
/// [weatherSkyIsLight] only while [sky] is still null (nothing has baked yet),
/// so ink is never left unset before the first frame.
///
/// Uses [ThemeData.estimateBrightnessForColor] rather than a hand-picked
/// luminance cutoff — it is the same "is this background light or dark"
/// judgment Flutter already ships and tunes, so a border-hue sky (dawn, a
/// hazy overcast) resolves the way the rest of the framework would resolve it.
bool skyIsLightFrom(Color? sky, WeatherMode fallbackMode) {
if (sky == null) return weatherSkyIsLight(fallbackMode);
return ThemeData.estimateBrightnessForColor(sky) == Brightness.light;
}

/// Ink for content drawn **on** the weather sky (header, region badges).
///
/// As [reveal] rises, shifts toward dark ink on a light sky (critical in dark
Expand Down
3 changes: 3 additions & 0 deletions lib/bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import 'package:dpip/core/geo/town_boundaries.dart';
import 'package:dpip/core/geo/town_directory.dart';
import 'package:dpip/core/settings/experimental_settings.dart';
import 'package:dpip/core/settings/locale_controller.dart';
import 'package:dpip/core/settings/map_layer_order_controller.dart';
import 'package:dpip/core/settings/onboarding_store.dart';
import 'package:dpip/core/settings/prefs.dart';
import 'package:dpip/core/settings/region_store.dart';
Expand Down Expand Up @@ -85,6 +86,7 @@ Future<void> bootstrap() async {
final locale = LocaleController(prefs);
final theme = ThemeController(prefs);
final defaultMapLayer = DefaultMapLayerController(prefs);
final mapLayerOrder = MapLayerOrderController(prefs);
final cache = await _openCache();
// MapLibre asks Dart for every ExpTech tile before it asks the network, so
// this must be bound before the first map is built.
Expand Down Expand Up @@ -184,6 +186,7 @@ Future<void> bootstrap() async {
locale: locale,
theme: theme,
defaultMapLayer: defaultMapLayer,
mapLayerOrder: mapLayerOrder,
etagCache: cache?.etag,
networkUsage: cache?.usage,
mapTileCache: mapTileCache,
Expand Down
4 changes: 4 additions & 0 deletions lib/core/di/core_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:dpip/core/realtime/realtime_service.dart';
import 'package:dpip/core/settings/default_map_layer_controller.dart';
import 'package:dpip/core/settings/experimental_settings.dart';
import 'package:dpip/core/settings/locale_controller.dart';
import 'package:dpip/core/settings/map_layer_order_controller.dart';
import 'package:dpip/core/settings/onboarding_store.dart';
import 'package:dpip/core/settings/region_store.dart';
import 'package:dpip/core/settings/theme_controller.dart';
Expand All @@ -33,6 +34,9 @@ List<SingleChildWidget> coreProviders(SharedDeps deps) => [
ChangeNotifierProvider<DefaultMapLayerController>.value(
value: deps.defaultMapLayer,
),
ChangeNotifierProvider<MapLayerOrderController>.value(
value: deps.mapLayerOrder,
),
Provider<TownDirectory>.value(value: deps.townDirectory),
Provider<Future<TownBoundaries>>.value(value: deps.townBoundaries),
Provider<LocationService>.value(value: deps.locationService),
Expand Down
5 changes: 5 additions & 0 deletions lib/core/di/shared_deps.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'package:dpip/core/realtime/server_clock.dart';
import 'package:dpip/core/settings/default_map_layer_controller.dart';
import 'package:dpip/core/settings/experimental_settings.dart';
import 'package:dpip/core/settings/locale_controller.dart';
import 'package:dpip/core/settings/map_layer_order_controller.dart';
import 'package:dpip/core/settings/onboarding_store.dart';
import 'package:dpip/core/settings/prefs.dart';
import 'package:dpip/core/settings/region_store.dart';
Expand Down Expand Up @@ -48,6 +49,7 @@ class SharedDeps {
required this.locale,
required this.theme,
required this.defaultMapLayer,
required this.mapLayerOrder,
this.etagCache,
this.networkUsage,
this.mapTileCache,
Expand Down Expand Up @@ -113,6 +115,9 @@ class SharedDeps {
/// Default Map-tab overlay (also provided; drives nav chrome + map open).
final DefaultMapLayerController defaultMapLayer;

/// User-customised map layer-picker order (also provided).
final MapLayerOrderController mapLayerOrder;

/// On-disk ETag HTTP cache (also provided) — null if the cache DB couldn't be
/// opened. Exposed for the Debug page's cache stats.
final EtagCacheStore? etagCache;
Expand Down
10 changes: 10 additions & 0 deletions lib/core/format.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/// Number-to-text helpers shared across display sites.
library;

/// Formats [value] to [digits] decimals, dropping a trailing `.0` — e.g. a
/// latitude `25.0` reads as `25`, matching what the chart / fact rows did by
/// hand in the typhoon panel and the station trend axis.
String trimTrailingZero(double value, {int digits = 1}) {
final s = value.toStringAsFixed(digits);
return s.endsWith('.0') ? s.substring(0, s.length - 2) : s;
}
8 changes: 8 additions & 0 deletions lib/core/geo/geo_math.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/// Angular + distance helpers shared by geo code and map layers.
library;

import 'dart:math' as math;

/// Degrees → radians — the conversion used whenever a latitude / longitude /
/// heading is fed to a trig function.
double degToRad(double degrees) => degrees * math.pi / 180.0;
Loading
Loading