Add some ui components

This commit is contained in:
Damontecres 2025-09-23 22:19:09 -04:00
parent 913334b43d
commit d3eeb67877
No known key found for this signature in database
12 changed files with 1169 additions and 16 deletions

View file

@ -1,5 +1,7 @@
package com.github.damontecres.dolphin.ui package com.github.damontecres.dolphin.ui
import android.content.Context
import android.media.AudioManager
import android.view.KeyEvent import android.view.KeyEvent
import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.MarqueeAnimationMode
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.basicMarquee
@ -7,11 +9,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import timber.log.Timber import timber.log.Timber
@ -122,3 +127,28 @@ fun Modifier.enableMarquee(focused: Boolean) =
} else { } else {
basicMarquee(animationMode = MarqueeAnimationMode.WhileFocused) basicMarquee(animationMode = MarqueeAnimationMode.WhileFocused)
} }
@Composable
fun Modifier.playSoundOnFocus(enabled: Boolean): Modifier {
if (!enabled) {
return this
}
val context = LocalContext.current
val audioManager =
remember {
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
return onFocusChanged {
if (it.isFocused) {
audioManager.playSoundEffect(AudioManager.FX_FOCUS_NAVIGATION_UP)
}
}
}
fun playOnClickSound(
context: Context,
effectType: Int = AudioManager.FX_KEY_CLICK,
) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.playSoundEffect(effectType)
}

View file

@ -0,0 +1,23 @@
package com.github.damontecres.dolphin.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.ui.ifElse
@Composable
fun CircularProgress(
modifier: Modifier = Modifier,
fillMaxSize: Boolean = true,
) {
Box(modifier = modifier.ifElse(fillMaxSize, Modifier.fillMaxSize())) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}

View file

@ -0,0 +1,298 @@
package com.github.damontecres.dolphin.ui.components
import android.view.KeyEvent
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.dolphin.ui.FontAwesome
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
sealed interface DialogItemEntry
data object DialogItemDivider : DialogItemEntry
data class DialogItem(
val headlineContent: @Composable () -> Unit,
val onClick: () -> Unit,
val overlineContent: @Composable (() -> Unit)? = null,
val supportingContent: @Composable (() -> Unit)? = null,
val leadingContent: @Composable (BoxScope.() -> Unit)? = null,
val trailingContent: @Composable (() -> Unit)? = null,
val enabled: Boolean = true,
) : DialogItemEntry {
constructor(
text: String,
@StringRes iconStringRes: Int,
onClick: () -> Unit,
) : this(
headlineContent = {
Text(
text = text,
// color = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
leadingContent = {
Text(
text = stringResource(id = iconStringRes),
// color = MaterialTheme.colorScheme.onPrimaryContainer,
fontFamily = FontAwesome,
)
},
onClick = onClick,
)
constructor(
text: String,
icon: ImageVector,
onClick: () -> Unit,
) : this(
headlineContent = {
Text(
text = text,
// color = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
leadingContent = {
Icon(
imageVector = icon,
contentDescription = text,
// tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
onClick = onClick,
)
constructor(
text: String,
onClick: () -> Unit,
) : this(
headlineContent = {
Text(
text = text,
// color = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
onClick = onClick,
)
companion object {
fun divider(): DialogItemEntry = DialogItemDivider
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DialogPopup(
showDialog: Boolean,
title: String,
dialogItems: List<DialogItemEntry>,
onDismissRequest: () -> Unit,
dismissOnClick: Boolean = true,
waitToLoad: Boolean = true,
properties: DialogProperties = DialogProperties(),
) {
var waiting by remember { mutableStateOf(waitToLoad) }
if (showDialog) {
if (waitToLoad) {
LaunchedEffect(Unit) {
// This is a hack because a long click will propagate here and click the first list item
// So this disables the list items assuming the user will stop pressing when the dialog appears
// This is also bypassed in the code below if the user releases the enter/d-pad center button
waiting = true
delay(1000)
waiting = false
}
} else {
waiting = false
}
Dialog(
onDismissRequest = onDismissRequest,
properties = properties,
) {
val elevatedContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
LazyColumn(
modifier =
Modifier
// .widthIn(min = 520.dp, max = 300.dp)
// .dialogFocusable()
.graphicsLayer {
this.clip = true
this.shape = RoundedCornerShape(28.0.dp)
}.drawBehind { drawRect(color = elevatedContainerColor) }
.padding(PaddingValues(24.dp))
.onKeyEvent { event ->
val code = event.nativeKeyEvent.keyCode
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
code in
setOf(
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER,
)
) {
waiting = false
}
false
},
) {
stickyHeader {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
items(dialogItems) {
when (it) {
is DialogItemDivider -> HorizontalDivider(Modifier.height(16.dp))
is DialogItem ->
ListItem(
selected = false,
enabled = !waiting && it.enabled,
onClick = {
if (dismissOnClick) {
onDismissRequest.invoke()
}
it.onClick.invoke()
},
headlineContent = it.headlineContent,
overlineContent = it.overlineContent,
supportingContent = it.supportingContent,
leadingContent = it.leadingContent,
trailingContent = it.trailingContent,
modifier = Modifier,
)
}
}
}
}
}
}
@Composable
fun ScrollableDialog(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
content: LazyListScope.() -> Unit,
) {
val scrollAmount = 100f
val columnState = rememberLazyListState()
val scope = rememberCoroutineScope()
fun scroll(reverse: Boolean = false) {
scope.launch {
columnState.scrollBy(if (reverse) -scrollAmount else scrollAmount)
}
}
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content,
modifier =
modifier
.width(600.dp)
.height(380.dp)
.focusable()
.background(
MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(8.dp),
).onKeyEvent {
if (it.type == KeyEventType.KeyUp) {
return@onKeyEvent false
}
if (it.key == Key.DirectionDown) {
scroll(false)
return@onKeyEvent true
}
if (it.key == Key.DirectionUp) {
scroll(true)
return@onKeyEvent true
}
return@onKeyEvent false
},
)
}
}
@Composable
fun MarkerDurationDialog(
onDismissRequest: () -> Unit,
onClick: (Long) -> Unit,
) {
val dialogItems =
remember {
listOf(
15.seconds,
20.seconds,
30.seconds,
60.seconds,
5.minutes,
10.minutes,
20.minutes,
).map {
DialogItem(it.toString()) {
onClick.invoke(it.inWholeMilliseconds)
}
}
}
DialogPopup(
showDialog = true,
title = "How long?",
dialogItems = dialogItems,
onDismissRequest = onDismissRequest,
dismissOnClick = false,
waitToLoad = false,
properties = DialogProperties(),
)
}

View file

@ -0,0 +1,62 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.damontecres.dolphin.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@Composable
fun DotSeparatedRow(
texts: List<String>,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
texts.forEachIndexed { index, text ->
Text(
text = text,
style = textStyle,
color = MaterialTheme.colorScheme.onSurface,
)
if (index != texts.lastIndex) {
Box(
modifier =
Modifier
.padding(horizontal = 8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 1f))
.size(4.dp),
)
}
}
}
}

View file

@ -0,0 +1,178 @@
package com.github.damontecres.dolphin.ui.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TextFieldDefaults.Container
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditTextBox(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
leadingIcon: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
height: Dp = 40.dp,
isInputValid: (String) -> Boolean = { true },
supportingText: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
// From ButtonDefaults
val colors =
TextFieldDefaults.colors(
unfocusedTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
focusedTextColor = MaterialTheme.colorScheme.inverseOnSurface,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f),
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
errorContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
focusedIndicatorColor = MaterialTheme.colorScheme.border,
unfocusedLabelColor = Color.Unspecified,
)
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
value = value,
modifier =
modifier
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = height,
).height(height),
onValueChange = onValueChange,
enabled = enabled,
readOnly = readOnly,
textStyle = MaterialTheme.typography.bodyLarge.merge(MaterialTheme.colorScheme.onPrimaryContainer),
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
interactionSource = interactionSource,
singleLine = true,
maxLines = 1,
minLines = 1,
visualTransformation =
if (keyboardOptions.keyboardType == KeyboardType.Password ||
keyboardOptions.keyboardType == KeyboardType.NumberPassword
) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
decorationBox =
@Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = value,
visualTransformation =
if (keyboardOptions.keyboardType == KeyboardType.Password ||
keyboardOptions.keyboardType == KeyboardType.NumberPassword
) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
innerTextField = innerTextField,
placeholder = placeholder,
label = null,
leadingIcon = leadingIcon,
trailingIcon = null,
prefix = null,
suffix = null,
supportingText = supportingText,
shape = CircleShape,
singleLine = true,
enabled = enabled,
isError = false,
interactionSource = interactionSource,
colors = colors,
contentPadding =
PaddingValues(
horizontal = 8.dp,
vertical = 10.dp,
),
container = {
Container(
enabled = enabled,
isError = !isInputValid.invoke(value),
interactionSource = interactionSource,
modifier = Modifier,
colors = colors,
shape = CircleShape,
focusedIndicatorLineThickness = 4.dp,
unfocusedIndicatorLineThickness = 0.dp,
)
},
)
},
)
}
}
@Composable
fun SearchEditTextBox(
value: String,
onValueChange: (String) -> Unit,
onSearchClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
height: Dp = 40.dp,
) {
EditTextBox(
value,
onValueChange,
modifier,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
imeAction = ImeAction.Search,
),
keyboardActions =
KeyboardActions(
onSearch = {
onSearchClick.invoke()
this.defaultKeyboardAction(ImeAction.Done)
},
),
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
enabled,
readOnly,
height,
)
}

View file

@ -0,0 +1,210 @@
package com.github.damontecres.dolphin.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.ui.AppColors
import com.github.damontecres.dolphin.ui.playOnClickSound
import com.github.damontecres.dolphin.ui.playSoundOnFocus
import com.github.damontecres.dolphin.ui.tryRequestFocus
enum class StarRatingPrecision {
FULL,
HALF,
QUARTER,
;
companion object {
fun fromFloat(value: Float): StarRatingPrecision =
if (value <= .25f) {
QUARTER
} else if (value <= .5f) {
HALF
} else {
FULL
}
}
}
val FilledStarColor = Color(0xFFFFC700)
val EmptyStarColor = Color(0x2AFFC700)
val ratingBarHeight: Dp = 32.dp
@Composable
fun StarRating(
rating100: Int,
onRatingChange: (Int) -> Unit,
precision: StarRatingPrecision,
enabled: Boolean,
playSoundOnFocus: Boolean,
modifier: Modifier = Modifier,
bgColor: Color = AppColors.TransparentBlack75, // MaterialTheme.colorScheme.background,
) {
val context = LocalContext.current
var tempRating by remember(rating100) { mutableIntStateOf(rating100) }
val percentage = (if (enabled) tempRating else rating100) / 100f
val focusRequesters = remember { List(5) { FocusRequester() } }
Box(
modifier =
modifier
.clip(RoundedCornerShape(16.dp))
.background(bgColor),
) {
LazyRow(
modifier =
Modifier
.selectableGroup()
.padding(4.dp)
.drawWithCache {
onDrawWithContent {
drawContent()
if (percentage in 0f..<1f) {
drawRect(
color = bgColor,
topLeft = Offset(size.width * percentage, 0f),
blendMode = BlendMode.SrcAtop,
)
}
}
}.focusGroup()
.focusProperties {
onEnter = {
val index =
if (rating100 <= 20) {
0
} else if (rating100 <= 40) {
1
} else if (rating100 <= 60) {
2
} else if (rating100 <= 80) {
3
} else {
4
}
focusRequesters[index].tryRequestFocus()
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
for (i in 1..5) {
item {
val isRated = (if (enabled) tempRating else rating100) >= (i * 20)
val icon = Icons.Filled.Star
var focused by remember { mutableStateOf(false) }
val focusedColor =
if (focused) {
MaterialTheme.colorScheme.border
} else {
Color.Unspecified
}
Box(
modifier =
Modifier
.clip(RoundedCornerShape(16.dp))
.background(focusedColor),
) {
Icon(
imageVector = icon,
tint = FilledStarColor,
contentDescription = null,
modifier =
if (enabled) {
Modifier
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) {
tempRating = i * 20
} else {
tempRating = rating100
}
}.playSoundOnFocus(playSoundOnFocus)
.focusRequester(focusRequesters[i - 1])
.focusProperties {
left =
if (i == 1) focusRequesters.last() else FocusRequester.Default
right =
if (i == 5) focusRequesters.first() else FocusRequester.Default
}.selectable(
selected = isRated,
onClick = {
if (playSoundOnFocus) playOnClickSound(context)
val newRating100 =
when (precision) {
StarRatingPrecision.FULL ->
if (i == 1 && rating100 > 0 && rating100 <= 20) 0 else i * 20
StarRatingPrecision.HALF -> {
if (rating100 > i * 20) {
i * 20
} else if (rating100 == i * 20) {
i * 20 - 10
} else if (rating100 == i * 20 - 10) {
(i - 1) * 20
} else if (i == 1 && rating100 == 0) {
20
} else if (i == 1 && rating100 > 10) {
10
} else if (i == 1 && rating100 <= 10) {
0
} else {
(i) * 20
}
}
StarRatingPrecision.QUARTER -> {
// TODO
null
}
}
if (newRating100 != null) {
tempRating = newRating100
onRatingChange(newRating100)
}
},
)
} else {
Modifier
}.fillMaxHeight()
.aspectRatio(1f),
)
}
}
}
}
}
}

View file

@ -0,0 +1,79 @@
package com.github.damontecres.dolphin.ui.components
import android.widget.Toast
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
@Composable
fun SwitchWithLabel(
label: String,
checked: Boolean,
onStateChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
enabled: Boolean = true,
) {
val context = LocalContext.current
val isFocused by interactionSource.collectIsFocusedAsState()
Row(
modifier =
modifier
.clip(shape = RoundedCornerShape(20.dp))
.background(
if (isFocused) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f)
},
).clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
role = Role.Switch,
onClick = {
if (enabled) {
onStateChange(!checked)
} else {
// TODO there are other uses, so shouldn't hardcode this toast
Toast
.makeText(context, "Item has no children", Toast.LENGTH_SHORT)
.show()
}
},
).padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
color = if (isFocused) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelLarge,
)
Spacer(modifier = Modifier.padding(start = 8.dp))
Switch(
checked = checked,
enabled = enabled,
onCheckedChange = {
onStateChange(!checked)
},
)
}
}

View file

@ -0,0 +1,132 @@
package com.github.damontecres.dolphin.ui.components
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
@Composable
fun TableRowComposable(
row: TableRow,
modifier: Modifier = Modifier,
keyWeight: Float = .3f,
valueWeight: Float = .7f,
focusable: Boolean = true,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onBackground),
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val background =
if (isFocused && row.onClick != null) {
MaterialTheme.colorScheme.border.copy(alpha = .75f)
} else if (isFocused) {
MaterialTheme.colorScheme.onBackground.copy(alpha = .25f)
} else {
Color.Unspecified
}
Row(
modifier
.background(background)
.ifElse(
row.onClick != null,
ifTrueModifier =
Modifier
.clickable(
enabled = true,
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = { row.onClick?.invoke() },
),
ifFalseModifier =
Modifier.focusable(
enabled = focusable, // TODO, this allows scrolling
interactionSource = interactionSource,
),
),
) {
val keyModifier =
Modifier
.weight(keyWeight)
val valueModifier =
Modifier
.weight(valueWeight)
ProvideTextStyle(textStyle) {
Box(modifier = keyModifier) {
row.key.invoke(this, Modifier.padding(4.dp))
}
Box(modifier = valueModifier) {
row.value.invoke(this, Modifier.padding(4.dp))
}
}
}
}
data class TableRow(
val key: @Composable BoxScope.(modifier: Modifier) -> Unit,
val value: @Composable BoxScope.(modifier: Modifier) -> Unit,
val onClick: (() -> Unit)? = null,
) {
constructor(key: String, value: String, onClick: (() -> Unit)? = null) : this(
{ modifier: Modifier -> Text(text = "$key:", modifier = modifier) },
{ modifier: Modifier -> Text(text = value, modifier = modifier) },
onClick,
)
companion object {
@Composable
fun from(
@StringRes keyStringId: Int,
value: String?,
onClick: (() -> Unit)? = null,
): TableRow? =
if (value.isNotNullOrBlank()) {
TableRow(stringResource(keyStringId), value, onClick)
} else {
null
}
fun from(
context: Context,
@StringRes keyStringId: Int,
value: String?,
onClick: (() -> Unit)? = null,
): TableRow? =
if (value.isNotNullOrBlank()) {
TableRow(context.getString(keyStringId), value, onClick)
} else {
null
}
fun from(
key: String,
value: String?,
onClick: (() -> Unit)? = null,
): TableRow? =
if (value.isNotNullOrBlank()) {
TableRow(key, value, onClick)
} else {
null
}
}
}

View file

@ -0,0 +1,122 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.damontecres.dolphin.ui.components
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.playOnClickSound
@Composable
fun TitleValueText(
title: String,
value: String,
modifier: Modifier = Modifier,
playSoundOnFocus: Boolean = false,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
MaybeClickColumn(
onClick = onClick,
onLongClick = onLongClick,
playSoundOnFocus = playSoundOnFocus,
modifier = modifier,
) {
Text(
modifier = Modifier.alpha(0.8f),
text = title,
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = value,
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun MaybeClickColumn(
playSoundOnFocus: Boolean,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
if (onClick != null || onLongClick != null) {
val context = LocalContext.current
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
if (playSoundOnFocus) {
LaunchedEffect(isFocused) {
if (isFocused) playOnClickSound(context)
}
}
val bgColor =
if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .75f)
} else {
Color.Unspecified
}
Column(
content = content,
modifier =
modifier
.background(bgColor, shape = RoundedCornerShape(4.dp))
.ifElse(isFocused, Modifier.scale(1.1f))
.combinedClickable(
enabled = true,
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = {
if (playSoundOnFocus) playOnClickSound(context)
onClick?.invoke()
},
onLongClick = {
if (playSoundOnFocus) playOnClickSound(context)
onLongClick?.invoke()
},
),
)
} else {
Column(content = content, modifier = modifier)
}
}

View file

@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -12,12 +13,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
@ -30,7 +28,10 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -45,9 +46,11 @@ import androidx.tv.material3.NavigationDrawerScope
import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.rememberDrawerState import androidx.tv.material3.rememberDrawerState
import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.data.ServerRepository import com.github.damontecres.dolphin.data.ServerRepository
import com.github.damontecres.dolphin.data.model.Library import com.github.damontecres.dolphin.data.model.Library
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.FontAwesome
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -121,7 +124,14 @@ fun NavDrawer(
IconNavItem( IconNavItem(
text = user?.name ?: "", text = user?.name ?: "",
icon = Icons.Default.AccountCircle, icon = Icons.Default.AccountCircle,
onClick = {}, onClick = { navigationManager.navigateTo(Destination.Setup) },
)
}
item {
IconNavItem(
text = "Search",
icon = Icons.Default.Search,
onClick = { navigationManager.navigateTo(Destination.Search) },
) )
} }
item { item {
@ -148,7 +158,7 @@ fun NavDrawer(
IconNavItem( IconNavItem(
text = "Settings", text = "Settings",
icon = Icons.Default.Settings, icon = Icons.Default.Settings,
onClick = {}, onClick = { navigationManager.navigateTo(Destination.Settings) },
) )
} }
} }
@ -202,20 +212,26 @@ fun NavigationDrawerScope.LibraryNavItem(
// TODO // TODO
val icon = val icon =
when (library.collectionType) { when (library.collectionType) {
CollectionType.MOVIES -> Icons.Default.Email CollectionType.MOVIES -> R.string.fa_film
CollectionType.TVSHOWS -> Icons.Default.DateRange CollectionType.TVSHOWS -> R.string.fa_tv
CollectionType.HOMEVIDEOS -> Icons.Default.ShoppingCart CollectionType.HOMEVIDEOS -> R.string.fa_video
else -> Icons.Default.Info CollectionType.LIVETV -> R.string.fa_tv
CollectionType.MUSIC -> R.string.fa_music
else -> R.string.fa_film
} }
NavigationDrawerItem( NavigationDrawerItem(
modifier = modifier, modifier = modifier,
selected = false, selected = false,
onClick = onClick, onClick = onClick,
leadingContent = { leadingContent = {
Icon( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
icon, Text(
contentDescription = null, text = stringResource(icon),
) textAlign = TextAlign.Center,
fontSize = 16.sp,
fontFamily = FontAwesome,
)
}
}, },
interactionSource = null, interactionSource = null,
) { ) {

View file

@ -26,4 +26,6 @@
<string name="fa_angles_down" translatable="false">&#xf103;</string> <string name="fa_angles_down" translatable="false">&#xf103;</string>
<string name="fa_house" translatable="false">&#xf015;</string> <string name="fa_house" translatable="false">&#xf015;</string>
<string name="fa_closed_captioning" translatable="false">&#xf20a;</string> <string name="fa_closed_captioning" translatable="false">&#xf20a;</string>
</resources> <string name="fa_tv" translatable="false">&#xf26c;</string>
<string name="fa_music" translatable="false">&#xf001;</string>
</resources>

View file

@ -11,4 +11,5 @@
<string name="home_section_next_up">Next up</string> <string name="home_section_next_up">Next up</string>
<string name="home_section_livetv">Live TV</string> <string name="home_section_livetv">Live TV</string>
<string name="home_section_none">None</string> <string name="home_section_none">None</string>
</resources> <string name="search">Search</string>
</resources>