Skip to content

Commit ecaf15c

Browse files
committed
Trip widget in place of favourite stops; plain search bar; train widget works in the background
- Your trip: where you're up to on a trip you've started, the rides ahead, when you'll get there, and End; a way to plan one otherwise. It takes the favourite stops widget's receiver, so a placed one turns into it. - Search: just a search bar, app icon and all, straight into search. - Train map: the phone may keep an app offline in the background, which left the widget without its map or trains. The map is now drawn once (when the app's open if need be) and kept, with the trains put on it each time; the trains are fetched in jobs that wait for the internet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011C2fAvFFcMRkaF7qSvRA1v
1 parent 6c8f902 commit ecaf15c

14 files changed

Lines changed: 467 additions & 180 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,14 @@ journeys with 1, 2, 3 or more rides, walking between nearby stops for changes.
140140
many stops away it is, its model and how full it is, updated every 20 s. It
141141
buzzes when it's time to leave (your walk plus a lead time you choose), and
142142
stops itself once the bus has been.
143-
- **Home screen widget**: your next bus each way, refreshed in the background.
143+
- **Home screen widgets**:
144+
- **Next bus**: your next bus each way, refreshed in the background.
145+
- **Your trip**: once you start a trip, where you're up to (walk to the
146+
stop, the bus is 2 stops away, 4 stops to go), the rides ahead, when you'll
147+
get there, and End.
148+
- **Train map**: the Trains tab's map at the widget's size, with every train
149+
on it.
150+
- **Search**: a search bar that opens straight into search.
144151
- **Quick settings tile**: "27H · 6 min" in the notification shade.
145152
- **App shortcuts**: long-press the icon for Directions, Live, Trains, Search
146153
and your favourite stops.

‎app/src/main/java/nz/aryan/akllive/MainActivity.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
1919
import kotlinx.coroutines.launch
2020
import nz.aryan.akllive.system.Glance
2121
import nz.aryan.akllive.system.Shortcuts
22+
import nz.aryan.akllive.system.TrainsNow
2223
import nz.aryan.akllive.ui.AklApp
2324
import kotlin.math.sqrt
2425

@@ -56,6 +57,7 @@ class MainActivity : ComponentActivity(), SensorEventListener {
5657
override fun onStart() {
5758
super.onStart()
5859
vm.setVisible(true)
60+
TrainsNow.appOpened(this)
5961
}
6062

6163
override fun onResume() {

‎app/src/main/java/nz/aryan/akllive/system/Glance.kt‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package nz.aryan.akllive.system
22

33
import android.content.Context
4+
import androidx.glance.appwidget.GlanceAppWidgetManager
5+
import androidx.work.Constraints
46
import androidx.work.CoroutineWorker
7+
import androidx.work.NetworkType
58
import androidx.work.ExistingPeriodicWorkPolicy
69
import androidx.work.PeriodicWorkRequestBuilder
710
import androidx.work.WorkManager
@@ -91,17 +94,25 @@ object Glance {
9194

9295
/** Refresh the widget every 15 minutes in the background (Android's shortest interval). */
9396
fun schedule(ctx: Context) {
94-
val req = PeriodicWorkRequestBuilder<WidgetWorker>(15, TimeUnit.MINUTES).build()
95-
WorkManager.getInstance(ctx).enqueueUniquePeriodicWork("next-bus-widget", ExistingPeriodicWorkPolicy.KEEP, req)
97+
// run with the internet up: a job that needs it is let online even while the app's in the background
98+
val req = PeriodicWorkRequestBuilder<WidgetWorker>(15, TimeUnit.MINUTES)
99+
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
100+
.build()
101+
WorkManager.getInstance(ctx).enqueueUniquePeriodicWork("next-bus-widget", ExistingPeriodicWorkPolicy.UPDATE, req)
96102
}
97103
}
98104

99105
class WidgetWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
100106
override suspend fun doWork(): Result {
101107
Glance.fetch(applicationContext)
102108
NextBusWidget().updateAll(applicationContext)
103-
try { FavouritesWidget().updateAll(applicationContext) } catch (_: Exception) { }
104-
try { TrainWidget().updateAll(applicationContext) } catch (_: Exception) { }
109+
try {
110+
// the trains only when there's a train widget to show them
111+
if (GlanceAppWidgetManager(applicationContext).getGlanceIds(TrainWidget::class.java).isNotEmpty()) {
112+
TrainsNow.get(applicationContext, fresh = true)
113+
TrainWidget().updateAll(applicationContext)
114+
}
115+
} catch (_: Exception) { }
105116
return Result.success()
106117
}
107118
}

‎app/src/main/java/nz/aryan/akllive/system/MoreWidgets.kt‎

Lines changed: 51 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ import androidx.glance.text.FontWeight
4848
import androidx.glance.text.Text
4949
import androidx.glance.text.TextStyle
5050
import androidx.glance.unit.ColorProvider
51+
import androidx.work.Constraints
52+
import androidx.work.CoroutineWorker
53+
import androidx.work.ExistingWorkPolicy
54+
import androidx.work.NetworkType
55+
import androidx.work.OneTimeWorkRequestBuilder
56+
import androidx.work.WorkManager
57+
import androidx.work.WorkerParameters
5158
import kotlinx.coroutines.CoroutineScope
5259
import kotlinx.coroutines.Dispatchers
5360
import kotlinx.coroutines.launch
@@ -57,8 +64,6 @@ import nz.aryan.akllive.R
5764
import nz.aryan.akllive.data.AtApi
5865
import nz.aryan.akllive.data.MapData
5966
import nz.aryan.akllive.data.Nz
60-
import nz.aryan.akllive.data.StopDeparture
61-
import nz.aryan.akllive.data.StopRepo
6267
import nz.aryan.akllive.data.Train
6368
import nz.aryan.akllive.data.TrainRepo
6469
import nz.aryan.akllive.ui.TrainArt
@@ -68,7 +73,7 @@ private fun open(ctx: Context, link: String) =
6873

6974
// ======================= search =======================
7075

71-
/** A search bar on the home screen: straight into search, or plan a trip. */
76+
/** A search bar on the home screen, like Google's: tap it and you're typing in the app's search. */
7277
class SearchWidget : GlanceAppWidget() {
7378
override val sizeMode = SizeMode.Exact
7479

@@ -78,21 +83,18 @@ class SearchWidget : GlanceAppWidget() {
7883

7984
@Composable
8085
private fun Body(ctx: Context) {
81-
val wide = LocalSize.current.width > 250.dp
82-
Row(GlanceModifier.fillMaxSize().cornerRadius(28.dp).background(GlanceTheme.colors.widgetBackground).padding(6.dp),
83-
verticalAlignment = Alignment.CenterVertically) {
84-
Row(GlanceModifier.defaultWeight().height(44.dp).cornerRadius(22.dp).background(GlanceTheme.colors.secondaryContainer)
85-
.padding(horizontal = 14.dp).clickable(open(ctx, "akllive://search")),
86+
val size = LocalSize.current
87+
val wide = size.width > 220.dp
88+
val h = minOf(size.height - 8.dp, 56.dp)
89+
Box(GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) {
90+
Row(GlanceModifier.fillMaxWidth().height(h).cornerRadius(28.dp).background(GlanceTheme.colors.surface)
91+
.padding(start = 10.dp, end = 16.dp).clickable(open(ctx, "akllive://search")),
8692
verticalAlignment = Alignment.CenterVertically) {
87-
Image(ImageProvider(R.drawable.ic_sc_search), null, GlanceModifier.size(22.dp))
88-
Spacer(GlanceModifier.width(10.dp))
89-
Text(if (wide) "Stops, places, routes, buses…" else "Search", maxLines = 1,
90-
style = TextStyle(color = GlanceTheme.colors.onSecondaryContainer, fontSize = 14.sp))
91-
}
92-
Spacer(GlanceModifier.width(6.dp))
93-
Box(GlanceModifier.size(44.dp).cornerRadius(22.dp).background(GlanceTheme.colors.primary).clickable(open(ctx, "akllive://plan")),
94-
contentAlignment = Alignment.Center) {
95-
Image(ImageProvider(R.drawable.ic_sc_plan), "Plan a trip", GlanceModifier.size(26.dp))
93+
Image(ImageProvider(R.drawable.ic_widget_logo), "AKL Live", GlanceModifier.size(30.dp))
94+
Spacer(GlanceModifier.width(12.dp))
95+
Text(if (wide) "Search stops, places, routes" else "Search", GlanceModifier.defaultWeight(), maxLines = 1,
96+
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 16.sp))
97+
Image(ImageProvider(R.drawable.ic_widget_glass), null, GlanceModifier.size(22.dp))
9698
}
9799
}
98100
}
@@ -102,67 +104,6 @@ class SearchWidgetReceiver : GlanceAppWidgetReceiver() {
102104
override val glanceAppWidget: GlanceAppWidget = SearchWidget()
103105
}
104106

105-
// ======================= favourite stops =======================
106-
107-
/** Your favourite stops, each with its next departures. */
108-
class FavouritesWidget : GlanceAppWidget() {
109-
override val sizeMode = SizeMode.Exact
110-
111-
override suspend fun provideGlance(context: Context, id: GlanceId) {
112-
val s = Prefs(context).load()
113-
val favs = s.favs.take(3)
114-
val repo = StopRepo(AtApi { s.apiKey })
115-
val deps = if (s.apiKey.isBlank()) emptyMap() else favs.associate { f ->
116-
f.id to (try { repo.departures(mapOf(f.id to "")) } catch (_: Exception) { null })
117-
}
118-
provideContent { GlanceTheme { Body(context, favs.map { it.id to it.title }, deps, s.apiKey.isBlank()) } }
119-
}
120-
121-
@Composable
122-
private fun Body(ctx: Context, favs: List<Pair<String, String>>, deps: Map<String, List<StopDeparture>?>, noKey: Boolean) {
123-
val now = Nz.nowSec()
124-
val rows = if (LocalSize.current.height < 170.dp) 1 else 2
125-
Column(GlanceModifier.fillMaxSize().cornerRadius(24.dp).background(GlanceTheme.colors.widgetBackground).padding(14.dp)
126-
.clickable(open(ctx, "akllive://home"))) {
127-
Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
128-
Text("★ Favourite stops", GlanceModifier.defaultWeight(),
129-
style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp))
130-
Text("↻", GlanceModifier.clickable(actionRunCallback<RefreshMore>()),
131-
style = TextStyle(color = GlanceTheme.colors.primary, fontWeight = FontWeight.Bold, fontSize = 18.sp))
132-
}
133-
when {
134-
noKey -> Text("Open AKL Live and add your AT key", style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp))
135-
favs.isEmpty() -> Text("Star a stop in the app and it shows up here", style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp))
136-
else -> for ((id, title) in favs) {
137-
Spacer(GlanceModifier.height(8.dp))
138-
Column(GlanceModifier.fillMaxWidth().clickable(open(ctx, "akllive://stop/" + Uri.encode(id)))) {
139-
Text(title, maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 11.sp, fontWeight = FontWeight.Medium))
140-
val list = deps[id]
141-
if (list.isNullOrEmpty()) Text(if (list == null) "Couldn't load" else "Nothing for a while",
142-
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp))
143-
else list.filter { !it.cancelled }.take(rows).forEach { d ->
144-
Row(GlanceModifier.fillMaxWidth().padding(top = 2.dp), verticalAlignment = Alignment.CenterVertically) {
145-
Box(GlanceModifier.cornerRadius(8.dp).background(GlanceTheme.colors.primary).padding(horizontal = 6.dp, vertical = 1.dp)) {
146-
Text(d.route, style = TextStyle(color = GlanceTheme.colors.onPrimary, fontWeight = FontWeight.Bold, fontSize = 12.sp))
147-
}
148-
Spacer(GlanceModifier.width(6.dp))
149-
Text(d.headsign, GlanceModifier.defaultWeight(), maxLines = 1,
150-
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp))
151-
Text(mins(d.expected - now), style = TextStyle(color = GlanceTheme.colors.primary, fontWeight = FontWeight.Bold, fontSize = 13.sp))
152-
}
153-
}
154-
}
155-
}
156-
}
157-
}
158-
}
159-
}
160-
161-
class FavouritesWidgetReceiver : GlanceAppWidgetReceiver() {
162-
override val glanceAppWidget: GlanceAppWidget = FavouritesWidget()
163-
override fun onEnabled(context: Context) { super.onEnabled(context); Glance.schedule(context) }
164-
}
165-
166107
// ======================= the train map =======================
167108

168109
/** The train map, as the app draws it, with every train on it, live. */
@@ -200,7 +141,7 @@ class TrainWidget : GlanceAppWidget() {
200141
Text("Ngā Tereina", GlanceModifier.defaultWeight(),
201142
style = TextStyle(color = ColorProvider(Color.White), fontWeight = FontWeight.Bold, fontSize = 15.sp))
202143
Text(when {
203-
trains == null -> "Open the app first"
144+
trains == null -> "Tap ↻ for trains"
204145
else -> "${trains.size} running · ${Nz.time(TrainsNow.at)}"
205146
}, style = TextStyle(color = ColorProvider(Color(0xFFE6ECF5)), fontSize = 11.sp))
206147
Spacer(GlanceModifier.width(8.dp))
@@ -242,8 +183,26 @@ object TrainsNow {
242183
}
243184
}
244185

245-
suspend fun get(ctx: Context): List<Train>? {
246-
if (list != null && Nz.nowSec() - at < 60) return list
186+
/** The app's been opened: a good time to draw the widget's map, with the internet sure to be there. */
187+
fun appOpened(ctx: Context) {
188+
val now = System.currentTimeMillis()
189+
if (now - lastOpen < 10 * 60_000) return
190+
lastOpen = now
191+
val app = ctx.applicationContext
192+
CoroutineScope(Dispatchers.Default).launch { try { TrainWidget().updateAll(app) } catch (_: Exception) { } }
193+
}
194+
private var lastOpen = 0L
195+
196+
/** Fetch and redraw in a job that waits for the internet. */
197+
fun refresh(ctx: Context) {
198+
val req = OneTimeWorkRequestBuilder<TrainWidgetWorker>()
199+
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
200+
.build()
201+
WorkManager.getInstance(ctx).enqueueUniqueWork("train-widget-refresh", ExistingWorkPolicy.REPLACE, req)
202+
}
203+
204+
suspend fun get(ctx: Context, fresh: Boolean = false): List<Train>? {
205+
if (!fresh && list != null && Nz.nowSec() - at < 60) return list
247206
val s = Prefs(ctx).load()
248207
if (s.apiKey.isBlank()) return list
249208
val r = repo ?: TrainRepo(AtApi { Prefs(ctx).load().apiKey }).also { repo = it }
@@ -300,16 +259,18 @@ private fun drawTrains(trains: List<Train>): Bitmap {
300259
return bmp
301260
}
302261

303-
private fun mins(secs: Long) = when {
304-
secs < 45 -> "Due"
305-
secs < 3600 -> "${secs / 60} min"
306-
else -> "${secs / 3600} h"
307-
}
308-
309-
/** ↻ on the favourites or train widget. */
262+
/** ↻ on the train widget: fetched in a job, which gets the internet even when the app's in the background. */
310263
class RefreshMore : ActionCallback {
311264
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
312-
FavouritesWidget().updateAll(context)
313-
TrainWidget().updateAll(context)
265+
TrainsNow.refresh(context)
266+
}
267+
}
268+
269+
/** Fetches the trains and redraws the train widget. */
270+
class TrainWidgetWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
271+
override suspend fun doWork(): Result {
272+
TrainsNow.get(applicationContext, fresh = true)
273+
TrainWidget().updateAll(applicationContext)
274+
return Result.success()
314275
}
315276
}

‎app/src/main/java/nz/aryan/akllive/system/TripService.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class TripService : Service(), LocationListener {
6868
} catch (e: Exception) {
6969
// location isn't allowed: nothing to follow you with
7070
TripTracker.clear()
71+
TripWidget.nudge(this, force = true)
7172
stopSelf()
7273
return START_NOT_STICKY
7374
}
@@ -104,6 +105,7 @@ class TripService : Service(), LocationListener {
104105
if (gps) lastGps = t else if (t - lastGps < 15_000) return
105106
val events = TripTracker.onFix(loc.latitude, loc.longitude, if (loc.hasSpeed()) loc.speed else null, loc.accuracy)
106107
post(build(), NID)
108+
TripWidget.nudge(this, force = events.isNotEmpty())
107109
events.forEach { alert(it) }
108110
if (events.any { it is TripEvent.Arrived }) scope.launch { delay(90_000); TripTracker.clear(); finish() }
109111
}
@@ -124,6 +126,7 @@ class TripService : Service(), LocationListener {
124126
val ids = TripTracker.watching()
125127
if (ids.isNotEmpty()) try { TripTracker.setLive(liveFor(api, ids)) } catch (_: Exception) { }
126128
post(build(), NID)
129+
TripWidget.nudge(this)
127130
delay(15_000)
128131
}
129132
}
@@ -192,6 +195,7 @@ class TripService : Service(), LocationListener {
192195

193196
private fun finish() {
194197
poll?.cancel()
198+
TripWidget.nudge(this, force = true)
195199
try { getSystemService(LocationManager::class.java)?.removeUpdates(this) } catch (_: Exception) { }
196200
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
197201
stopSelf()

‎app/src/main/java/nz/aryan/akllive/system/TripTracker.kt‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,14 @@ object TripTracker {
6969
_state.value = TripProgress(it, from, to, 0, if (it.legs.firstOrNull() is RideLeg) Phase.Waiting else Phase.Walking,
7070
headline = "Finding you…", detail = "Waiting for a GPS fix", updated = Nz.nowSec())
7171
ContextCompat.startForegroundService(ctx, Intent(ctx, TripService::class.java))
72+
TripWidget.nudge(ctx, force = true)
7273
}
7374

7475
fun stop(ctx: Context) {
75-
ctx.startService(Intent(ctx, TripService::class.java).setAction(TripService.ACTION_STOP))
76+
// the service may already be gone (the trip finished on its own)
77+
try { ctx.startService(Intent(ctx, TripService::class.java).setAction(TripService.ACTION_STOP)) } catch (_: Exception) { }
7678
_state.value = TripProgress()
79+
TripWidget.nudge(ctx, force = true)
7780
}
7881

7982
internal fun clear() { _state.value = TripProgress() }

0 commit comments

Comments
 (0)