diff --git a/lib/core/bus_repository.dart b/lib/core/bus_repository.dart index e675502..11b0d6a 100644 --- a/lib/core/bus_repository.dart +++ b/lib/core/bus_repository.dart @@ -81,6 +81,8 @@ class BusRepository { _stationPassbyInFlight.clear(); _routeRealtimeBusesCache.clear(); _routeRealtimeBusesInFlight.clear(); + _routeAlertsCache.clear(); + _routeAlertsInFlight.clear(); _routeDetailCache.clear(); _routeDetailInFlight.clear(); _routeStopsApiCache.clear(); @@ -132,7 +134,7 @@ class BusRepository { final Map>> _taichungCancelledDepartureInFlight = >>{}; - static const _routeAlertsCacheTtl = Duration(hours: 2); + static const _routeAlertsCacheTtl = Duration(minutes: 5); final Map>> _routeAlertsCache = >>{}; final Map>> _routeAlertsInFlight = @@ -3375,10 +3377,13 @@ class BusRepository { } final future = _loadRouteAlerts(routeId); + final generation = _routeDataGeneration; _routeAlertsInFlight[routeId] = future; try { final alerts = await future; - _routeAlertsCache[routeId] = _TimedValue>(alerts); + if (generation == _routeDataGeneration) { + _routeAlertsCache[routeId] = _TimedValue>(alerts); + } return alerts; } finally { if (identical(_routeAlertsInFlight[routeId], future)) { diff --git a/lib/core/models.dart b/lib/core/models.dart index e096072..5074c78 100644 --- a/lib/core/models.dart +++ b/lib/core/models.dart @@ -2504,30 +2504,78 @@ class RouteAlert { required this.endTime, required this.publishTime, required this.updatedTime, + this.source, + this.sourceUrl, }); factory RouteAlert.fromJson(Map json) { + final rawSource = json['source']; + final source = rawSource is Map + ? _firstString(rawSource, const ['name', 'title', 'label']) + : _firstString(json, const ['source', 'source_name', 'provider']); + final sourceUrl = rawSource is Map + ? _firstString(rawSource, const ['url', 'uri', 'link']) + : _firstString(json, const [ + 'source_url', + 'official_url', + 'info_url', + 'url', + ]) ?? + (source != null && Uri.tryParse(source)?.hasScheme == true + ? source + : null); return RouteAlert( alertId: json['alert_id']?.toString() ?? '', title: json['title']?.toString() ?? '', description: json['description']?.toString() ?? '', - status: (json['status'] as num?)?.toInt(), - cause: (json['cause'] as num?)?.toInt(), - effect: (json['effect'] as num?)?.toInt(), - direction: (json['direction'] as num?)?.toInt(), - scope: json['scope']?.toString(), + status: _intValue(json['status']), + cause: _intValue(json['cause']), + effect: _intValue(json['effect']), + direction: _intValue(json['direction']), + scope: _scopeString(json['scope']), stopIds: (json['stop_ids'] as List?) ?.map((e) => e.toString()) .toList() ?? const [], - startTime: (json['start_time'] as num?)?.toInt(), - endTime: (json['end_time'] as num?)?.toInt(), - publishTime: (json['publish_time'] as num?)?.toInt(), - updatedTime: (json['updated_time'] as num?)?.toInt(), + startTime: _intValue(json['start_time']), + endTime: _intValue(json['end_time']), + publishTime: _intValue(json['publish_time']), + updatedTime: _intValue(json['updated_time']), + source: source, + sourceUrl: sourceUrl, ); } + static String? _firstString(Map json, List keys) { + for (final key in keys) { + final value = json[key]?.toString().trim(); + if (value != null && value.isNotEmpty) { + return value; + } + } + return null; + } + + static int? _intValue(Object? value) { + if (value is num) { + return value.toInt(); + } + return int.tryParse(value?.toString().trim() ?? ''); + } + + static String? _scopeString(Object? value) { + if (value is List) { + final values = value + .map((item) => item.toString().trim()) + .where((item) => item.isNotEmpty) + .toList(); + return values.isEmpty ? null : values.join('、'); + } + final text = value?.toString().trim(); + return text == null || text.isEmpty ? null : text; + } + final String alertId; final String title; final String description; @@ -2541,6 +2589,8 @@ class RouteAlert { final int? endTime; final int? publishTime; final int? updatedTime; + final String? source; + final String? sourceUrl; String get statusText => switch (status) { 0 => '全部營運停止', diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 424dbeb..dbea652 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -549,6 +549,12 @@ "routeDetailDirectionHeading": "{direction}:", "@routeDetailDirectionHeading": {"placeholders": {"direction": {"type": "String"}}}, "routeDetailOperationsNotice": "Service notice", + "routeAlertAffectedRoute": "Affected route", + "routeAlertType": "Type", + "routeAlertTime": "Time", + "routeAlertImpact": "Impact", + "routeAlertSource": "Official source", + "routeAlertOpenSource": "Open official source", "routeDetailViewRoutePresence": "Viewing route", "routeDetailBackgroundPromptTitle": "Enable background trip alerts?", "routeDetailBackgroundPromptMessage": "YABus can keep tracking this route in the background and alert you as you approach your destination.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8eb44c6..bf91509 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2761,6 +2761,24 @@ abstract class AppLocalizations { /// **'Service notice'** String get routeDetailOperationsNotice; + /// No description provided for @routeAlertAffectedRoute. + String get routeAlertAffectedRoute; + + /// No description provided for @routeAlertType. + String get routeAlertType; + + /// No description provided for @routeAlertTime. + String get routeAlertTime; + + /// No description provided for @routeAlertImpact. + String get routeAlertImpact; + + /// No description provided for @routeAlertSource. + String get routeAlertSource; + + /// No description provided for @routeAlertOpenSource. + String get routeAlertOpenSource; + /// No description provided for @routeDetailViewRoutePresence. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a4fe268..3192629 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1746,6 +1746,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get routeDetailOperationsNotice => 'Service notice'; + @override + String get routeAlertAffectedRoute => 'Affected route'; + + @override + String get routeAlertType => 'Type'; + + @override + String get routeAlertTime => 'Time'; + + @override + String get routeAlertImpact => 'Impact'; + + @override + String get routeAlertSource => 'Official source'; + + @override + String get routeAlertOpenSource => 'Open official source'; + @override String get routeDetailViewRoutePresence => 'Viewing route'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index fbf2b2d..9db0e80 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1545,6 +1545,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get routeDetailOperationsNotice => '營運通知'; + @override + String get routeAlertAffectedRoute => '受影響路線'; + + @override + String get routeAlertType => '異常類型'; + + @override + String get routeAlertTime => '發生時間'; + + @override + String get routeAlertImpact => '影響範圍'; + + @override + String get routeAlertSource => '官方公告來源'; + + @override + String get routeAlertOpenSource => '開啟官方公告'; + @override String get routeDetailViewRoutePresence => '查看路線'; @@ -4748,6 +4766,24 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get routeDetailOperationsNotice => '營運通知'; + @override + String get routeAlertAffectedRoute => '受影響路線'; + + @override + String get routeAlertType => '異常類型'; + + @override + String get routeAlertTime => '發生時間'; + + @override + String get routeAlertImpact => '影響範圍'; + + @override + String get routeAlertSource => '官方公告來源'; + + @override + String get routeAlertOpenSource => '開啟官方公告'; + @override String get routeDetailViewRoutePresence => '查看路線'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8effa2c..5e7c426 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -549,6 +549,12 @@ "routeDetailDirectionHeading": "{direction}:", "@routeDetailDirectionHeading": {"placeholders": {"direction": {"type": "String"}}}, "routeDetailOperationsNotice": "營運通知", + "routeAlertAffectedRoute": "受影響路線", + "routeAlertType": "異常類型", + "routeAlertTime": "發生時間", + "routeAlertImpact": "影響範圍", + "routeAlertSource": "官方公告來源", + "routeAlertOpenSource": "開啟官方公告", "routeDetailViewRoutePresence": "查看路線", "routeDetailBackgroundPromptTitle": "啓用背景乘車提醒?", "routeDetailBackgroundPromptMessage": "YABus 可以在你把 app 丟到背景後繼續追蹤這條路線,並在接近目的地下車前提醒你。", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 56b5afa..cb14a95 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -524,6 +524,12 @@ "routeDetailDirectionHeading": "{direction}:", "@routeDetailDirectionHeading": {"placeholders": {"direction": {"type": "String"}}}, "routeDetailOperationsNotice": "營運通知", + "routeAlertAffectedRoute": "受影響路線", + "routeAlertType": "異常類型", + "routeAlertTime": "發生時間", + "routeAlertImpact": "影響範圍", + "routeAlertSource": "官方公告來源", + "routeAlertOpenSource": "開啟官方公告", "routeDetailViewRoutePresence": "查看路線", "routeDetailBackgroundPromptTitle": "啓用背景乘車提醒?", "routeDetailBackgroundPromptMessage": "YABus 可以在你把 app 丟到背景後繼續追蹤這條路線,並在接近目的地下車前提醒你。", diff --git a/lib/screens/route_detail_screen.dart b/lib/screens/route_detail_screen.dart index ba03b83..cb31b54 100644 --- a/lib/screens/route_detail_screen.dart +++ b/lib/screens/route_detail_screen.dart @@ -928,6 +928,15 @@ class _RouteDetailScreenState extends State } } + Future _openRouteAlertSource(RouteAlert alert) async { + final value = alert.sourceUrl?.trim(); + final uri = value == null ? null : Uri.tryParse(value); + if (uri == null || !uri.hasScheme) { + return; + } + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + Widget _buildAlertTile(RouteAlert alert, ThemeData theme) { final effectLabel = alert.effectText; final causeLabel = alert.causeText; @@ -981,6 +990,17 @@ class _RouteDetailScreenState extends State padding: const EdgeInsets.only(top: 4, left: 16), child: Text(alert.description, style: theme.textTheme.bodySmall), ), + _buildRouteAlertMetadata( + context: context, + theme: theme, + alert: alert, + routeName: _detail?.route.transitName.displayForLocale( + Localizations.localeOf(context).toLanguageTag(), + ), + onOpenSource: alert.sourceUrl?.trim().isNotEmpty == true + ? () => _openRouteAlertSource(alert) + : null, + ), ], ); } @@ -6877,6 +6897,100 @@ class _RouteStatusPill extends StatelessWidget { } } +DateTime? _routeAlertDateTime(int? timestamp) { + if (timestamp == null || timestamp <= 0) { + return null; + } + final milliseconds = timestamp > 1000000000000 + ? timestamp + : timestamp * Duration.millisecondsPerSecond; + return DateTime.fromMillisecondsSinceEpoch(milliseconds).toLocal(); +} + +String _routeAlertTimeText(BuildContext context, RouteAlert alert) { + final start = _routeAlertDateTime(alert.startTime); + final end = _routeAlertDateTime(alert.endTime); + final published = _routeAlertDateTime(alert.publishTime); + final values = []; + String format(DateTime value) { + final date = MaterialLocalizations.of(context).formatMediumDate(value); + final time = MaterialLocalizations.of(context).formatTimeOfDay( + TimeOfDay.fromDateTime(value), + ); + return '$date $time'; + } + + if (start != null) values.add(format(start)); + if (end != null) values.add(format(end)); + if (values.isEmpty && published != null) { + values.add(format(published)); + } + return values.join(' – '); +} + +Widget _buildRouteAlertMetadata({ + required BuildContext context, + required ThemeData theme, + required RouteAlert alert, + String? routeName, + Future Function()? onOpenSource, +}) { + final l10n = AppLocalizations.of(context); + final time = _routeAlertTimeText(context, alert); + final source = alert.source?.trim() ?? ''; + final sourceUrl = alert.sourceUrl?.trim() ?? ''; + final rows = []; + void addRow(String label, String value) { + if (value.isEmpty) return; + final separator = l10n.localeName.startsWith('zh') ? ':' : ': '; + rows.add( + Padding( + padding: const EdgeInsets.only(top: 4, left: 16), + child: Text( + '$label$separator$value', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + addRow(l10n.routeAlertAffectedRoute, routeName?.trim() ?? ''); + addRow( + l10n.routeAlertType, + [alert.effectText, alert.causeText] + .where((value) => value.isNotEmpty) + .join(' · '), + ); + addRow(l10n.routeAlertTime, time); + addRow(l10n.routeAlertImpact, alert.scope?.trim() ?? ''); + addRow(l10n.routeAlertSource, source.isNotEmpty ? source : sourceUrl); + if (sourceUrl.isNotEmpty && onOpenSource != null) { + rows.add( + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: onOpenSource, + icon: const Icon(Icons.open_in_new_rounded, size: 16), + label: Text(l10n.routeAlertOpenSource), + style: TextButton.styleFrom( + padding: const EdgeInsets.only(left: 16, right: 8), + visualDensity: VisualDensity.compact, + ), + ), + ), + ); + } + if (rows.isEmpty) { + return const SizedBox.shrink(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: rows, + ); +} + class _RouteInfoDialog extends StatefulWidget { const _RouteInfoDialog({ required this.detail, @@ -7225,6 +7339,18 @@ class _RouteInfoDialogState extends State<_RouteInfoDialog> { } } + Future _openRouteAlertSource(RouteAlert alert) async { + final value = alert.source?.trim(); + final uri = value == null ? null : Uri.tryParse(value); + final sourceUrl = alert.sourceUrl?.trim(); + final sourceUri = sourceUrl == null ? null : Uri.tryParse(sourceUrl); + final target = sourceUri ?? uri; + if (target == null || !target.hasScheme) { + return; + } + await launchUrl(target, mode: LaunchMode.externalApplication); + } + Widget _buildExpandableAlertItem(RouteAlert alert, ThemeData theme) { final expanded = _expandedAlertIds.contains(alert.alertId); return InkWell( @@ -7300,6 +7426,17 @@ class _RouteInfoDialogState extends State<_RouteInfoDialog> { style: theme.textTheme.bodySmall, ), ), + _buildRouteAlertMetadata( + context: context, + theme: theme, + alert: alert, + routeName: widget.detail.route.transitName.displayForLocale( + Localizations.localeOf(context).toLanguageTag(), + ), + onOpenSource: alert.sourceUrl?.trim().isNotEmpty == true + ? () => _openRouteAlertSource(alert) + : null, + ), ], ], ), diff --git a/test/route_alert_test.dart b/test/route_alert_test.dart new file mode 100644 index 0000000..836c67b --- /dev/null +++ b/test/route_alert_test.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:taiwanbus_flutter/core/bus_repository.dart'; +import 'package:taiwanbus_flutter/core/models.dart'; + +void main() { + test('parses alert impact and official source metadata', () { + final alert = RouteAlert.fromJson({ + 'alert_id': 'alert-1', + 'title': 'Road work', + 'description': 'The route is detoured.', + 'status': 2, + 'cause': 4, + 'effect': 1, + 'scope': ['Stop A', 'Stop B'], + 'start_time': 1790000000, + 'end_time': 1790003600, + 'source': {'name': 'TDX', 'url': 'https://tdx.transportdata.tw/'}, + }); + + expect(alert.scope, 'Stop A、Stop B'); + expect(alert.source, 'TDX'); + expect(alert.sourceUrl, 'https://tdx.transportdata.tw/'); + expect(alert.effectText, '車輛改道/站牌不停靠'); + expect(alert.causeText, '施工'); + expect(alert.isNegative, isTrue); + }); + + test('fetches route alerts from the encoded route endpoint and caches them', + () async { + var requests = 0; + Uri? requestUri; + final repository = BusRepository( + client: MockClient((request) async { + requests++; + requestUri = request.url; + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'routeid': 'TPE/123', + 'alerts': [ + { + 'alert_id': 'alert-1', + 'title': 'Delay', + 'description': '', + 'status': 2, + 'effect': 7, + }, + ], + }), + ), + 200, + headers: const {'content-type': 'application/json'}, + ); + }), + ); + + final first = await repository.fetchRouteAlerts('TPE/123'); + final second = await repository.fetchRouteAlerts('TPE/123'); + + expect(requests, 1); + expect( + requestUri?.pathSegments, + containsAllInOrder(const ['TPE/123', 'alerts']), + ); + expect(first.single.alertId, 'alert-1'); + expect(second.single.effectText, '重大延遲'); + + repository.invalidateRouteData(); + await repository.fetchRouteAlerts('TPE/123'); + expect(requests, 2); + }); +}