app/src/main/java/dev/equwal/inkrecents/Ui.kt (11928 bytes)
1 // SPDX-License-Identifier: GPL-3.0-or-later 2 // Copyright (c) 2026 equwal 3 package dev.equwal.inkrecents 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. 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 /** Screen title, for a screen that draws no top bar. */ 139 fun LinearLayout.title(text: String) = add(TextView(context).apply { 140 this.text = text 141 setTextColor(INK) 142 setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f) 143 setTypeface(Typeface.DEFAULT_BOLD) 144 setPadding(0, context.dp(4), 0, context.dp(8)) 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 /** 228 * A button with the platform look taken off: our own outline, no elevation, 229 * and no ripple, which smears on an e-ink panel. 230 */ 231 fun LinearLayout.button(text: String, onClick: () -> Unit) = add( 232 Button(context).apply { 233 this.text = text 234 isAllCaps = false 235 gravity = Gravity.CENTER 236 setTextColor(INK) 237 setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) 238 setTypeface(Typeface.DEFAULT_BOLD) 239 background = outline(context) 240 stateListAnimator = null 241 elevation = 0f 242 minWidth = 0 243 minimumWidth = 0 244 minHeight = 0 245 minimumHeight = 0 246 setPadding(context.dp(16), 0, context.dp(16), 0) 247 layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, context.dp(48)).apply { 248 topMargin = context.dp(8) 249 bottomMargin = context.dp(8) 250 } 251 setOnClickListener { onClick() } 252 } 253 ) 254 255 // ---- drawing ----------------------------------------------------------- 256 257 /** A black outline on a fill: the one shape this app is built from. */ 258 fun outline(c: Context, fill: Int = Color.WHITE, radius: Int = 2): GradientDrawable = 259 GradientDrawable().apply { 260 shape = GradientDrawable.RECTANGLE 261 setColor(fill) 262 cornerRadius = c.dp(radius).toFloat() 263 setStroke(c.px(1), INK) 264 } 265 266 /** A short state word, in an outlined box. */ 267 fun chip(c: Context, text: String): TextView = TextView(c).apply { 268 this.text = text 269 setTextColor(INK) 270 setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) 271 setTypeface(Typeface.DEFAULT_BOLD) 272 background = outline(c, radius = 10) 273 setPadding(c.dp(8), c.dp(3), c.dp(8), c.dp(3)) 274 maxLines = 1 275 } 276 277 /** 278 * The back arrow and the row chevron. They are drawn, not typed, because a 279 * firmware font can be without the characters. 280 */ 281 private class Glyph(c: Context, private val kind: Int) : View(c) { 282 283 private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { 284 color = INK 285 style = Paint.Style.STROKE 286 strokeWidth = c.resources.displayMetrics.density * 2f 287 strokeCap = Paint.Cap.ROUND 288 strokeJoin = Paint.Join.ROUND 289 } 290 private val path = Path() 291 292 override fun onDraw(canvas: Canvas) { 293 val cx = width / 2f 294 val cy = height / 2f 295 path.reset() 296 if (kind == BACK) { 297 val r = minOf(width, height) * 0.24f 298 val h = r * 0.62f 299 path.moveTo(cx + r, cy) 300 path.lineTo(cx - r, cy) 301 path.moveTo(cx - r + h, cy - h) 302 path.lineTo(cx - r, cy) 303 path.lineTo(cx - r + h, cy + h) 304 } else { 305 val w = minOf(width, height) * 0.17f 306 val h = w * 1.7f 307 path.moveTo(cx - w, cy - h) 308 path.lineTo(cx + w, cy) 309 path.lineTo(cx - w, cy + h) 310 } 311 canvas.drawPath(path, paint) 312 } 313 314 companion object { 315 const val BACK = 0 316 const val CHEVRON = 1 317 } 318 } 319 320 /** Full width by default. A caller that wants otherwise sets the params first. */ 321 private fun LinearLayout.add(v: View) { 322 if (v.layoutParams == null) { 323 v.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) 324 } 325 addView(v) 326 } 327 }