Skip to content

Commit 139e326

Browse files
committed
Train widget: the app's own train map, not the old diagram
The widget now draws the same map as the Trains tab's Diagram view, off screen at the widget's size: the quiet dark map, the lines along their real tracks side by side, the stations and names, and every train on its own line. The map layers and the line/station building are shared with the app, and the real tracks are kept on the phone so the widget can draw them when the timetable isn't loaded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011C2fAvFFcMRkaF7qSvRA1v
1 parent f2e2d41 commit 139e326

4 files changed

Lines changed: 314 additions & 104 deletions

File tree

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

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package nz.aryan.akllive.system
22

3+
import android.appwidget.AppWidgetManager
34
import android.content.Context
45
import android.content.Intent
56
import android.graphics.Bitmap
@@ -21,6 +22,7 @@ import androidx.glance.LocalSize
2122
import androidx.glance.action.ActionParameters
2223
import androidx.glance.action.clickable
2324
import androidx.glance.appwidget.GlanceAppWidget
25+
import androidx.glance.appwidget.GlanceAppWidgetManager
2426
import androidx.glance.appwidget.GlanceAppWidgetReceiver
2527
import androidx.glance.appwidget.SizeMode
2628
import androidx.glance.appwidget.action.ActionCallback
@@ -59,6 +61,7 @@ import nz.aryan.akllive.data.StopDeparture
5961
import nz.aryan.akllive.data.StopRepo
6062
import nz.aryan.akllive.data.Train
6163
import nz.aryan.akllive.data.TrainRepo
64+
import nz.aryan.akllive.ui.TrainArt
6265

6366
private fun open(ctx: Context, link: String) =
6467
actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link), ctx, MainActivity::class.java))
@@ -162,35 +165,55 @@ class FavouritesWidgetReceiver : GlanceAppWidgetReceiver() {
162165

163166
// ======================= the train map =======================
164167

165-
/** The train network diagram with every train on it, live. */
168+
/** The train map, as the app draws it, with every train on it, live. */
166169
class TrainWidget : GlanceAppWidget() {
167170
override val sizeMode = SizeMode.Exact
168171

169172
override suspend fun provideGlance(context: Context, id: GlanceId) {
170173
val trains = TrainsNow.get(context)
171-
val map = drawTrains(trains ?: emptyList())
172-
provideContent { GlanceTheme { Body(context, map, trains) } }
174+
val (w, h) = widgetSize(context, id)
175+
// the app's own Diagram view; the plain diagram only if the map can't be drawn
176+
val map = try { TrainArt.render(context, trains ?: emptyList(), w, h, top = HEADER) } catch (e: Exception) { null }
177+
provideContent { GlanceTheme { Body(context, map ?: drawTrains(trains ?: emptyList()), map != null, trains) } }
178+
}
179+
180+
/** The widget's size on the home screen, in dp (portrait: its narrowest width and tallest height). */
181+
private fun widgetSize(ctx: Context, id: GlanceId): Pair<Int, Int> = try {
182+
val wid = GlanceAppWidgetManager(ctx).getAppWidgetId(id)
183+
val o = AppWidgetManager.getInstance(ctx).getAppWidgetOptions(wid)
184+
val w = o.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
185+
val h = o.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT)
186+
if (w > 80 && h > 80) w to h else 320 to 260
187+
} catch (e: Exception) {
188+
320 to 260
173189
}
174190

175191
@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) {
192+
private fun Body(ctx: Context, map: Bitmap, fullBleed: Boolean, trains: List<Train>?) {
193+
Box(GlanceModifier.fillMaxSize().cornerRadius(24.dp).background(ColorProvider(Color(0xFF172234)))
194+
.clickable(open(ctx, "akllive://trains"))) {
195+
if (fullBleed) Image(ImageProvider(map), "Train map", GlanceModifier.fillMaxSize(), contentScale = ContentScale.Crop)
196+
else Column(GlanceModifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = HEADER.dp)) {
197+
Image(ImageProvider(map), "Train map", GlanceModifier.fillMaxSize(), contentScale = ContentScale.Fit)
198+
}
199+
Row(GlanceModifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
180200
Text("Ngā Tereina", GlanceModifier.defaultWeight(),
181-
style = TextStyle(color = ColorProvider(Color.White), fontWeight = FontWeight.Bold, fontSize = 14.sp))
201+
style = TextStyle(color = ColorProvider(Color.White), fontWeight = FontWeight.Bold, fontSize = 15.sp))
182202
Text(when {
183203
trains == null -> "Open the app first"
184204
else -> "${trains.size} running · ${Nz.time(TrainsNow.at)}"
185-
}, style = TextStyle(color = ColorProvider(Color(0xFFB9C2CF)), fontSize = 11.sp))
205+
}, style = TextStyle(color = ColorProvider(Color(0xFFE6ECF5)), fontSize = 11.sp))
186206
Spacer(GlanceModifier.width(8.dp))
187207
Text("↻", GlanceModifier.clickable(actionRunCallback<RefreshMore>()),
188208
style = TextStyle(color = ColorProvider(Color.White), fontWeight = FontWeight.Bold, fontSize = 18.sp))
189209
}
190-
Spacer(GlanceModifier.height(4.dp))
191-
Image(ImageProvider(map), "Train map", GlanceModifier.fillMaxSize(), contentScale = ContentScale.Fit)
192210
}
193211
}
212+
213+
private companion object {
214+
/** room for the header over the top of the map, dp */
215+
const val HEADER = 40
216+
}
194217
}
195218

196219
class TrainWidgetReceiver : GlanceAppWidgetReceiver() {

‎app/src/main/java/nz/aryan/akllive/ui/LiveMap.kt‎

Lines changed: 75 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ object MapStyles {
157157
val QUIET_DARK = Quiet("#172234", "#0B1625", "#172A24", "#233049", "#1E2A40", "#2E3C57", "#1C283B", "#6C7B94", "#46709A")
158158

159159
/** A quiet map for the train diagram: land, water, parks, faint roads and suburb names, so the lines stand out. */
160+
fun diagramJson(dark: Boolean): String = diagram(dark)
161+
160162
private fun diagram(dark: Boolean): String {
161163
val c = if (dark) QUIET_DARK else QUIET_LIGHT
162164
fun arr(vararg v: Any) = JSONArray().also { a -> v.forEach { a.put(it) } }
@@ -523,61 +525,76 @@ private fun byZoom(p: String) = Expression.interpolate(Expression.linear(), Expr
523525
*/
524526
private fun drawLayers(s: Style, lines: List<MapLine>, links: List<MapLine>, stops: List<MapStop>, basemap: Basemap, dark: Boolean) {
525527
fun put(layer: Layer) = if (s.getLayer("crowd-dots") != null) s.addLayerBelow(layer, "crowd-dots") else s.addLayer(layer)
526-
val zoom = Expression.zoom()
527528
if (s.getSource("akl-lines") == null) {
528-
val dg = basemap == Basemap.Diagram
529-
val caseCol = when {
530-
dg -> if (dark) MapStyles.QUIET_DARK.bg else MapStyles.QUIET_LIGHT.bg
531-
basemap == Basemap.Streets && !dark -> "#FFFFFF"
532-
else -> "#0B1628"
533-
}
534529
for (id in listOf("akl-lines", "akl-links", "akl-stops")) s.addSource(GeoJsonSource(id))
535-
val round = arrayOf(PropertyFactory.lineCap(Property.LINE_CAP_ROUND), PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND))
536-
put(LineLayer("akl-lines-case", "akl-lines").withProperties(*round,
537-
PropertyFactory.lineColor(Expression.switchCase(Expression.toBool(Expression.get("z")), Expression.color(android.graphics.Color.parseColor(caseCol)),
538-
Expression.color(android.graphics.Color.parseColor("#0B1628")))),
539-
PropertyFactory.lineOpacity(Expression.switchCase(Expression.toBool(Expression.get("z")), Expression.literal(if (dg) 1f else 0.8f), Expression.literal(0.55f))),
540-
PropertyFactory.lineWidth(byZoom("cw")),
541-
PropertyFactory.lineOffset(byZoom("o"))))
542-
put(LineLayer("akl-lines", "akl-lines").withProperties(*round,
543-
PropertyFactory.lineColor(Expression.toColor(Expression.get("c"))),
544-
PropertyFactory.lineWidth(byZoom("w")),
545-
PropertyFactory.lineOffset(byZoom("o"))))
546-
put(LineLayer("akl-links-case", "akl-links").withProperties(*round,
547-
PropertyFactory.lineColor(Expression.toColor(Expression.get("c"))),
548-
PropertyFactory.lineWidth(Expression.interpolate(Expression.linear(), zoom,
549-
Expression.stop(9, 6f), Expression.stop(12, 11.4f), Expression.stop(16, 19f)))))
550-
put(LineLayer("akl-links", "akl-links").withProperties(*round,
551-
PropertyFactory.lineColor(Color.White.toArgb()),
552-
PropertyFactory.lineWidth(Expression.interpolate(Expression.linear(), zoom,
553-
Expression.stop(9, 2.8f), Expression.stop(12, 6.2f), Expression.stop(16, 13.8f)))))
554-
put(CircleLayer("akl-stops", "akl-stops").withProperties(
555-
PropertyFactory.circleColor(Color.White.toArgb()),
556-
PropertyFactory.circleStrokeColor(Expression.toColor(Expression.get("c"))),
557-
PropertyFactory.circleRadius(byZoom("rad")),
558-
PropertyFactory.circleStrokeWidth(Expression.interpolate(Expression.linear(), zoom,
559-
Expression.stop(9, num("sw9")), Expression.stop(13, num("sw13"))))))
560-
// names: the most important stations win the space, the rest appear as you zoom in
561-
val darkLabels = basemap != Basemap.Satellite && dark
562-
put(SymbolLayer("akl-stop-labels", "akl-stops").withProperties(
563-
PropertyFactory.textField(Expression.step(zoom, Expression.toString(Expression.get("l0")),
564-
Expression.stop(10.4, Expression.toString(Expression.get("l1"))), Expression.stop(11, Expression.toString(Expression.get("label"))))),
565-
PropertyFactory.textFont(arrayOf("Noto Sans Bold")),
566-
PropertyFactory.textSize(Expression.interpolate(Expression.linear(), zoom,
567-
Expression.stop(10, num("ts10")), Expression.stop(14, num("ts14")))),
568-
PropertyFactory.textVariableAnchor(arrayOf(Property.TEXT_ANCHOR_LEFT, Property.TEXT_ANCHOR_RIGHT, Property.TEXT_ANCHOR_TOP,
569-
Property.TEXT_ANCHOR_BOTTOM, Property.TEXT_ANCHOR_TOP_LEFT, Property.TEXT_ANCHOR_BOTTOM_RIGHT)),
570-
PropertyFactory.textRadialOffset(0.95f),
571-
PropertyFactory.textJustify(Property.TEXT_JUSTIFY_AUTO),
572-
PropertyFactory.symbolSortKey(num("rank")),
573-
PropertyFactory.textPadding(3f),
574-
PropertyFactory.textMaxWidth(9f),
575-
PropertyFactory.textColor(android.graphics.Color.parseColor(if (darkLabels) "#E6ECF5" else "#1A2744")),
576-
PropertyFactory.textHaloColor(if (darkLabels) android.graphics.Color.parseColor(if (dg) MapStyles.QUIET_DARK.bg else "#0B1628")
577-
else android.graphics.Color.argb(235, 255, 255, 255)),
578-
PropertyFactory.textHaloWidth(2.2f)))
530+
aklLayers(basemap, dark).forEach { put(it) }
579531
}
580-
s.getSourceAs<GeoJsonSource>("akl-lines")?.setGeoJson(FeatureCollection.fromFeatures(lines.map { l ->
532+
val (l, k, st) = aklData(lines, links, stops)
533+
s.getSourceAs<GeoJsonSource>("akl-lines")?.setGeoJson(l)
534+
s.getSourceAs<GeoJsonSource>("akl-links")?.setGeoJson(k)
535+
s.getSourceAs<GeoJsonSource>("akl-stops")?.setGeoJson(st)
536+
}
537+
538+
/** The route lines, platform bars, stop dots and names as map layers, bottom to top, fed by akl-lines, akl-links and akl-stops. */
539+
internal fun aklLayers(basemap: Basemap, dark: Boolean): List<Layer> {
540+
val out = ArrayList<Layer>()
541+
val zoom = Expression.zoom()
542+
val dg = basemap == Basemap.Diagram
543+
val caseCol = when {
544+
dg -> if (dark) MapStyles.QUIET_DARK.bg else MapStyles.QUIET_LIGHT.bg
545+
basemap == Basemap.Streets && !dark -> "#FFFFFF"
546+
else -> "#0B1628"
547+
}
548+
val round = arrayOf(PropertyFactory.lineCap(Property.LINE_CAP_ROUND), PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND))
549+
out += LineLayer("akl-lines-case", "akl-lines").withProperties(*round,
550+
PropertyFactory.lineColor(Expression.switchCase(Expression.toBool(Expression.get("z")), Expression.color(android.graphics.Color.parseColor(caseCol)),
551+
Expression.color(android.graphics.Color.parseColor("#0B1628")))),
552+
PropertyFactory.lineOpacity(Expression.switchCase(Expression.toBool(Expression.get("z")), Expression.literal(if (dg) 1f else 0.8f), Expression.literal(0.55f))),
553+
PropertyFactory.lineWidth(byZoom("cw")),
554+
PropertyFactory.lineOffset(byZoom("o")))
555+
out += LineLayer("akl-lines", "akl-lines").withProperties(*round,
556+
PropertyFactory.lineColor(Expression.toColor(Expression.get("c"))),
557+
PropertyFactory.lineWidth(byZoom("w")),
558+
PropertyFactory.lineOffset(byZoom("o")))
559+
out += LineLayer("akl-links-case", "akl-links").withProperties(*round,
560+
PropertyFactory.lineColor(Expression.toColor(Expression.get("c"))),
561+
PropertyFactory.lineWidth(Expression.interpolate(Expression.linear(), zoom,
562+
Expression.stop(9, 6f), Expression.stop(12, 11.4f), Expression.stop(16, 19f))))
563+
out += LineLayer("akl-links", "akl-links").withProperties(*round,
564+
PropertyFactory.lineColor(Color.White.toArgb()),
565+
PropertyFactory.lineWidth(Expression.interpolate(Expression.linear(), zoom,
566+
Expression.stop(9, 2.8f), Expression.stop(12, 6.2f), Expression.stop(16, 13.8f))))
567+
out += CircleLayer("akl-stops", "akl-stops").withProperties(
568+
PropertyFactory.circleColor(Color.White.toArgb()),
569+
PropertyFactory.circleStrokeColor(Expression.toColor(Expression.get("c"))),
570+
PropertyFactory.circleRadius(byZoom("rad")),
571+
PropertyFactory.circleStrokeWidth(Expression.interpolate(Expression.linear(), zoom,
572+
Expression.stop(9, num("sw9")), Expression.stop(13, num("sw13")))))
573+
// names: the most important stations win the space, the rest appear as you zoom in
574+
val darkLabels = basemap != Basemap.Satellite && dark
575+
out += SymbolLayer("akl-stop-labels", "akl-stops").withProperties(
576+
PropertyFactory.textField(Expression.step(zoom, Expression.toString(Expression.get("l0")),
577+
Expression.stop(10.4, Expression.toString(Expression.get("l1"))), Expression.stop(11, Expression.toString(Expression.get("label"))))),
578+
PropertyFactory.textFont(arrayOf("Noto Sans Bold")),
579+
PropertyFactory.textSize(Expression.interpolate(Expression.linear(), zoom,
580+
Expression.stop(10, num("ts10")), Expression.stop(14, num("ts14")))),
581+
PropertyFactory.textVariableAnchor(arrayOf(Property.TEXT_ANCHOR_LEFT, Property.TEXT_ANCHOR_RIGHT, Property.TEXT_ANCHOR_TOP,
582+
Property.TEXT_ANCHOR_BOTTOM, Property.TEXT_ANCHOR_TOP_LEFT, Property.TEXT_ANCHOR_BOTTOM_RIGHT)),
583+
PropertyFactory.textRadialOffset(0.95f),
584+
PropertyFactory.textJustify(Property.TEXT_JUSTIFY_AUTO),
585+
PropertyFactory.symbolSortKey(num("rank")),
586+
PropertyFactory.textPadding(3f),
587+
PropertyFactory.textMaxWidth(9f),
588+
PropertyFactory.textColor(android.graphics.Color.parseColor(if (darkLabels) "#E6ECF5" else "#1A2744")),
589+
PropertyFactory.textHaloColor(if (darkLabels) android.graphics.Color.parseColor(if (dg) MapStyles.QUIET_DARK.bg else "#0B1628")
590+
else android.graphics.Color.argb(235, 255, 255, 255)),
591+
PropertyFactory.textHaloWidth(2.2f))
592+
return out
593+
}
594+
595+
/** The features for akl-lines, akl-links and akl-stops. */
596+
internal fun aklData(lines: List<MapLine>, links: List<MapLine>, stops: List<MapStop>): Triple<FeatureCollection, FeatureCollection, FeatureCollection> {
597+
val lineFc = FeatureCollection.fromFeatures(lines.map { l ->
581598
Feature.fromGeometry(MultiLineString.fromLngLats(l.parts.map { part -> part.map { Point.fromLngLat(it.second, it.first) } })).also { f ->
582599
val k9 = if (l.z) 0.6f else 1f
583600
val k16 = if (l.z) 1.9f else 1f
@@ -590,12 +607,12 @@ private fun drawLayers(s: Style, lines: List<MapLine>, links: List<MapLine>, sto
590607
f.addNumberProperty("o$k", l.offset * v)
591608
}
592609
}
593-
}))
594-
s.getSourceAs<GeoJsonSource>("akl-links")?.setGeoJson(FeatureCollection.fromFeatures(links.map { l ->
610+
})
611+
val linkFc = FeatureCollection.fromFeatures(links.map { l ->
595612
Feature.fromGeometry(MultiLineString.fromLngLats(l.parts.map { part -> part.map { Point.fromLngLat(it.second, it.first) } }))
596613
.also { it.addStringProperty("c", hex(l.color)) }
597-
}))
598-
s.getSourceAs<GeoJsonSource>("akl-stops")?.setGeoJson(FeatureCollection.fromFeatures(stops.map { st ->
614+
})
615+
val stopFc = FeatureCollection.fromFeatures(stops.map { st ->
599616
Feature.fromGeometry(Point.fromLngLat(st.lon, st.lat)).also { f ->
600617
val label = st.label ?: ""
601618
f.addStringProperty("c", hex(st.color))
@@ -611,7 +628,8 @@ private fun drawLayers(s: Style, lines: List<MapLine>, links: List<MapLine>, sto
611628
f.addNumberProperty("ts10", if (st.rank <= 0) 12f else 11f)
612629
f.addNumberProperty("ts14", if (st.rank <= 0) 14f else 12.5f)
613630
}
614-
}))
631+
})
632+
return Triple(lineFc, linkFc, stopFc)
615633
}
616634

617635
/** The crowd's source and layers: a dot per vehicle, a heading arrow, and the route once zoomed in. */

0 commit comments

Comments
 (0)