0.2.97
This commit is contained in:
@@ -2895,21 +2895,18 @@ thumbnails had three copies of the same lift-and-grow at 1.06, 1.06 and 1.05 ove
|
|||||||
value is read only inside the `graphicsLayer` block, so travelling a grid redraws two cards
|
value is read only inside the `graphicsLayer` block, so travelling a grid redraws two cards
|
||||||
rather than recomposing every card in it — which is why it is a modifier and not a wrapper.
|
rather than recomposing every card in it — which is why it is a modifier and not a wrapper.
|
||||||
|
|
||||||
**"Close Memby?" is the one full-stop dialog**, `ui/ExitConfirmation.kt`, drawn when the
|
**"Exit Memby?" is the one full-stop dialog**, `ui/ExitMembyScreen.kt`, drawn when the
|
||||||
viewer has asked to be asked (`confirmExitMemby`). It is the panel that appears *over*
|
viewer has asked to be asked (`confirmExitMemby`). It is the panel that appears *over*
|
||||||
Memby, so it was the one thing on screen that must not look like somebody else's app — and
|
Memby, so it was the one thing on screen that must not look like somebody else's app — and
|
||||||
it was two identical raw `androidx.tv.material3.Button`s wearing Material's own colours,
|
it was two identical raw `androidx.tv.material3.Button`s wearing Material's own colours,
|
||||||
which meant the destructive answer and the safe one were the same shape at three metres.
|
which meant the destructive answer and the safe one were the same shape at three metres.
|
||||||
Things to preserve: the two actions **do not look alike** (Stay is the accent fill and takes
|
Things to preserve: the two actions **do not look alike** (Stay is the accent fill and takes
|
||||||
focus first, closing is a quiet outline), the entrance is read only inside `graphicsLayer`
|
focus first, closing is a quiet outline), and **Back means stay** — it is the key that raised
|
||||||
lambdas so nothing recomposes while the panel arrives, and **Back means stay** — it is the
|
the panel and pressing it again must not be what closes the app. The treatment stays plain:
|
||||||
key that raised the panel and pressing it again must not be what closes the app. The scrim
|
a dimmed backdrop, a compact panel, one question and two actions. `focusedForCapture` exists
|
||||||
is a wash rather than a flat black, so the library underneath stays faintly readable and
|
because Robolectric's window never takes focus and the focus ring is the whole of what says
|
||||||
this reads as a question asked over it. `focusedForCapture` exists because Robolectric's
|
which action a press would take; `ExitConfirmationScreenshotTest` renders both answers under
|
||||||
window never takes focus and the focus ring is the whole of what says which action a press
|
it, plus one capture under a foreign palette.
|
||||||
would take; `ExitConfirmationScreenshotTest` renders both answers under it, plus one
|
|
||||||
capture under a foreign palette — this is the only dialog drawn entirely from the tokens,
|
|
||||||
so a theme that could not reach it would show here and nowhere else.
|
|
||||||
|
|
||||||
**Settings is black, flat, and says one thing once.** `ui/settings/SettingsSheet.kt` had four
|
**Settings is black, flat, and says one thing once.** `ui/settings/SettingsSheet.kt` had four
|
||||||
stacked surfaces to show two switches — the page, the rail, a titled section card, and the
|
stacked surfaces to show two switches — the page, the rail, a titled section card, and the
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// A release workflow can derive the app version from its Git tag without editing the
|
||||||
// source tree. Local builds keep using the checked-in default.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.96"
|
val defaultVersionName = "0.2.97"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.key
|
|||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.ponzischeme89.memby.R
|
import com.ponzischeme89.memby.R
|
||||||
import com.ponzischeme89.memby.BuildConfig
|
import com.ponzischeme89.memby.BuildConfig
|
||||||
@@ -457,14 +458,16 @@ internal fun AppRoot(
|
|||||||
else -> FirstRunScreen(onGetStarted = { startingFirstRun = true })
|
else -> FirstRunScreen(onGetStarted = { startingFirstRun = true })
|
||||||
}
|
}
|
||||||
if (confirmingExit) {
|
if (confirmingExit) {
|
||||||
ExitMembyConfirmation(
|
ExitMembyScreen(
|
||||||
onStay = { confirmingExit = false },
|
onStay = { confirmingExit = false },
|
||||||
onExit = onCloseSettings,
|
onExit = onCloseSettings,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.zIndex(20f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExitMembyConfirmation lives in ui/ExitConfirmation.kt — it is drawn from the design
|
// ExitMembyScreen is a small, stateless component and is screenshot-tested on its own.
|
||||||
// tokens rather than from raw Material buttons, and is screenshot-tested on its own.
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
package com.ponzischeme89.memby.ui
|
|
||||||
|
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
|
||||||
import com.ponzischeme89.memby.ui.theme.mark
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import androidx.tv.material3.Icon
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
|
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
|
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySplashTint
|
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
/** The two answers the panel offers, named so a capture can say which one is under focus. */
|
|
||||||
internal enum class ExitChoice { STAY, CLOSE }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Close Memby?" — the last thing between a press of Back on the launcher and the TV's own
|
|
||||||
* home screen. Only drawn when the viewer has asked for it (Settings → `confirmExitMemby`).
|
|
||||||
*
|
|
||||||
* It is deliberately the only dialog in the app drawn from the design tokens rather than
|
|
||||||
* from raw `androidx.tv.material3.Button`s. Those buttons carry Material's own colour
|
|
||||||
* scheme, which nothing else on the launcher uses, so the one panel that appears *over*
|
|
||||||
* Memby was the one thing on screen that did not look like it — and the destructive choice
|
|
||||||
* and the safe one were drawn identically, on a screen read from three metres away by
|
|
||||||
* somebody who pressed a key by accident.
|
|
||||||
*
|
|
||||||
* Three things are worth keeping:
|
|
||||||
*
|
|
||||||
* - **The two actions do not look alike.** Stay is the accent fill and takes focus first;
|
|
||||||
* closing is a quiet outline. A remote has no pointer, so the loud shape is the whole of
|
|
||||||
* what says which one the viewer probably wants.
|
|
||||||
* - **Nothing here recomposes while it appears.** The entrance is one `animateFloatAsState`
|
|
||||||
* read inside `graphicsLayer`/`drawBehind` lambdas — the draw and layer phases — never in
|
|
||||||
* a composable body. This app ships to weak TV boxes and a dialog that recomposed the
|
|
||||||
* panel per frame would stutter on the way in.
|
|
||||||
* - **Back means stay.** It is the key that raised this panel, and pressing it again must
|
|
||||||
* not be the thing that closes the app.
|
|
||||||
*
|
|
||||||
* The last two parameters exist only so the panel can be captured: `ExitConfirmationScreenshotTest`
|
|
||||||
* needs the settled frame rather than whatever the animation clock happened to be at, and
|
|
||||||
* Robolectric's window never takes focus, so the focus ring — the whole of what says which
|
|
||||||
* action a press would take — cannot be photographed without being asked for.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
|
||||||
internal fun ExitMembyConfirmation(
|
|
||||||
onStay: () -> Unit,
|
|
||||||
onExit: () -> Unit,
|
|
||||||
animateIn: Boolean = true,
|
|
||||||
focusedForCapture: ExitChoice? = null,
|
|
||||||
) {
|
|
||||||
val stayFocus = remember { FocusRequester() }
|
|
||||||
val exitFocus = remember { FocusRequester() }
|
|
||||||
var appeared by remember { mutableStateOf(!animateIn) }
|
|
||||||
val appear by animateFloatAsState(
|
|
||||||
targetValue = if (appeared) 1f else 0f,
|
|
||||||
animationSpec = tween(durationMillis = 180),
|
|
||||||
label = "exit-dialog-appear",
|
|
||||||
)
|
|
||||||
BackHandler(onBack = onStay)
|
|
||||||
// The delay is the one the panel this replaced used: a FocusRequester attached on the
|
|
||||||
// frame it is requested on is not placed yet and the request is dropped — which on a
|
|
||||||
// dialog leaves a television with nothing focused and no way out of it.
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
appeared = true
|
|
||||||
delay(16.milliseconds)
|
|
||||||
runCatching { stayFocus.requestFocus() }
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.zIndex(20f)
|
|
||||||
// A wash rather than a flat 82% black: the launcher stays faintly readable
|
|
||||||
// underneath, so this reads as a question asked over your library rather than
|
|
||||||
// as a screen the app has navigated to.
|
|
||||||
.graphicsLayer { alpha = appear }
|
|
||||||
.background(
|
|
||||||
Brush.radialGradient(
|
|
||||||
listOf(
|
|
||||||
MembySplashTint.copy(alpha = 0.80f),
|
|
||||||
Color.Black.copy(alpha = 0.93f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
val panelShape = RoundedCornerShape(MembyPanelCorner)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(520.dp)
|
|
||||||
// Settles the last few pixels down onto the scrim as it fades in. Read in
|
|
||||||
// the layer phase, so the panel itself is composed once.
|
|
||||||
.graphicsLayer {
|
|
||||||
val t = appear
|
|
||||||
scaleX = 0.96f + 0.04f * t
|
|
||||||
scaleY = 0.96f + 0.04f * t
|
|
||||||
translationY = (1f - t) * 14.dp.toPx()
|
|
||||||
}
|
|
||||||
.shadow(30.dp, panelShape)
|
|
||||||
.clip(panelShape)
|
|
||||||
.background(MembySurfaceRaised)
|
|
||||||
.border(1.dp, MembyHairline, panelShape)
|
|
||||||
.padding(horizontal = 40.dp, vertical = 34.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(58.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MembyAccentMuted)
|
|
||||||
.border(1.dp, MembyAccentBright.copy(alpha = 0.45f), CircleShape),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
MembyIcon.Power.mark,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MembyAccentBright,
|
|
||||||
modifier = Modifier.size(27.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
Text(
|
|
||||||
"Close Memby?",
|
|
||||||
color = MembyOnSurface,
|
|
||||||
fontSize = 30.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(10.dp))
|
|
||||||
Text(
|
|
||||||
"Your place is saved. You can pick up where you left off next time you " +
|
|
||||||
"open Memby.",
|
|
||||||
color = MembyMutedText,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 23.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(26.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
) {
|
|
||||||
MembyDialogAction(
|
|
||||||
label = "Stay in Memby",
|
|
||||||
primary = true,
|
|
||||||
onClick = onStay,
|
|
||||||
focusedForCapture = focusedForCapture == ExitChoice.STAY,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.focusRequester(stayFocus)
|
|
||||||
.focusProperties {
|
|
||||||
left = FocusRequester.Cancel
|
|
||||||
right = exitFocus
|
|
||||||
up = FocusRequester.Cancel
|
|
||||||
down = FocusRequester.Cancel
|
|
||||||
},
|
|
||||||
)
|
|
||||||
MembyDialogAction(
|
|
||||||
label = "Close Memby",
|
|
||||||
primary = false,
|
|
||||||
onClick = onExit,
|
|
||||||
focusedForCapture = focusedForCapture == ExitChoice.CLOSE,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.focusRequester(exitFocus)
|
|
||||||
.focusProperties {
|
|
||||||
left = stayFocus
|
|
||||||
right = FocusRequester.Cancel
|
|
||||||
up = FocusRequester.Cancel
|
|
||||||
down = FocusRequester.Cancel
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
"Press Back to stay",
|
|
||||||
color = MembyQuietText,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/** The two answers the screen offers, named so a capture can say which one is under focus. */
|
||||||
|
internal enum class ExitChoice { STAY, CLOSE }
|
||||||
|
|
||||||
|
/** A restrained confirmation shown before Back closes Memby from the launcher. */
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
internal fun ExitMembyScreen(
|
||||||
|
onStay: () -> Unit,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
focusedForCapture: ExitChoice? = null,
|
||||||
|
) {
|
||||||
|
val stayFocus = remember { FocusRequester() }
|
||||||
|
val exitFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
BackHandler(onBack = onStay)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
// Wait until the focus target has been placed before requesting focus.
|
||||||
|
delay(16.milliseconds)
|
||||||
|
runCatching { stayFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.background(Color.Black.copy(alpha = 0.78f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val panelShape = RoundedCornerShape(MembyPanelCorner)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(430.dp)
|
||||||
|
.background(MembySurfaceRaised, panelShape)
|
||||||
|
.border(1.dp, MembyHairline, panelShape)
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
androidx.tv.material3.Text(
|
||||||
|
"Exit Memby?",
|
||||||
|
color = MembyOnSurface,
|
||||||
|
fontSize = 26.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
MembyDialogAction(
|
||||||
|
label = "Stay",
|
||||||
|
primary = true,
|
||||||
|
onClick = onStay,
|
||||||
|
focusedForCapture = focusedForCapture == ExitChoice.STAY,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusRequester(stayFocus)
|
||||||
|
.focusProperties {
|
||||||
|
left = FocusRequester.Cancel
|
||||||
|
right = exitFocus
|
||||||
|
up = FocusRequester.Cancel
|
||||||
|
down = FocusRequester.Cancel
|
||||||
|
},
|
||||||
|
)
|
||||||
|
MembyDialogAction(
|
||||||
|
label = "Exit Memby",
|
||||||
|
primary = false,
|
||||||
|
onClick = onExit,
|
||||||
|
focusedForCapture = focusedForCapture == ExitChoice.CLOSE,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusRequester(exitFocus)
|
||||||
|
.focusProperties {
|
||||||
|
left = stayFocus
|
||||||
|
right = FocusRequester.Cancel
|
||||||
|
up = FocusRequester.Cancel
|
||||||
|
down = FocusRequester.Cancel
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -830,6 +830,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
*/
|
*/
|
||||||
internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseItem =
|
internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseItem =
|
||||||
(metadata ?: item).copy(
|
(metadata ?: item).copy(
|
||||||
|
// Detail requests enrich the facts panel, but their Fields list does not ask Emby
|
||||||
|
// for artwork. Keep the row card's image identity when that sparse response arrives
|
||||||
|
// after focus settles. This matters again after the 32-entry metadata cache turns
|
||||||
|
// over: revisiting an early row fetches details again, and replacing the card
|
||||||
|
// wholesale used to drop its Logo tag and switch the hero back to a text heading.
|
||||||
|
// Merge ImageTags individually because a detail response may carry Primary while
|
||||||
|
// still omitting Logo; treating a non-empty map as authoritative loses the latter.
|
||||||
|
imageTags = item.imageTags + metadata?.imageTags.orEmpty(),
|
||||||
|
backdropImageTags = metadata?.backdropImageTags
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
?: item.backdropImageTags,
|
||||||
|
primaryImageAspectRatio = metadata?.primaryImageAspectRatio
|
||||||
|
?: item.primaryImageAspectRatio,
|
||||||
|
parentBackdropItemId = metadata?.parentBackdropItemId
|
||||||
|
?: item.parentBackdropItemId,
|
||||||
|
parentBackdropImageTags = metadata?.parentBackdropImageTags
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
?: item.parentBackdropImageTags,
|
||||||
|
parentLogoItemId = metadata?.parentLogoItemId
|
||||||
|
?: item.parentLogoItemId,
|
||||||
|
parentLogoImageTag = metadata?.parentLogoImageTag
|
||||||
|
?: item.parentLogoImageTag,
|
||||||
userData = item.userData ?: metadata?.userData,
|
userData = item.userData ?: metadata?.userData,
|
||||||
membyAiringToday = item.membyAiringToday || metadata?.membyAiringToday == true,
|
membyAiringToday = item.membyAiringToday || metadata?.membyAiringToday == true,
|
||||||
membyRecommendationReason = item.membyRecommendationReason
|
membyRecommendationReason = item.membyRecommendationReason
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ internal fun MembyChoiceChip(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One answer in a full-stop dialog: the shape [ExitMembyConfirmation] draws and the shape
|
* One answer in a full-stop dialog: the shape [ExitMembyScreen] draws and the shape
|
||||||
* every panel that asks a yes/no question over the app should draw.
|
* every panel that asks a yes/no question over the app should draw.
|
||||||
*
|
*
|
||||||
* The two answers must never look alike — a remote has no pointer, so the loud shape is the
|
* The two answers must never look alike — a remote has no pointer, so the loud shape is the
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ internal fun ProfileChooser(
|
|||||||
* Draws the screen as though the remote were on this card, so a capture can show the
|
* Draws the screen as though the remote were on this card, so a capture can show the
|
||||||
* focused avatar and the revealed remove control at once. Robolectric's window never
|
* focused avatar and the revealed remove control at once. Robolectric's window never
|
||||||
* takes focus and the focus state is most of what this screen has to say — the flag
|
* takes focus and the focus state is most of what this screen has to say — the flag
|
||||||
* `ViewerActionButton` and `ExitMembyConfirmation` carry, for the same reason. The index
|
* `ViewerActionButton` and `ExitMembyScreen` carry, for the same reason. The index
|
||||||
* one past the last profile is the Add card.
|
* one past the last profile is the Add card.
|
||||||
*/
|
*/
|
||||||
focusedCardForCapture: Int? = null,
|
focusedCardForCapture: Int? = null,
|
||||||
@@ -654,7 +654,7 @@ private fun ProfileRemoveButton(
|
|||||||
* "Remove Matt from this TV?" — the full stop between one accidental press and a sign-in
|
* "Remove Matt from this TV?" — the full stop between one accidental press and a sign-in
|
||||||
* somebody has to type back in on a remote.
|
* somebody has to type back in on a remote.
|
||||||
*
|
*
|
||||||
* Drawn from the design tokens in the shape `ExitMembyConfirmation` set, and for the same
|
* Drawn from the design tokens in the shape `ExitMembyScreen` set, and for the same
|
||||||
* reason: the two answers must not look alike. Keeping them is the accent fill and takes
|
* reason: the two answers must not look alike. Keeping them is the accent fill and takes
|
||||||
* focus first; removing is a quiet outline that turns red only once the remote is on it.
|
* focus first; removing is a quiet outline that turns red only once the remote is on it.
|
||||||
* Back cancels — it is the key most likely to be pressed by somebody who did not mean to be
|
* Back cancels — it is the key most likely to be pressed by somebody who did not mean to be
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ internal enum class SettingsPage(
|
|||||||
// its download URL with it.
|
// its download URL with it.
|
||||||
DEVICES("Devices", "TVs signed in to your account", MembyIcon.Devices),
|
DEVICES("Devices", "TVs signed in to your account", MembyIcon.Devices),
|
||||||
STORAGE("Storage", "Artwork Memby keeps on this TV", MembyIcon.Storage),
|
STORAGE("Storage", "Artwork Memby keeps on this TV", MembyIcon.Storage),
|
||||||
ABOUT("About", "Version and release notes", MembyIcon.Info),
|
ABOUT("About", "Version/changelog", MembyIcon.Info),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Black, and one lit thing at a time.
|
// Black, and one lit thing at a time.
|
||||||
@@ -1111,7 +1111,7 @@ internal fun SettingsPanelContent(
|
|||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.app_name),
|
"Version",
|
||||||
color = TextPrimary,
|
color = TextPrimary,
|
||||||
fontSize = 17.sp,
|
fontSize = 17.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
@@ -1131,7 +1131,7 @@ internal fun SettingsPanelContent(
|
|||||||
}
|
}
|
||||||
SettingDivider()
|
SettingDivider()
|
||||||
VersionRow(
|
VersionRow(
|
||||||
label = "Memby gateway",
|
label = "Gateway server version",
|
||||||
version = gatewayVersionLabel(state.gatewayVersion, state.embyVersion),
|
version = gatewayVersionLabel(state.gatewayVersion, state.embyVersion),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import com.ponzischeme89.memby.ui.theme.mark
|
|||||||
* [emphasised] marks the answer the screen is *for* — adding the person, saving the name —
|
* [emphasised] marks the answer the screen is *for* — adding the person, saving the name —
|
||||||
* so that on a row of two the destructive or the neutral one is never the one wearing the
|
* so that on a row of two the destructive or the neutral one is never the one wearing the
|
||||||
* accent. It is a quiet outline the rest of the time, which is the same distinction
|
* accent. It is a quiet outline the rest of the time, which is the same distinction
|
||||||
* `ExitConfirmation` draws between staying and closing.
|
* `ExitMembyScreen` draws between staying and closing.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewerActionButton(
|
internal fun ViewerActionButton(
|
||||||
@@ -59,7 +59,7 @@ internal fun ViewerActionButton(
|
|||||||
/**
|
/**
|
||||||
* Draws the button as though the remote were on it. Robolectric's window never takes
|
* Draws the button as though the remote were on it. Robolectric's window never takes
|
||||||
* focus and the ring is the whole of what says which answer a press would take, so a
|
* focus and the ring is the whole of what says which answer a press would take, so a
|
||||||
* capture of a two-answer row would otherwise prove nothing — the flag `ExitConfirmation`
|
* capture of a two-answer row would otherwise prove nothing — the flag `ExitMembyScreen`
|
||||||
* carries, for the same reason.
|
* carries, for the same reason.
|
||||||
*/
|
*/
|
||||||
focusedForCapture: Boolean = false,
|
focusedForCapture: Boolean = false,
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ private fun ViewerManageRow(
|
|||||||
* The full-stop question, because removing somebody deletes everything Memby kept for them
|
* The full-stop question, because removing somebody deletes everything Memby kept for them
|
||||||
* and there is nothing to undo it with.
|
* and there is nothing to undo it with.
|
||||||
*
|
*
|
||||||
* It follows `ExitConfirmation`'s rules, which are the app's rules for a question asked over
|
* It follows `ExitMembyScreen`'s rules, which are the app's rules for a question asked over
|
||||||
* the thing it is about: the two answers **do not look alike**, the safe one takes focus
|
* the thing it is about: the two answers **do not look alike**, the safe one takes focus
|
||||||
* first, and **Back means keep** — it is the key that raised the panel, and pressing it
|
* first, and **Back means keep** — it is the key that raised the panel, and pressing it
|
||||||
* again must not be what deletes a person.
|
* again must not be what deletes a person.
|
||||||
|
|||||||
@@ -13,6 +13,42 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ContinueWatchingResumeTest {
|
class ContinueWatchingResumeTest {
|
||||||
|
@Test
|
||||||
|
fun `detail metadata keeps artwork carried by the row card`() {
|
||||||
|
val card = BaseItem(
|
||||||
|
id = "episode",
|
||||||
|
name = "The Episode",
|
||||||
|
type = "Episode",
|
||||||
|
imageTags = mapOf("Primary" to "card-primary", "Logo" to "logo-tag"),
|
||||||
|
backdropImageTags = listOf("backdrop-tag"),
|
||||||
|
primaryImageAspectRatio = 0.67,
|
||||||
|
parentBackdropItemId = "series",
|
||||||
|
parentBackdropImageTags = listOf("parent-backdrop-tag"),
|
||||||
|
parentLogoItemId = "series",
|
||||||
|
parentLogoImageTag = "parent-logo-tag",
|
||||||
|
)
|
||||||
|
val sparseDetails = BaseItem(
|
||||||
|
id = "episode",
|
||||||
|
name = "The Episode",
|
||||||
|
type = "Episode",
|
||||||
|
overview = "The richer record fetched after focus.",
|
||||||
|
// A detail response can still carry one image type without carrying Logo.
|
||||||
|
imageTags = mapOf("Primary" to "detail-primary"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val focused = focusedItemWithMetadata(card, sparseDetails)
|
||||||
|
|
||||||
|
assertEquals("The richer record fetched after focus.", focused.overview)
|
||||||
|
assertEquals("detail-primary", focused.imageTags["Primary"])
|
||||||
|
assertEquals("logo-tag", focused.imageTags["Logo"])
|
||||||
|
assertEquals(listOf("backdrop-tag"), focused.backdropImageTags)
|
||||||
|
assertEquals(0.67, focused.primaryImageAspectRatio)
|
||||||
|
assertEquals("series", focused.parentBackdropItemId)
|
||||||
|
assertEquals(listOf("parent-backdrop-tag"), focused.parentBackdropImageTags)
|
||||||
|
assertEquals("series", focused.parentLogoItemId)
|
||||||
|
assertEquals("parent-logo-tag", focused.parentLogoImageTag)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `detail metadata keeps the continue watching playhead and resume label`() {
|
fun `detail metadata keeps the continue watching playhead and resume label`() {
|
||||||
val card = BaseItem(
|
val card = BaseItem(
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ import org.robolectric.annotation.GraphicsMode
|
|||||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*ExitConfirmationScreenshotTest"
|
* .\gradlew.bat :app:testDebugUnitTest --tests "*ExitConfirmationScreenshotTest"
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* It is drawn over a stand-in launcher rather than over black, because the whole point of
|
* It is drawn over a stand-in launcher so the simple dimmed backdrop can be judged in its
|
||||||
* the wash behind it is that the library stays faintly readable underneath — a capture on
|
* real context.
|
||||||
* a blank background would prove nothing about the one thing worth looking at.
|
|
||||||
*
|
*
|
||||||
* The second capture repaints under a different palette. This panel is the only full-stop
|
* The second capture repaints under a different palette. This panel is the only full-stop
|
||||||
* dialog in the app and it is now drawn entirely from the tokens, so a theme that cannot
|
* dialog in the app and it is now drawn entirely from the tokens, so a theme that cannot
|
||||||
@@ -92,12 +91,10 @@ class ExitConfirmationScreenshotTest {
|
|||||||
compose.setContent {
|
compose.setContent {
|
||||||
Box(Modifier.fillMaxSize().background(MembySurface)) {
|
Box(Modifier.fillMaxSize().background(MembySurface)) {
|
||||||
StandInLauncher()
|
StandInLauncher()
|
||||||
// Settled rather than mid-entrance: the frame worth looking at is the one
|
ExitMembyScreen(
|
||||||
// somebody actually reads.
|
|
||||||
ExitMembyConfirmation(
|
|
||||||
onStay = {},
|
onStay = {},
|
||||||
onExit = {},
|
onExit = {},
|
||||||
animateIn = false,
|
modifier = Modifier.fillMaxSize(),
|
||||||
// Robolectric's window never takes focus, so a capture that pressed
|
// Robolectric's window never takes focus, so a capture that pressed
|
||||||
// its way onto a button would photograph every action unfocused.
|
// its way onto a button would photograph every action unfocused.
|
||||||
focusedForCapture = focused,
|
focusedForCapture = focused,
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) (sonarrLifecycleResult
|
|||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Title: "Show cancelled",
|
Title: "Show cancelled",
|
||||||
Body: change.Current.Title + " is now listed as cancelled in Sonarr.",
|
Body: change.Current.Title + " has been cancelled.",
|
||||||
SourceKey: sourceKey,
|
SourceKey: sourceKey,
|
||||||
EventAt: &eventAt,
|
EventAt: &eventAt,
|
||||||
Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status},
|
Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status},
|
||||||
|
|||||||
Reference in New Issue
Block a user