diff --git a/assets/branding/app_icon_source.png b/assets/branding/app_icon_source.png index f34b688..4882c6d 100644 Binary files a/assets/branding/app_icon_source.png and b/assets/branding/app_icon_source.png differ diff --git a/assets/branding/icon.png b/assets/branding/icon.png index c7d0b4d..3023b14 100644 Binary files a/assets/branding/icon.png and b/assets/branding/icon.png differ diff --git a/assets/branding/icon_transparent.png b/assets/branding/icon_transparent.png index 93e7750..3023b14 100644 Binary files a/assets/branding/icon_transparent.png and b/assets/branding/icon_transparent.png differ diff --git a/flutter_native_splash.yaml b/flutter_native_splash.yaml index ff32fa0..6b72fa5 100644 --- a/flutter_native_splash.yaml +++ b/flutter_native_splash.yaml @@ -1,8 +1,8 @@ flutter_native_splash: color: "#F5F7F2" color_dark: "#111418" - image: assets/branding/splash_android.png - image_dark: assets/branding/splash_android.png + image: assets/branding/icon.png + image_dark: assets/branding/icon.png info_plist_files: - ios/YABus/Info.plist @@ -13,5 +13,5 @@ flutter_native_splash: android_12: color: "#F5F7F2" color_dark: "#111418" - image: assets/branding/splash_android.png - image_dark: assets/branding/splash_android.png + image: assets/branding/icon.png + image_dark: assets/branding/icon.png diff --git a/lib/core/catchability_estimator.dart b/lib/core/catchability_estimator.dart new file mode 100644 index 0000000..d5efd4b --- /dev/null +++ b/lib/core/catchability_estimator.dart @@ -0,0 +1,107 @@ +import 'dart:math' as math; + +/// The confidence level of an estimate that the user can reach a stop before +/// the next bus arrives. +enum CatchabilityStatus { likely, possible, unlikely, departed, unavailable } + +class CatchabilityAssessment { + const CatchabilityAssessment({ + required this.status, + required this.message, + this.walkingMinutes, + this.remainingMinutes, + this.safetyBufferMinutes = 0, + }); + + final CatchabilityStatus status; + final String message; + final int? walkingMinutes; + final int? remainingMinutes; + final int safetyBufferMinutes; + + bool get hasEstimate => status != CatchabilityStatus.unavailable; +} + +/// Estimates whether a person can walk to a nearby stop before a bus arrives. +/// +/// This intentionally uses conservative wording and margins. The nearby-stop +/// distance is a straight-line distance, so [walkingRouteFactor] approximates +/// the extra distance of real streets. [dataDelayBufferMinutes] and the +/// location-accuracy margin cover GPS error, crossings, and realtime delay. +CatchabilityAssessment estimateCatchability({ + required double distanceMeters, + required int? etaSeconds, + double? locationAccuracyMeters, + double walkingSpeedMetersPerMinute = 75, + double walkingRouteFactor = 1.35, + int dataDelayBufferMinutes = 2, +}) { + if (!distanceMeters.isFinite || distanceMeters < 0 || etaSeconds == null) { + return const CatchabilityAssessment( + status: CatchabilityStatus.unavailable, + message: '定位或即時資料不足,無法準確判斷', + ); + } + if (!walkingSpeedMetersPerMinute.isFinite || + walkingSpeedMetersPerMinute <= 0 || + !walkingRouteFactor.isFinite || + walkingRouteFactor <= 0 || + dataDelayBufferMinutes < 0) { + return const CatchabilityAssessment( + status: CatchabilityStatus.unavailable, + message: '定位或即時資料不足,無法準確判斷', + ); + } + + if (etaSeconds <= 60) { + return const CatchabilityAssessment( + status: CatchabilityStatus.departed, + message: '公車可能已進站或離站', + ); + } + + final accuracy = locationAccuracyMeters; + final accuracyBuffer = accuracy != null && accuracy.isFinite + ? accuracy >= 100 + ? 2 + : accuracy >= 50 + ? 1 + : 0 + : 0; + final safetyBufferMinutes = dataDelayBufferMinutes + accuracyBuffer; + final walkingMinutes = math.max( + 1, + ((distanceMeters * walkingRouteFactor) / walkingSpeedMetersPerMinute) + .ceil(), + ); + // Full minutes are used for the available time so that a partial minute is + // never presented as spare time. + final availableMinutes = etaSeconds ~/ 60; + final remainingMinutes = availableMinutes - walkingMinutes; + + if (remainingMinutes < 0) { + return CatchabilityAssessment( + status: CatchabilityStatus.unlikely, + message: '可能來不及,步行時間約 $walkingMinutes 分鐘;建議搭乘下一班', + walkingMinutes: walkingMinutes, + remainingMinutes: remainingMinutes, + safetyBufferMinutes: safetyBufferMinutes, + ); + } + if (remainingMinutes <= safetyBufferMinutes) { + return CatchabilityAssessment( + status: CatchabilityStatus.possible, + message: '可能來得及,步行時間約 $walkingMinutes 分鐘', + walkingMinutes: walkingMinutes, + remainingMinutes: remainingMinutes, + safetyBufferMinutes: safetyBufferMinutes, + ); + } + return CatchabilityAssessment( + status: CatchabilityStatus.likely, + message: '預估來得及,約剩餘 $remainingMinutes 分鐘', + walkingMinutes: walkingMinutes, + remainingMinutes: remainingMinutes, + safetyBufferMinutes: safetyBufferMinutes, + ); +} diff --git a/lib/screens/nearby_screen.dart b/lib/screens/nearby_screen.dart index 118dd07..104fa78 100644 --- a/lib/screens/nearby_screen.dart +++ b/lib/screens/nearby_screen.dart @@ -7,6 +7,7 @@ import '../widgets/ad_banner_widget.dart'; import '../app/bus_app.dart'; import '../widgets/app_content_transition.dart'; import '../core/bus_repository.dart'; +import '../core/catchability_estimator.dart'; import '../core/models.dart'; import '../core/route_direction_label.dart'; import '../core/user_location.dart'; @@ -26,12 +27,103 @@ class NearbyScreen extends StatefulWidget { State createState() => _NearbyScreenState(); } +String _catchabilityMessage( + AppLocalizations l10n, + CatchabilityAssessment assessment, +) { + final isChinese = l10n.localeName.startsWith('zh'); + return switch (assessment.status) { + CatchabilityStatus.likely => + isChinese + ? '預估來得及,約剩餘 ${assessment.remainingMinutes} 分鐘' + : 'Likely in time, about ${assessment.remainingMinutes} min left', + CatchabilityStatus.possible => + isChinese + ? '可能來得及,步行時間約 ${assessment.walkingMinutes} 分鐘' + : 'Possible, walking takes about ${assessment.walkingMinutes} min', + CatchabilityStatus.unlikely => + isChinese + ? '可能來不及,步行約 ${assessment.walkingMinutes} 分鐘;建議搭乘下一班' + : 'May miss it; walking takes about ${assessment.walkingMinutes} min. Try the next bus', + CatchabilityStatus.departed => + isChinese ? '公車可能已進站或離站' : 'The bus may already be at or past the stop', + CatchabilityStatus.unavailable => + isChinese + ? '定位或即時資料不足,無法準確判斷' + : 'Not enough location or live data for an accurate estimate', + }; +} + +class _CatchabilityLabel extends StatelessWidget { + const _CatchabilityLabel({ + required this.assessment, + required this.theme, + required this.l10n, + }); + + final CatchabilityAssessment assessment; + final ThemeData theme; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + final (icon, color) = switch (assessment.status) { + CatchabilityStatus.likely => ( + Icons.directions_walk_rounded, + theme.colorScheme.tertiary, + ), + CatchabilityStatus.possible => ( + Icons.access_time_rounded, + theme.colorScheme.primary, + ), + CatchabilityStatus.unlikely => ( + Icons.warning_amber_rounded, + theme.colorScheme.error, + ), + CatchabilityStatus.departed => ( + Icons.directions_bus_filled_rounded, + theme.colorScheme.onSurfaceVariant, + ), + CatchabilityStatus.unavailable => ( + Icons.help_outline_rounded, + theme.colorScheme.onSurfaceVariant, + ), + }; + final message = _catchabilityMessage(l10n, assessment); + final hint = l10n.localeName.startsWith('zh') + ? '步行時間為估算值,已保留定位誤差與資料延遲緩衝,僅供參考。' + : 'Walking time is an estimate with location and realtime buffers.'; + + return Tooltip( + message: '$message\n$hint', + child: Semantics( + label: message, + child: Row( + children: [ + Icon(icon, size: 15, color: color), + const SizedBox(width: 4), + Expanded( + child: Text( + message, + style: theme.textTheme.labelSmall?.copyWith(color: color), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} + class _NearbyScreenState extends State { bool _loading = true; String? _error; LocationFailure? _locationFailure; List _results = const []; Map _liveMaps = const {}; + double? _locationAccuracyMeters; bool _loadingEtas = false; int _requestGeneration = 0; int _etaLoadsInFlight = 0; @@ -54,6 +146,7 @@ class _NearbyScreenState extends State { _error = null; _locationFailure = null; _liveMaps = const {}; + _locationAccuracyMeters = null; _liveMapSequenceByRoute.clear(); _etaLoadsInFlight = 0; _loadingEtas = false; @@ -71,6 +164,9 @@ class _NearbyScreenState extends State { } setState(() { _results = results; + _locationAccuracyMeters = position.accuracy.isFinite + ? position.accuracy + : null; }); unawaited(_loadEtas(results, requestGeneration: requestGeneration)); @@ -331,6 +427,13 @@ class _NearbyScreenState extends State { required bool alwaysShowSeconds, }) { final item = row.result; + final liveStop = _liveStop(item); + final catchability = estimateCatchability( + distanceMeters: item.distanceMeters, + etaSeconds: effectiveStopEtaSeconds(liveStop), + locationAccuracyMeters: _locationAccuracyMeters, + ); + final l10n = AppLocalizations.of(context); final locale = Localizations.localeOf(context).toLanguageTag(); final providerLabel = busProviderFromString( item.route.sourceProvider, @@ -346,7 +449,7 @@ class _NearbyScreenState extends State { child: Row( children: [ EtaBadge( - stop: _liveStop(item), + stop: liveStop, alwaysShowSeconds: alwaysShowSeconds, size: 44, ), @@ -365,13 +468,28 @@ class _NearbyScreenState extends State { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - Text( - providerLabel, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Row( + children: [ + Expanded( + child: Text( + providerLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Flexible( + flex: 2, + child: _CatchabilityLabel( + assessment: catchability, + theme: theme, + l10n: l10n, + ), + ), + ], ), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index 0c9991a..8eedc2a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -104,7 +104,7 @@ flutter: assets: - assets/branding/YABus-black.svg - assets/branding/icon.svg - - assets/branding/splash_android.png + - assets/branding/icon.png - assets/branding/icon_transparent.png - assets/cat_cry.png - assets/cat_laugh.png diff --git a/test/catchability_estimator_test.dart b/test/catchability_estimator_test.dart new file mode 100644 index 0000000..a76a585 --- /dev/null +++ b/test/catchability_estimator_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:taiwanbus_flutter/core/catchability_estimator.dart'; +import 'package:taiwanbus_flutter/core/models.dart'; + +void main() { + test('reports a likely catch with remaining time after walking', () { + final result = estimateCatchability( + distanceMeters: 300, + etaSeconds: 11 * 60, + ); + + expect(result.status, CatchabilityStatus.likely); + expect(result.walkingMinutes, 6); + expect(result.remainingMinutes, 5); + expect(result.message, '預估來得及,約剩餘 5 分鐘'); + }); + + test('downgrades a close arrival when GPS accuracy adds a buffer', () { + final result = estimateCatchability( + distanceMeters: 300, + etaSeconds: 8 * 60, + locationAccuracyMeters: 120, + ); + + expect(result.status, CatchabilityStatus.possible); + expect(result.safetyBufferMinutes, 4); + expect(result.message, '可能來得及,步行時間約 6 分鐘'); + }); + + test('recommends the next bus when walking takes too long', () { + final result = estimateCatchability( + distanceMeters: 600, + etaSeconds: 10 * 60, + ); + + expect(result.status, CatchabilityStatus.unlikely); + expect(result.message, contains('可能來不及')); + expect(result.message, contains('建議搭乘下一班')); + }); + + test('does not promise a bus that is already at the stop', () { + final result = estimateCatchability(distanceMeters: 20, etaSeconds: 60); + + expect(result.status, CatchabilityStatus.departed); + expect(result.message, '公車可能已進站或離站'); + }); + + test('returns an explicit unavailable message without ETA data', () { + final result = estimateCatchability(distanceMeters: 200, etaSeconds: null); + + expect(result.status, CatchabilityStatus.unavailable); + expect(result.hasEstimate, isFalse); + expect(result.message, '定位或即時資料不足,無法準確判斷'); + }); + + test( + 'uses the effective ETA when realtime data is already four minutes old', + () { + final now = DateTime(2026, 6, 9, 8, 0); + final stop = StopInfo( + routeKey: 1, + pathId: 0, + stopId: 10, + stopName: 'Main Station', + sequence: 1, + lon: 121.5, + lat: 25.0, + sec: 300, + t: now.subtract(const Duration(minutes: 4)).toIso8601String(), + ); + final effectiveEta = effectiveStopEtaSeconds(stop, now: now); + + expect(effectiveEta, 60); + expect( + estimateCatchability( + distanceMeters: 100, + etaSeconds: effectiveEta, + ).status, + CatchabilityStatus.departed, + ); + expect( + estimateCatchability(distanceMeters: 100, etaSeconds: stop.sec).status, + CatchabilityStatus.likely, + ); + }, + ); +}