From 4fa09a3323091de5af454d065e0ecc4402e0222a Mon Sep 17 00:00:00 2001 From: Steven <142199173+steventeng2022@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:40:32 +0800 Subject: [PATCH] feat(metro): locate nearest station across systems --- README.md | 6 + lib/core/nearest_metro_station.dart | 128 ++++++ lib/core/user_location.dart | 12 +- lib/l10n/app_en.arb | 4 + lib/l10n/app_localizations.dart | 24 ++ lib/l10n/app_localizations_en.dart | 14 + lib/l10n/app_localizations_zh.dart | 24 ++ lib/l10n/app_zh.arb | 4 + lib/l10n/app_zh_TW.arb | 4 + lib/screens/metro_dashboard_screen.dart | 174 ++++++++- test/metro_nearest_station_test.dart | 497 ++++++++++++++++++++++++ test/nearest_metro_station_test.dart | 212 ++++++++++ test/user_location_test.dart | 45 +++ 13 files changed, 1139 insertions(+), 9 deletions(-) create mode 100644 lib/core/nearest_metro_station.dart create mode 100644 test/metro_nearest_station_test.dart create mode 100644 test/nearest_metro_station_test.dart diff --git a/README.md b/README.md index b12db8e..2558c14 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ YetAnotherBusApp(YABus)是一個以 Flutter 開發的跨平台公車動態 - 快速查看常用公車 - 個人化首頁 +### 捷運最近車站 + +- 在捷運分頁點選「定位最近的車站」,跨系統尋找最近站點,自動選擇系統、路線與車站並開啟地圖及到站資訊。 +- 定位僅在點選後使用,距離在裝置端計算;若部分系統資料暫時無法取得,仍顯示可用結果並提示查詢範圍不完整。 +- 定位失敗時保留目前選擇,仍可手動切換系統與路線。 + ### 地圖顯示 - 顯示公車即時位置 diff --git a/lib/core/nearest_metro_station.dart b/lib/core/nearest_metro_station.dart new file mode 100644 index 0000000..3857845 --- /dev/null +++ b/lib/core/nearest_metro_station.dart @@ -0,0 +1,128 @@ +import 'package:geolocator/geolocator.dart'; + +import 'transit_repository.dart'; + +/// The static data needed to select a station and its serving line together. +class MetroSystemData { + const MetroSystemData({ + required this.system, + required this.lines, + required this.stations, + required this.stationOfLine, + }); + + final MetroSystem system; + final List lines; + final List stations; + final List stationOfLine; +} + +Future loadMetroSystemData( + TransitRepository repository, + MetroSystem system, +) async { + final results = await Future.wait([ + repository.getMetroLines(system.system), + repository.getMetroStations(system.system), + repository.getMetroStationOfLine(system.system), + ]); + return MetroSystemData( + system: system, + lines: results[0] as List, + stations: results[1] as List, + stationOfLine: results[2] as List, + ); +} + +class NearestMetroStation { + const NearestMetroStation({ + required this.data, + required this.station, + required this.line, + required this.distanceMeters, + }); + + final MetroSystemData data; + final MetroStation station; + final MetroLine line; + final double distanceMeters; +} + +/// Finds the nearest station that can be displayed on an available metro line. +/// +/// Station-of-line membership is authoritative: the stations endpoint can +/// return an empty `line_id`. At equal distances, keep the preferred system +/// and then its preferred line (useful at interchanges); otherwise retain input +/// order. A preference never overrides a closer station. +NearestMetroStation? nearestMetroStation( + List systems, { + required double latitude, + required double longitude, + String? preferredSystem, + String? preferredLineId, +}) { + if (!_validCoordinates(latitude, longitude)) { + return null; + } + + NearestMetroStation? nearest; + var bestPreference = -1; + for (final data in systems) { + final linesById = { + for (final line in data.lines) + if (line.lineId.isNotEmpty) line.lineId: line, + }; + final stationLines = >{}; + for (final entry in data.stationOfLine) { + if (!linesById.containsKey(entry.lineId)) { + continue; + } + for (final station in entry.stations) { + stationLines + .putIfAbsent(station.stationId, () => {}) + .add(entry.lineId); + } + } + + for (final station in data.stations) { + if (station.stationId.isEmpty || + !_validCoordinates(station.lat, station.lon) || + (station.lat == 0 && station.lon == 0)) { + continue; + } + final lineIds = stationLines[station.stationId]; + if (lineIds == null) { + continue; + } + final distance = Geolocator.distanceBetween( + latitude, + longitude, + station.lat, + station.lon, + ); + for (final lineId in lineIds) { + final preferred = data.system.system == preferredSystem; + final preference = preferred ? (lineId == preferredLineId ? 2 : 1) : 0; + if (nearest == null || + distance < nearest.distanceMeters || + (distance == nearest.distanceMeters && + preference > bestPreference)) { + nearest = NearestMetroStation( + data: data, + station: station, + line: linesById[lineId]!, + distanceMeters: distance, + ); + bestPreference = preference; + } + } + } + } + return nearest; +} + +bool _validCoordinates(double latitude, double longitude) => + latitude.isFinite && + longitude.isFinite && + latitude.abs() <= 90 && + longitude.abs() <= 180; diff --git a/lib/core/user_location.dart b/lib/core/user_location.dart index ea71685..d123bd7 100644 --- a/lib/core/user_location.dart +++ b/lib/core/user_location.dart @@ -7,6 +7,7 @@ class LocationFailure implements Exception { const LocationFailure( this.message, { this.deniedForever = false, + this.permissionDenied = false, this.serviceDisabled = false, }); @@ -15,6 +16,9 @@ class LocationFailure implements Exception { /// The user has to go into system settings; asking again will not help. final bool deniedForever; + /// Location access was denied, including when requesting again is possible. + final bool permissionDenied; + /// The device-wide location switch is off, so app permission settings alone /// cannot resolve the failure. final bool serviceDisabled; @@ -43,10 +47,14 @@ Future resolveUserPosition({ permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.deniedForever) { - throw const LocationFailure('沒有取得定位權限。', deniedForever: true); + throw const LocationFailure( + '沒有取得定位權限。', + deniedForever: true, + permissionDenied: true, + ); } if (permission == LocationPermission.denied) { - throw const LocationFailure('沒有取得定位權限。'); + throw const LocationFailure('沒有取得定位權限。', permissionDenied: true); } Position? lastKnown; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index da8fa29..7cd6ca0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1047,6 +1047,10 @@ "@metroFrequencyOnlyEstimate": {"placeholders": {"min": {"type": "int"}, "max": {"type": "int"}}}, "metroDestination": "Toward {destination}", "@metroDestination": {"placeholders": {"destination": {"type": "String"}}}, + "metroLocateNearest": "Find nearest station", + "metroLocating": "Finding nearest station…", + "metroNearbyPartial": "Some metro systems could not be checked. Showing the nearest station from the available data.", + "metroStationLookupFailed": "Could not load metro stations. Please try again.", "railOperatingNotices": "Service notices", "railAllStations": "All stations", "railOtherStations": "Other", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4d0ec0a..08d76c8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5368,6 +5368,30 @@ abstract class AppLocalizations { /// **'Toward {destination}'** String metroDestination(String destination); + /// No description provided for @metroLocateNearest. + /// + /// In en, this message translates to: + /// **'Find nearest station'** + String get metroLocateNearest; + + /// No description provided for @metroLocating. + /// + /// In en, this message translates to: + /// **'Finding nearest station…'** + String get metroLocating; + + /// No description provided for @metroNearbyPartial. + /// + /// In en, this message translates to: + /// **'Some metro systems could not be checked. Showing the nearest station from the available data.'** + String get metroNearbyPartial; + + /// No description provided for @metroStationLookupFailed. + /// + /// In en, this message translates to: + /// **'Could not load metro stations. Please try again.'** + String get metroStationLookupFailed; + /// No description provided for @railOperatingNotices. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 75ac385..dd358bc 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3308,6 +3308,20 @@ class AppLocalizationsEn extends AppLocalizations { return 'Toward $destination'; } + @override + String get metroLocateNearest => 'Find nearest station'; + + @override + String get metroLocating => 'Finding nearest station…'; + + @override + String get metroNearbyPartial => + 'Some metro systems could not be checked. Showing the nearest station from the available data.'; + + @override + String get metroStationLookupFailed => + 'Could not load metro stations. Please try again.'; + @override String get railOperatingNotices => 'Service notices'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0e7a7a1..98dfe8f 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3031,6 +3031,18 @@ class AppLocalizationsZh extends AppLocalizations { return '往 $destination'; } + @override + String get metroLocateNearest => '定位最近的車站'; + + @override + String get metroLocating => '正在尋找最近的車站…'; + + @override + String get metroNearbyPartial => '部分捷運系統暫時無法查詢,已選擇目前可取得資料中最近的車站。'; + + @override + String get metroStationLookupFailed => '無法載入捷運站資料,請稍後再試。'; + @override String get railOperatingNotices => '營運公告'; @@ -6363,6 +6375,18 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '往 $destination'; } + @override + String get metroLocateNearest => '定位最近的車站'; + + @override + String get metroLocating => '正在尋找最近的車站…'; + + @override + String get metroNearbyPartial => '部分捷運系統暫時無法查詢,已選擇目前可取得資料中最近的車站。'; + + @override + String get metroStationLookupFailed => '無法載入捷運站資料,請稍後再試。'; + @override String get railOperatingNotices => '營運公告'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c91e015..e2902d4 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,5 +1,9 @@ { "@@locale": "zh", + "metroLocateNearest": "定位最近的車站", + "metroLocating": "正在尋找最近的車站…", + "metroNearbyPartial": "部分捷運系統暫時無法查詢,已選擇目前可取得資料中最近的車站。", + "metroStationLookupFailed": "無法載入捷運站資料,請稍後再試。", "appTitle": "YetAnotherBusApp", "settingsAppearanceDescription": "語言、主題、介面大小與個人化", "settingsUsageDescription": "到站資訊更新、智慧推薦與使用偏好", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index f4bc121..3f9d672 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1,5 +1,9 @@ { "@@locale": "zh_TW", + "metroLocateNearest": "定位最近的車站", + "metroLocating": "正在尋找最近的車站…", + "metroNearbyPartial": "部分捷運系統暫時無法查詢,已選擇目前可取得資料中最近的車站。", + "metroStationLookupFailed": "無法載入捷運站資料,請稍後再試。", "appTitle": "YetAnotherBusApp", "settingsAppearanceDescription": "語言、主題、介面大小與個人化", "settingsUsageDescription": "到站資訊更新、智慧推薦與使用偏好", diff --git a/lib/screens/metro_dashboard_screen.dart b/lib/screens/metro_dashboard_screen.dart index 504c0e2..c2af993 100644 --- a/lib/screens/metro_dashboard_screen.dart +++ b/lib/screens/metro_dashboard_screen.dart @@ -1,13 +1,16 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; import '../app/bus_app.dart'; import '../widgets/app_content_transition.dart'; import '../core/metro_direction.dart'; +import '../core/nearest_metro_station.dart'; import '../core/request_sequence.dart'; import '../core/transit_repository.dart'; import '../core/transit_name.dart'; +import '../core/user_location.dart'; import '../l10n/app_localizations.dart'; import '../l10n/localized_labels.dart'; import '../widgets/background_image_wrapper.dart'; @@ -23,23 +26,29 @@ class MetroScreen extends StatefulWidget { required this.isActive, this.showAdBanner = true, this.adMinimumDensity = 1, + this.repository, + this.positionResolver, super.key, }); final bool isActive; final bool showAdBanner; final int adMinimumDensity; + final TransitRepository? repository; + final Future Function()? positionResolver; @override State createState() => _MetroScreenState(); } class _MetroScreenState extends State { - final TransitRepository _repo = TransitRepository.shared; + late final TransitRepository _repo = + widget.repository ?? TransitRepository.shared; bool _loading = true; bool _loadingSystem = false; bool _loadingEta = false; + bool _locating = false; String? _pageError; String? _lineError; _MetroPanel _panel = _MetroPanel.live; @@ -61,6 +70,7 @@ class _MetroScreenState extends State { final _systemsRequest = RequestSequence(); final _systemDataRequest = RequestSequence(); final _etaRequest = RequestSequence(); + final _locationRequest = RequestSequence(); @override void initState() { @@ -81,6 +91,7 @@ class _MetroScreenState extends State { return; } if (!widget.isActive) { + _cancelNearestLookup(); _refreshTimer?.cancel(); _refreshTimer = null; return; @@ -93,6 +104,7 @@ class _MetroScreenState extends State { } Future _loadSystems({bool refresh = false}) async { + _cancelNearestLookup(); final request = _systemsRequest.next(); if (refresh) { _repo.invalidateCache('metro_'); @@ -137,6 +149,7 @@ class _MetroScreenState extends State { required MetroSystem system, bool refresh = false, }) async { + _cancelNearestLookup(); final request = _systemDataRequest.next(); _etaRequest.next(); if (refresh) { @@ -207,6 +220,124 @@ class _MetroScreenState extends State { } } + void _cancelNearestLookup() { + _locationRequest.next(); + if (_locating) { + setState(() => _locating = false); + } + } + + void _showLocationMessage(String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + + Future _locateNearestStation() async { + if (_locating || _loading || _loadingSystem || _systems.isEmpty) { + return; + } + final request = _locationRequest.next(); + setState(() => _locating = true); + try { + final position = await (widget.positionResolver ?? resolveUserPosition)(); + if (!mounted || !_locationRequest.isCurrent(request)) { + return; + } + // Search every system: the current tab may still show a different city. + // Keep usable systems when one provider is temporarily unavailable. + final results = await Future.wait( + _systems.map((system) async { + try { + return await loadMetroSystemData(_repo, system); + } catch (_) { + return null; + } + }), + ); + if (!mounted || !_locationRequest.isCurrent(request)) { + return; + } + final l10n = AppLocalizations.of(context); + final available = results.whereType().toList(); + final partial = available.length != results.length; + if (available.isEmpty) { + _showLocationMessage(l10n.metroStationLookupFailed); + return; + } + final nearest = nearestMetroStation( + available, + latitude: position.latitude, + longitude: position.longitude, + preferredSystem: _selectedSystem?.system, + preferredLineId: _selectedLine?.lineId, + ); + if (nearest == null) { + _showLocationMessage( + partial ? l10n.metroStationLookupFailed : l10n.railPickerNoNearby, + ); + return; + } + + _systemDataRequest.next(); + _etaRequest.next(); + _refreshTimer?.cancel(); + setState(() { + _selectedSystem = nearest.data.system; + _lines = nearest.data.lines; + _stations = nearest.data.stations; + _stationOfLine = nearest.data.stationOfLine; + _selectedLine = nearest.line; + _selectedStationId = nearest.station.stationId; + _selectedDirection = null; + _etaEntries = const []; + _etaSource = 'unknown'; + _etaMessage = null; + _frequency = null; + _pageError = null; + _lineError = null; + _panel = _MetroPanel.map; + }); + final distance = nearest.distanceMeters < 1000 + ? l10n.distanceMetersValue(nearest.distanceMeters.round()) + : l10n.distanceKilometersValue( + (nearest.distanceMeters / 1000).toStringAsFixed(1), + ); + final name = TransitName( + zh: nearest.station.name, + en: nearest.station.nameEn, + stableId: nearest.station.stationId, + ).forLocale(l10n.localeName); + final message = l10n.railPickerNearest(name, distance); + _showLocationMessage( + partial ? '$message\n${l10n.metroNearbyPartial}' : message, + ); + unawaited(_loadLineEta()); + } on LocationFailure catch (error) { + if (!mounted || !_locationRequest.isCurrent(request)) { + return; + } + final l10n = AppLocalizations.of(context); + _showLocationMessage( + error.serviceDisabled + ? l10n.locationServicesDisabled + : error.permissionDenied || error.deniedForever + ? l10n.locationPermissionDenied + : l10n.railLocationUnavailable, + ); + } catch (_) { + if (mounted && _locationRequest.isCurrent(request)) { + _showLocationMessage( + AppLocalizations.of(context).railLocationUnavailable, + ); + } + } finally { + if (mounted && _locationRequest.isCurrent(request)) { + setState(() => _locating = false); + } + } + } + Future _loadLineEta({MetroLine? line, bool resetTimer = true}) async { final activeSystem = _selectedSystem; final activeLine = line ?? _selectedLine; @@ -572,8 +703,10 @@ class _MetroScreenState extends State { ], _MetroPanelButtons( current: _panel, - onChanged: (panel) => - setState(() => _panel = panel), + onChanged: (panel) { + _cancelNearestLookup(); + setState(() => _panel = panel); + }, ), const SizedBox(height: 16), AppContentTransition( @@ -624,6 +757,23 @@ class _MetroScreenState extends State { ), ), const SizedBox(height: 14), + FilledButton.tonalIcon( + key: const ValueKey('metro_locate_nearest'), + onPressed: + _loading || _loadingSystem || _locating || _systems.isEmpty + ? null + : _locateNearestStation, + icon: _locating + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.my_location_rounded), + label: Text( + _locating ? l10n.metroLocating : l10n.metroLocateNearest, + ), + ), + const SizedBox(height: 14), Wrap( spacing: 10, runSpacing: 10, @@ -706,7 +856,10 @@ class _MetroScreenState extends State { selected: selected, onTap: _loadingSystem ? null - : () => _loadLineEta(line: line), + : () { + _cancelNearestLookup(); + unawaited(_loadLineEta(line: line)); + }, ); }) .toList(growable: false), @@ -798,7 +951,10 @@ class _MetroScreenState extends State { MetroDirectionSelector( directions: directions, selectedDirection: direction.direction, - onChanged: (value) => setState(() => _selectedDirection = value), + onChanged: (value) { + _cancelNearestLookup(); + setState(() => _selectedDirection = value); + }, ), const SizedBox(height: 20), ], @@ -811,6 +967,7 @@ class _MetroScreenState extends State { headway: _currentHeadway, etaSource: _etaSource, onStationTap: (stationId) { + _cancelNearestLookup(); setState(() { _selectedStationId = stationId; _panel = _MetroPanel.map; @@ -878,8 +1035,10 @@ class _MetroScreenState extends State { ), ), FilledButton.tonalIcon( - onPressed: () => - setState(() => _panel = _MetroPanel.live), + onPressed: () { + _cancelNearestLookup(); + setState(() => _panel = _MetroPanel.live); + }, icon: const Icon(Icons.access_time_filled_rounded), label: Text(l10n.metroViewEta), ), @@ -897,6 +1056,7 @@ class _MetroScreenState extends State { points: points, selectedPointId: _selectedStationId, onPointSelected: (point) { + _cancelNearestLookup(); setState(() => _selectedStationId = point.id); }, height: 360, diff --git a/test/metro_nearest_station_test.dart b/test/metro_nearest_station_test.dart new file mode 100644 index 0000000..83113fe --- /dev/null +++ b/test/metro_nearest_station_test.dart @@ -0,0 +1,497 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:taiwanbus_flutter/app/bus_app.dart'; +import 'package:taiwanbus_flutter/core/account_sync_service.dart'; +import 'package:taiwanbus_flutter/core/app_analytics.dart'; +import 'package:taiwanbus_flutter/core/app_build_info.dart'; +import 'package:taiwanbus_flutter/core/app_controller.dart'; +import 'package:taiwanbus_flutter/core/app_update_installer.dart'; +import 'package:taiwanbus_flutter/core/app_update_service.dart'; +import 'package:taiwanbus_flutter/core/auth_service.dart'; +import 'package:taiwanbus_flutter/core/bus_repository.dart'; +import 'package:taiwanbus_flutter/core/models.dart'; +import 'package:taiwanbus_flutter/core/storage_service.dart'; +import 'package:taiwanbus_flutter/core/transit_repository.dart'; +import 'package:taiwanbus_flutter/core/user_location.dart'; +import 'package:taiwanbus_flutter/l10n/app_localizations.dart'; +import 'package:taiwanbus_flutter/screens/metro_dashboard_screen.dart'; +import 'package:taiwanbus_flutter/widgets/transit_station_map.dart'; + +const _taipei = MetroSystem( + system: 'TRTC', + city: 'Taipei', + name: 'Taipei Metro', + nameEn: 'Taipei Metro', +); +const _kaohsiung = MetroSystem( + system: 'KRTC', + city: 'Kaohsiung', + name: 'Kaohsiung Metro', + nameEn: 'Kaohsiung Metro', +); + +MetroLine _line(String id) => MetroLine( + lineId: id, + lineNo: id, + name: 'Line $id', + nameEn: '', + color: '#FF6600', +); + +MetroStation _station(String id, double lat, double lon) => MetroStation( + stationId: id, + name: 'Station $id', + nameEn: 'Station $id', + // The stations API does not reliably provide line membership. + lineId: '', + lat: lat, + lon: lon, +); + +MetroStationOfLine _sequence(String line, List ids) => + MetroStationOfLine( + lineId: line, + direction: 0, + stations: [ + for (var i = 0; i < ids.length; i++) + MetroStationSequence( + stationId: ids[i], + name: 'Station ${ids[i]}', + nameEn: 'Station ${ids[i]}', + sequence: i + 1, + ), + ], + ); + +class _MetroRepository extends TransitRepository { + final failedSystems = {}; + final stationRequests = []; + final etaRequests = []; + bool missingCoordinates = false; + + @override + Future> getMetroSystems() async => [_taipei, _kaohsiung]; + + @override + Future> getMetroLines(String system) async => + system == 'TRTC' ? [_line('T')] : [_line('R'), _line('O')]; + + @override + Future> getMetroStations(String system) async { + stationRequests.add(system); + if (failedSystems.contains(system)) { + throw StateError('Station data unavailable'); + } + final stations = system == 'TRTC' + ? [_station('T1', 25.04, 121.51), _station('T2', 25.05, 121.52)] + : [ + _station('R1', 22.66, 120.31), + _station('R2', 22.67, 120.32), + _station('O1', 22.64, 120.29), + _station('O2', 22.63, 120.30), + ]; + return missingCoordinates + ? stations.map((station) => _station(station.stationId, 0, 0)).toList() + : stations; + } + + @override + Future> getMetroStationOfLine(String system) async => + system == 'TRTC' + ? [ + _sequence('T', ['T1', 'T2']), + ] + : [ + _sequence('R', ['R1', 'R2']), + _sequence('O', ['O1', 'O2']), + ]; + + @override + Future getMetroLineEta(String system, String lineId) async { + etaRequests.add('$system/$lineId'); + return const MetroEtaResponse( + source: 'liveboard', + currentTime: '', + entries: [], + ); + } +} + +Position _position({double latitude = 22.6301, double longitude = 120.3001}) => + Position( + latitude: latitude, + longitude: longitude, + timestamp: DateTime(2026, 11, 3), + altitude: 0, + altitudeAccuracy: 0, + accuracy: 10, + heading: 0, + headingAccuracy: 0, + speed: 0, + speedAccuracy: 0, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppController controller; + late _MetroRepository repository; + late HttpOverrides? previousHttpOverrides; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + previousHttpOverrides = HttpOverrides.current; + HttpOverrides.global = _TileHttpOverrides(); + controller = await _buildController(); + repository = _MetroRepository(); + }); + + tearDown(() { + controller.dispose(); + HttpOverrides.global = previousHttpOverrides; + }); + + Future pumpMetro( + WidgetTester tester, { + Future Function()? positionResolver, + }) async { + tester.view.physicalSize = const Size(1000, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AppControllerScope( + controller: controller, + child: MetroScreen( + isActive: false, + showAdBanner: false, + repository: repository, + positionResolver: positionResolver ?? () async => _position(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Finder locateButton() => find.byKey(const ValueKey('metro_locate_nearest')); + + String selectedSystem(WidgetTester tester) => + (tester + .widgetList(find.byType(ChoiceChip)) + .singleWhere((chip) => chip.selected) + .label + as Text) + .data!; + + testMetroWidgets('location is requested only after an explicit tap', ( + tester, + ) async { + var locationCalls = 0; + await pumpMetro( + tester, + positionResolver: () async { + locationCalls++; + return _position(); + }, + ); + + expect(locationCalls, 0); + expect(selectedSystem(tester), 'Taipei Metro'); + expect(tester.widget(locateButton()).onPressed, isNotNull); + + await tester.tap(locateButton()); + await tester.pumpAndSettle(); + + expect(locationCalls, 1); + expect(selectedSystem(tester), 'Kaohsiung Metro'); + final map = tester.widget( + find.byType(TransitStationMap), + ); + expect(map.selectedPointId, 'O2'); + expect(map.points.map((point) => point.id), ['O1', 'O2']); + expect(repository.etaRequests.last, 'KRTC/O'); + expect(repository.stationRequests, containsAll(['TRTC', 'KRTC'])); + expect(find.textContaining('Nearest station: Station O2'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testMetroWidgets( + 'a later manual system selection wins over a pending location', + (tester) async { + final position = Completer(); + await pumpMetro(tester, positionResolver: () => position.future); + + await tester.tap(locateButton()); + await tester.pump(); + expect(tester.widget(locateButton()).onPressed, isNull); + + await tester.tap(find.widgetWithText(ChoiceChip, 'Kaohsiung Metro')); + await tester.pumpAndSettle(); + position.complete(_position(latitude: 25.0401, longitude: 121.5101)); + await tester.pumpAndSettle(); + + expect(selectedSystem(tester), 'Kaohsiung Metro'); + expect(repository.etaRequests.last, 'KRTC/R'); + expect(find.byType(TransitStationMap), findsNothing); + expect(tester.widget(locateButton()).onPressed, isNotNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + testMetroWidgets( + 'partial data failure keeps usable results and explains scope', + (tester) async { + await pumpMetro(tester); + repository.failedSystems.add('TRTC'); + + await tester.tap(locateButton()); + await tester.pumpAndSettle(); + + expect(selectedSystem(tester), 'Kaohsiung Metro'); + expect( + tester + .widget(find.byType(TransitStationMap)) + .selectedPointId, + 'O2', + ); + expect( + find.textContaining('Some metro systems could not be checked.'), + findsOneWidget, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + testMetroWidgets( + 'total data failure preserves the current selection and can retry', + (tester) async { + await pumpMetro(tester); + repository.failedSystems.addAll(['TRTC', 'KRTC']); + + await tester.tap(locateButton()); + await tester.pumpAndSettle(); + + expect(selectedSystem(tester), 'Taipei Metro'); + expect( + find.text('Could not load metro stations. Please try again.'), + findsOneWidget, + ); + expect(tester.widget(locateButton()).onPressed, isNotNull); + + repository.failedSystems.clear(); + await tester.tap(locateButton()); + await tester.pumpAndSettle(); + expect(selectedSystem(tester), 'Kaohsiung Metro'); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + testMetroWidgets( + 'missing coordinates do not silently select the first station', + (tester) async { + await pumpMetro(tester); + repository.missingCoordinates = true; + + await tester.tap(locateButton()); + await tester.pumpAndSettle(); + + expect(selectedSystem(tester), 'Taipei Metro'); + expect(find.text('No nearby station found.'), findsOneWidget); + expect(find.byType(TransitStationMap), findsNothing); + expect(repository.etaRequests, ['TRTC/T']); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + for (final failure in [ + ( + error: const LocationFailure('定位服務尚未開啟。', serviceDisabled: true), + message: 'Location services are turned off.', + ), + ( + error: const LocationFailure('沒有取得定位權限。', permissionDenied: true), + message: 'Location permission was not granted.', + ), + ( + error: const LocationFailure('沒有取得定位權限。', deniedForever: true), + message: 'Location permission was not granted.', + ), + ( + error: const LocationFailure('目前無法取得定位,請稍後再試。'), + message: + 'Your location is unavailable right now. Please try again later.', + ), + ]) { + testMetroWidgets('reports location failure: ${failure.error} ' + '(permanent: ${failure.error.deniedForever})', (tester) async { + await pumpMetro( + tester, + positionResolver: () async => throw failure.error, + ); + final initialRequests = repository.stationRequests.length; + + await tester.tap(locateButton()); + await tester.pumpAndSettle(); + + expect(find.text(failure.message), findsOneWidget); + expect(selectedSystem(tester), 'Taipei Metro'); + expect(repository.stationRequests.length, initialRequests); + expect(tester.widget(locateButton()).onPressed, isNotNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + + testMetroWidgets('disposing while locating ignores the late result', ( + tester, + ) async { + final position = Completer(); + await pumpMetro(tester, positionResolver: () => position.future); + await tester.tap(locateButton()); + await tester.pump(); + + await tester.pumpWidget(const SizedBox.shrink()); + position.complete(_position()); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(repository.stationRequests, ['TRTC']); + }); +} + +void testMetroWidgets(String description, WidgetTesterCallback callback) { + testWidgets( + description, + callback, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); +} + +Future _buildController() async { + const buildInfo = AppBuildInfo( + version: '1.0.0', + buildNumber: '1', + gitSha: 'test', + defaultUpdateChannel: AppUpdateChannel.release, + ); + final client = MockClient((_) async => http.Response('{}', 200)); + final controller = AppController( + repository: BusRepository(client: client), + storage: StorageService(), + analytics: await AppAnalytics.initialize(), + buildInfo: buildInfo, + appUpdateService: AppUpdateService(buildInfo: buildInfo, client: client), + appUpdateInstaller: createAppUpdateInstaller(), + authService: AuthService(), + accountSyncService: AccountSyncService(client: client), + ); + await controller.updateDesktopDiscordPresenceEnabled(false); + return controller; +} + +// Keep the real map widget in the integration tests without requesting tiles. +final _transparentPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', +); + +class _TileHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) => _TileHttpClient(); +} + +class _TileHttpClient implements HttpClient { + @override + Future openUrl(String method, Uri url) async => + _TileRequest(); + + @override + Future getUrl(Uri url) => openUrl('GET', url); + + @override + void close({bool force = false}) {} + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +class _TileRequest implements HttpClientRequest { + @override + final HttpHeaders headers = _TileHeaders(); + + @override + Future addStream(Stream> stream) async { + await stream.drain(); + } + + @override + Future close() async => _TileResponse(); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +class _TileHeaders implements HttpHeaders { + @override + void forEach(void Function(String name, List values) action) { + action('content-type', ['image/png']); + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +class _TileResponse extends Stream> implements HttpClientResponse { + @override + final HttpHeaders headers = _TileHeaders(); + + @override + int get statusCode => HttpStatus.ok; + + @override + String get reasonPhrase => 'OK'; + + @override + int get contentLength => _transparentPng.length; + + @override + bool get isRedirect => false; + + @override + bool get persistentConnection => true; + + @override + List get redirects => const []; + + @override + HttpClientResponseCompressionState get compressionState => + HttpClientResponseCompressionState.notCompressed; + + @override + StreamSubscription> listen( + void Function(List)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => Stream>.value(_transparentPng).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} diff --git a/test/nearest_metro_station_test.dart b/test/nearest_metro_station_test.dart new file mode 100644 index 0000000..c2ca76b --- /dev/null +++ b/test/nearest_metro_station_test.dart @@ -0,0 +1,212 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:taiwanbus_flutter/core/nearest_metro_station.dart'; +import 'package:taiwanbus_flutter/core/transit_repository.dart'; + +MetroLine _line(String id) => + MetroLine(lineId: id, lineNo: id, name: id, nameEn: id, color: ''); + +MetroStation _station( + String id, { + double latitude = 25, + double longitude = 121, + String lineId = '', +}) => MetroStation( + stationId: id, + name: id, + nameEn: id, + lineId: lineId, + lat: latitude, + lon: longitude, +); + +MetroStationOfLine _membership(String lineId, List stationIds) => + MetroStationOfLine( + lineId: lineId, + direction: 0, + stations: [ + for (var index = 0; index < stationIds.length; index++) + MetroStationSequence( + stationId: stationIds[index], + name: stationIds[index], + nameEn: stationIds[index], + sequence: index + 1, + ), + ], + ); + +MetroSystemData _system( + String code, { + required List stations, + List lines = const ['R'], + List? stationOfLine, +}) => MetroSystemData( + system: MetroSystem(system: code, city: code, name: code, nameEn: code), + lines: lines.map(_line).toList(), + stations: stations, + stationOfLine: + stationOfLine ?? + [_membership(lines.first, stations.map((s) => s.stationId).toList())], +); + +void main() { + test( + 'chooses the closest station across systems regardless of preference', + () { + final result = nearestMetroStation( + [ + _system('TRTC', stations: [_station('far', latitude: 25.2)]), + _system('TYMC', stations: [_station('near', latitude: 25.001)]), + ], + latitude: 25, + longitude: 121, + preferredSystem: 'TRTC', + preferredLineId: 'R', + ); + + expect(result!.data.system.system, 'TYMC'); + expect(result.station.stationId, 'near'); + expect(result.distanceMeters, closeTo(111, 1)); + }, + ); + + test( + 'uses station membership even when station line ID is empty or wrong', + () { + for (final lineId in ['', 'unknown']) { + final result = nearestMetroStation( + [ + _system('TRTC', stations: [_station('R01', lineId: lineId)]), + ], + latitude: 25, + longitude: 121, + ); + + expect(result!.line.lineId, 'R'); + expect(result.station.stationId, 'R01'); + } + }, + ); + + test('ignores missing and invalid station coordinates', () { + final invalidStations = [ + _station('zero', latitude: 0, longitude: 0), + _station('nan', latitude: double.nan), + _station('infinity', longitude: double.infinity), + _station('latitude', latitude: 91), + _station('longitude', longitude: -181), + ]; + final result = nearestMetroStation( + [ + _system( + 'TRTC', + stations: [...invalidStations, _station('valid', latitude: 25.1)], + ), + ], + latitude: 25, + longitude: 121, + ); + + expect(result!.station.stationId, 'valid'); + expect( + nearestMetroStation( + [_system('TRTC', stations: invalidStations)], + latitude: 25, + longitude: 121, + ), + isNull, + ); + }); + + test('ignores stations without membership in an available line', () { + final result = nearestMetroStation( + [ + _system( + 'TRTC', + stations: [ + _station('orphan', lineId: 'R'), + _station('unknown-line'), + _station('empty-line'), + _station('valid', latitude: 25.1), + ], + lines: ['R', ''], + stationOfLine: [ + _membership('missing', ['unknown-line']), + _membership('', ['empty-line']), + _membership('R', ['valid']), + ], + ), + ], + latitude: 25, + longitude: 121, + ); + + expect(result!.station.stationId, 'valid'); + }); + + test('prefers the current line at an interchange only in its own system', () { + final systems = [ + _system('TYMC', stations: [_station('A1')], lines: ['G']), + _system( + 'TRTC', + stations: [_station('interchange')], + lines: ['R', 'G'], + stationOfLine: [ + _membership('R', ['interchange']), + _membership('G', ['interchange']), + ], + ), + ]; + final result = nearestMetroStation( + systems, + latitude: 25, + longitude: 121, + preferredSystem: 'TRTC', + preferredLineId: 'G', + ); + + expect(result!.data.system.system, 'TRTC'); + expect(result.line.lineId, 'G'); + expect(result.station.stationId, 'interchange'); + }); + + test( + 'equal distances preserve input order without a matching preference', + () { + final result = nearestMetroStation( + [ + _system('first', stations: [_station('first'), _station('second')]), + _system('second', stations: [_station('third')]), + ], + latitude: 25, + longitude: 121, + preferredSystem: 'missing', + preferredLineId: 'R', + ); + + expect(result!.data.system.system, 'first'); + expect(result.station.stationId, 'first'); + }, + ); + + test('returns null for empty data or an invalid user location', () { + expect(nearestMetroStation([], latitude: 25, longitude: 121), isNull); + final systems = [ + _system('TRTC', stations: [_station('R01')]), + ]; + for (final coordinates in [ + (double.nan, 121.0), + (25.0, double.infinity), + (-91.0, 121.0), + (25.0, 181.0), + ]) { + expect( + nearestMetroStation( + systems, + latitude: coordinates.$1, + longitude: coordinates.$2, + ), + isNull, + ); + } + }); +} diff --git a/test/user_location_test.dart b/test/user_location_test.dart index addb7a7..52fa551 100644 --- a/test/user_location_test.dart +++ b/test/user_location_test.dart @@ -27,6 +27,23 @@ class _HangingCurrentPosition extends GeolocatorPlatform { Completer().future; } +class _DeniedPosition extends _HangingCurrentPosition { + _DeniedPosition(this.permission) : super(null); + + final LocationPermission permission; + + @override + Future checkPermission() async => permission; + + @override + Future requestPermission() async => permission; + + @override + Future getCurrentPosition({LocationSettings? locationSettings}) { + throw StateError('Must not request a position without permission'); + } +} + Position _position() => Position( latitude: 25.033, longitude: 121.5654, @@ -41,6 +58,34 @@ Position _position() => Position( ); void main() { + for (final permission in [ + LocationPermission.denied, + LocationPermission.deniedForever, + ]) { + test('reports $permission as a permission failure', () async { + final original = GeolocatorPlatform.instance; + GeolocatorPlatform.instance = _DeniedPosition(permission); + addTearDown(() => GeolocatorPlatform.instance = original); + + await expectLater( + resolveUserPosition(), + throwsA( + isA() + .having( + (error) => error.permissionDenied, + 'permissionDenied', + true, + ) + .having( + (error) => error.deniedForever, + 'deniedForever', + permission == LocationPermission.deniedForever, + ), + ), + ); + }); + } + test('reports when the device location service is switched off', () async { final original = GeolocatorPlatform.instance; GeolocatorPlatform.instance = _HangingCurrentPosition(