diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt index cf730666..8cccfc32 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt @@ -1,5 +1,7 @@ package com.github.damontecres.dolphin.ui +import android.content.Context +import android.media.AudioManager import android.view.KeyEvent import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.basicMarquee @@ -7,11 +9,14 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import timber.log.Timber @@ -122,3 +127,28 @@ fun Modifier.enableMarquee(focused: Boolean) = } else { 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) +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt new file mode 100644 index 00000000..8204eed5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt new file mode 100644 index 00000000..0541c638 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt @@ -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, + 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(), + ) +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/DotSeparatedRow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/DotSeparatedRow.kt new file mode 100644 index 00000000..8ecaabf3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/DotSeparatedRow.kt @@ -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, + 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), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/EditTextBox.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/EditTextBox.kt new file mode 100644 index 00000000..bcffa3cc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/EditTextBox.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Rating.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Rating.kt new file mode 100644 index 00000000..6ce08fde --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Rating.kt @@ -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), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/SwitchWithLabel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/SwitchWithLabel.kt new file mode 100644 index 00000000..d6dd0c74 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/SwitchWithLabel.kt @@ -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) + }, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/TableRow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/TableRow.kt new file mode 100644 index 00000000..8737d7d7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/TableRow.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/TitleValueText.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/TitleValueText.kt new file mode 100644 index 00000000..cebf2aa7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/TitleValueText.kt @@ -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) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt index 7cb9c189..74364ccd 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler 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.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight 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.material.icons.Icons 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.Info +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.platform.LocalContext 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.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -45,9 +46,11 @@ import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text import androidx.tv.material3.rememberDrawerState +import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.data.ServerRepository import com.github.damontecres.dolphin.data.model.Library import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.FontAwesome import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient @@ -121,7 +124,14 @@ fun NavDrawer( IconNavItem( text = user?.name ?: "", icon = Icons.Default.AccountCircle, - onClick = {}, + onClick = { navigationManager.navigateTo(Destination.Setup) }, + ) + } + item { + IconNavItem( + text = "Search", + icon = Icons.Default.Search, + onClick = { navigationManager.navigateTo(Destination.Search) }, ) } item { @@ -148,7 +158,7 @@ fun NavDrawer( IconNavItem( text = "Settings", icon = Icons.Default.Settings, - onClick = {}, + onClick = { navigationManager.navigateTo(Destination.Settings) }, ) } } @@ -202,20 +212,26 @@ fun NavigationDrawerScope.LibraryNavItem( // TODO val icon = when (library.collectionType) { - CollectionType.MOVIES -> Icons.Default.Email - CollectionType.TVSHOWS -> Icons.Default.DateRange - CollectionType.HOMEVIDEOS -> Icons.Default.ShoppingCart - else -> Icons.Default.Info + CollectionType.MOVIES -> R.string.fa_film + CollectionType.TVSHOWS -> R.string.fa_tv + CollectionType.HOMEVIDEOS -> R.string.fa_video + CollectionType.LIVETV -> R.string.fa_tv + CollectionType.MUSIC -> R.string.fa_music + else -> R.string.fa_film } NavigationDrawerItem( modifier = modifier, selected = false, onClick = onClick, leadingContent = { - Icon( - icon, - contentDescription = null, - ) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(icon), + textAlign = TextAlign.Center, + fontSize = 16.sp, + fontFamily = FontAwesome, + ) + } }, interactionSource = null, ) { diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index f9ef8475..a04370c1 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -26,4 +26,6 @@ - \ No newline at end of file + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e7d7e3f..5f59a0fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,4 +11,5 @@ Next up Live TV None - \ No newline at end of file + Search +