|
| 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 | +} |
0 commit comments