Skip to content

Commit 34e832e

Browse files
committed
Journey sheet tucks away; search, favourites and train map widgets
- Journey: swipe the sheet down to tuck it away and see the whole map, with a slim bar (times, or where you're up to on a trip); tap or swipe it up to bring the sheet back - Widgets: - Search: a search bar straight into search, and a plan-a-trip button - Favourite stops: your starred stops with their next departures - Train map: the network diagram with every train on it, live, late ones marked - Next bus shows the ones after it too when you make it bigger Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011C2fAvFFcMRkaF7qSvRA1v
1 parent ede50dc commit 34e832e

10 files changed

Lines changed: 426 additions & 1 deletion

File tree

‎app/src/main/AndroidManifest.xml‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,34 @@
7373
</intent-filter>
7474
<meta-data android:name="android.appwidget.provider" android:resource="@xml/next_bus_widget" />
7575
</receiver>
76+
<receiver
77+
android:name=".system.SearchWidgetReceiver"
78+
android:exported="true"
79+
android:label="@string/search_widget_label">
80+
<intent-filter>
81+
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
82+
</intent-filter>
83+
<meta-data android:name="android.appwidget.provider" android:resource="@xml/search_widget" />
84+
</receiver>
85+
86+
<receiver
87+
android:name=".system.FavouritesWidgetReceiver"
88+
android:exported="true"
89+
android:label="@string/fav_widget_label">
90+
<intent-filter>
91+
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
92+
</intent-filter>
93+
<meta-data android:name="android.appwidget.provider" android:resource="@xml/favourites_widget" />
94+
</receiver>
95+
96+
<receiver
97+
android:name=".system.TrainWidgetReceiver"
98+
android:exported="true"
99+
android:label="@string/train_widget_label">
100+
<intent-filter>
101+
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
102+
</intent-filter>
103+
<meta-data android:name="android.appwidget.provider" android:resource="@xml/train_widget" />
104+
</receiver>
76105
</application>
77106
</manifest>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
292292
private suspend fun refreshTrains() {
293293
try {
294294
val fresh = trainRepo.poll()
295+
nz.aryan.akllive.system.TrainsNow.save(getApplication(), fresh)
295296
val old = _trains.value
296297
val elapsed = SystemClock.elapsedRealtime()
297298
// glide from wherever each marker is right now

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ class WidgetWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
100100
override suspend fun doWork(): Result {
101101
Glance.fetch(applicationContext)
102102
NextBusWidget().updateAll(applicationContext)
103+
try { FavouritesWidget().updateAll(applicationContext) } catch (_: Exception) { }
104+
try { TrainWidget().updateAll(applicationContext) } catch (_: Exception) { }
103105
return Result.success()
104106
}
105107
}
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
package nz.aryan.akllive.system
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.graphics.Bitmap
6+
import android.graphics.Canvas
7+
import android.graphics.Paint
8+
import android.graphics.Path
9+
import android.graphics.Typeface
10+
import android.net.Uri
11+
import androidx.compose.runtime.Composable
12+
import androidx.compose.ui.graphics.Color
13+
import androidx.compose.ui.unit.dp
14+
import androidx.compose.ui.unit.sp
15+
import androidx.glance.GlanceId
16+
import androidx.glance.GlanceModifier
17+
import androidx.glance.GlanceTheme
18+
import androidx.glance.Image
19+
import androidx.glance.ImageProvider
20+
import androidx.glance.LocalSize
21+
import androidx.glance.action.ActionParameters
22+
import androidx.glance.action.clickable
23+
import androidx.glance.appwidget.GlanceAppWidget
24+
import androidx.glance.appwidget.GlanceAppWidgetReceiver
25+
import androidx.glance.appwidget.SizeMode
26+
import androidx.glance.appwidget.action.ActionCallback
27+
import androidx.glance.appwidget.action.actionRunCallback
28+
import androidx.glance.appwidget.action.actionStartActivity
29+
import androidx.glance.appwidget.cornerRadius
30+
import androidx.glance.appwidget.provideContent
31+
import androidx.glance.appwidget.updateAll
32+
import androidx.glance.background
33+
import androidx.glance.layout.Alignment
34+
import androidx.glance.layout.Box
35+
import androidx.glance.layout.Column
36+
import androidx.glance.layout.ContentScale
37+
import androidx.glance.layout.Row
38+
import androidx.glance.layout.Spacer
39+
import androidx.glance.layout.fillMaxSize
40+
import androidx.glance.layout.fillMaxWidth
41+
import androidx.glance.layout.height
42+
import androidx.glance.layout.padding
43+
import androidx.glance.layout.size
44+
import androidx.glance.layout.width
45+
import androidx.glance.text.FontWeight
46+
import androidx.glance.text.Text
47+
import androidx.glance.text.TextStyle
48+
import androidx.glance.unit.ColorProvider
49+
import kotlinx.coroutines.CoroutineScope
50+
import kotlinx.coroutines.Dispatchers
51+
import kotlinx.coroutines.launch
52+
import nz.aryan.akllive.MainActivity
53+
import nz.aryan.akllive.Prefs
54+
import nz.aryan.akllive.R
55+
import nz.aryan.akllive.data.AtApi
56+
import nz.aryan.akllive.data.MapData
57+
import nz.aryan.akllive.data.Nz
58+
import nz.aryan.akllive.data.StopDeparture
59+
import nz.aryan.akllive.data.StopRepo
60+
import nz.aryan.akllive.data.Train
61+
import nz.aryan.akllive.data.TrainRepo
62+
63+
private fun open(ctx: Context, link: String) =
64+
actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link), ctx, MainActivity::class.java))
65+
66+
// ======================= search =======================
67+
68+
/** A search bar on the home screen: straight into search, or plan a trip. */
69+
class SearchWidget : GlanceAppWidget() {
70+
override val sizeMode = SizeMode.Exact
71+
72+
override suspend fun provideGlance(context: Context, id: GlanceId) {
73+
provideContent { GlanceTheme { Body(context) } }
74+
}
75+
76+
@Composable
77+
private fun Body(ctx: Context) {
78+
val wide = LocalSize.current.width > 250.dp
79+
Row(GlanceModifier.fillMaxSize().cornerRadius(28.dp).background(GlanceTheme.colors.widgetBackground).padding(6.dp),
80+
verticalAlignment = Alignment.CenterVertically) {
81+
Row(GlanceModifier.defaultWeight().height(44.dp).cornerRadius(22.dp).background(GlanceTheme.colors.secondaryContainer)
82+
.padding(horizontal = 14.dp).clickable(open(ctx, "akllive://search")),
83+
verticalAlignment = Alignment.CenterVertically) {
84+
Image(ImageProvider(R.drawable.ic_sc_search), null, GlanceModifier.size(22.dp))
85+
Spacer(GlanceModifier.width(10.dp))
86+
Text(if (wide) "Stops, places, routes, buses…" else "Search", maxLines = 1,
87+
style = TextStyle(color = GlanceTheme.colors.onSecondaryContainer, fontSize = 14.sp))
88+
}
89+
Spacer(GlanceModifier.width(6.dp))
90+
Box(GlanceModifier.size(44.dp).cornerRadius(22.dp).background(GlanceTheme.colors.primary).clickable(open(ctx, "akllive://plan")),
91+
contentAlignment = Alignment.Center) {
92+
Image(ImageProvider(R.drawable.ic_sc_plan), "Plan a trip", GlanceModifier.size(26.dp))
93+
}
94+
}
95+
}
96+
}
97+
98+
class SearchWidgetReceiver : GlanceAppWidgetReceiver() {
99+
override val glanceAppWidget: GlanceAppWidget = SearchWidget()
100+
}
101+
102+
// ======================= favourite stops =======================
103+
104+
/** Your favourite stops, each with its next departures. */
105+
class FavouritesWidget : GlanceAppWidget() {
106+
override val sizeMode = SizeMode.Exact
107+
108+
override suspend fun provideGlance(context: Context, id: GlanceId) {
109+
val s = Prefs(context).load()
110+
val favs = s.favs.take(3)
111+
val repo = StopRepo(AtApi { s.apiKey })
112+
val deps = if (s.apiKey.isBlank()) emptyMap() else favs.associate { f ->
113+
f.id to (try { repo.departures(mapOf(f.id to "")) } catch (_: Exception) { null })
114+
}
115+
provideContent { GlanceTheme { Body(context, favs.map { it.id to it.title }, deps, s.apiKey.isBlank()) } }
116+
}
117+
118+
@Composable
119+
private fun Body(ctx: Context, favs: List<Pair<String, String>>, deps: Map<String, List<StopDeparture>?>, noKey: Boolean) {
120+
val now = Nz.nowSec()
121+
val rows = if (LocalSize.current.height < 170.dp) 1 else 2
122+
Column(GlanceModifier.fillMaxSize().cornerRadius(24.dp).background(GlanceTheme.colors.widgetBackground).padding(14.dp)
123+
.clickable(open(ctx, "akllive://home"))) {
124+
Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
125+
Text("★ Favourite stops", GlanceModifier.defaultWeight(),
126+
style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp))
127+
Text("↻", GlanceModifier.clickable(actionRunCallback<RefreshMore>()),
128+
style = TextStyle(color = GlanceTheme.colors.primary, fontWeight = FontWeight.Bold, fontSize = 18.sp))
129+
}
130+
when {
131+
noKey -> Text("Open AKL Live and add your AT key", style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp))
132+
favs.isEmpty() -> Text("Star a stop in the app and it shows up here", style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp))
133+
else -> for ((id, title) in favs) {
134+
Spacer(GlanceModifier.height(8.dp))
135+
Column(GlanceModifier.fillMaxWidth().clickable(open(ctx, "akllive://stop/" + Uri.encode(id)))) {
136+
Text(title, maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 11.sp, fontWeight = FontWeight.Medium))
137+
val list = deps[id]
138+
if (list.isNullOrEmpty()) Text(if (list == null) "Couldn't load" else "Nothing for a while",
139+
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp))
140+
else list.filter { !it.cancelled }.take(rows).forEach { d ->
141+
Row(GlanceModifier.fillMaxWidth().padding(top = 2.dp), verticalAlignment = Alignment.CenterVertically) {
142+
Box(GlanceModifier.cornerRadius(8.dp).background(GlanceTheme.colors.primary).padding(horizontal = 6.dp, vertical = 1.dp)) {
143+
Text(d.route, style = TextStyle(color = GlanceTheme.colors.onPrimary, fontWeight = FontWeight.Bold, fontSize = 12.sp))
144+
}
145+
Spacer(GlanceModifier.width(6.dp))
146+
Text(d.headsign, GlanceModifier.defaultWeight(), maxLines = 1,
147+
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp))
148+
Text(mins(d.expected - now), style = TextStyle(color = GlanceTheme.colors.primary, fontWeight = FontWeight.Bold, fontSize = 13.sp))
149+
}
150+
}
151+
}
152+
}
153+
}
154+
}
155+
}
156+
}
157+
158+
class FavouritesWidgetReceiver : GlanceAppWidgetReceiver() {
159+
override val glanceAppWidget: GlanceAppWidget = FavouritesWidget()
160+
override fun onEnabled(context: Context) { super.onEnabled(context); Glance.schedule(context) }
161+
}
162+
163+
// ======================= the train map =======================
164+
165+
/** The train network diagram with every train on it, live. */
166+
class TrainWidget : GlanceAppWidget() {
167+
override val sizeMode = SizeMode.Exact
168+
169+
override suspend fun provideGlance(context: Context, id: GlanceId) {
170+
val trains = TrainsNow.get(context)
171+
val map = drawTrains(trains ?: emptyList())
172+
provideContent { GlanceTheme { Body(context, map, trains) } }
173+
}
174+
175+
@Composable
176+
private fun Body(ctx: Context, map: Bitmap, trains: List<Train>?) {
177+
Column(GlanceModifier.fillMaxSize().cornerRadius(24.dp).background(ColorProvider(Color(0xFF0B1220))).padding(10.dp)
178+
.clickable(open(ctx, "akllive://trains"))) {
179+
Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
180+
Text("Ngā Tereina", GlanceModifier.defaultWeight(),
181+
style = TextStyle(color = ColorProvider(Color.White), fontWeight = FontWeight.Bold, fontSize = 14.sp))
182+
Text(when {
183+
trains == null -> "Open the app first"
184+
else -> "${trains.size} running · ${Nz.time(TrainsNow.at)}"
185+
}, style = TextStyle(color = ColorProvider(Color(0xFFB9C2CF)), fontSize = 11.sp))
186+
Spacer(GlanceModifier.width(8.dp))
187+
Text("↻", GlanceModifier.clickable(actionRunCallback<RefreshMore>()),
188+
style = TextStyle(color = ColorProvider(Color.White), fontWeight = FontWeight.Bold, fontSize = 18.sp))
189+
}
190+
Spacer(GlanceModifier.height(4.dp))
191+
Image(ImageProvider(map), "Train map", GlanceModifier.fillMaxSize(), contentScale = ContentScale.Fit)
192+
}
193+
}
194+
}
195+
196+
class TrainWidgetReceiver : GlanceAppWidgetReceiver() {
197+
override val glanceAppWidget: GlanceAppWidget = TrainWidget()
198+
override fun onEnabled(context: Context) { super.onEnabled(context); Glance.schedule(context) }
199+
}
200+
201+
/** The latest trains, shared by the app and the widget. */
202+
object TrainsNow {
203+
@Volatile var list: List<Train>? = null
204+
private set
205+
@Volatile var at = 0L
206+
private set
207+
private var lastWidget = 0L
208+
private var repo: TrainRepo? = null
209+
210+
/** The app polled the trains: keep them, and redraw the widget now and then. */
211+
fun save(ctx: Context, trains: List<Train>) {
212+
list = trains
213+
at = Nz.nowSec()
214+
val now = System.currentTimeMillis()
215+
if (now - lastWidget > 60_000) {
216+
lastWidget = now
217+
val app = ctx.applicationContext
218+
CoroutineScope(Dispatchers.Default).launch { try { TrainWidget().updateAll(app) } catch (_: Exception) { } }
219+
}
220+
}
221+
222+
suspend fun get(ctx: Context): List<Train>? {
223+
if (list != null && Nz.nowSec() - at < 60) return list
224+
val s = Prefs(ctx).load()
225+
if (s.apiKey.isBlank()) return list
226+
val r = repo ?: TrainRepo(AtApi { Prefs(ctx).load().apiKey }).also { repo = it }
227+
return try { r.poll().also { list = it; at = Nz.nowSec() } } catch (_: Exception) { list }
228+
}
229+
}
230+
231+
/** The post-CRL diagram and every train on it, drawn for the widget. */
232+
private fun drawTrains(trains: List<Train>): Bitmap {
233+
val k = 2.4f
234+
val w = (MapData.W * k).toInt()
235+
val h = (MapData.H * k).toInt()
236+
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
237+
val c = Canvas(bmp)
238+
val line = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = 3.2f * k; strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND }
239+
MapData.LINE_DRAW.forEachIndexed { li, pts ->
240+
val p = Path()
241+
for (i in 0 until pts.size / 2) {
242+
val x = pts[i * 2] * k
243+
val y = pts[i * 2 + 1] * k
244+
if (i == 0) p.moveTo(x, y) else p.lineTo(x, y)
245+
}
246+
line.color = MapData.LINE_COLORS[li].toInt()
247+
c.drawPath(p, line)
248+
}
249+
val dot = Paint(Paint.ANTI_ALIAS_FLAG)
250+
val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = 1.2f * k; color = 0xFF1A2744.toInt() }
251+
val label = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFFE6ECF5.toInt(); textSize = 6.5f * k; typeface = Typeface.DEFAULT_BOLD }
252+
for (st in MapData.STATIONS) {
253+
for (i in 0 until st.pts.size / 2) {
254+
dot.color = 0xFFFFFFFF.toInt()
255+
c.drawCircle(st.pts[i * 2] * k, st.pts[i * 2 + 1] * k, 2.2f * k, dot)
256+
c.drawCircle(st.pts[i * 2] * k, st.pts[i * 2 + 1] * k, 2.2f * k, ring)
257+
}
258+
if (st.priority == 0) {
259+
val tw = label.measureText(st.name)
260+
val x = (st.x * k + st.dx * k).let { if (st.anchor == 'r') it - tw else if (st.anchor == 't' || st.anchor == 'b') it - tw / 2 else it }
261+
c.drawText(st.name, x.coerceIn(2f, w - tw - 2f), st.y * k + st.dy * k + label.textSize / 3, label)
262+
}
263+
}
264+
for (t in trains) {
265+
val x = t.x * k
266+
val y = t.y * k
267+
dot.color = 0xFFFFFFFF.toInt()
268+
c.drawCircle(x, y, 4.6f * k, dot)
269+
dot.color = MapData.LINE_COLORS[t.line].toInt()
270+
c.drawCircle(x, y, 3.4f * k, dot)
271+
val late = t.delay ?: 0
272+
if (late >= 120) {
273+
dot.color = if (late >= 300) 0xFFE0412F.toInt() else 0xFFEFA20E.toInt()
274+
c.drawCircle(x + 3.4f * k, y - 3.4f * k, 1.8f * k, dot)
275+
}
276+
}
277+
return bmp
278+
}
279+
280+
private fun mins(secs: Long) = when {
281+
secs < 45 -> "Due"
282+
secs < 3600 -> "${secs / 60} min"
283+
else -> "${secs / 3600} h"
284+
}
285+
286+
/** ↻ on the favourites or train widget. */
287+
class RefreshMore : ActionCallback {
288+
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
289+
FavouritesWidget().updateAll(context)
290+
TrainWidget().updateAll(context)
291+
}
292+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ class NextBusWidget : GlanceAppWidget() {
9696
Text(b?.let { mins(it.expected - now) } ?: "—",
9797
style = TextStyle(color = GlanceTheme.colors.primary, fontWeight = FontWeight.Bold, fontSize = if (small) 16.sp else 20.sp))
9898
}
99+
// sized up: the ones after it too
100+
if (LocalSize.current.height >= 230.dp) {
101+
val later = soon.drop(1).take(2)
102+
if (later.isNotEmpty()) Text("then " + later.joinToString(", ") { "${Nz.time(it.expected)} (${mins(it.expected - now)})" },
103+
GlanceModifier.padding(start = 4.dp, top = 2.dp), maxLines = 1,
104+
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 11.sp))
105+
}
99106
}
100107
if (!small) {
101108
Spacer(GlanceModifier.defaultWeight())

0 commit comments

Comments
 (0)