app/src/main/java/dev/equwal/inkdim/Ui.kt (12089 bytes)
1 // SPDX-License-Identifier: GPL-3.0-or-later 2 // Copyright (c) 2026 equwal 3 package dev.equwal.inkdim 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 fun LinearLayout.header(text: String) = add(TextView(context).apply { 139 this.text = text.uppercase() 140 setTextColor(DIM) 141 setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) 142 setTypeface(Typeface.DEFAULT_BOLD) 143 letterSpacing = 0.08f 144 setPadding(0, context.dp(24), 0, context.dp(8)) 145 }) 146 147 fun LinearLayout.note(text: String) = add(TextView(context).apply { 148 this.text = text 149 setTextColor(DIM) 150 setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) 151 setLineSpacing(context.dp(3).toFloat(), 1f) 152 setPadding(0, 0, 0, context.dp(8)) 153 }) 154 155 fun LinearLayout.rule() = add(View(context).apply { 156 setBackgroundColor(RULE) 157 layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 1) 158 }) 159 160 // ---- rows -------------------------------------------------------------- 161 162 /** 163 * A row: what it is, what it does now, and either a state chip or a chevron 164 * on the right. The whole row is the touch target. 165 */ 166 fun LinearLayout.row( 167 title: String, 168 subtitle: String? = null, 169 enabled: Boolean = true, 170 state: String? = null, 171 onClick: (() -> Unit)? = null 172 ): LinearLayout { 173 val box = LinearLayout(context).apply { 174 orientation = LinearLayout.HORIZONTAL 175 gravity = Gravity.CENTER_VERTICAL 176 minimumHeight = context.dp(56) 177 val v = context.dp(10) 178 setPadding(0, v, 0, v) 179 isClickable = onClick != null && enabled 180 if (isClickable) setOnClickListener { onClick?.invoke() } 181 } 182 val texts = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } 183 texts.addView(TextView(context).apply { 184 text = title 185 setTextColor(if (enabled) INK else DIM) 186 setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f) 187 }) 188 if (!subtitle.isNullOrBlank()) { 189 texts.addView(TextView(context).apply { 190 text = subtitle 191 setTextColor(DIM) 192 setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) 193 setPadding(0, context.dp(2), 0, 0) 194 }) 195 } 196 box.addView(texts, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)) 197 198 if (state != null) { 199 box.addView(chip(context, state).apply { 200 layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { 201 marginStart = context.dp(8) 202 } 203 }) 204 } else if (box.isClickable) { 205 box.addView(Glyph(context, Glyph.CHEVRON).apply { 206 layoutParams = LinearLayout.LayoutParams(context.dp(24), context.dp(24)).apply { 207 marginStart = context.dp(8) 208 } 209 }) 210 } 211 add(box) 212 rule() 213 return box 214 } 215 216 // ---- buttons ----------------------------------------------------------- 217 218 /** The main action of a screen: filled, 56 dp, full width. */ 219 fun LinearLayout.primaryButton(text: String, onClick: () -> Unit) = 220 add(flatButton(context, text, 56, Color.WHITE, INK, 17f).apply { 221 setOnClickListener { onClick() } 222 }) 223 224 /** Every other action: outlined, 48 dp, full width. */ 225 fun LinearLayout.button(text: String, onClick: () -> Unit) = 226 add(flatButton(context, text, 48, INK, Color.WHITE, 16f).apply { 227 setOnClickListener { onClick() } 228 }) 229 230 /** 231 * A button with the platform look taken off: our own outline or fill, no 232 * elevation, and no ripple, which smears on an e-ink panel. 233 */ 234 private fun flatButton( 235 c: Context, 236 text: String, 237 height: Int, 238 textColor: Int, 239 fill: Int, 240 size: Float 241 ): Button = Button(c).apply { 242 this.text = text 243 isAllCaps = false 244 gravity = Gravity.CENTER 245 setTextColor(textColor) 246 setTextSize(TypedValue.COMPLEX_UNIT_SP, size) 247 setTypeface(Typeface.DEFAULT_BOLD) 248 background = outline(c, fill) 249 stateListAnimator = null 250 elevation = 0f 251 minWidth = 0 252 minimumWidth = 0 253 minHeight = 0 254 minimumHeight = 0 255 setPadding(c.dp(16), 0, c.dp(16), 0) 256 layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, c.dp(height)).apply { 257 topMargin = c.dp(8) 258 bottomMargin = c.dp(8) 259 } 260 } 261 262 // ---- drawing ----------------------------------------------------------- 263 264 /** A black outline on a fill: the one shape this app is built from. */ 265 fun outline(c: Context, fill: Int = Color.WHITE, radius: Int = 2): GradientDrawable = 266 GradientDrawable().apply { 267 shape = GradientDrawable.RECTANGLE 268 setColor(fill) 269 cornerRadius = c.dp(radius).toFloat() 270 setStroke(c.px(1), INK) 271 } 272 273 /** A short state word, in an outlined box. */ 274 fun chip(c: Context, text: String): TextView = TextView(c).apply { 275 this.text = text 276 setTextColor(INK) 277 setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) 278 setTypeface(Typeface.DEFAULT_BOLD) 279 background = outline(c, radius = 10) 280 setPadding(c.dp(8), c.dp(3), c.dp(8), c.dp(3)) 281 maxLines = 1 282 } 283 284 /** 285 * The back arrow and the row chevron. They are drawn, not typed, because a 286 * firmware font can be without the characters. 287 */ 288 private class Glyph(c: Context, private val kind: Int) : View(c) { 289 290 private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { 291 color = INK 292 style = Paint.Style.STROKE 293 strokeWidth = c.resources.displayMetrics.density * 2f 294 strokeCap = Paint.Cap.ROUND 295 strokeJoin = Paint.Join.ROUND 296 } 297 private val path = Path() 298 299 override fun onDraw(canvas: Canvas) { 300 val cx = width / 2f 301 val cy = height / 2f 302 path.reset() 303 if (kind == BACK) { 304 val r = minOf(width, height) * 0.24f 305 val h = r * 0.62f 306 path.moveTo(cx + r, cy) 307 path.lineTo(cx - r, cy) 308 path.moveTo(cx - r + h, cy - h) 309 path.lineTo(cx - r, cy) 310 path.lineTo(cx - r + h, cy + h) 311 } else { 312 val w = minOf(width, height) * 0.17f 313 val h = w * 1.7f 314 path.moveTo(cx - w, cy - h) 315 path.lineTo(cx + w, cy) 316 path.lineTo(cx - w, cy + h) 317 } 318 canvas.drawPath(path, paint) 319 } 320 321 companion object { 322 const val BACK = 0 323 const val CHEVRON = 1 324 } 325 } 326 327 /** Full width by default. A caller that wants otherwise sets the params first. */ 328 private fun LinearLayout.add(v: View) { 329 if (v.layoutParams == null) { 330 v.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) 331 } 332 addView(v) 333 } 334 }