Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Binary file modified assets/branding/app_icon_source.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/branding/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/branding/icon_transparent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions flutter_native_splash.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
107 changes: 107 additions & 0 deletions lib/core/catchability_estimator.dart
Original file line number Diff line number Diff line change
@@ -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<int>(
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,
);
}
134 changes: 126 additions & 8 deletions lib/screens/nearby_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,12 +27,103 @@ class NearbyScreen extends StatefulWidget {
State<NearbyScreen> 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<NearbyScreen> {
bool _loading = true;
String? _error;
LocationFailure? _locationFailure;
List<NearbyStopResult> _results = const [];
Map<String, LiveStopMap> _liveMaps = const {};
double? _locationAccuracyMeters;
bool _loadingEtas = false;
int _requestGeneration = 0;
int _etaLoadsInFlight = 0;
Expand All @@ -54,6 +146,7 @@ class _NearbyScreenState extends State<NearbyScreen> {
_error = null;
_locationFailure = null;
_liveMaps = const {};
_locationAccuracyMeters = null;
_liveMapSequenceByRoute.clear();
_etaLoadsInFlight = 0;
_loadingEtas = false;
Expand All @@ -71,6 +164,9 @@ class _NearbyScreenState extends State<NearbyScreen> {
}
setState(() {
_results = results;
_locationAccuracyMeters = position.accuracy.isFinite
? position.accuracy
: null;
});

unawaited(_loadEtas(results, requestGeneration: requestGeneration));
Expand Down Expand Up @@ -331,6 +427,13 @@ class _NearbyScreenState extends State<NearbyScreen> {
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,
Expand All @@ -346,7 +449,7 @@ class _NearbyScreenState extends State<NearbyScreen> {
child: Row(
children: [
EtaBadge(
stop: _liveStop(item),
stop: liveStop,
alwaysShowSeconds: alwaysShowSeconds,
size: 44,
),
Expand All @@ -365,13 +468,28 @@ class _NearbyScreenState extends State<NearbyScreen> {
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,
),
),
],
),
],
),
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading