Recently Written · git

ink-update

Ink Update: tells you when Rebind, Ink Recents or Ink Dim has a new version. F-Droid, then Google Play, then GitHub.

git clone https://github.com/equwal/ink-update

Log | Files | Refs


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 }