From 78c8f70162c6c1d6c9a96fdfdd11c13850be7135 Mon Sep 17 00:00:00 2001 From: steven Date: Sat, 3 Oct 2026 14:23:04 +0800 Subject: [PATCH] fix: distinguish unavailable widget ETAs and refresh failures --- .github/workflows/build.yml | 4 ++ .../flutter/FavoriteGroupWidgetProvider.kt | 59 ++++++++++--------- .../taiwanbus/flutter/WidgetEtaText.kt | 15 +++++ .../flutter/FavoriteWidgetRenderingTest.kt | 55 +++++++++++++++++ .../taiwanbus/flutter/WidgetEtaTextTest.kt | 37 ++++++++++++ 5 files changed, 142 insertions(+), 28 deletions(-) create mode 100644 android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/WidgetEtaText.kt create mode 100644 android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteWidgetRenderingTest.kt create mode 100644 android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/WidgetEtaTextTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b35ffcf..a060d51 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -121,6 +121,10 @@ jobs: --dart-define=YABUS_GOOGLE_IOS_CLIENT_ID=${{ env.YABUS_GOOGLE_IOS_CLIENT_ID }} --dart-define=APP_NIGHTLY_ARTIFACT=android-apk-release + - name: Test Android widget ETA and rendering + working-directory: android + run: ./gradlew :app:testDebugUnitTest --tests 'tw.avianjay.taiwanbus.flutter.*Widget*Test' + - name: Upload Android artifact uses: actions/upload-artifact@v6 with: diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt index 1f16c4f..61fbd61 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt @@ -129,7 +129,8 @@ object FavoriteGroupWidgetSupport { if (showLoading) { appWidgetIds.forEach { appWidgetId -> runCatching { - appWidgetManager.updateAppWidget( + // Update only the status, keeping the previous arrivals visible. + appWidgetManager.partiallyUpdateAppWidget( appWidgetId, buildLoadingRemoteViews(appContext, appWidgetId), ) @@ -245,10 +246,11 @@ object FavoriteGroupWidgetSupport { } private fun buildLoadingRemoteViews(context: Context, appWidgetId: Int): RemoteViews { - val title = loadConfiguredGroup(context, appWidgetId) ?: "YABus" - return buildBaseRemoteViews(context, appWidgetId, title).apply { - setViewVisibility(R.id.favorite_widget_empty, View.VISIBLE) - setTextViewText(R.id.favorite_widget_empty, "更新中...") + return RemoteViews(context.packageName, R.layout.favorite_group_widget).apply { + setTextViewText( + R.id.favorite_widget_updated_at, + "${formatLastUpdated(context, loadLastUpdated(context, appWidgetId))} · 更新中...", + ) } } @@ -265,25 +267,36 @@ object FavoriteGroupWidgetSupport { return WidgetRenderResult(views, updateTimestamp = true) } - val boardingItems = items.filter { it.type == "boarding" } + val visibleItems = items.take(MAX_WIDGET_ITEMS) + val boardingItems = visibleItems.filter { it.type == "boarding" } val liveStopsByRoute = linkedMapOf>() + val failedRoutes = mutableSetOf() + val failedStations = mutableSetOf() var successfulFetches = 0 boardingItems.associateBy(::routeRequestKey).forEach { (requestKey, item) -> val fetchResult = fetchLiveStopMap(context, item) if (fetchResult.success) { successfulFetches += 1 + } else { + failedRoutes += requestKey } liveStopsByRoute[requestKey] = fetchResult.liveStops } val stationArrivals = linkedMapOf() - items.filter { it.type == "station" }.forEach { item -> + visibleItems.filter { it.type == "station" }.associateBy(::stationRequestKey).forEach { (requestKey, item) -> val fetchResult = fetchStationArrival(item) if (fetchResult.success) successfulFetches += 1 - stationArrivals[stationRequestKey(item)] = fetchResult.arrival + else failedStations += requestKey + stationArrivals[requestKey] = fetchResult.arrival } views.removeAllViews(R.id.favorite_widget_items_container) - items.take(MAX_WIDGET_ITEMS).forEach { item -> + visibleItems.forEach { item -> + val fetchFailed = when (item.type) { + "boarding" -> routeRequestKey(item) in failedRoutes + "station" -> stationRequestKey(item) in failedStations + else -> false + } val liveStop = if (item.type == "boarding") { liveStopsByRoute[routeRequestKey(item)]?.get("${item.pathId}:${item.stopId}") } else { @@ -293,7 +306,7 @@ object FavoriteGroupWidgetSupport { val itemViews = RemoteViews(context.packageName, R.layout.favorite_group_widget_item) itemViews.setTextViewText( R.id.favorite_widget_item_eta, - formatEtaText(liveStop), + if (fetchFailed) "更新失敗" else widgetEtaText(liveStop?.sec, liveStop?.msg), ) itemViews.setTextViewText( R.id.favorite_widget_item_route, @@ -308,7 +321,7 @@ object FavoriteGroupWidgetSupport { when (item.type) { "route" -> item.routeDescription.orEmpty().ifBlank { item.provider.uppercase() } "station" -> stationArrival?.let { "${it.routeName} · ${it.sideLabel} 側" } - ?: "目前沒有即將抵達班次" + ?: if (fetchFailed) "無法取得班次,請重試" else "目前沒有即將抵達班次" else -> item.stopName.ifBlank { "站牌 ${item.stopId}" } }, ) @@ -327,9 +340,15 @@ object FavoriteGroupWidgetSupport { views.addView(R.id.favorite_widget_items_container, itemViews) } + val hasFailures = failedRoutes.isNotEmpty() || failedStations.isNotEmpty() + if (hasFailures) { + views.setViewVisibility(R.id.favorite_widget_empty, View.VISIBLE) + views.setTextViewText(R.id.favorite_widget_empty, "部分資料更新失敗,請點右上角重試。") + } return WidgetRenderResult( views = views, - updateTimestamp = successfulFetches > 0, + // The timestamp describes the last complete refresh, not partial success. + updateTimestamp = successfulFetches > 0 && !hasFailures, ) } @@ -738,22 +757,6 @@ object FavoriteGroupWidgetSupport { return null } - private fun formatEtaText(liveStop: WidgetLiveStop?): String { - liveStop ?: return "--" - val message = liveStop.msg?.trim().orEmpty() - if (message.isNotEmpty()) { - return message - } - val seconds = liveStop.sec ?: return "--" - if (seconds <= 0) { - return "進站中" - } - if (seconds < 60) { - return "即將進站" - } - return "${seconds / 60}分" - } - private fun formatLastUpdated( context: Context, timestamp: Long?, diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/WidgetEtaText.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/WidgetEtaText.kt new file mode 100644 index 0000000..b5e4998 --- /dev/null +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/WidgetEtaText.kt @@ -0,0 +1,15 @@ +package tw.avianjay.taiwanbus.flutter + +/** Keep unavailable estimates distinct from a bus actually entering the stop. */ +internal fun widgetEtaText(seconds: Int?, message: String?): String { + val normalizedMessage = message?.trim().orEmpty() + if (normalizedMessage.isNotEmpty() && !normalizedMessage.equals("null", ignoreCase = true)) { + return normalizedMessage + } + return when { + seconds == null || seconds < 0 -> "--" + seconds == 0 -> "進站中" + seconds < 60 -> "即將進站" + else -> "${seconds / 60}分" + } +} diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteWidgetRenderingTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteWidgetRenderingTest.kt new file mode 100644 index 0000000..f16e0e0 --- /dev/null +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteWidgetRenderingTest.kt @@ -0,0 +1,55 @@ +package tw.avianjay.taiwanbus.flutter + +import android.content.Context +import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class FavoriteWidgetRenderingTest { + private val context: Context = RuntimeEnvironment.getApplication() + + @Test + fun failedStationIsNotReportedAsNoUpcomingBus() { + // Blank provider fails locally, with no network request. + val result = render(listOf(item("station"))) + val root = result.views.apply(context, FrameLayout(context)) + assertEquals("更新失敗", root.findViewById(R.id.favorite_widget_item_eta).text.toString()) + assertEquals("無法取得班次,請重試", root.findViewById(R.id.favorite_widget_item_stop).text.toString()) + assertEquals(View.VISIBLE, root.findViewById(R.id.favorite_widget_empty).visibility) + assertFalse(result.updateTimestamp) + } + + @Test + fun hiddenEntriesDoNotTriggerFailures() { + val result = render(List(6) { item("route") } + item("station")) + val root = result.views.apply(context, FrameLayout(context)) + assertEquals(6, root.findViewById(R.id.favorite_widget_items_container).childCount) + assertEquals(View.GONE, root.findViewById(R.id.favorite_widget_empty).visibility) + } + + private fun render(items: List): WidgetRenderResult { + val method = FavoriteGroupWidgetSupport::class.java.getDeclaredMethod( + "buildContentRemoteViews", Context::class.java, Int::class.javaPrimitiveType, + String::class.java, List::class.java, + ) + method.isAccessible = true + return method.invoke(FavoriteGroupWidgetSupport, context, 1, "Test", items) as WidgetRenderResult + } + + private fun item(type: String) = FavoriteWidgetItem( + type = type, provider = "", routeKey = 1, pathId = 0, stopId = 1, + routeId = null, routeName = "Test route", routeDescription = null, + stopName = "Test stop", stationId = "test", stationName = "Test station", + destinationPathId = null, destinationStopId = null, destinationStopName = null, + ) +} diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/WidgetEtaTextTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/WidgetEtaTextTest.kt new file mode 100644 index 0000000..b17f586 --- /dev/null +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/WidgetEtaTextTest.kt @@ -0,0 +1,37 @@ +package tw.avianjay.taiwanbus.flutter + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WidgetEtaTextTest { + @Test + fun unavailableEstimatesAreNotArriving() { + listOf(null, -1, -60, Int.MIN_VALUE).forEach { seconds -> + assertEquals("--", widgetEtaText(seconds, null)) + } + } + + @Test + fun arrivalBoundaries() { + assertEquals("進站中", widgetEtaText(0, null)) + assertEquals("即將進站", widgetEtaText(1, null)) + assertEquals("即將進站", widgetEtaText(59, null)) + assertEquals("1分", widgetEtaText(60, null)) + assertEquals("1分", widgetEtaText(119, null)) + assertEquals("2分", widgetEtaText(120, null)) + } + + @Test + fun meaningfulMessagesTakePrecedence() { + assertEquals("未發車", widgetEtaText(-1, " 未發車 ")) + assertEquals("末班已過", widgetEtaText(0, "末班已過")) + } + + @Test + fun emptyAndJsonNullMessagesDoNotHideEstimates() { + listOf(null, "", " ", "null", " NULL ").forEach { message -> + assertEquals("2分", widgetEtaText(120, message)) + assertEquals("--", widgetEtaText(-1, message)) + } + } +}