From a6b2b67166f663e93dbf3fa6b4aa9bafc5e6a4e5 Mon Sep 17 00:00:00 2001 From: Static Date: Thu, 5 Nov 2026 09:02:10 -0500 Subject: [PATCH 1/4] The Nine Lives widget plans its face from its real size: altar style, rows of Lives, tile size Pure layout logic for the v2 widget. The plan scores each altar and row count by the cover area it fills, keeps 5 percent of the reported height in reserve because launchers over report, and drops parts as the widget shrinks. The grid now runs to nine Lives with the current book as LIFE I, and the Lives covers are 176 px for the bigger tiles, still inside the bitmap budget. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSe18pmZggK4TswCohg --- .../ninelivesaudio/app/widget/WidgetCovers.kt | 4 +- .../app/widget/home/HomeWidgetLayout.kt | 267 +++++++++++++++--- .../app/widget/home/HomeWidgetModel.kt | 12 +- .../main/res/drawable/widget_home_rule.xml | 5 - .../main/res/drawable/widget_home_slab.xml | 9 - .../app/widget/home/HomeWidgetLayoutTest.kt | 204 ++++++++++--- .../app/widget/home/HomeWidgetModelTest.kt | 15 +- 7 files changed, 415 insertions(+), 101 deletions(-) delete mode 100644 app/src/main/res/drawable/widget_home_rule.xml delete mode 100644 app/src/main/res/drawable/widget_home_slab.xml diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt index f5bd2ce7..759bab52 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt @@ -17,8 +17,8 @@ internal const val BINDER_COVER_PX = 192 /** The large widget's current book cover (W2). */ internal const val HOME_CURRENT_COVER_PX = 192 -/** One of the large widget's next Lives tiles (W2). */ -internal const val HOME_LIFE_COVER_PX = 128 +/** One of the large widget's next Lives tiles (W2, raised for the v2 grid's bigger tiles). */ +internal const val HOME_LIFE_COVER_PX = 176 /** Bitmap bytes one RemoteViews payload may carry, well under the 1 MB transaction. */ internal const val WIDGET_BITMAP_BUDGET_BYTES = 600 * 1024 diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt index bc2726aa..d798c2b8 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt @@ -3,65 +3,250 @@ package com.ninelivesaudio.app.widget.home import com.ninelivesaudio.app.widget.HOME_CURRENT_COVER_PX import com.ninelivesaudio.app.widget.HOME_LIFE_COVER_PX import com.ninelivesaudio.app.widget.rgb565Bytes +import kotlin.math.ceil +import kotlin.math.floor + +/* + * The Nine Lives widget is a small Home screen: the altar card, the Lives + * grid with the current book as LIFE I, and one line with the title and + * chapter where Home has its mini player. The widget runs in SizeMode.Exact, + * so [homePlanFor] gets the launcher's real size and decides everything from + * it. As the widget shrinks it drops, in order: the altar line, the tall + * altar (for the compact one), rows of Lives, the Lives beyond the current + * book, the eye, and the altar itself. The current book with play and pause + * is in every plan. + */ + +/** The widget's overall arrangement. */ +internal enum class HomeFace { + /** Altar on top, the Lives grid, the title line. */ + STACKED, + + /** Two cells wide: a mini altar, LIFE I large, the title and chapter under it. */ + NARROW, + + /** One cell high: LIFE I, the title and chapter beside it, a small rank block if it fits. */ + ROW, +} + +/** How the altar card is drawn. */ +internal enum class AltarStyle { + /** Home's: rank, the eye above the wordmark, total time. */ + TALL, + + /** The eye beside a stack of rank, wordmark and total time, still centered. */ + COMPACT, + + /** Rank, wordmark and total time, no eye. */ + MINI, + + /** No altar at all. */ + NONE, +} /** - * What the Nine Lives widget keeps at a size. The design's priority order is - * rank card, then current book, then the next Lives, and smaller sizes drop - * from the bottom of that list: Lives first, then the rank card. The current - * book with its play button is in every layout. + * Everything the face needs to know about its size, in dp. [rows] times three + * is how many Lives slots the grid has (NARROW and ROW show LIFE I only). */ -internal enum class HomeLayout { CURRENT_ONLY, RANK_AND_CURRENT, FULL } +internal data class HomePlan( + val face: HomeFace, + val altar: AltarStyle, + val rows: Int, + val tileDp: Int, + val eyeDp: Int, + val showPhrase: Boolean, +) { + val slots: Int get() = if (face == HomeFace.STACKED) rows * LIFE_COLUMNS else 1 -/** Width and height in dp, as Glance hands them to the layout. */ -internal fun homeLayoutFor(widthDp: Float, heightDp: Float): HomeLayout = when { - widthDp < 250f || heightDp < 180f -> HomeLayout.CURRENT_ONLY - heightDp < 260f -> HomeLayout.RANK_AND_CURRENT - else -> HomeLayout.FULL + /** The grid's width, which the altar card and the title line share so all three line up. */ + val gridWidthDp: Int get() = if (face == HomeFace.STACKED) tileDp * LIFE_COLUMNS + LIFE_GAP_DP * (LIFE_COLUMNS - 1) else tileDp } -/** How many next Lives fit (decision J3): three under 300dp wide, four from 300dp. */ -internal fun livesSlotsFor(widthDp: Float): Int = if (widthDp < 300f) 3 else 4 +/** Padding on every edge, enough for a tile corner to clear One UI's round widget corners. */ +internal const val HOME_PAD_DP = 14 + +internal const val LIFE_COLUMNS = 3 +internal const val LIFE_GAP_DP = 8 +internal const val MAX_LIFE_ROWS = 3 -/** The widest a Lives tile grows, so the full face still fits its height. */ -internal const val MAX_LIFE_TILE_DP = 72f +/** Tiles stop growing here, so a very wide widget gets margins, not huge covers. */ +internal const val MAX_TILE_DP = 116 + +/** Below this a tile is too small to read, and the plan gives up a row or the grid. */ +internal const val MIN_TILE_DP = 52 + +/** Gap under the altar card. */ +internal const val SECTION_GAP_DP = 10 + +/** Gap above the title line. */ +internal const val LINE_GAP_DP = 6 + +/** The title line (12sp, one line). */ +internal const val TITLE_LINE_DP = 17 + +/** The two line title block of the NARROW face. */ +internal const val TITLE_BLOCK_DP = 32 + +/** + * The tall altar's eye: [MIN_EYE_DP] when the tiles need the height, growing + * with what they leave (see [tallAltarExtras]). + */ +internal const val MAX_EYE_DP = 56 +internal const val MID_EYE_DP = 48 +internal const val MIN_EYE_DP = 32 +internal const val COMPACT_EYE_DP = 44 + +/** The altar line costs this much height, and shows when the slack covers it. */ +internal const val PHRASE_DP = 17 + +/** + * How much the tall altar's covered area counts for against the compact one's. + * It wins when it costs the Lives less than about a tenth of their size. + */ +internal const val TALL_ALTAR_BONUS = 1.2f -/** Gap between two Lives tiles. */ -internal const val LIFE_GAP_DP = 10f +/** Heights of the altar styles, in dp, without the altar line. */ +internal fun altarHeightDp(style: AltarStyle, eyeDp: Int = MIN_EYE_DP): Int = when (style) { + // padding 10 + rank 13 + 4 + eye + 4 + wordmark 24 + 2 + total 16 + padding 10 + AltarStyle.TALL -> 83 + eyeDp + // padding 8 + max(eye 44, rank 13 + wordmark 22 + total 16) + padding 8 + AltarStyle.COMPACT -> 16 + maxOf(COMPACT_EYE_DP, 51) + // padding 8 + rank 12 + wordmark 20 + total 15 + padding 8 + AltarStyle.MINI -> 63 + AltarStyle.NONE -> 0 +} /** - * One square Lives tile, in dp: [slots] tiles and their gaps share the width - * inside the widget's 12dp padding, capped at [MAX_LIFE_TILE_DP]. + * Share of the reported height the plan keeps in reserve. Launchers report a + * widget's size from their cell estimate, not its real bounds: Pixel Launcher + * reports a 4x3 as 359dp high when it is drawn 344dp high. The reserve lands in + * the face's weighted gaps when the report is honest. */ -internal fun lifeTileDp(widthDp: Float, slots: Int): Float = - minOf(MAX_LIFE_TILE_DP, (widthDp - 24f - LIFE_GAP_DP * (slots - 1)) / slots) +internal const val REPORTED_SIZE_SLACK = 0.05f + +/** The face is one cell high below this inner height. */ +internal const val ROW_FACE_MAX_INNER_DP = 132 -/** Most Lives any layout shows. */ -internal const val MAX_LIVES = 4 +/** The face is two cells wide below this inner width. */ +internal const val NARROW_FACE_MAX_INNER_DP = 200 /** - * The Glance `SizeMode.Responsive` buckets, width to height in dp. Glance - * lays out each bucket once and the launcher picks the biggest that fits, so - * the layout only ever sees these sizes. One starts at 300dp wide so a widget - * that wide gets its four Lives (J3). The smallest matches the resize floor in - * widget_home_info.xml, since a launcher rounds cells and a 2x2 or a one row - * widget can come out under 180 by 110 (Pixel: about 172 by 99). + * The plan for a widget of [widthDp] by [heightDp] (the launcher's real size) + * showing [tiles] books, the current one included. [tiles] only ever lowers + * the row count, so a short history gets bigger tiles instead of empty rows. */ -internal val HOME_WIDGET_SIZES: List> = - listOf(160f to 80f, 160f to 180f, 250f to 180f, 250f to 260f, 300f to 260f) +internal fun homePlanFor(widthDp: Float, heightDp: Float, tiles: Int = MAX_LIFE_ROWS * LIFE_COLUMNS): HomePlan { + val w = widthDp - 2 * HOME_PAD_DP + val h = heightDp * (1f - REPORTED_SIZE_SLACK) - 2 * HOME_PAD_DP -/** A current only widget tall enough to stack the cover and play above the title (a 2x2). */ -internal fun isTallCurrentOnly(widthDp: Float, heightDp: Float): Boolean = - homeLayoutFor(widthDp, heightDp) == HomeLayout.CURRENT_ONLY && heightDp >= 180f + if (h < ROW_FACE_MAX_INNER_DP) { + val tile = h.toInt().coerceAtLeast(1) + return HomePlan(HomeFace.ROW, AltarStyle.NONE, rows = 0, tileDp = tile, eyeDp = 0, showPhrase = false) + } + if (w < NARROW_FACE_MAX_INNER_DP) { + val withAltar = h - altarHeightDp(AltarStyle.MINI) - SECTION_GAP_DP - LINE_GAP_DP - TITLE_BLOCK_DP + val altar = if (withAltar >= MIN_TILE_DP + 20) AltarStyle.MINI else AltarStyle.NONE + val room = if (altar == AltarStyle.MINI) withAltar else h - LINE_GAP_DP - TITLE_BLOCK_DP + val tile = minOf(w, room, MAX_TILE_DP.toFloat()).toInt().coerceAtLeast(1) + return HomePlan(HomeFace.NARROW, altar, rows = 0, tileDp = tile, eyeDp = 0, showPhrase = false) + } + + val tileByWidth = minOf(MAX_TILE_DP.toFloat(), (w - LIFE_GAP_DP * (LIFE_COLUMNS - 1)) / LIFE_COLUMNS) + val maxRows = ceil(tiles.coerceAtLeast(1) / LIFE_COLUMNS.toFloat()).toInt().coerceIn(1, MAX_LIFE_ROWS) + + // Each altar and row count is scored by the cover area it fills, the tall + // altar with a bonus for being Home's own. The tiles take the height first, + // at the smallest eye, and whatever they leave grows the eye. + var best: HomePlan? = null + var bestScore = 0f + for ((altar, rows) in ALTAR_CHOICES) { + if (rows > maxRows) continue + val eye = if (altar == AltarStyle.TALL) MIN_EYE_DP else COMPACT_EYE_DP + val room = gridRoom(h, altarHeightDp(altar, eye)) + val tile = tileFor(room, tileByWidth, rows) + if (tile < MIN_TILE_DP) continue + val score = minOf(rows * LIFE_COLUMNS, tiles) * tile * tile * (if (altar == AltarStyle.TALL) TALL_ALTAR_BONUS else 1f) + if (score <= bestScore) continue + bestScore = score + best = if (altar == AltarStyle.TALL) { + val slack = (room - (tile * rows + LIFE_GAP_DP * (rows - 1))).toInt() + val (grownEye, phrase) = tallAltarExtras(slack) + HomePlan(HomeFace.STACKED, altar, rows, tile.toInt(), grownEye, phrase) + } else { + HomePlan(HomeFace.STACKED, altar, rows, tile.toInt(), eye, showPhrase = false) + } + } + best?.let { return it } + return HomePlan(HomeFace.ROW, AltarStyle.NONE, rows = 0, tileDp = h.toInt(), eyeDp = 0, showPhrase = false) +} -/** Cover bytes one bucket draws: the current book, plus the Lives only where they show. */ -internal fun homeBucketBitmapBytes(widthDp: Float, heightDp: Float): Int { - val lives = if (homeLayoutFor(widthDp, heightDp) == HomeLayout.FULL) livesSlotsFor(widthDp) else 0 - return rgb565Bytes(HOME_CURRENT_COVER_PX) + lives * rgb565Bytes(HOME_LIFE_COVER_PX) +/** + * Spends the tall altar's [slack] (dp the grid does not need): the eye grows + * to [MID_EYE_DP] first, then the altar line comes in, then the eye grows on + * to [MAX_EYE_DP]. Returns the eye size and whether the line shows. + */ +internal fun tallAltarExtras(slack: Int): Pair { + var left = slack.coerceAtLeast(0) + var eye = MIN_EYE_DP + minOf(left, MID_EYE_DP - MIN_EYE_DP) + left -= eye - MIN_EYE_DP + val phrase = left >= PHRASE_DP + if (phrase) left -= PHRASE_DP + eye += minOf(left, MAX_EYE_DP - eye) + return eye to phrase } /** - * The most cover bytes one update can carry. API 30 sends one RemoteViews per - * size and does not share a bitmap between them, so every bucket counts. + * The altar and row counts the stacked face chooses between. Three rows only + * ever come with the tall altar, so a widget never trades Home's look for a + * third row of small tiles. + */ +private val ALTAR_CHOICES: List> = listOf( + AltarStyle.TALL to 3, + AltarStyle.TALL to 2, + AltarStyle.COMPACT to 2, + AltarStyle.TALL to 1, + AltarStyle.COMPACT to 1, +) + +/** Height left for the grid under an altar of [altarDp], above the title line. */ +private fun gridRoom(innerHeight: Float, altarDp: Int): Float = + innerHeight - altarDp - SECTION_GAP_DP - LINE_GAP_DP - TITLE_LINE_DP + +/** The square tile for [rows] rows in [room], never wider than [tileByWidth]. */ +private fun tileFor(room: Float, tileByWidth: Float, rows: Int): Float = + floor(minOf(tileByWidth, (room - LIFE_GAP_DP * (rows - 1)) / rows)) + +/** The play or pause disc on LIFE I: about a third of the tile, never too small to hit. */ +internal fun badgeDp(tileDp: Int): Int = (tileDp * 0.36f).toInt().coerceIn(MIN_BADGE_DP, MAX_BADGE_DP) + +internal const val MIN_BADGE_DP = 26 +internal const val MAX_BADGE_DP = 44 + +/** "LIFE I" to "LIFE IX", as Home labels its tiles. Past nine, plain digits. */ +internal fun lifeLabel(index: Int): String = "LIFE " + lifeNumeral(index + 1) + +private val NUMERALS = listOf("I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX") + +internal fun lifeNumeral(n: Int): String = NUMERALS.getOrNull(n - 1) ?: n.toString() + +/** + * Glance cannot set letter spacing, so tracked caps are spelled with a thin + * space between letters. A thin space is about 0.2 em, the tracking Home + * gives its wordmark (4sp at 20sp) and its rank label (2sp at 10sp). A word + * gap keeps its normal space plus the tracking on both sides. + */ +internal fun trackedCaps(text: String): String = + text.uppercase().toList().joinToString(THIN_SPACE) + +internal const val THIN_SPACE = " " + +/** Home's wordmark, tracked. */ +internal val WORDMARK: String = trackedCaps("Nine Lives") + +/** + * The most cover bytes one update carries: LIFE I and the next Lives, one + * bitmap each. Every size Glance lays out reuses the same bitmaps, and a + * RemoteViews keeps one copy of a bitmap however many views show it. */ -internal fun homeWorstCaseBitmapBytes(): Int = - HOME_WIDGET_SIZES.sumOf { (w, h) -> homeBucketBitmapBytes(w, h) } +internal fun homeUpdateBitmapBytes(): Int = + rgb565Bytes(HOME_CURRENT_COVER_PX) + MAX_LIVES * rgb565Bytes(HOME_LIFE_COVER_PX) diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetModel.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetModel.kt index 0267d13f..ef096659 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetModel.kt @@ -16,8 +16,14 @@ import kotlin.time.DurationUnit /** The current book's button. WAIT is a dimmed play while a book loads, like the app. */ internal enum class CurrentButton { PLAY, PAUSE, WAIT, OPEN } -/** Home's altar card, the same words Home shows. */ -internal data class RankCard(val epithet: String, val totalTimeText: String) +/** Home's altar card, the same words Home shows. [phrase] is the altar line under the wordmark. */ +internal data class RankCard(val epithet: String, val totalTimeText: String, val phrase: String = "") + +/** + * The next Lives after the current book. With the current book as LIFE I + * that is nine tiles at most, Home's full grid. + */ +internal const val MAX_LIVES = 8 internal data class CurrentCard( val bookId: String, @@ -86,7 +92,7 @@ internal fun buildHomeWidgetModel( if (content != WidgetContent.READY) return HomeWidgetModel(content, isLocalMode, null, null, emptyList()) val total = totalTimeGivenSeconds(recent.map { it.currentTime.toDouble(DurationUnit.SECONDS) }) - val rank = RankCard(AltarCopy.epithet(total), "Total time given: " + formatListeningTime(total)) + val rank = RankCard(AltarCopy.epithet(total), "Total time given: " + formatListeningTime(total), AltarCopy.phrase(total)) val shown = current ?: recent.first() val chapter = if (current != null) { diff --git a/app/src/main/res/drawable/widget_home_rule.xml b/app/src/main/res/drawable/widget_home_rule.xml deleted file mode 100644 index b2e454a6..00000000 --- a/app/src/main/res/drawable/widget_home_rule.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/widget_home_slab.xml b/app/src/main/res/drawable/widget_home_slab.xml deleted file mode 100644 index 48177e98..00000000 --- a/app/src/main/res/drawable/widget_home_slab.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt index fa2ac0be..078a7c46 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt @@ -2,67 +2,191 @@ package com.ninelivesaudio.app.widget.home import com.ninelivesaudio.app.widget.WIDGET_BITMAP_BUDGET_BYTES import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class HomeWidgetLayoutTest { - @Test fun `smaller sizes drop the lives first, then the rank card`() { - assertEquals(HomeLayout.FULL, homeLayoutFor(320f, 270f)) - assertEquals(HomeLayout.RANK_AND_CURRENT, homeLayoutFor(320f, 259f)) - assertEquals(HomeLayout.CURRENT_ONLY, homeLayoutFor(320f, 179f)) - assertEquals(HomeLayout.CURRENT_ONLY, homeLayoutFor(249f, 400f)) + // Sizes as the launcher reports them, in dp. Pixel 6 (Pixel Launcher), as + // logged on the emulator: it reports each size about 15dp taller than it + // draws it. One UI from Jeff's S26 Ultra screenshot. + private val pixel4x3 = 360f to 359f + private val pixel3x3 = 266f to 359f + private val pixel4x2 = 360f to 234f + private val pixel3x2 = 266f to 234f + private val pixel2x2 = 172f to 234f + private val pixel2x1 = 172f to 99f + private val oneUi4x3 = 400f to 340f + private val oneUi4x4 = 400f to 453f + + private fun plan(size: Pair, tiles: Int = 9) = homePlanFor(size.first, size.second, tiles) + + /** Height the plan's stacked face needs, smallest gaps, in dp. */ + private fun stackedHeight(p: HomePlan): Int { + val altar = altarHeightDp(p.altar, p.eyeDp) + if (p.showPhrase) PHRASE_DP else 0 + return 2 * HOME_PAD_DP + altar + SECTION_GAP_DP + p.rows * p.tileDp + (p.rows - 1) * LIFE_GAP_DP + LINE_GAP_DP + TITLE_LINE_DP + } + + @Test fun `the default 4x3 shows two rows of three Lives under the altar`() { + for (size in listOf(pixel4x3, oneUi4x3)) { + val p = plan(size) + assertEquals("$size", HomeFace.STACKED, p.face) + assertEquals("$size", 2, p.rows) + assertEquals("$size", 6, p.slots) + } + } + + @Test fun `the 4x3 grid keeps nearly the full width`() { + val p = plan(pixel4x3) + val inner = pixel4x3.first - 2 * HOME_PAD_DP + assertTrue("${p.gridWidthDp} of $inner", p.gridWidthDp >= 0.95f * inner) + } + + @Test fun `a wide widget never buys the tall altar with empty side bands`() { + // The S26 Ultra's screen faked on the emulator: a 4x3 draws 406 by 385dp. + val p = plan(406f to 385f) + assertEquals(AltarStyle.COMPACT, p.altar) + assertEquals(2, p.rows) + assertTrue("${p.gridWidthDp}", p.gridWidthDp >= 0.9f * (406 - 2 * HOME_PAD_DP)) + } + + @Test fun `a 3x3 gets Home's tall altar with the eye above the wordmark`() { + val p = plan(pixel3x3) + assertEquals(AltarStyle.TALL, p.altar) + assertEquals(2, p.rows) } - @Test fun `every responsive bucket maps to a layout that keeps play`() { - assertEquals( - listOf(HomeLayout.CURRENT_ONLY, HomeLayout.CURRENT_ONLY, HomeLayout.RANK_AND_CURRENT, HomeLayout.FULL, HomeLayout.FULL), - HOME_WIDGET_SIZES.map { (w, h) -> homeLayoutFor(w, h) }, - ) + @Test fun `a taller widget gets the third row, Home's full nine`() { + // Pixel reports a 4x4 about 478dp high. + val p = plan(360f to 478f) + assertEquals(AltarStyle.TALL, p.altar) + assertEquals(3, p.rows) + assertEquals(9, p.slots) } - @Test fun `the smallest bucket is the resize floor and keeps play`() { - assertEquals(160f to 80f, HOME_WIDGET_SIZES.first()) - assertEquals(HomeLayout.CURRENT_ONLY, homeLayoutFor(160f, 80f)) + @Test fun `three rows only ever come with the tall altar`() { + for (w in 220..520 step 20) for (h in 160..620 step 20) { + val p = homePlanFor(w.toFloat(), h.toFloat()) + if (p.rows == 3) assertEquals("$w x $h", AltarStyle.TALL, p.altar) + } } - @Test fun `wider widgets show four lives`() { - assertEquals(3, livesSlotsFor(299f)) - assertEquals(4, livesSlotsFor(300f)) + @Test fun `two rows high keeps the altar compact and one row of Lives`() { + for (size in listOf(pixel4x2, pixel3x2)) { + val p = plan(size) + assertEquals("$size", HomeFace.STACKED, p.face) + assertEquals("$size", AltarStyle.COMPACT, p.altar) + assertEquals("$size", 1, p.rows) + } } - @Test fun `the four lives breakpoint is a responsive bucket, so a 300dp widget gets four`() { - // Glance hands the layout the bucket's size, not the real one, so the - // J3 breakpoint only holds if some bucket starts exactly there. - assertTrue(HOME_WIDGET_SIZES.any { (w, h) -> w == 300f && homeLayoutFor(w, h) == HomeLayout.FULL }) - assertEquals(3, HOME_WIDGET_SIZES.filter { homeLayoutFor(it.first, it.second) == HomeLayout.FULL }.minOf { livesSlotsFor(it.first) }) + @Test fun `two cells wide shows LIFE I large under a mini altar`() { + val p = plan(pixel2x2) + assertEquals(HomeFace.NARROW, p.face) + assertEquals(AltarStyle.MINI, p.altar) + assertEquals(1, p.slots) + assertTrue("tile ${p.tileDp}", p.tileDp >= 80) } - @Test fun `the large widget stays inside the bitmap budget at every size`() { - assertTrue(homeWorstCaseBitmapBytes() <= WIDGET_BITMAP_BUDGET_BYTES) - // Lives only count where they are drawn. - assertEquals(192 * 192 * 2, homeBucketBitmapBytes(250f, 180f)) - assertEquals(192 * 192 * 2 + 4 * 128 * 128 * 2, homeBucketBitmapBytes(300f, 260f)) + @Test fun `one cell high keeps LIFE I and its disc in a row`() { + val p = plan(pixel2x1) + assertEquals(HomeFace.ROW, p.face) + assertEquals(AltarStyle.NONE, p.altar) + assertEquals((99 * (1 - REPORTED_SIZE_SLACK) - 2 * HOME_PAD_DP).toInt(), p.tileDp) } - @Test fun `a 2x2 stacks the current book, a short one keeps it in a row`() { - assertTrue(isTallCurrentOnly(160f, 180f)) - assertTrue(!isTallCurrentOnly(160f, 80f)) - assertTrue(!isTallCurrentOnly(250f, 180f)) + @Test fun `a short history gets bigger tiles, not empty rows`() { + val full = plan(pixel4x3, tiles = 9) + val few = plan(pixel4x3, tiles = 2) + assertEquals(1, few.rows) + assertTrue(few.tileDp >= full.tileDp) + // With the room a second row would have taken, Home's tall altar comes back with its line. + assertEquals(AltarStyle.TALL, few.altar) + assertTrue(few.showPhrase) } - @Test fun `the lives share the row width evenly, square, with 10dp gaps`() { - // 12dp padding a side, so the tiles and their gaps span the width inside it. - assertEquals((300f - 24f - 3 * 10f) / 4, lifeTileDp(300f, 4), 0.01f) - assertEquals((250f - 24f - 2 * 10f) / 3, lifeTileDp(250f, 3), 0.01f) - // A very wide widget stops growing the tiles, so the column still fits its height. - assertEquals(MAX_LIFE_TILE_DP, lifeTileDp(600f, 3), 0.01f) + @Test fun `every stacked plan fits its height and width, with no tile below the floor`() { + for (w in 230..520 step 10) for (h in 160..620 step 10) for (tiles in listOf(1, 4, 9)) { + val p = homePlanFor(w.toFloat(), h.toFloat(), tiles) + if (p.face != HomeFace.STACKED) continue + assertTrue("$w x $h h", stackedHeight(p) <= h * (1 - REPORTED_SIZE_SLACK)) + assertTrue("$w x $h w", p.gridWidthDp <= w - 2 * HOME_PAD_DP) + assertTrue("$w x $h tile", p.tileDp in MIN_TILE_DP..MAX_TILE_DP) + } } - @Test fun `the full face fits its smallest height with the tiles at their size`() { - // Rank slab 56, current book 64, rule 1 plus its 10dp gap, the tiles, 12dp padding a side. - for ((w, h) in HOME_WIDGET_SIZES.filter { homeLayoutFor(it.first, it.second) == HomeLayout.FULL }) { - assertTrue("$w x $h", 56f + 64f + 11f + lifeTileDp(w, livesSlotsFor(w)) + 24f <= h) + @Test fun `the narrow and row faces fit too`() { + for (w in 160..224 step 4) for (h in 176..500 step 8) { + val p = homePlanFor(w.toFloat(), h.toFloat()) + assertEquals("$w x $h", HomeFace.NARROW, p.face) + val altar = if (p.altar == AltarStyle.MINI) altarHeightDp(AltarStyle.MINI) + SECTION_GAP_DP else 0 + assertTrue("$w x $h", altar + p.tileDp + LINE_GAP_DP + TITLE_BLOCK_DP <= h * (1 - REPORTED_SIZE_SLACK) - 2 * HOME_PAD_DP) + assertTrue("$w x $h", p.tileDp <= w - 2 * HOME_PAD_DP) } + for (w in 160..520 step 20) { + val p = homePlanFor(w.toFloat(), 80f) + assertEquals(HomeFace.ROW, p.face) + assertEquals((80 * (1 - REPORTED_SIZE_SLACK) - 2 * HOME_PAD_DP).toInt(), p.tileDp) + } + } + + @Test fun `the One UI tall cell shape still fills, tall altar and two big rows`() { + val p = plan(oneUi4x4) + assertEquals(AltarStyle.TALL, p.altar) + assertTrue(p.rows >= 2) + assertTrue(p.tileDp >= 100) + } + + @Test fun `the tiles take the height before the eye grows`() { + val p = plan(pixel3x3) + assertEquals(AltarStyle.TALL, p.altar) + // Width limited: (266 - 28 - 16) / 3. + assertEquals(74, p.tileDp) + assertTrue(p.eyeDp in MIN_EYE_DP..MID_EYE_DP) + } + + @Test fun `the tall altar spends its slack on the eye, then the line, then the eye again`() { + assertEquals(MIN_EYE_DP to false, tallAltarExtras(0)) + assertEquals(MIN_EYE_DP to false, tallAltarExtras(-20)) + assertEquals(40 to false, tallAltarExtras(8)) + // Not enough for the line: the rest grows the eye instead. + assertEquals(MAX_EYE_DP to false, tallAltarExtras(16 + PHRASE_DP - 1)) + assertEquals(MID_EYE_DP to true, tallAltarExtras(16 + PHRASE_DP)) + assertEquals(MAX_EYE_DP to true, tallAltarExtras(200)) + val roomy = plan(pixel4x3, tiles = 2) + assertEquals(MAX_EYE_DP, roomy.eyeDp) + assertTrue(roomy.showPhrase) + } + + @Test fun `a launcher that reports a few percent too tall still fits`() { + // Pixel Launcher reports its 4x3 as 359dp high and draws it 344dp high. + val p = homePlanFor(360f, 359f) + assertTrue(stackedHeight(p) <= 344) + } + + @Test fun `life labels are Home's roman numerals`() { + assertEquals("LIFE I", lifeLabel(0)) + assertEquals("LIFE IV", lifeLabel(3)) + assertEquals("LIFE IX", lifeLabel(8)) + assertEquals("LIFE 10", lifeLabel(9)) + } + + @Test fun `tracked caps put a thin space between letters`() { + assertEquals("N O V I C E", trackedCaps("Novice")) + assertEquals("N I N E   L I V E S", WORDMARK) + assertEquals("", trackedCaps("")) + } + + @Test fun `the disc is about a third of the tile, within bounds`() { + assertEquals(MIN_BADGE_DP, badgeDp(50)) + assertEquals(37, badgeDp(104)) + assertEquals(MAX_BADGE_DP, badgeDp(200)) + } + + @Test fun `one update stays inside the bitmap budget with all nine Lives`() { + assertEquals(192 * 192 * 2 + 8 * 176 * 176 * 2, homeUpdateBitmapBytes()) + assertTrue(homeUpdateBitmapBytes() <= WIDGET_BITMAP_BUDGET_BYTES) + assertFalse(MAX_LIVES + 1 > MAX_LIFE_ROWS * LIFE_COLUMNS) } } diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetModelTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetModelTest.kt index 12eaa58b..14a2c281 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetModelTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetModelTest.kt @@ -80,6 +80,7 @@ class HomeWidgetModelTest { val total = books.sumOf { it.currentTime.inWholeSeconds.toDouble() } assertEquals(AltarCopy.epithet(total), m.rank!!.epithet) assertEquals("Total time given: " + formatListeningTime(total), m.rank!!.totalTimeText) + assertEquals(AltarCopy.phrase(total), m.rank!!.phrase) } @Test fun `the current book is the one in the player, else the most recent`() { @@ -87,11 +88,23 @@ class HomeWidgetModelTest { assertEquals("b1", ready(current = null).current!!.bookId) } - @Test fun `the next lives skip the current book and stop at four`() { + @Test fun `the next lives skip the current book and keep Home's order`() { assertEquals(listOf("b1", "b2", "b4", "b5"), ready(current = books[2]).lives.map { it.bookId }) assertEquals(listOf("b2", "b3", "b4", "b5"), ready(current = null).lives.map { it.bookId }) } + @Test fun `with the current book as LIFE I the grid stops at Home's nine`() { + val nine = (1..9).map { i -> AudioBook(id = "n$i", title = "N $i", currentTime = 60.seconds) } + val m = buildHomeWidgetModel(lib, true, 9, nine, null, null, PlaybackState.STOPPED, false, true) + assertEquals("n1", m.current!!.bookId) + assertEquals((2..9).map { "n$it" }, m.lives.map { it.bookId }) + // A player book outside the nine still leads, and the grid still stops at nine. + val outside = buildHomeWidgetModel(lib, true, 10, nine, AudioBook(id = "x"), null, PlaybackState.PAUSED, false, true) + assertEquals("x", outside.current!!.bookId) + assertEquals(MAX_LIVES, outside.lives.size) + assertEquals((1..8).map { "n$it" }, outside.lives.map { it.bookId }) + } + @Test fun `chapter comes from the player when live, else from the saved position`() { assertEquals("Live", ready(current = books[0], liveChapter = "Live").current!!.chapterTitle) val cold = buildHomeWidgetModel(lib, true, 5, listOf(books[0].copy(chapters = ch, currentTime = 400.seconds)), null, null, PlaybackState.STOPPED, false, true) From f8505254f3b3be35ebe722215269fe293a9f48dc Mon Sep 17 00:00:00 2001 From: Static Date: Thu, 5 Nov 2026 09:02:10 -0500 Subject: [PATCH 2/4] The Nine Lives widget looks like a small Home: the altar card, the Lives grid, play on LIFE I Glance Exact size mode, so the face is laid out for the launcher's real size instead of being stretched from a bucket. Home's altar card with the tracked gold wordmark, a three column grid of ringed tiles with their LIFE numerals, the current book as LIFE I with the gold play or pause disc, and the title and chapter on one line where Home has its mini player. The locked face shows the same layout empty, with a lock on LIFE I. The picker preview matches. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSe18pmZggK4TswCohg --- .../app/widget/home/NineLivesHomeWidget.kt | 564 +++++++++++++----- .../main/res/drawable/widget_badge_halo.xml | 5 + .../main/res/drawable/widget_home_altar.xml | 7 + .../main/res/drawable/widget_home_frame.xml | 8 + .../main/res/drawable/widget_life_label.xml | 6 + .../res/drawable/widget_life_placeholder.xml | 6 + .../main/res/drawable/widget_life_ring.xml | 19 + .../res/drawable/widget_life_ring_current.xml | 17 + .../main/res/layout/widget_home_preview.xml | 108 ++-- .../main/res/values/strings_widget_home.xml | 6 + 10 files changed, 539 insertions(+), 207 deletions(-) create mode 100644 app/src/main/res/drawable/widget_badge_halo.xml create mode 100644 app/src/main/res/drawable/widget_home_altar.xml create mode 100644 app/src/main/res/drawable/widget_home_frame.xml create mode 100644 app/src/main/res/drawable/widget_life_label.xml create mode 100644 app/src/main/res/drawable/widget_life_placeholder.xml create mode 100644 app/src/main/res/drawable/widget_life_ring.xml create mode 100644 app/src/main/res/drawable/widget_life_ring_current.xml create mode 100644 app/src/main/res/values/strings_widget_home.xml diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt index 72799865..7814b0cb 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt @@ -1,10 +1,11 @@ package com.ninelivesaudio.app.widget.home import android.content.Context +import android.graphics.Bitmap +import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.glance.GlanceId @@ -54,10 +55,15 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent /** - * Nine Lives, the large home screen widget: Home's rank card, the current - * book with play and pause, and the next Lives. Always the Noir palette - * (plan Design delta 10). The model and covers come from [HomeWidgetStore], - * so drawing never reads the database and never touches the network. + * Nine Lives, the large home screen widget: a small Home screen. Home's altar + * card, the Lives grid with the current book as LIFE I carrying play and + * pause, and the title and chapter where Home has its mini player. Always the + * Noir palette (plan Design delta 10). The model and covers come from + * [HomeWidgetStore], so drawing never reads the database and never touches + * the network. + * + * SizeMode.Exact, so the face is laid out for the launcher's real size + * ([homePlanFor]) instead of being stretched from the nearest bucket. * * Every element that can be tapped in some state is given its action on * every draw. A launcher can reapply a new draw onto the old views, and a @@ -71,8 +77,7 @@ class NineLivesHomeWidget : GlanceAppWidget() { fun homeWidgetStore(): HomeWidgetStore } - override val sizeMode: SizeMode = - SizeMode.Responsive(HOME_WIDGET_SIZES.map { (w, h) -> DpSize(w.dp, h.dp) }.toSet()) + override val sizeMode: SizeMode = SizeMode.Exact override suspend fun provideGlance(context: Context, id: GlanceId) { val store = EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java).homeWidgetStore() @@ -84,6 +89,8 @@ class NineLivesHomeWidget : GlanceAppWidget() { } } +private const val TAG = "NineLivesHomeWidget" + private val textPrimary = ColorProvider(NoirColors.archiveTextPrimary) private val textSecondary = ColorProvider(NoirColors.archiveTextSecondary) private val textMuted = ColorProvider(NoirColors.archiveTextMuted) @@ -93,7 +100,6 @@ private val gold = ColorProvider(NoirColors.goldFilament) private fun HomeWidgetFace(snapshot: HomeWidgetSnapshot?) { val context = LocalContext.current val size = LocalSize.current - val layout = homeLayoutFor(size.width.value, size.height.value) val model = snapshot?.model val rootLaunch = if (model?.content == WidgetContent.LOCKED) WidgetLaunch.OpenUnlock else WidgetLaunch.OpenApp val rootLabel = when (model?.content) { @@ -101,67 +107,79 @@ private fun HomeWidgetFace(snapshot: HomeWidgetSnapshot?) { null, WidgetContent.READY -> WidgetCopy.CD_OPEN_APP else -> WidgetCopy.emptyLine(model.content, model.isLocalMode) ?: WidgetCopy.CD_OPEN_APP } + val tiles = when (model?.content) { + WidgetContent.READY -> (if (model.current != null) 1 else 0) + model.lives.size + // The locked face lays out a full grid of empty Lives, the same plan a full history gets. + else -> MAX_LIFE_ROWS * LIFE_COLUMNS + } + val plan = homePlanFor(size.width.value, size.height.value, tiles) + Log.d(TAG, "face ${size.width.value}x${size.height.value} tiles=$tiles $plan") + val rootAction = actionStartActivity(widgetMainIntent(context, rootLaunch)) Box( modifier = GlanceModifier .fillMaxSize() .appWidgetBackground() - .background(ImageProvider(R.drawable.widget_frame)) + .background(ImageProvider(R.drawable.widget_home_frame)) .cornerRadius(16.dp) - .clickable(actionStartActivity(widgetMainIntent(context, rootLaunch))) + .clickable(rootAction) .semantics { contentDescription = rootLabel } - .padding(if (layout == HomeLayout.CURRENT_ONLY) 10.dp else 12.dp), + .padding(HOME_PAD_DP.dp), contentAlignment = Alignment.Center, ) { when { - model == null -> Message(line = null, locked = false, layout = layout) - model.content == WidgetContent.LOCKED -> Message(WidgetCopy.HOME_LOCKED, locked = true, layout = layout) - model.content != WidgetContent.READY -> Message(WidgetCopy.emptyLine(model.content, model.isLocalMode), locked = false, layout = layout) - else -> Ready(model, snapshot.art, layout, size.width.value, isTallCurrentOnly(size.width.value, size.height.value)) + model == null -> Message(line = null, locked = false, plan = plan, action = rootAction) + model.content == WidgetContent.LOCKED && plan.face == HomeFace.STACKED -> LockedFace(plan, rootAction) + model.content == WidgetContent.LOCKED -> Message(WidgetCopy.HOME_LOCKED, locked = true, plan = plan, action = rootAction) + model.content != WidgetContent.READY -> Message(WidgetCopy.emptyLine(model.content, model.isLocalMode), locked = false, plan = plan, action = rootAction) + else -> Ready(model, snapshot.art, plan) } } } -/** Locked, signed out, empty: the eye and one calm line. Never a book, a cover or a rank. */ +/** + * Signed out, empty, and a locked widget too small for [LockedFace]: the eye, + * the wordmark and one calm line. Never a book, a cover or a rank. + */ @Composable -private fun Message(line: String?, locked: Boolean, layout: HomeLayout) { +private fun Message(line: String?, locked: Boolean, plan: HomePlan, action: Action) { + val roomy = plan.face == HomeFace.STACKED Column( modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalAlignment = Alignment.CenterVertically, ) { - Image( - provider = ImageProvider(R.drawable.widget_eye), - contentDescription = null, - modifier = GlanceModifier.size(if (layout == HomeLayout.CURRENT_ONLY) 40.dp else 56.dp), - ) - if (line != null) { - Spacer(GlanceModifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - if (locked) { - Image( - provider = ImageProvider(R.drawable.widget_lock), - contentDescription = null, - modifier = GlanceModifier.size(16.dp), - ) - Spacer(GlanceModifier.width(6.dp)) - } - Text( - text = line, - maxLines = 3, - style = TextStyle(color = textSecondary, fontSize = 12.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center), + Column( + modifier = GlanceModifier + .then(if (roomy) GlanceModifier.width(plan.gridWidthDp.dp) else GlanceModifier.fillMaxWidth()) + .then(if (roomy) GlanceModifier.background(ImageProvider(R.drawable.widget_home_altar)).cornerRadius(14.dp) else GlanceModifier) + .clickable(action) + .padding(horizontal = 10.dp, vertical = if (roomy) 12.dp else 0.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (plan.face != HomeFace.ROW) { + Image( + provider = ImageProvider(R.drawable.widget_eye), + contentDescription = null, + modifier = GlanceModifier.size(if (roomy) 48.dp else 36.dp), ) + Spacer(GlanceModifier.height(4.dp)) } - } - if (locked && layout == HomeLayout.FULL) { - // An altar waiting for its Lives: empty sleeves, generic art only. - Spacer(GlanceModifier.height(14.dp)) - Row { - repeat(3) { i -> - if (i > 0) Spacer(GlanceModifier.width(10.dp)) - Image( - provider = ImageProvider(R.drawable.widget_sleeve_empty), - contentDescription = null, - modifier = GlanceModifier.size(40.dp), + Wordmark(if (roomy) 18 else 14) + if (line != null) { + Spacer(GlanceModifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + if (locked) { + Image( + provider = ImageProvider(R.drawable.widget_lock), + contentDescription = null, + modifier = GlanceModifier.size(14.dp), + ) + Spacer(GlanceModifier.width(6.dp)) + } + Text( + text = line, + maxLines = 3, + style = TextStyle(color = textSecondary, fontSize = 12.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center), ) } } @@ -170,152 +188,360 @@ private fun Message(line: String?, locked: Boolean, layout: HomeLayout) { } /** - * The rank card anchors to the top and the Lives to the bottom, with the - * current book between them, and equal weighted gaps take whatever height the - * launcher gives. Without Lives the current book sits in the middle of the - * space under the rank card. Only the one row face stays centered. + * Locked, with room: the same plan as a full widget, laid out empty. The altar + * with the eye and the wordmark, the grid of empty Lives with a lock where + * LIFE I's play disc goes, and the lock line where the title goes. Generic + * art only, never a book, a cover or a rank. Any tap opens the unlock. */ @Composable -private fun Ready(model: HomeWidgetModel, art: HomeWidgetArt, layout: HomeLayout, widthDp: Float, tall: Boolean) { - val context = LocalContext.current - if (layout == HomeLayout.CURRENT_ONLY) { - Column( - modifier = GlanceModifier.fillMaxSize(), - verticalAlignment = if (tall) Alignment.Top else Alignment.CenterVertically, - ) { - model.current?.let { - if (tall) CurrentBookStacked(it, art.current) else CurrentBook(it, art.current, compact = true) +private fun LockedFace(plan: HomePlan, unlock: Action) { + Column(modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + val card = GlanceModifier + .width(plan.gridWidthDp.dp) + .background(ImageProvider(R.drawable.widget_home_altar)) + .cornerRadius(14.dp) + .clickable(unlock) + if (plan.altar == AltarStyle.TALL) { + Column(modifier = card.padding(vertical = 12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Image(provider = ImageProvider(R.drawable.widget_eye), contentDescription = null, modifier = GlanceModifier.size(plan.eyeDp.dp)) + Spacer(GlanceModifier.height(4.dp)) + Wordmark(19) + } + } else { + Row( + modifier = card.padding(vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Image(provider = ImageProvider(R.drawable.widget_eye), contentDescription = null, modifier = GlanceModifier.size(COMPACT_EYE_DP.dp)) + Spacer(GlanceModifier.width(12.dp)) + Wordmark(17) } } - return - } - Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.Top) { - model.rank?.let { RankSlab(it, actionStartActivity(widgetMainIntent(context, WidgetLaunch.OpenApp))) } - Spacer(GlanceModifier.defaultWeight()) - model.current?.let { CurrentBook(it, art.current, compact = false) } + Spacer(GlanceModifier.height(SECTION_GAP_DP.dp)) Spacer(GlanceModifier.defaultWeight()) - val slots = livesSlotsFor(widthDp) - val lives = model.lives.take(slots) - if (layout == HomeLayout.FULL && lives.isNotEmpty()) { - Image( - provider = ImageProvider(R.drawable.widget_home_rule), - contentDescription = null, - contentScale = ContentScale.FillBounds, - modifier = GlanceModifier.fillMaxWidth().height(1.dp), - ) - Spacer(GlanceModifier.height(LIFE_GAP_DP.dp)) - // Every slot is laid out, the first and last flush with the edges and - // equal weighted gaps between, so the tiles span the full width at any - // real width. A slot without a Life stays empty, as on Home's grid. - val tile = lifeTileDp(widthDp, slots).toInt() - Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - repeat(slots) { i -> - if (i > 0) Spacer(GlanceModifier.defaultWeight()) - val life = lives.getOrNull(i) - if (life != null) { - Cover( - bitmap = art.lives[life.bookId], - label = life.title, - sizeDp = tile, - action = openBook(context, life.bookId), - ) - } else { - Spacer(GlanceModifier.size(tile.dp)) + Column { + repeat(plan.rows) { r -> + if (r > 0) Spacer(GlanceModifier.height(LIFE_GAP_DP.dp)) + Row { + repeat(LIFE_COLUMNS) { c -> + if (c > 0) Spacer(GlanceModifier.width(LIFE_GAP_DP.dp)) + val i = r * LIFE_COLUMNS + c + Box( + modifier = GlanceModifier + .size(plan.tileDp.dp) + .background(ImageProvider(if (i == 0) R.drawable.widget_life_ring_current else R.drawable.widget_life_ring)) + .clickable(unlock), + contentAlignment = Alignment.Center, + ) { + if (i == 0) { + Box(modifier = GlanceModifier.fillMaxSize().padding(bottom = 10.dp), contentAlignment = Alignment.Center) { + Image( + provider = ImageProvider(R.drawable.widget_lock), + contentDescription = null, + modifier = GlanceModifier.size(badgeDp(plan.tileDp).dp), + ) + } + } + Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + LifeLabel(lifeLabel(i), plan.tileDp, dim = true) + } + } } } } } + Spacer(GlanceModifier.height(LINE_GAP_DP.dp)) + Spacer(GlanceModifier.defaultWeight()) + Text( + text = WidgetCopy.HOME_LOCKED, + maxLines = 1, + modifier = GlanceModifier.clickable(unlock), + style = TextStyle(color = textSecondary, fontSize = 12.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center), + ) } } -/** Home's altar card, small: the eye, the epithet, and the total time given. Opens Home. */ @Composable -private fun RankSlab(rank: RankCard, action: Action) { - Row( - modifier = GlanceModifier - .fillMaxWidth() - .background(ImageProvider(R.drawable.widget_home_slab)) - .cornerRadius(12.dp) - .clickable(action) - .semantics { contentDescription = rank.epithet + ". " + rank.totalTimeText } - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - provider = ImageProvider(R.drawable.widget_eye), - contentDescription = null, - modifier = GlanceModifier.size(40.dp), - ) - Spacer(GlanceModifier.width(10.dp)) - Column { - Text( - text = rank.epithet, - maxLines = 1, - style = TextStyle(color = textMuted, fontSize = 10.sp, fontWeight = FontWeight.Bold), - ) - Spacer(GlanceModifier.height(2.dp)) +private fun Ready(model: HomeWidgetModel, art: HomeWidgetArt, plan: HomePlan) { + val context = LocalContext.current + val current = model.current ?: return + val openApp = actionStartActivity(widgetMainIntent(context, WidgetLaunch.OpenApp)) + when (plan.face) { + HomeFace.ROW -> RowFace(model, art, plan, current, openApp) + HomeFace.NARROW -> NarrowFace(model, art, plan, current, openApp) + HomeFace.STACKED -> StackedFace(model, art, plan, current, openApp) + } +} + +/** + * Home, small: the altar card on top, the Lives grid, the title line at the + * bottom. All three share the grid's width so their edges line up, and the + * height left over goes into the two gaps between them. + */ +@Composable +private fun StackedFace(model: HomeWidgetModel, art: HomeWidgetArt, plan: HomePlan, current: CurrentCard, openApp: Action) { + Column(modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + model.rank?.let { Altar(it, plan, openApp) } + Spacer(GlanceModifier.height(SECTION_GAP_DP.dp)) + Spacer(GlanceModifier.defaultWeight()) + LivesGrid(model, art, plan, current) + Spacer(GlanceModifier.height(LINE_GAP_DP.dp)) + Spacer(GlanceModifier.defaultWeight()) + TitleLine(current, plan.gridWidthDp) + } +} + +/** Two cells wide: a mini altar, LIFE I large with its disc, the title and chapter under it. */ +@Composable +private fun NarrowFace(model: HomeWidgetModel, art: HomeWidgetArt, plan: HomePlan, current: CurrentCard, openApp: Action) { + Column(modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + if (plan.altar == AltarStyle.MINI) { + model.rank?.let { MiniAltar(it, openApp) } + Spacer(GlanceModifier.height(SECTION_GAP_DP.dp)) + } + Spacer(GlanceModifier.defaultWeight()) + Life(art.current, current.title, lifeLabel(0), plan.tileDp, current) + Spacer(GlanceModifier.height(LINE_GAP_DP.dp)) + Spacer(GlanceModifier.defaultWeight()) + Column( + modifier = GlanceModifier.fillMaxWidth().clickable(openBook(LocalContext.current, current.bookId)), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text( - text = rank.totalTimeText, + text = current.title, maxLines = 1, - style = TextStyle(color = gold, fontSize = 12.sp, fontWeight = FontWeight.Medium), + style = TextStyle(color = textPrimary, fontSize = 12.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center), ) + current.chapterTitle?.let { + Text(text = it, maxLines = 1, style = TextStyle(color = textSecondary, fontSize = 11.sp, textAlign = TextAlign.Center)) + } } } } +/** One cell high: LIFE I, the title and chapter beside it, and the rank at the far end if it fits. */ @Composable -private fun CurrentBook(current: CurrentCard, cover: android.graphics.Bitmap?, compact: Boolean) { +private fun RowFace(model: HomeWidgetModel, art: HomeWidgetArt, plan: HomePlan, current: CurrentCard, openApp: Action) { val context = LocalContext.current - val open = openBook(context, current.bookId) - Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Cover(bitmap = cover, label = current.title, sizeDp = if (compact) 48 else 64, action = open) - Spacer(GlanceModifier.width(if (compact) 8.dp else 10.dp)) - Column( - modifier = GlanceModifier.defaultWeight().clickable(open), - verticalAlignment = Alignment.CenterVertically, - ) { + val width = LocalSize.current.width.value - 2 * HOME_PAD_DP + Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + Life(art.current, current.title, lifeLabel(0), plan.tileDp, current) + Spacer(GlanceModifier.width(10.dp)) + Column(modifier = GlanceModifier.defaultWeight().clickable(openBook(context, current.bookId))) { Text( text = current.title, maxLines = 2, - style = TextStyle(color = textPrimary, fontSize = if (compact) 13.sp else 14.sp, fontWeight = FontWeight.Medium), + style = TextStyle(color = textPrimary, fontSize = 13.sp, fontWeight = FontWeight.Medium), ) current.chapterTitle?.let { Spacer(GlanceModifier.height(2.dp)) - Text(text = it, maxLines = 1, style = TextStyle(color = textSecondary, fontSize = 12.sp)) + Text(text = it, maxLines = 1, style = TextStyle(color = textSecondary, fontSize = 11.sp)) + } + } + val rank = model.rank + if (rank != null && width - plan.tileDp - 10 >= ROW_RANK_MIN_DP) { + Spacer(GlanceModifier.width(10.dp)) + Column( + modifier = GlanceModifier.clickable(openApp).semantics { contentDescription = rank.epithet + ". " + rank.totalTimeText }, + horizontalAlignment = Alignment.End, + ) { + Text(text = trackedCaps(rank.epithet), maxLines = 1, style = TextStyle(color = textMuted, fontSize = 8.sp, fontWeight = FontWeight.Medium)) + Spacer(GlanceModifier.height(2.dp)) + Wordmark(12) + Spacer(GlanceModifier.height(2.dp)) + Text(text = rank.totalTimeText, maxLines = 1, style = TextStyle(color = gold, fontSize = 10.sp, fontWeight = FontWeight.Medium)) + } + } + } +} + +/** Width the row face needs beside LIFE I before it also shows the rank block. */ +private const val ROW_RANK_MIN_DP = 200 + +/** + * Home's altar card. TALL is Home's own order: rank in tracked caps, the eye, + * the wordmark, the altar line when there is room, the total time given. + * COMPACT keeps the same words with the eye beside them, centered. Opens Home. + */ +@Composable +private fun Altar(rank: RankCard, plan: HomePlan, openApp: Action) { + val card = GlanceModifier + .width(plan.gridWidthDp.dp) + .background(ImageProvider(R.drawable.widget_home_altar)) + .cornerRadius(14.dp) + .clickable(openApp) + .semantics { contentDescription = rank.epithet + ". " + rank.totalTimeText } + if (plan.altar == AltarStyle.TALL) { + Column(modifier = card.padding(horizontal = 10.dp, vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = trackedCaps(rank.epithet), maxLines = 1, style = TextStyle(color = textMuted, fontSize = 9.sp, fontWeight = FontWeight.Medium)) + Spacer(GlanceModifier.height(4.dp)) + Image(provider = ImageProvider(R.drawable.widget_eye), contentDescription = null, modifier = GlanceModifier.size(plan.eyeDp.dp)) + Spacer(GlanceModifier.height(4.dp)) + Wordmark(19) + if (plan.showPhrase && rank.phrase.isNotBlank()) { + Spacer(GlanceModifier.height(2.dp)) + Text(text = rank.phrase, maxLines = 1, style = TextStyle(color = textSecondary, fontSize = 11.sp, textAlign = TextAlign.Center)) } + Spacer(GlanceModifier.height(2.dp)) + Text(text = rank.totalTimeText, maxLines = 1, style = TextStyle(color = gold, fontSize = 11.sp, fontWeight = FontWeight.Medium)) } - Spacer(GlanceModifier.width(if (compact) 6.dp else 8.dp)) - PlayButton(current, open, if (compact) 40 else 44) + } else { + Row( + modifier = card.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Image(provider = ImageProvider(R.drawable.widget_eye), contentDescription = null, modifier = GlanceModifier.size(COMPACT_EYE_DP.dp)) + Spacer(GlanceModifier.width(12.dp)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = trackedCaps(rank.epithet), maxLines = 1, style = TextStyle(color = textMuted, fontSize = 9.sp, fontWeight = FontWeight.Medium)) + Wordmark(17) + Text(text = rank.totalTimeText, maxLines = 1, style = TextStyle(color = gold, fontSize = 11.sp, fontWeight = FontWeight.Medium)) + } + } + } +} + +/** The two cell altar: rank, wordmark, total time. No eye, the cover needs the height. */ +@Composable +private fun MiniAltar(rank: RankCard, openApp: Action) { + Column( + modifier = GlanceModifier + .fillMaxWidth() + .background(ImageProvider(R.drawable.widget_home_altar)) + .cornerRadius(14.dp) + .clickable(openApp) + .semantics { contentDescription = rank.epithet + ". " + rank.totalTimeText } + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = trackedCaps(rank.epithet), maxLines = 1, style = TextStyle(color = textMuted, fontSize = 8.sp, fontWeight = FontWeight.Medium)) + Wordmark(14) + Text(text = rank.totalTimeText, maxLines = 1, style = TextStyle(color = gold, fontSize = 10.sp, fontWeight = FontWeight.Medium)) } } -/** A 2x2: cover and play side by side at the top, the title and chapter anchored to the bottom. */ +/** NINE LIVES in Home's bold tracked gold. Text, not an image, so it follows the system font like the app does. */ +@Composable +private fun Wordmark(sizeSp: Int) { + Text( + text = WORDMARK, + maxLines = 1, + style = TextStyle(color = gold, fontSize = sizeSp.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), + ) +} + +/** Home's grid: three columns, the current book first, empty slots left empty as on Home. */ @Composable -private fun CurrentBookStacked(current: CurrentCard, cover: android.graphics.Bitmap?) { +private fun LivesGrid(model: HomeWidgetModel, art: HomeWidgetArt, plan: HomePlan, current: CurrentCard) { val context = LocalContext.current - val open = openBook(context, current.bookId) - Column(modifier = GlanceModifier.fillMaxSize()) { - Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Cover(bitmap = cover, label = current.title, sizeDp = 72, action = open) - Spacer(GlanceModifier.defaultWeight()) - PlayButton(current, open, 44) + val books = listOf(current.bookId to current.title) + model.lives.map { it.bookId to it.title } + Column { + repeat(plan.rows) { r -> + if (r > 0) Spacer(GlanceModifier.height(LIFE_GAP_DP.dp)) + Row { + repeat(LIFE_COLUMNS) { c -> + if (c > 0) Spacer(GlanceModifier.width(LIFE_GAP_DP.dp)) + val i = r * LIFE_COLUMNS + c + val book = books.getOrNull(i) + when { + book == null -> Spacer(GlanceModifier.size(plan.tileDp.dp)) + i == 0 -> Life(art.current, current.title, lifeLabel(0), plan.tileDp, current) + else -> Life(art.lives[book.first], book.second, lifeLabel(i), plan.tileDp, null, openBook(context, book.first)) + } + } + } } - Spacer(GlanceModifier.height(10.dp)) - Spacer(GlanceModifier.defaultWeight()) - Column(modifier = GlanceModifier.fillMaxWidth().clickable(open)) { - Text( - text = current.title, - maxLines = 2, - style = TextStyle(color = textPrimary, fontSize = 14.sp, fontWeight = FontWeight.Medium), + } +} + +/** + * One Lives tile, after Home's: the ring, the cover inset in it, the LIFE + * numeral at the bottom. Tapping it opens the book. LIFE I ([current] set) + * has the full gold ring and the play or pause disc in its middle. + */ +@Composable +private fun Life( + bitmap: Bitmap?, + title: String, + label: String, + sizeDp: Int, + current: CurrentCard?, + action: Action? = null, +) { + val context = LocalContext.current + val open = action ?: openBook(context, current?.bookId.orEmpty()) + val coverDp = sizeDp - 2 * TILE_INSET_DP + Box( + modifier = GlanceModifier + .size(sizeDp.dp) + .background(ImageProvider(if (current != null) R.drawable.widget_life_ring_current else R.drawable.widget_life_ring)) + .clickable(open) + .semantics { contentDescription = "$label. $title" }, + contentAlignment = Alignment.Center, + ) { + if (bitmap != null) { + Image( + provider = ImageProvider(bitmap), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = GlanceModifier.size(coverDp.dp), ) - current.chapterTitle?.let { - Spacer(GlanceModifier.height(2.dp)) - Text(text = it, maxLines = 1, style = TextStyle(color = textSecondary, fontSize = 12.sp)) + } else { + // No cover yet: the title on a plain slot, as Home's placeholder does. + Box( + modifier = GlanceModifier.size(coverDp.dp).background(ImageProvider(R.drawable.widget_life_placeholder)).padding(6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = title, + maxLines = 3, + style = TextStyle(color = textSecondary, fontSize = if (sizeDp >= 80) 11.sp else 9.sp, textAlign = TextAlign.Center), + ) + } + } + Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + LifeLabel(label, sizeDp, dim = false) + } + if (current != null) { + val disc = badgeDp(sizeDp) + // A little above the middle, so the disc clears the LIFE plate on small tiles. + Box(modifier = GlanceModifier.fillMaxSize().padding(bottom = 10.dp), contentAlignment = Alignment.Center) { + Box( + modifier = GlanceModifier.size((disc + 6).dp).background(ImageProvider(R.drawable.widget_badge_halo)), + contentAlignment = Alignment.Center, + ) { + PlayButton(current, open, disc) + } } } } } +/** Space between a tile's edge and its cover, room for the ring. */ +private const val TILE_INSET_DP = 7 + +/** The LIFE numeral on its small dark plate, above the tile's bottom edge. */ +@Composable +private fun LifeLabel(label: String, sizeDp: Int, dim: Boolean) { + Column(modifier = GlanceModifier.padding(bottom = if (sizeDp >= 80) 10.dp else 8.dp)) { + Text( + text = label, + maxLines = 1, + modifier = GlanceModifier + .background(ImageProvider(R.drawable.widget_life_label)) + .cornerRadius(5.dp) + .padding(horizontal = 5.dp, vertical = 1.dp), + style = TextStyle( + color = if (dim) textMuted else gold, + fontSize = if (sizeDp >= 80) 9.sp else 8.sp, + fontWeight = FontWeight.Bold, + ), + ) + } +} + @Composable private fun PlayButton(current: CurrentCard, open: Action, sizeDp: Int) { val context = LocalContext.current @@ -335,18 +561,32 @@ private fun PlayButton(current: CurrentCard, open: Action, sizeDp: Int) { } /** - * A square cover, fitted whole: the bitmap is already square with its - * corners rounded on the widget's own surface colour (WidgetCoverLoader), so - * nothing is cropped and the corners match on every API level. + * Where Home has its mini player: the title on the left, the chapter on the + * right, one line across the grid's width. Opens the book. */ @Composable -private fun Cover(bitmap: android.graphics.Bitmap?, label: String, sizeDp: Int, action: Action) { - Image( - provider = bitmap?.let { ImageProvider(it) } ?: ImageProvider(R.drawable.widget_sleeve_empty), - contentDescription = label, - contentScale = ContentScale.Fit, - modifier = GlanceModifier.size(sizeDp.dp).clickable(action), - ) +private fun TitleLine(current: CurrentCard, widthDp: Int) { + val context = LocalContext.current + Row( + modifier = GlanceModifier.width(widthDp.dp).clickable(openBook(context, current.bookId)).padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = current.title, + maxLines = 1, + modifier = GlanceModifier.defaultWeight(), + style = TextStyle(color = textPrimary, fontSize = 12.sp, fontWeight = FontWeight.Medium), + ) + current.chapterTitle?.let { + Spacer(GlanceModifier.width(10.dp)) + Text( + text = it, + maxLines = 1, + modifier = GlanceModifier.defaultWeight(), + style = TextStyle(color = textSecondary, fontSize = 11.sp, textAlign = TextAlign.End), + ) + } + } } /** Open its page goes through the trampoline, which drops a stale tap from another library. */ diff --git a/app/src/main/res/drawable/widget_badge_halo.xml b/app/src/main/res/drawable/widget_badge_halo.xml new file mode 100644 index 00000000..4a43f9be --- /dev/null +++ b/app/src/main/res/drawable/widget_badge_halo.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_home_altar.xml b/app/src/main/res/drawable/widget_home_altar.xml new file mode 100644 index 00000000..ca08432e --- /dev/null +++ b/app/src/main/res/drawable/widget_home_altar.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/widget_home_frame.xml b/app/src/main/res/drawable/widget_home_frame.xml new file mode 100644 index 00000000..f1f421b4 --- /dev/null +++ b/app/src/main/res/drawable/widget_home_frame.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/widget_life_label.xml b/app/src/main/res/drawable/widget_life_label.xml new file mode 100644 index 00000000..63a4a88e --- /dev/null +++ b/app/src/main/res/drawable/widget_life_label.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_life_placeholder.xml b/app/src/main/res/drawable/widget_life_placeholder.xml new file mode 100644 index 00000000..c5b8715c --- /dev/null +++ b/app/src/main/res/drawable/widget_life_placeholder.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_life_ring.xml b/app/src/main/res/drawable/widget_life_ring.xml new file mode 100644 index 00000000..e2e0d90e --- /dev/null +++ b/app/src/main/res/drawable/widget_life_ring.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_life_ring_current.xml b/app/src/main/res/drawable/widget_life_ring_current.xml new file mode 100644 index 00000000..b987bcd1 --- /dev/null +++ b/app/src/main/res/drawable/widget_life_ring_current.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_home_preview.xml b/app/src/main/res/layout/widget_home_preview.xml index 33e28e8e..878c55a3 100644 --- a/app/src/main/res/layout/widget_home_preview.xml +++ b/app/src/main/res/layout/widget_home_preview.xml @@ -1,21 +1,21 @@ + A small Home: the altar card with the eye and the wordmark, then two rows + of empty Lives, the first ringed in full gold with the play disc. --> + android:padding="14dp"> @@ -24,62 +24,80 @@ android:layout_height="40dp" android:importantForAccessibility="no" android:src="@drawable/widget_eye" /> + + - + + + + + android:layout_height="match_parent" + android:layout_marginStart="8dp" + android:layout_weight="1" + android:background="@drawable/widget_life_ring" /> - + - - - + - + - + diff --git a/app/src/main/res/values/strings_widget_home.xml b/app/src/main/res/values/strings_widget_home.xml new file mode 100644 index 00000000..fdb67d5c --- /dev/null +++ b/app/src/main/res/values/strings_widget_home.xml @@ -0,0 +1,6 @@ + + + + N I N E   L I V E S + From 85f211522a65dfc83bf1ddc5c7b62eee97ce9d15 Mon Sep 17 00:00:00 2001 From: Static Date: Thu, 5 Nov 2026 09:08:04 -0500 Subject: [PATCH 3/4] Nine Lives widget: lighter LIFE plates, Home's tracked numerals and altar dot, square preview tiles After a side by side with Home: the numeral plates drop to 60 percent so the tiles read closer to Home's bare numerals, the numerals get Home's light tracking, the tall altar gets Home's small gold dot above the eye, and the picker preview's tiles stay square. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSe18pmZggK4TswCohg --- .../app/widget/home/HomeWidgetLayout.kt | 12 ++++-- .../app/widget/home/NineLivesHomeWidget.kt | 7 +++- .../main/res/drawable/widget_altar_dot.xml | 5 +++ .../main/res/drawable/widget_life_label.xml | 4 +- .../main/res/layout/widget_home_preview.xml | 42 ++++++++----------- .../app/widget/home/HomeWidgetLayoutTest.kt | 10 +++-- 6 files changed, 45 insertions(+), 35 deletions(-) create mode 100644 app/src/main/res/drawable/widget_altar_dot.xml diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt index d798c2b8..ce76fd6f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt @@ -107,8 +107,8 @@ internal const val TALL_ALTAR_BONUS = 1.2f /** Heights of the altar styles, in dp, without the altar line. */ internal fun altarHeightDp(style: AltarStyle, eyeDp: Int = MIN_EYE_DP): Int = when (style) { - // padding 10 + rank 13 + 4 + eye + 4 + wordmark 24 + 2 + total 16 + padding 10 - AltarStyle.TALL -> 83 + eyeDp + // padding 10 + rank 13 + 3 + dot 4 + 3 + eye + 4 + wordmark 24 + 2 + total 16 + padding 10 + AltarStyle.TALL -> 89 + eyeDp // padding 8 + max(eye 44, rank 13 + wordmark 22 + total 16) + padding 8 AltarStyle.COMPACT -> 16 + maxOf(COMPACT_EYE_DP, 51) // padding 8 + rank 12 + wordmark 20 + total 15 + padding 8 @@ -238,7 +238,13 @@ internal fun lifeNumeral(n: Int): String = NUMERALS.getOrNull(n - 1) ?: n.toStri internal fun trackedCaps(text: String): String = text.uppercase().toList().joinToString(THIN_SPACE) -internal const val THIN_SPACE = " " +internal const val THIN_SPACE = "\u2009" + +/** A hair space, for Home's lighter 1sp tracking on its 9sp LIFE numerals (about 0.1 em). */ +internal const val HAIR_SPACE = "\u200A" + +/** A LIFE numeral tracked like Home's, with a hair space between letters. */ +internal fun trackedLabel(text: String): String = text.toList().joinToString(HAIR_SPACE) /** Home's wordmark, tracked. */ internal val WORDMARK: String = trackedCaps("Nine Lives") diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt index 7814b0cb..905e0280 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt @@ -375,7 +375,10 @@ private fun Altar(rank: RankCard, plan: HomePlan, openApp: Action) { if (plan.altar == AltarStyle.TALL) { Column(modifier = card.padding(horizontal = 10.dp, vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text(text = trackedCaps(rank.epithet), maxLines = 1, style = TextStyle(color = textMuted, fontSize = 9.sp, fontWeight = FontWeight.Medium)) - Spacer(GlanceModifier.height(4.dp)) + Spacer(GlanceModifier.height(3.dp)) + // Home's small gold dot between the rank and the eye. + Image(provider = ImageProvider(R.drawable.widget_altar_dot), contentDescription = null, modifier = GlanceModifier.size(4.dp)) + Spacer(GlanceModifier.height(3.dp)) Image(provider = ImageProvider(R.drawable.widget_eye), contentDescription = null, modifier = GlanceModifier.size(plan.eyeDp.dp)) Spacer(GlanceModifier.height(4.dp)) Wordmark(19) @@ -527,7 +530,7 @@ private const val TILE_INSET_DP = 7 private fun LifeLabel(label: String, sizeDp: Int, dim: Boolean) { Column(modifier = GlanceModifier.padding(bottom = if (sizeDp >= 80) 10.dp else 8.dp)) { Text( - text = label, + text = trackedLabel(label), maxLines = 1, modifier = GlanceModifier .background(ImageProvider(R.drawable.widget_life_label)) diff --git a/app/src/main/res/drawable/widget_altar_dot.xml b/app/src/main/res/drawable/widget_altar_dot.xml new file mode 100644 index 00000000..23d6746e --- /dev/null +++ b/app/src/main/res/drawable/widget_altar_dot.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_life_label.xml b/app/src/main/res/drawable/widget_life_label.xml index 63a4a88e..e4cbc10e 100644 --- a/app/src/main/res/drawable/widget_life_label.xml +++ b/app/src/main/res/drawable/widget_life_label.xml @@ -1,6 +1,6 @@ - + - + diff --git a/app/src/main/res/layout/widget_home_preview.xml b/app/src/main/res/layout/widget_home_preview.xml index 878c55a3..5972ecff 100644 --- a/app/src/main/res/layout/widget_home_preview.xml +++ b/app/src/main/res/layout/widget_home_preview.xml @@ -1,7 +1,7 @@ + of empty square Lives, the first ringed in full gold with the play disc. --> diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt index 078a7c46..22bcae5b 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt @@ -58,8 +58,9 @@ class HomeWidgetLayoutTest { } @Test fun `a taller widget gets the third row, Home's full nine`() { - // Pixel reports a 4x4 about 478dp high. - val p = plan(360f to 478f) + // At a Pixel 4x4 (about 478dp reported) two rows of 105 and three of 85 + // cover about the same area and two rows win. A little taller, three do. + val p = plan(360f to 500f) assertEquals(AltarStyle.TALL, p.altar) assertEquals(3, p.rows) assertEquals(9, p.slots) @@ -173,9 +174,10 @@ class HomeWidgetLayoutTest { } @Test fun `tracked caps put a thin space between letters`() { - assertEquals("N O V I C E", trackedCaps("Novice")) - assertEquals("N I N E   L I V E S", WORDMARK) + assertEquals("N\u2009O\u2009V\u2009I\u2009C\u2009E", trackedCaps("Novice")) + assertEquals("N\u2009I\u2009N\u2009E\u2009 \u2009L\u2009I\u2009V\u2009E\u2009S", WORDMARK) assertEquals("", trackedCaps("")) + assertEquals("L\u200AI\u200AF\u200AE\u200A \u200AI\u200AI", trackedLabel(lifeLabel(1))) } @Test fun `the disc is about a third of the tile, within bounds`() { From 5a2b00bd82e66a8eb8131e35a2d053bb2dc5ae6f Mon Sep 17 00:00:00 2001 From: Static Date: Thu, 5 Nov 2026 09:15:46 -0500 Subject: [PATCH 4/4] Nine Lives widget: the altar keeps its total time, and a short narrow widget keeps its title Review round 1. The tall altar with its altar line had eleven children and Glance keeps only ten in a Column, so the total time dropped off. Its gaps are padding now. The one row face sized LIFE I from the height alone, so a 160 by 168 widget left the title no width. The cover now leaves the title at least 64dp. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSe18pmZggK4TswCohg --- .../app/widget/home/HomeWidgetLayout.kt | 23 ++++++++++---- .../app/widget/home/NineLivesHomeWidget.kt | 30 ++++++++++++------- .../app/widget/home/HomeWidgetLayoutTest.kt | 11 +++++++ 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt index ce76fd6f..1e184646 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt @@ -139,10 +139,7 @@ internal fun homePlanFor(widthDp: Float, heightDp: Float, tiles: Int = MAX_LIFE_ val w = widthDp - 2 * HOME_PAD_DP val h = heightDp * (1f - REPORTED_SIZE_SLACK) - 2 * HOME_PAD_DP - if (h < ROW_FACE_MAX_INNER_DP) { - val tile = h.toInt().coerceAtLeast(1) - return HomePlan(HomeFace.ROW, AltarStyle.NONE, rows = 0, tileDp = tile, eyeDp = 0, showPhrase = false) - } + if (h < ROW_FACE_MAX_INNER_DP) return rowPlan(w, h) if (w < NARROW_FACE_MAX_INNER_DP) { val withAltar = h - altarHeightDp(AltarStyle.MINI) - SECTION_GAP_DP - LINE_GAP_DP - TITLE_BLOCK_DP val altar = if (withAltar >= MIN_TILE_DP + 20) AltarStyle.MINI else AltarStyle.NONE @@ -176,10 +173,24 @@ internal fun homePlanFor(widthDp: Float, heightDp: Float, tiles: Int = MAX_LIFE_ HomePlan(HomeFace.STACKED, altar, rows, tile.toInt(), eye, showPhrase = false) } } - best?.let { return it } - return HomePlan(HomeFace.ROW, AltarStyle.NONE, rows = 0, tileDp = h.toInt(), eyeDp = 0, showPhrase = false) + return best ?: rowPlan(w, h) } +/** + * The one row face: LIFE I as tall as the row, but never so wide that the + * title and chapter beside it lose their [ROW_TITLE_MIN_DP]. + */ +private fun rowPlan(w: Float, h: Float): HomePlan { + val tile = minOf(h, w - ROW_GAP_DP - ROW_TITLE_MIN_DP).toInt().coerceAtLeast(1) + return HomePlan(HomeFace.ROW, AltarStyle.NONE, rows = 0, tileDp = tile, eyeDp = 0, showPhrase = false) +} + +/** The row face's gap between LIFE I and its title. */ +internal const val ROW_GAP_DP = 10 + +/** The least width the row face keeps for the title and chapter. */ +internal const val ROW_TITLE_MIN_DP = 64 + /** * Spends the tall altar's [slack] (dp the grid does not need): the eye grows * to [MID_EYE_DP] first, then the altar line comes in, then the eye grows on diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt index 905e0280..af0a5a65 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt @@ -327,7 +327,7 @@ private fun RowFace(model: HomeWidgetModel, art: HomeWidgetArt, plan: HomePlan, val width = LocalSize.current.width.value - 2 * HOME_PAD_DP Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { Life(art.current, current.title, lifeLabel(0), plan.tileDp, current) - Spacer(GlanceModifier.width(10.dp)) + Spacer(GlanceModifier.width(ROW_GAP_DP.dp)) Column(modifier = GlanceModifier.defaultWeight().clickable(openBook(context, current.bookId))) { Text( text = current.title, @@ -340,7 +340,7 @@ private fun RowFace(model: HomeWidgetModel, art: HomeWidgetArt, plan: HomePlan, } } val rank = model.rank - if (rank != null && width - plan.tileDp - 10 >= ROW_RANK_MIN_DP) { + if (rank != null && width - plan.tileDp - ROW_GAP_DP >= ROW_RANK_MIN_DP) { Spacer(GlanceModifier.width(10.dp)) Column( modifier = GlanceModifier.clickable(openApp).semantics { contentDescription = rank.epithet + ". " + rank.totalTimeText }, @@ -373,21 +373,29 @@ private fun Altar(rank: RankCard, plan: HomePlan, openApp: Action) { .clickable(openApp) .semantics { contentDescription = rank.epithet + ". " + rank.totalTimeText } if (plan.altar == AltarStyle.TALL) { + // Gaps are padding, not Spacers: a Glance Column keeps only its first ten children. Column(modifier = card.padding(horizontal = 10.dp, vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text(text = trackedCaps(rank.epithet), maxLines = 1, style = TextStyle(color = textMuted, fontSize = 9.sp, fontWeight = FontWeight.Medium)) - Spacer(GlanceModifier.height(3.dp)) // Home's small gold dot between the rank and the eye. - Image(provider = ImageProvider(R.drawable.widget_altar_dot), contentDescription = null, modifier = GlanceModifier.size(4.dp)) - Spacer(GlanceModifier.height(3.dp)) + Box(modifier = GlanceModifier.padding(vertical = 3.dp)) { + Image(provider = ImageProvider(R.drawable.widget_altar_dot), contentDescription = null, modifier = GlanceModifier.size(4.dp)) + } Image(provider = ImageProvider(R.drawable.widget_eye), contentDescription = null, modifier = GlanceModifier.size(plan.eyeDp.dp)) - Spacer(GlanceModifier.height(4.dp)) - Wordmark(19) + Box(modifier = GlanceModifier.padding(top = 4.dp)) { Wordmark(19) } if (plan.showPhrase && rank.phrase.isNotBlank()) { - Spacer(GlanceModifier.height(2.dp)) - Text(text = rank.phrase, maxLines = 1, style = TextStyle(color = textSecondary, fontSize = 11.sp, textAlign = TextAlign.Center)) + Text( + text = rank.phrase, + maxLines = 1, + modifier = GlanceModifier.padding(top = 2.dp), + style = TextStyle(color = textSecondary, fontSize = 11.sp, textAlign = TextAlign.Center), + ) } - Spacer(GlanceModifier.height(2.dp)) - Text(text = rank.totalTimeText, maxLines = 1, style = TextStyle(color = gold, fontSize = 11.sp, fontWeight = FontWeight.Medium)) + Text( + text = rank.totalTimeText, + maxLines = 1, + modifier = GlanceModifier.padding(top = 2.dp), + style = TextStyle(color = gold, fontSize = 11.sp, fontWeight = FontWeight.Medium), + ) } } else { Row( diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt index 22bcae5b..405ac742 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt @@ -132,6 +132,17 @@ class HomeWidgetLayoutTest { } } + @Test fun `the row face always leaves the title its room`() { + // Codex round 1: a 160 by 168 widget drew a 131dp cover in a 132dp row. + for (w in 160..520 step 4) for (h in 80..168 step 4) { + val p = homePlanFor(w.toFloat(), h.toFloat()) + if (p.face != HomeFace.ROW) continue + assertTrue("$w x $h", p.tileDp + ROW_GAP_DP + ROW_TITLE_MIN_DP <= w - 2 * HOME_PAD_DP) + assertTrue("$w x $h", p.tileDp >= 1) + } + assertEquals(160 - 2 * HOME_PAD_DP - ROW_GAP_DP - ROW_TITLE_MIN_DP, homePlanFor(160f, 168f).tileDp) + } + @Test fun `the One UI tall cell shape still fills, tall altar and two big rows`() { val p = plan(oneUi4x4) assertEquals(AltarStyle.TALL, p.altar)