app/src/main/java/dev/equwal/inkupdate/Ui.kt (12460 bytes)
1 // SPDX-License-Identifier: GPL-3.0-or-later 2 // Copyright (c) 2026 equwal 3 package dev.equwal.inkupdate 4 5 import android.app.Activity 6 import android.content.Context 7 import android.graphics.Canvas 8 import android.graphics.Color 9 import android.graphics.Paint 10 import android.graphics.Path 11 import android.graphics.Typeface 12 import android.graphics.drawable.GradientDrawable 13 import android.text.TextUtils 14 import android.util.TypedValue 15 import android.view.Gravity 16 import android.view.View 17 import android.view.ViewGroup.LayoutParams.MATCH_PARENT 18 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT 19 import android.view.WindowInsets 20 import android.widget.Button 21 import android.widget.LinearLayout 22 import android.widget.ScrollView 23 import android.widget.TextView 24 25 /** 26 * View construction, in code, with no support library. 27 * 28 * The screen is e-ink: no colour, slow refresh, and a hard time with light 29 * greys. So everything here is black on white at generous sizes, an outline is 30 * a real black line, and nothing animates. 31 * 32 * The rules this file keeps, so that each screen looks the same: 33 * 34 * - 16 dp side margins, and spacing on an 8 dp grid. 35 * - A row is at least 56 dp high, and the whole row is the touch target. 36 * - Titles are 17 sp, notes 14 sp, section headers 13 sp small caps. 37 * - Secondary text is #444444. Nothing lighter carries a fact. 38 * - A button is 48 dp high and outlined. The main button is 56 dp and filled. 39 */ 40 object Ui { 41 42 const val INK = Color.BLACK 43 44 /** Secondary text. The lightest grey that still reads on e-ink. */ 45 val DIM = Color.rgb(68, 68, 68) 46 47 /** Separator lines between rows. */ 48 val RULE = Color.rgb(150, 150, 150) 49 50 fun Context.dp(v: Int): Int = TypedValue.applyDimension( 51 TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics 52 ).toInt() 53 54 private fun Context.px(v: Int): Int = maxOf(1, dp(v)) 55 56 // ---- page -------------------------------------------------------------- 57 58 /** 59 * Root of a screen. It gives back the column that the caller fills. 60 * 61 * With a [title] the screen gets the standard top bar: a back arrow with a 62 * 48 dp touch target, the title, and a rule under it. The content below 63 * scrolls only when it does not fit. 64 */ 65 fun page(a: Activity, title: String? = null): LinearLayout { 66 val root = LinearLayout(a).apply { 67 orientation = LinearLayout.VERTICAL 68 setBackgroundColor(Color.WHITE) 69 } 70 if (title != null) { 71 root.addView(topBar(a, title)) 72 root.addView(View(a).apply { 73 setBackgroundColor(RULE) 74 layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 1) 75 }) 76 } 77 78 val col = LinearLayout(a).apply { 79 orientation = LinearLayout.VERTICAL 80 setPadding(a.dp(16), a.dp(if (title == null) 8 else 12), a.dp(16), a.dp(24)) 81 } 82 val scroll = ScrollView(a).apply { 83 isFillViewport = true 84 clipToPadding = false 85 addView(col, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) 86 } 87 root.addView(scroll, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) 88 89 // Edge to edge is enforced from Android 15, so the system bars overlap 90 // the window. The top bar moves down by what the status bar covers, and 91 // the content keeps clear of the navigation bar. 92 root.setOnApplyWindowInsetsListener { v, insets -> 93 val bars = insets.getInsets( 94 WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout() 95 ) 96 v.setPadding(bars.left, bars.top, bars.right, 0) 97 col.setPadding( 98 col.paddingLeft, col.paddingTop, col.paddingRight, a.dp(24) + bars.bottom 99 ) 100 insets 101 } 102 a.setContentView(root) 103 root.requestApplyInsets() 104 return col 105 } 106 107 private fun topBar(a: Activity, text: String): View { 108 val bar = LinearLayout(a).apply { 109 orientation = LinearLayout.HORIZONTAL 110 gravity = Gravity.CENTER_VERTICAL 111 setPadding(a.dp(4), 0, a.dp(16), 0) 112 layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, a.dp(56)) 113 } 114 bar.addView(Glyph(a, Glyph.BACK).apply { 115 contentDescription = "Back" 116 isClickable = true 117 isFocusable = true 118 @Suppress("DEPRECATION") 119 setOnClickListener { a.onBackPressed() } 120 layoutParams = LinearLayout.LayoutParams(a.dp(48), a.dp(48)) 121 }) 122 bar.addView(TextView(a).apply { 123 this.text = text 124 setTextColor(INK) 125 setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f) 126 setTypeface(Typeface.DEFAULT_BOLD) 127 maxLines = 1 128 ellipsize = TextUtils.TruncateAt.END 129 layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f).apply { 130 marginStart = a.dp(4) 131 } 132 }) 133 return bar 134 } 135 136 // ---- text -------------------------------------------------------------- 137 138 /** The name of a screen that has no back arrow, because it is the first one. */ 139 fun LinearLayout.title(text: String) = add(TextView(context).apply { 140 this.text = text 141 setTextColor(INK) 142 setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) 143 setTypeface(Typeface.DEFAULT_BOLD) 144 setPadding(0, context.dp(8), 0, context.dp(4)) 145 }) 146 147 fun LinearLayout.header(text: String) = add(TextView(context).apply { 148 this.text = text.uppercase() 149 setTextColor(DIM) 150 setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) 151 setTypeface(Typeface.DEFAULT_BOLD) 152 letterSpacing = 0.08f 153 setPadding(0, context.dp(24), 0, context.dp(8)) 154 }) 155 156 fun LinearLayout.note(text: String) = add(TextView(context).apply { 157 this.text = text 158 setTextColor(DIM) 159 setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) 160 setLineSpacing(context.dp(3).toFloat(), 1f) 161 setPadding(0, 0, 0, context.dp(8)) 162 }) 163 164 fun LinearLayout.rule() = add(View(context).apply { 165 setBackgroundColor(RULE) 166 layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 1) 167 }) 168 169 // ---- rows -------------------------------------------------------------- 170 171 /** 172 * A row: what it is, what it does now, and either a state chip or a chevron 173 * on the right. The whole row is the touch target. 174 */ 175 fun LinearLayout.row( 176 title: String, 177 subtitle: String? = null, 178 enabled: Boolean = true, 179 state: String? = null, 180 onClick: (() -> Unit)? = null 181 ): LinearLayout { 182 val box = LinearLayout(context).apply { 183 orientation = LinearLayout.HORIZONTAL 184 gravity = Gravity.CENTER_VERTICAL 185 minimumHeight = context.dp(56) 186 val v = context.dp(10) 187 setPadding(0, v, 0, v) 188 isClickable = onClick != null && enabled 189 if (isClickable) setOnClickListener { onClick?.invoke() } 190 } 191 val texts = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } 192 texts.addView(TextView(context).apply { 193 text = title 194 setTextColor(if (enabled) INK else DIM) 195 setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f) 196 }) 197 if (!subtitle.isNullOrBlank()) { 198 texts.addView(TextView(context).apply { 199 text = subtitle 200 setTextColor(DIM) 201 setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) 202 setPadding(0, context.dp(2), 0, 0) 203 }) 204 } 205 box.addView(texts, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)) 206 207 if (state != null) { 208 box.addView(chip(context, state).apply { 209 layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { 210 marginStart = context.dp(8) 211 } 212 }) 213 } else if (box.isClickable) { 214 box.addView(Glyph(context, Glyph.CHEVRON).apply { 215 layoutParams = LinearLayout.LayoutParams(context.dp(24), context.dp(24)).apply { 216 marginStart = context.dp(8) 217 } 218 }) 219 } 220 add(box) 221 rule() 222 return box 223 } 224 225 // ---- buttons ----------------------------------------------------------- 226 227 /** The main action of a screen: filled, 56 dp, full width. */ 228 fun LinearLayout.primaryButton(text: String, onClick: () -> Unit) = 229 add(flatButton(context, text, 56, Color.WHITE, INK, 17f).apply { 230 setOnClickListener { onClick() } 231 }) 232 233 /** Every other action: outlined, 48 dp, full width. */ 234 fun LinearLayout.button(text: String, onClick: () -> Unit) = 235 add(flatButton(context, text, 48, INK, Color.WHITE, 16f).apply { 236 setOnClickListener { onClick() } 237 }) 238 239 /** 240 * A button with the platform look taken off: our own outline or fill, no 241 * elevation, and no ripple, which smears on an e-ink panel. 242 */ 243 private fun flatButton( 244 c: Context, 245 text: String, 246 height: Int, 247 textColor: Int, 248 fill: Int, 249 size: Float 250 ): Button = Button(c).apply { 251 this.text = text 252 isAllCaps = false 253 gravity = Gravity.CENTER 254 setTextColor(textColor) 255 setTextSize(TypedValue.COMPLEX_UNIT_SP, size) 256 setTypeface(Typeface.DEFAULT_BOLD) 257 background = outline(c, fill) 258 stateListAnimator = null 259 elevation = 0f 260 minWidth = 0 261 minimumWidth = 0 262 minHeight = 0 263 minimumHeight = 0 264 setPadding(c.dp(16), 0, c.dp(16), 0) 265 layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, c.dp(height)).apply { 266 topMargin = c.dp(8) 267 bottomMargin = c.dp(8) 268 } 269 } 270 271 // ---- drawing ----------------------------------------------------------- 272 273 /** A black outline on a fill: the one shape this app is built from. */ 274 fun outline(c: Context, fill: Int = Color.WHITE, radius: Int = 2): GradientDrawable = 275 GradientDrawable().apply { 276 shape = GradientDrawable.RECTANGLE 277 setColor(fill) 278 cornerRadius = c.dp(radius).toFloat() 279 setStroke(c.px(1), INK) 280 } 281 282 /** A short state word, in an outlined box. */ 283 fun chip(c: Context, text: String): TextView = TextView(c).apply { 284 this.text = text 285 setTextColor(INK) 286 setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) 287 setTypeface(Typeface.DEFAULT_BOLD) 288 background = outline(c, radius = 10) 289 setPadding(c.dp(8), c.dp(3), c.dp(8), c.dp(3)) 290 maxLines = 1 291 } 292 293 /** 294 * The back arrow and the row chevron. They are drawn, not typed, because a 295 * firmware font can be without the characters. 296 */ 297 private class Glyph(c: Context, private val kind: Int) : View(c) { 298 299 private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { 300 color = INK 301 style = Paint.Style.STROKE 302 strokeWidth = c.resources.displayMetrics.density * 2f 303 strokeCap = Paint.Cap.ROUND 304 strokeJoin = Paint.Join.ROUND 305 } 306 private val path = Path() 307 308 override fun onDraw(canvas: Canvas) { 309 val cx = width / 2f 310 val cy = height / 2f 311 path.reset() 312 if (kind == BACK) { 313 val r = minOf(width, height) * 0.24f 314 val h = r * 0.62f 315 path.moveTo(cx + r, cy) 316 path.lineTo(cx - r, cy) 317 path.moveTo(cx - r + h, cy - h) 318 path.lineTo(cx - r, cy) 319 path.lineTo(cx - r + h, cy + h) 320 } else { 321 val w = minOf(width, height) * 0.17f 322 val h = w * 1.7f 323 path.moveTo(cx - w, cy - h) 324 path.lineTo(cx + w, cy) 325 path.lineTo(cx - w, cy + h) 326 } 327 canvas.drawPath(path, paint) 328 } 329 330 companion object { 331 const val BACK = 0 332 const val CHEVRON = 1 333 } 334 } 335 336 /** Full width by default. A caller that wants otherwise sets the params first. */ 337 private fun LinearLayout.add(v: View) { 338 if (v.layoutParams == null) { 339 v.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) 340 } 341 addView(v) 342 } 343 }