Skip to content

Commit 1f951f9

Browse files
claudeintelarc
authored andcommitted
Trains map: lines glide where they meet and part, names beside platform bars; journey sheet slides down in one go; no train widget
- Where lines join or leave shared rails they now slide across to their new place over about 200 m either side, instead of stepping sideways. - A station whose lines use different platforms has its name beside the bar joining them, not across it. - The header fits: a one-line title, a shorter subtitle, a smaller view toggle; and a note when no trains are running. - The journey sheet is one sheet that opens to half the screen and slides down to a slim strip (the trip's status when you're on one), with no second bar popping in. - The train map widget is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011C2fAvFFcMRkaF7qSvRA1v
1 parent 26c433c commit 1f951f9

12 files changed

Lines changed: 137 additions & 257 deletions

File tree

‎README.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@ journeys with 1, 2, 3 or more rides, walking between nearby stops for changes.
145145
- **Your trip**: once you start a trip, where you're up to (walk to the
146146
stop, the bus is 2 stops away, 4 stops to go), the rides ahead, when you'll
147147
get there, and End.
148-
- **Train map**: the train diagram with every train on it, live.
149148
- **Search**: a search bar that opens straight into search.
150149
- **Quick settings tile**: "27H · 6 min" in the notification shade.
151150
- **App shortcuts**: long-press the icon for Directions, Live, Trains, Search

‎app/src/main/AndroidManifest.xml‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,5 @@
9393
<meta-data android:name="android.appwidget.provider" android:resource="@xml/favourites_widget" />
9494
</receiver>
9595

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>
10596
</application>
10697
</manifest>

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,6 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
295295
private suspend fun refreshTrains() {
296296
try {
297297
val fresh = trainRepo.poll()
298-
nz.aryan.akllive.system.TrainsNow.save(getApplication(), fresh)
299298
val old = _trains.value
300299
val elapsed = SystemClock.elapsedRealtime()
301300
// glide from wherever each marker is right now

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ 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
2322
import nz.aryan.akllive.ui.AklApp
2423
import kotlin.math.sqrt
2524

@@ -57,7 +56,6 @@ class MainActivity : ComponentActivity(), SensorEventListener {
5756
override fun onStart() {
5857
super.onStart()
5958
vm.setVisible(true)
60-
TrainsNow.appOpened(this)
6159
}
6260

6361
override fun onResume() {

‎app/src/main/java/nz/aryan/akllive/gtfs/Rail.kt‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ object Rail {
104104
private const val IN_M = 25.0 // rails this close and parallel are drawn as one track...
105105
private const val OUT_M = 40.0 // ...until they're this far apart
106106
private const val STEP_M = 20.0
107+
private const val EASE_M = 200.0 // lines slide between slots over this far either side...
108+
private const val EASE_STEPS = 10 // ...in this many steps
107109

108110
val HUIA_STOPS = listOf(
109111
RailStation("The Strand", -36.84853, 174.77934, 1 shl HUIA, 1, true),
@@ -386,6 +388,12 @@ object Rail {
386388
val run = at.subList(a, min(at.size, b + 2))
387389
if (run.size > 1) out += RailPiece(g.line, ArrayList(run), w[slot], if (same) off else -off)
388390
}
391+
// where lines meet and part, slide across between slots instead of stepping
392+
if (out.size - first > 1) {
393+
val eased = ease(out.subList(first, out.size).toList())
394+
while (out.size > first) out.removeAt(out.size - 1)
395+
out += eased
396+
}
389397
if (g.line != HUIA) track[g.line] += DoubleArray(at.size) { at[it].x * KX } to DoubleArray(at.size) { at[it].y }
390398
for ((st, k) in g.stops) ends[st] += g.line to at[k]
391399
if (out.size > first && g.stops.isNotEmpty()) {
@@ -408,6 +416,65 @@ object Rail {
408416
return RailDrawing(out, markers, track)
409417
}
410418

419+
/**
420+
* A line's pieces with the change in slot (offset and width) between one and the next
421+
* spread over [EASE_M] either side, in [EASE_STEPS] little pieces, so a line glides
422+
* across to its new place beside the others where they meet or part.
423+
*/
424+
private fun ease(pieces: List<RailPiece>): List<RailPiece> {
425+
val out = ArrayList<RailPiece>()
426+
var cur = pieces[0]
427+
for (i in 1 until pieces.size) {
428+
val next = pieces[i]
429+
val joined = metres(cur.coords.last(), next.coords.first()) < 1.0
430+
val change = abs(cur.offset - next.offset) > 0.05 || abs(cur.width - next.width) > 0.05
431+
val l = minOf(EASE_M, length(cur.coords) * 0.45, length(next.coords) * 0.45)
432+
if (!joined || !change || l < 5) { out += cur; cur = next; continue }
433+
val (keepA, tailA) = cut(cur.coords, length(cur.coords) - l)
434+
val (headB, keepB) = cut(next.coords, l)
435+
if (keepA.size > 1) out += RailPiece(cur.line, keepA, cur.width, cur.offset)
436+
var rest: List<Pt> = tailA + headB.drop(1)
437+
val step = length(rest) / EASE_STEPS
438+
for (j in 0 until EASE_STEPS) {
439+
val t = (j + 0.5) / EASE_STEPS
440+
val split: Pair<List<Pt>, List<Pt>> = if (j == EASE_STEPS - 1) rest to emptyList() else cut(rest, step)
441+
val (part, after) = split
442+
rest = after
443+
if (part.size > 1) out += RailPiece(cur.line, ArrayList(part), cur.width + (next.width - cur.width) * t,
444+
cur.offset + (next.offset - cur.offset) * t)
445+
}
446+
cur = RailPiece(next.line, ArrayList(keepB), next.width, next.offset)
447+
}
448+
out += cur
449+
return out
450+
}
451+
452+
private fun length(pts: List<Pt>): Double {
453+
var d = 0.0
454+
for (i in 1 until pts.size) d += metres(pts[i - 1], pts[i])
455+
return d
456+
}
457+
458+
/** A line cut [at] metres along it: the part before and the part after, both with the cut point. */
459+
private fun cut(pts: List<Pt>, at: Double): Pair<MutableList<Pt>, MutableList<Pt>> {
460+
val before = arrayListOf(pts[0])
461+
var d = 0.0
462+
for (i in 1 until pts.size) {
463+
val seg = metres(pts[i - 1], pts[i])
464+
if (d + seg >= at) {
465+
val t = if (seg > 0) ((at - d) / seg).coerceIn(0.0, 1.0) else 0.0
466+
val c = Pt(pts[i - 1].x + (pts[i].x - pts[i - 1].x) * t, pts[i - 1].y + (pts[i].y - pts[i - 1].y) * t)
467+
before += c
468+
val after = arrayListOf(c)
469+
for (j in i until pts.size) after += pts[j]
470+
return before to after
471+
}
472+
d += seg
473+
before += pts[i]
474+
}
475+
return before to arrayListOf(pts.last())
476+
}
477+
411478
/**
412479
* Where a station's dot (or dots) go: its platforms on the lines showing. A
413480
* line's own ends there count once; lines on the same track share a dot.

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

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

33
import android.content.Context
4-
import androidx.glance.appwidget.GlanceAppWidgetManager
54
import androidx.work.Constraints
65
import androidx.work.CoroutineWorker
76
import androidx.work.NetworkType
@@ -106,13 +105,6 @@ class WidgetWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
106105
override suspend fun doWork(): Result {
107106
Glance.fetch(applicationContext)
108107
NextBusWidget().updateAll(applicationContext)
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) { }
116108
return Result.success()
117109
}
118110
}

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

Lines changed: 0 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -100,154 +100,3 @@ class SearchWidget : GlanceAppWidget() {
100100
class SearchWidgetReceiver : GlanceAppWidgetReceiver() {
101101
override val glanceAppWidget: GlanceAppWidget = SearchWidget()
102102
}
103-
104-
// ======================= the train map =======================
105-
106-
/** The train network diagram with every train on it, live. */
107-
class TrainWidget : GlanceAppWidget() {
108-
override val sizeMode = SizeMode.Exact
109-
110-
override suspend fun provideGlance(context: Context, id: GlanceId) {
111-
val trains = TrainsNow.get(context)
112-
val map = drawTrains(trains ?: emptyList())
113-
provideContent { GlanceTheme { Body(context, map, trains) } }
114-
}
115-
116-
@Composable
117-
private fun Body(ctx: Context, map: Bitmap, trains: List<Train>?) {
118-
Column(GlanceModifier.fillMaxSize().cornerRadius(24.dp).background(ColorProvider(Color(0xFF0B1220))).padding(10.dp)
119-
.clickable(open(ctx, "akllive://trains"))) {
120-
Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
121-
Text("Ngā Tereina", GlanceModifier.defaultWeight(),
122-
style = TextStyle(color = ColorProvider(Color.White), fontWeight = FontWeight.Bold, fontSize = 14.sp))
123-
Text(when {
124-
trains == null -> "Open the app first"
125-
else -> "${trains.size} running · ${Nz.time(TrainsNow.at)}"
126-
}, style = TextStyle(color = ColorProvider(Color(0xFFB9C2CF)), fontSize = 11.sp))
127-
Spacer(GlanceModifier.width(8.dp))
128-
Text("↻", GlanceModifier.clickable(actionRunCallback<RefreshMore>()),
129-
style = TextStyle(color = ColorProvider(Color.White), fontWeight = FontWeight.Bold, fontSize = 18.sp))
130-
}
131-
Spacer(GlanceModifier.height(4.dp))
132-
Image(ImageProvider(map), "Train map", GlanceModifier.fillMaxSize(), contentScale = ContentScale.Fit)
133-
}
134-
}
135-
}
136-
137-
class TrainWidgetReceiver : GlanceAppWidgetReceiver() {
138-
override val glanceAppWidget: GlanceAppWidget = TrainWidget()
139-
override fun onEnabled(context: Context) { super.onEnabled(context); Glance.schedule(context) }
140-
}
141-
142-
/** The latest trains, shared by the app and the widget. */
143-
object TrainsNow {
144-
@Volatile var list: List<Train>? = null
145-
private set
146-
@Volatile var at = 0L
147-
private set
148-
private var lastWidget = 0L
149-
private var repo: TrainRepo? = null
150-
151-
/** The app polled the trains: keep them, and redraw the widget now and then. */
152-
fun save(ctx: Context, trains: List<Train>) {
153-
list = trains
154-
at = Nz.nowSec()
155-
val now = System.currentTimeMillis()
156-
if (now - lastWidget > 60_000) {
157-
lastWidget = now
158-
val app = ctx.applicationContext
159-
CoroutineScope(Dispatchers.Default).launch { try { TrainWidget().updateAll(app) } catch (_: Exception) { } }
160-
}
161-
}
162-
163-
/** The app's been opened: a good time to draw the widget's map, with the internet sure to be there. */
164-
fun appOpened(ctx: Context) {
165-
val now = System.currentTimeMillis()
166-
if (now - lastOpen < 10 * 60_000) return
167-
lastOpen = now
168-
val app = ctx.applicationContext
169-
CoroutineScope(Dispatchers.Default).launch { try { TrainWidget().updateAll(app) } catch (_: Exception) { } }
170-
}
171-
private var lastOpen = 0L
172-
173-
/** Fetch and redraw in a job that waits for the internet. */
174-
fun refresh(ctx: Context) {
175-
val req = OneTimeWorkRequestBuilder<TrainWidgetWorker>()
176-
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
177-
.build()
178-
WorkManager.getInstance(ctx).enqueueUniqueWork("train-widget-refresh", ExistingWorkPolicy.REPLACE, req)
179-
}
180-
181-
suspend fun get(ctx: Context, fresh: Boolean = false): List<Train>? {
182-
if (!fresh && list != null && Nz.nowSec() - at < 60) return list
183-
val s = Prefs(ctx).load()
184-
if (s.apiKey.isBlank()) return list
185-
val r = repo ?: TrainRepo(AtApi { Prefs(ctx).load().apiKey }).also { repo = it }
186-
return try { r.poll().also { list = it; at = Nz.nowSec() } } catch (_: Exception) { list }
187-
}
188-
}
189-
190-
/** The post-CRL diagram and every train on it, drawn for the widget. */
191-
private fun drawTrains(trains: List<Train>): Bitmap {
192-
val k = 2.4f
193-
val w = (MapData.W * k).toInt()
194-
val h = (MapData.H * k).toInt()
195-
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
196-
val c = Canvas(bmp)
197-
val line = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = 3.2f * k; strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND }
198-
MapData.LINE_DRAW.forEachIndexed { li, pts ->
199-
val p = Path()
200-
for (i in 0 until pts.size / 2) {
201-
val x = pts[i * 2] * k
202-
val y = pts[i * 2 + 1] * k
203-
if (i == 0) p.moveTo(x, y) else p.lineTo(x, y)
204-
}
205-
line.color = MapData.LINE_COLORS[li].toInt()
206-
c.drawPath(p, line)
207-
}
208-
val dot = Paint(Paint.ANTI_ALIAS_FLAG)
209-
val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = 1.2f * k; color = 0xFF1A2744.toInt() }
210-
val label = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFFE6ECF5.toInt(); textSize = 6.5f * k; typeface = Typeface.DEFAULT_BOLD }
211-
for (st in MapData.STATIONS) {
212-
for (i in 0 until st.pts.size / 2) {
213-
dot.color = 0xFFFFFFFF.toInt()
214-
c.drawCircle(st.pts[i * 2] * k, st.pts[i * 2 + 1] * k, 2.2f * k, dot)
215-
c.drawCircle(st.pts[i * 2] * k, st.pts[i * 2 + 1] * k, 2.2f * k, ring)
216-
}
217-
if (st.priority == 0) {
218-
val tw = label.measureText(st.name)
219-
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 }
220-
c.drawText(st.name, x.coerceIn(2f, w - tw - 2f), st.y * k + st.dy * k + label.textSize / 3, label)
221-
}
222-
}
223-
for (t in trains) {
224-
val x = t.x * k
225-
val y = t.y * k
226-
dot.color = 0xFFFFFFFF.toInt()
227-
c.drawCircle(x, y, 4.6f * k, dot)
228-
dot.color = MapData.LINE_COLORS[t.line].toInt()
229-
c.drawCircle(x, y, 3.4f * k, dot)
230-
val late = t.delay ?: 0
231-
if (late >= 120) {
232-
dot.color = if (late >= 300) 0xFFE0412F.toInt() else 0xFFEFA20E.toInt()
233-
c.drawCircle(x + 3.4f * k, y - 3.4f * k, 1.8f * k, dot)
234-
}
235-
}
236-
return bmp
237-
}
238-
239-
/** ↻ on the train widget: fetched in a job, which gets the internet even when the app's in the background. */
240-
class RefreshMore : ActionCallback {
241-
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
242-
TrainsNow.refresh(context)
243-
}
244-
}
245-
246-
/** Fetches the trains and redraws the train widget. */
247-
class TrainWidgetWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
248-
override suspend fun doWork(): Result {
249-
TrainsNow.get(applicationContext, fresh = true)
250-
TrainWidget().updateAll(applicationContext)
251-
return Result.success()
252-
}
253-
}

0 commit comments

Comments
 (0)