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..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 @@ -3,65 +3,267 @@ 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, +} + +/** + * 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 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 + + /** 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 +} + +/** 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 + +/** 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 /** - * 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. + * 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 enum class HomeLayout { CURRENT_ONLY, RANK_AND_CURRENT, FULL } +internal const val TALL_ALTAR_BONUS = 1.2f -/** 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 +/** 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 + 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 + AltarStyle.MINI -> 63 + AltarStyle.NONE -> 0 } -/** 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 +/** + * 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 const val REPORTED_SIZE_SLACK = 0.05f -/** The widest a Lives tile grows, so the full face still fits its height. */ -internal const val MAX_LIFE_TILE_DP = 72f +/** The face is one cell high below this inner height. */ +internal const val ROW_FACE_MAX_INNER_DP = 132 -/** Gap between two Lives tiles. */ -internal const val LIFE_GAP_DP = 10f +/** The face is two cells wide below this inner width. */ +internal const val NARROW_FACE_MAX_INNER_DP = 200 /** - * 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]. + * 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 fun lifeTileDp(widthDp: Float, slots: Int): Float = - minOf(MAX_LIFE_TILE_DP, (widthDp - 24f - LIFE_GAP_DP * (slots - 1)) / slots) +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 + + 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 + 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) + } -/** Most Lives any layout shows. */ -internal const val MAX_LIVES = 4 + 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) + } + } + return best ?: rowPlan(w, h) +} /** - * 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 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]. */ -internal val HOME_WIDGET_SIZES: List> = - listOf(160f to 80f, 160f to 180f, 250f to 180f, 250f to 260f, 300f to 260f) +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) +} -/** 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 +/** The row face's gap between LIFE I and its title. */ +internal const val ROW_GAP_DP = 10 -/** 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) +/** 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 + * 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 = "\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") + +/** + * 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/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt index 72799865..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 @@ -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,149 +188,368 @@ 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(ROW_GAP_DP.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 - ROW_GAP_DP >= 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)) } } - Spacer(GlanceModifier.width(if (compact) 6.dp else 8.dp)) - PlayButton(current, open, if (compact) 40 else 44) } } -/** A 2x2: cover and play side by side at the top, the title and chapter anchored to the bottom. */ +/** 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 CurrentBookStacked(current: CurrentCard, cover: android.graphics.Bitmap?) { +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) { + // 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)) + // Home's small gold dot between the rank and the eye. + 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)) + Box(modifier = GlanceModifier.padding(top = 4.dp)) { Wordmark(19) } + if (plan.showPhrase && rank.phrase.isNotBlank()) { + Text( + text = rank.phrase, + maxLines = 1, + modifier = GlanceModifier.padding(top = 2.dp), + style = TextStyle(color = textSecondary, fontSize = 11.sp, textAlign = TextAlign.Center), + ) + } + Text( + text = rank.totalTimeText, + maxLines = 1, + modifier = GlanceModifier.padding(top = 2.dp), + style = TextStyle(color = gold, fontSize = 11.sp, fontWeight = FontWeight.Medium), + ) + } + } 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)) + } +} + +/** 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 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 = trackedLabel(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, + ), + ) } } @@ -335,18 +572,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_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_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_slab.xml b/app/src/main/res/drawable/widget_home_frame.xml similarity index 51% rename from app/src/main/res/drawable/widget_home_slab.xml rename to app/src/main/res/drawable/widget_home_frame.xml index 48177e98..f1f421b4 100644 --- a/app/src/main/res/drawable/widget_home_slab.xml +++ b/app/src/main/res/drawable/widget_home_frame.xml @@ -1,9 +1,8 @@ - + - - - + 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_life_label.xml b/app/src/main/res/drawable/widget_life_label.xml new file mode 100644 index 00000000..e4cbc10e --- /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..5972ecff 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 square Lives, the first ringed in full gold with the play disc. --> + android:padding="14dp"> @@ -24,62 +24,74 @@ android:layout_height="40dp" android:importantForAccessibility="no" android:src="@drawable/widget_eye" /> + + - + + + + + android:layout_width="84dp" + android:layout_height="84dp" + android:layout_marginStart="8dp" + 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 + 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..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 @@ -2,67 +2,204 @@ 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 `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 `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 `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 `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 `wider widgets show four lives`() { - assertEquals(3, livesSlotsFor(299f)) - assertEquals(4, livesSlotsFor(300f)) + @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 `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 `a taller widget gets the third row, Home's full nine`() { + // 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) } - @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 `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 `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 `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 `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 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 `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 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 `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 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 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 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) + 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\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`() { + 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)