Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
fix: degrade 所在地 to nationwide when GPS is unavailable
A lost GPS fix left RegionStore on the stale last-known township, so the
home backdrop kept framing and outlining a place the user wasn't in.
LocationMonitor now clears the code when location becomes unusable, and
the backdrop frames on a cached fix (never a live read) so a simulator
with no location falls back to the nationwide view instantly instead of
waiting out the 10s GPS timeout.
  • Loading branch information
whes1015 committed Aug 11, 2026
commit 165eb537f525bc89a30be8ea9fb5863c866afe57
12 changes: 11 additions & 1 deletion lib/core/geo/location_monitor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,19 @@ class LocationMonitor extends ChangeNotifier with WidgetsBindingObserver {
final previous = _status;
final status = await _location.status();
_status = status;
final wasUsable = _usable(previous);
final nowUsable = _usable(status);
// GPS lost (services off / permission revoked): 所在地 must not keep
// presenting the last-known township as where the user is. Clearing the
// code puts the whole app back in the "can't locate" state (nationwide map
// + the header's notice) instead of a stale place.
if (wasUsable && !nowUsable) {
_publishedCode = null;
_regions.setCurrentCode(null);
}
// Recover only on an unusable → usable transition (services back / granted),
// not on every resume — avoids re-subscribing the position stream needlessly.
if (_seeded && _usable(status) && !_usable(previous)) {
if (_seeded && nowUsable && !wasUsable) {
_reporter.restart();
final code = (await _location.currentTown())?.code;
_publishedCode = code;
Expand Down
56 changes: 45 additions & 11 deletions lib/core/geo/location_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ class LocationService {
this._boundaries,
Future<bool> Function()? isAvailable,
Future<GpsFix?> Function()? fix,
Future<GpsFix?> Function()? lastKnown,
Future<LocationStatus> Function()? status,
}) : _isAvailable = isAvailable ?? _geolocatorAvailable,
_fix = fix ?? _geolocatorFix;
_fix = fix ?? _geolocatorFix,
_lastKnownFix = lastKnown ?? _geolocatorLastKnown,
_status = status ?? _geolocatorStatus;

final TownDirectory _directory;

Expand All @@ -37,21 +41,14 @@ class LocationService {

final Future<bool> Function() _isAvailable;
final Future<GpsFix?> Function() _fix;
final Future<GpsFix?> Function() _lastKnownFix;
final Future<LocationStatus> Function() _status;

/// The combined location-availability [LocationStatus] (services + permission).
/// Never throws — a fault degrades to [LocationStatus.denied].
Future<LocationStatus> status() async {
try {
if (!await Geolocator.isLocationServiceEnabled()) {
return LocationStatus.serviceOff;
}
return switch (await Geolocator.checkPermission()) {
LocationPermission.always => LocationStatus.ready,
LocationPermission.whileInUse => LocationStatus.whileInUseOnly,
LocationPermission.deniedForever => LocationStatus.deniedForever,
LocationPermission.denied ||
LocationPermission.unableToDetermine => LocationStatus.denied,
};
return await _status();
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'location status');
return LocationStatus.denied;
Expand Down Expand Up @@ -182,6 +179,22 @@ class LocationService {
}
}

/// The most recent cached fix if it's fresh enough ([_maxLastKnownAge]),
/// else null — never a live read, so it returns immediately.
///
/// For surfaces that must frame instantly (the home backdrop) and would
/// otherwise wait out a GPS timeout: on a simulator with no location the
/// cached fix is null, so it can fall back to the nationwide view right away
/// instead of holding the previous frame for the live-read window.
Future<GpsFix?> lastKnownFix() async {
try {
return await _lastKnownFix();
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'last-known GPS fix');
return null;
}
}

/// The current township from GPS, or null if unavailable / no fix.
Future<Town?> currentTown() async {
try {
Expand Down Expand Up @@ -224,6 +237,27 @@ class LocationService {
permission == LocationPermission.whileInUse;
}

static Future<GpsFix?> _geolocatorLastKnown() async {
final cached = await Geolocator.getLastKnownPosition();
if (cached != null && _isFresh(cached.timestamp)) {
return (lat: cached.latitude, lng: cached.longitude);
}
return null;
}

static Future<LocationStatus> _geolocatorStatus() async {
if (!await Geolocator.isLocationServiceEnabled()) {
return LocationStatus.serviceOff;
}
return switch (await Geolocator.checkPermission()) {
LocationPermission.always => LocationStatus.ready,
LocationPermission.whileInUse => LocationStatus.whileInUseOnly,
LocationPermission.deniedForever => LocationStatus.deniedForever,
LocationPermission.denied ||
LocationPermission.unableToDetermine => LocationStatus.denied,
};
}

/// How old a cached OS fix may be and still stand in for a live one. The
/// cached fix is only a head start on the first frame, so it has to be recent
/// enough that it still describes where the user *is*: an hours-old fix from
Expand Down
78 changes: 48 additions & 30 deletions lib/features/home/presentation/widgets/home_map_backdrop.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,13 +159,6 @@ class _HomeMapBackdropState extends State<HomeMapBackdrop>
_refreshRadar();
}

/// The selected township code, or null for the nationwide (whole-island) view.
String? get _selectedCode => switch (_regions?.selected) {
SavedArea(:final code) => code,
CurrentArea(:final code) => code,
_ => null,
};

/// Frames the map on the selected township and highlights it — or fits the
/// whole island for the nationwide view. Skips unchanged selections.
Future<void> _applySelection() async {
Expand All @@ -179,38 +172,57 @@ class _HomeMapBackdropState extends State<HomeMapBackdrop>
// ends. Frame into that band, not the whole map.
final topInset = MediaQuery.paddingOf(context).top + RegionBar.height;
final boundariesFuture = context.read<Future<TownBoundaries>>();
final code = _selectedCode;
final selected = _regions?.selected;
final code = switch (selected) {
SavedArea(:final code) => code,
CurrentArea(:final code) => code,
_ => null,
};
final styleEpoch = _styleEpoch;
// 所在地 is a *place*, not a region: the current GPS fix is the point, so
// the camera centres on it (a fixed span around the fix) instead of framing
// the whole township the way a saved area does. Falls back to the township
// box — or the whole island when GPS is unavailable — if no fix is had.
final gpsCentred = _regions?.selected is CurrentArea;
// the whole township the way a saved area does.
final gpsCentred = selected is CurrentArea;
final location = context.read<LocationService>();
// Nothing changed since the last apply (an unrelated RegionStore notify) —
// don't re-add layers or re-run the camera.
if (code == _appliedCode && styleEpoch == _appliedCodeEpoch) return;
final gen = ++_selectionGen;

// The GPS fix decides 所在地's subject. No fix — services off, permission
// denied, or a fix timeout — degrades 所在地 to the nationwide view: the
// last-known township is not where the user is, so it must not frame or
// outline it. Falls back to the township box — or the whole island when no
// code is had — when a fix exists but the township isn't resolved.
//
// A *cached* fix, never a live read: `currentFix` can wait out a 10s GPS
// timeout, and while it did the backdrop sat on the base map's pre-layout
// camera (a tiny Taiwan) instead of framing. The cached fix answers
// instantly, and the position stream's own updates re-apply this when they
// land. A `CurrentArea` whose code is null means 所在地 has no township at
// all (GPS lost) — skip the lookup entirely and go nationwide.
GpsFix? fix;
if (gpsCentred && code != null) {
fix = await location.lastKnownFix();
if (!mounted || gen != _selectionGen || styleEpoch != _styleEpoch) return;
}
final effective = gpsCentred && fix == null ? null : code;

List<double>? bounds;
if (code != null) {
if (effective != null) {
final boundaries = await boundariesFuture;
if (!mounted || gen != _selectionGen || styleEpoch != _styleEpoch) return;
bounds = boundaries.boundsFor(code);
bounds = boundaries.boundsFor(effective);
}

LatLngBounds frame;
if (gpsCentred) {
final fix = await location.currentFix();
if (!mounted || gen != _selectionGen || styleEpoch != _styleEpoch) return;
frame = fix == null
? _latLngBounds(bounds)
: _latLngBounds([
fix.lng - _gpsSpan,
fix.lat - _gpsSpan,
fix.lng + _gpsSpan,
fix.lat + _gpsSpan,
]);
if (gpsCentred && fix != null) {
frame = _latLngBounds([
fix.lng - _gpsSpan,
fix.lat - _gpsSpan,
fix.lng + _gpsSpan,
fix.lat + _gpsSpan,
]);
} else {
frame = _latLngBounds(bounds);
}
Expand All @@ -229,7 +241,7 @@ class _HomeMapBackdropState extends State<HomeMapBackdrop>
final bottomInset = size.height * HomeSheetExtent.rest;
_queue(() async {
if (!mounted || gen != _selectionGen || styleEpoch != _styleEpoch) return;
await _restackOverlays(controller);
await _restackOverlays(controller, code: effective);
// Frame the box in the band above the resting sheet — computed in Dart
// (boundsFitCamera) and applied as a plain centre+zoom, so a degenerate or
// not-yet-laid-out viewport can't abort the app the way MapLibre's native
Expand All @@ -245,7 +257,7 @@ class _HomeMapBackdropState extends State<HomeMapBackdrop>
CameraUpdate.newLatLngZoom(fit.target, fit.zoom),
);
}
_appliedCode = code;
_appliedCode = effective;
_appliedCodeEpoch = styleEpoch;
});
}
Expand All @@ -261,9 +273,15 @@ class _HomeMapBackdropState extends State<HomeMapBackdrop>
/// whole-island framing is far enough out that 368 outlines collapse into a
/// grey wash over the echo, and there the county frame is the only boundary
/// carrying information.
Future<void> _restackOverlays(MapLibreMapController controller) async {
final code = _selectedCode;
final wanted = backdropBoundaries(code);
Future<void> _restackOverlays(
MapLibreMapController controller, {
String? code,
}) async {
// The caller passes the *effective* code (GPS-aware). A radar refresh has no
// selection context, so it falls back to the last applied one — the outline
// must match what the camera is framing, never drift back to a stale code.
final subject = code ?? _appliedCode;
final wanted = backdropBoundaries(subject);
// Bottom-up, so a later add lands above an earlier one: the coarse county
// frame should win where the two run together along a coastline.
for (final boundary in [AdminBoundary.town, AdminBoundary.county]) {
Expand All @@ -273,7 +291,7 @@ class _HomeMapBackdropState extends State<HomeMapBackdrop>
}
}
// The selection is the point of the backdrop — always last, always on top.
await _addSelectedLayers(controller, _filterFor(_townCode(code)));
await _addSelectedLayers(controller, _filterFor(_townCode(subject)));
}

/// (Re)adds the purple selection outline on the vector `town` layer (filtered
Expand Down
68 changes: 67 additions & 1 deletion test/core/geo/location_monitor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import 'dart:async';
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/geo/location_status.dart';
import 'package:dpip/core/geo/town_directory.dart';
import 'package:dpip/core/settings/prefs.dart';
import 'package:dpip/core/settings/region_store.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';

Expand Down Expand Up @@ -47,7 +49,9 @@ void main() {
SharedPreferences.setMockInitialValues({});
final prefs = Prefs(await SharedPreferences.getInstance());
regions = RegionStore(prefs);
positions = StreamController<GpsFix>();
// Broadcast so a recovery-driven `reporter.restart()` can re-subscribe (a
// single-subscription stream throws on the second listen).
positions = StreamController<GpsFix>.broadcast();
reporter = DeviceLocationReporter(
positions: () => positions.stream,
prefs: prefs,
Expand Down Expand Up @@ -133,4 +137,66 @@ void main() {
await pumpEventQueue();
expect(regions.currentCode, _tainanCode);
});

test('GPS loss clears the township so 所在地 degrades to nationwide', () async {
var available = true;
final location = LocationService(
_directory(),
isAvailable: () async => available,
fix: () async => _tainanFix,
status: () async =>
available ? LocationStatus.ready : LocationStatus.serviceOff,
);
final monitor = LocationMonitor(
location: location,
reporter: reporter,
regions: regions,
)..start();
reporter.start();
await pumpEventQueue();

positions.add((lat: _tainanFix.lat, lng: _tainanFix.lng));
await pumpEventQueue();
expect(regions.currentCode, _tainanCode);

// Services off → the monitor re-checks on resume and must not keep the
// last-known township as the current one (the backdrop would frame it).
available = false;
WidgetsBinding.instance.handleAppLifecycleStateChanged(
AppLifecycleState.resumed,
);
await pumpEventQueue();
expect(regions.currentCode, isNull);

monitor.dispose();
});

test('GPS returning re-resolves the current township', () async {
var available = false;
final location = LocationService(
_directory(),
isAvailable: () async => available,
fix: () async => _taipeiFix,
status: () async =>
available ? LocationStatus.ready : LocationStatus.serviceOff,
);
final monitor = LocationMonitor(
location: location,
reporter: reporter,
regions: regions,
)..start();
reporter.start();
await pumpEventQueue();
expect(regions.currentCode, isNull);

// Recovery re-resolves 所在地 instead of leaving it nationwide.
available = true;
WidgetsBinding.instance.handleAppLifecycleStateChanged(
AppLifecycleState.resumed,
);
await pumpEventQueue();
expect(regions.currentCode, _taipeiCode);

monitor.dispose();
});
}
31 changes: 31 additions & 0 deletions test/core/geo/location_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,35 @@ void main() {
);
expect(await service.currentTown(), isNull);
});

test('lastKnownFix serves the cached fix without a live read', () async {
var liveReads = 0;
final service = LocationService(
_dir(),
lastKnown: () async => (lat: 25.05, lng: 121.55),
fix: () async {
liveReads++;
return (lat: 25.05, lng: 121.55);
},
);
expect(await service.lastKnownFix(), (lat: 25.05, lng: 121.55));
expect(liveReads, 0, reason: 'the live read must not run');
});

test('lastKnownFix is null when no cached fix exists', () async {
final service = LocationService(
_dir(),
lastKnown: () async => null,
fix: () async => (lat: 25.05, lng: 121.55),
);
expect(await service.lastKnownFix(), isNull);
});

test('a lastKnownFix fault degrades to null, never throws', () async {
final service = LocationService(
_dir(),
lastKnown: () async => throw Exception('platform fault'),
);
expect(await service.lastKnownFix(), isNull);
});
}