mirror of
https://github.com/IllusionMan1212/lyrics-grabbr.git
synced 2026-10-24 18:12:27 +00:00
rewrite the app in native kotlin with compose
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.components
|
||||
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.withSave
|
||||
|
||||
const val DefaultAlpha = 1f
|
||||
|
||||
fun forwardingPainter(
|
||||
painter: Painter,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
size: Size,
|
||||
onDraw: DrawScope.(ForwardingDrawInfo) -> Unit = DefaultOnDraw,
|
||||
): Painter = ForwardingPainter(painter, alpha, colorFilter, size, onDraw)
|
||||
|
||||
data class ForwardingDrawInfo(
|
||||
val painter: Painter,
|
||||
val alpha: Float,
|
||||
val colorFilter: ColorFilter?,
|
||||
val size: Size,
|
||||
)
|
||||
|
||||
private class ForwardingPainter(
|
||||
private val painter: Painter,
|
||||
private var alpha: Float,
|
||||
private var colorFilter: ColorFilter?,
|
||||
private val size: Size,
|
||||
private val onDraw: DrawScope.(ForwardingDrawInfo) -> Unit,
|
||||
) : Painter() {
|
||||
|
||||
private var info = newInfo()
|
||||
|
||||
override val intrinsicSize get() = painter.intrinsicSize
|
||||
|
||||
override fun applyAlpha(alpha: Float): Boolean {
|
||||
if (alpha != DefaultAlpha) {
|
||||
this.alpha = alpha
|
||||
this.info = newInfo()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
|
||||
if (colorFilter != null) {
|
||||
this.colorFilter = colorFilter
|
||||
this.info = newInfo()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun DrawScope.onDraw() = onDraw(info)
|
||||
|
||||
private fun newInfo() = ForwardingDrawInfo(painter, alpha, colorFilter, size)
|
||||
}
|
||||
|
||||
private val DefaultOnDraw: DrawScope.(ForwardingDrawInfo) -> Unit = { info ->
|
||||
val size = info.size
|
||||
|
||||
drawIntoCanvas {
|
||||
val width = it.nativeCanvas.width.toFloat()
|
||||
val height = it.nativeCanvas.height.toFloat()
|
||||
val left = (width - size.width) / 2f
|
||||
val top = (height - size.height) / 2f
|
||||
|
||||
it.withSave {
|
||||
it.translate(left, top)
|
||||
with(info.painter) {
|
||||
draw(size, info.alpha, info.colorFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.illusionman1212.lyricsgrabbr.ui.theme.Typography
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LGAlertDialog(
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
title: String,
|
||||
buttons: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = properties,
|
||||
) {
|
||||
Surface (
|
||||
modifier = Modifier.wrapContentHeight().wrapContentWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = AlertDialogDefaults.TonalElevation
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(text = title, style = Typography.titleLarge)
|
||||
content()
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.PlainTooltipBox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LGIconButton(
|
||||
tooltip: String,
|
||||
onClick: () -> Unit,
|
||||
badge: (@Composable BoxScope.() -> Unit)? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Layout(
|
||||
content = {
|
||||
PlainTooltipBox(
|
||||
tooltip = {
|
||||
Text(text = tooltip)
|
||||
},
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
Modifier.tooltipAnchor(),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
Box(Modifier.layoutId("badge")) {
|
||||
badge?.let { it() }
|
||||
}
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
val iconPlaceable = measurables[0].measure(constraints)
|
||||
val badgePlaceable = measurables.firstOrNull { it.layoutId == "badge" }?.measure(constraints)
|
||||
|
||||
layout(iconPlaceable.width, iconPlaceable.height) {
|
||||
iconPlaceable.placeRelative(0, 0)
|
||||
badgePlaceable?.placeRelative(
|
||||
x = iconPlaceable.width - badgePlaceable.width - 16,
|
||||
y = 16,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PlainTooltipBox(tooltip = {
|
||||
// Text(text = tooltip)
|
||||
// }) {
|
||||
// if (badge != null) {
|
||||
// IconButton(
|
||||
// onClick = onClick,
|
||||
// Modifier.tooltipAnchor(),
|
||||
// ) {
|
||||
// BadgedBox(badge = badge) {
|
||||
// content()
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// IconButton(
|
||||
// onClick = onClick,
|
||||
// Modifier.tooltipAnchor(),
|
||||
// ) {
|
||||
// content()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Composable
|
||||
fun LGRadioButton(text: String, onClick: () -> Unit, selected: Boolean) {
|
||||
Box (Modifier.clickable { onClick() }.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun LGRadioButtonPreview() {
|
||||
LGRadioButton(text = "Radio Button", onClick = {}, selected = true)
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.screens
|
||||
|
||||
import android.Manifest.permission.POST_NOTIFICATIONS
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BrokenImage
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.LibraryMusic
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.compose.currentStateAsState
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.illusionman1212.lyricsgrabbr.NotificationEvent
|
||||
import com.illusionman1212.lyricsgrabbr.R
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGAlertDialog
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.HomeViewModel
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.SearchResult
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import coil.compose.AsyncImage
|
||||
import com.illusionman1212.lyricsgrabbr.LGApp
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.forwardingPainter
|
||||
import com.illusionman1212.lyricsgrabbr.utils.annotatedStringResource
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomePage(
|
||||
navigateToSettings: () -> Unit,
|
||||
navigateToLyrics: () -> Unit,
|
||||
goBack: () -> Unit,
|
||||
homeViewModel: HomeViewModel = viewModel(
|
||||
factory = HomeViewModel.Factory,
|
||||
),
|
||||
settingsViewModel: SettingsViewModel,
|
||||
lyricsViewModel: LyricsViewModel,
|
||||
lastNotification: MutableStateFlow<NotificationEvent?>
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val application = (context as Activity).application as LGApp
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
var artistUri by remember { mutableStateOf(Uri.EMPTY) }
|
||||
var songUri by remember { mutableStateOf(Uri.EMPTY) }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
var listeningPermission = homeViewModel.getListeningPermission()
|
||||
var notificationPermission by remember { mutableStateOf(
|
||||
ActivityCompat.checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED
|
||||
) }
|
||||
var shouldShowNotificationPermissionRationale by remember { mutableStateOf(
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
context as android.app.Activity,
|
||||
POST_NOTIFICATIONS
|
||||
)
|
||||
)}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
notificationPermission = isGranted
|
||||
|
||||
// we hide the rationale after launching once because launching again doesn't seem work
|
||||
shouldShowNotificationPermissionRationale = false
|
||||
}
|
||||
|
||||
val song = lastNotification.collectAsStateWithLifecycle().value
|
||||
val uiState = homeViewModel.uiState.collectAsStateWithLifecycle().value
|
||||
var getLyricsFromSpotify by remember { mutableStateOf(false)}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!shouldShowNotificationPermissionRationale)
|
||||
launcher.launch(POST_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
LaunchedEffect(lifecycleState) {
|
||||
when (lifecycleState) {
|
||||
Lifecycle.State.RESUMED -> {
|
||||
listeningPermission = homeViewModel.getListeningPermission()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(song) {
|
||||
if (song != null && song.title != uiState.notification?.title && song.artist != uiState.notification?.artist) {
|
||||
val spotifyApi = application.credentialStore.getSpotifyClientPkceApi()
|
||||
val useSpotifyApi = song.packageName == "com.spotify.music" && spotifyApi != null
|
||||
getLyricsFromSpotify = useSpotifyApi
|
||||
|
||||
homeViewModel.setSong(song)
|
||||
|
||||
if (useSpotifyApi) {
|
||||
homeViewModel.makeRequestToSpotify(spotifyApi!!)
|
||||
} else {
|
||||
homeViewModel.makeRequestToGenius(song.title, song.artist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(text = stringResource(id = R.string.app_name)) },
|
||||
actions = {
|
||||
LGIconButton(
|
||||
tooltip = stringResource(id = R.string.settings),
|
||||
onClick = navigateToSettings,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Settings,
|
||||
contentDescription = stringResource(id = R.string.settings)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.padding(top = 16.dp)
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (!notificationPermission && shouldShowNotificationPermissionRationale) {
|
||||
LGAlertDialog(
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
),
|
||||
onDismiss = goBack,
|
||||
title = stringResource(id = R.string.notification_post_permission),
|
||||
buttons = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
launcher.launch(POST_NOTIFICATIONS)
|
||||
}
|
||||
) {
|
||||
Text(stringResource(id = R.string.grant_permission))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.notification_post_permission_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!listeningPermission && notificationPermission) {
|
||||
LGAlertDialog(
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
),
|
||||
onDismiss = goBack,
|
||||
title = stringResource(id = R.string.notification_listen_permission),
|
||||
buttons = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
android.content.Intent(
|
||||
android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(stringResource(id = R.string.open_settings))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.notification_listen_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (song == null) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text( text = stringResource(id = R.string.no_supported_player), style = MaterialTheme.typography.headlineSmall )
|
||||
if (!notificationPermission) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.notification_post_permission_not_granted).uppercase(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
} else {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = song.title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = song.artist,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
if (uiState.error != null) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = uiState.error,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
fontStyle = FontStyle.Italic,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
homeViewModel.makeRequestToGenius(song.title, song.artist)
|
||||
}
|
||||
) {
|
||||
Text(stringResource(id = R.string.retry))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Results(
|
||||
uiState.results,
|
||||
showBottomSheet = { artistUrl, songUrl ->
|
||||
showBottomSheet = !(artistUrl == Uri.EMPTY && songUrl == Uri.EMPTY)
|
||||
artistUri = artistUrl
|
||||
songUri = songUrl
|
||||
},
|
||||
navigateToLyrics = navigateToLyrics,
|
||||
setLyricsTitle = { id, title, subtitle, url ->
|
||||
lyricsViewModel.onSongChange(id, title, subtitle, url, getLyricsFromSpotify)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier.height(36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
val linkColor = MaterialTheme.colorScheme.surfaceTint
|
||||
val text = annotatedStringResource(id = R.string.powered_by) {
|
||||
when (it.key) {
|
||||
"href" -> SpanStyle(color = linkColor)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val textColor = LocalContentColor.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
ClickableText(
|
||||
text,
|
||||
onClick = { offset ->
|
||||
text.getStringAnnotations(tag = "href", start = offset, end = offset)
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
uriHandler.openUri(it.item)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = textColor, fontStyle = FontStyle.Italic),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
LongClickBottomSheet(
|
||||
onDismiss = {
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
showBottomSheet = false
|
||||
}
|
||||
},
|
||||
sheetState = sheetState,
|
||||
artistUri = artistUri,
|
||||
songUri = songUri,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Results(
|
||||
results: List<SearchResult>,
|
||||
navigateToLyrics: () -> Unit,
|
||||
setLyricsTitle: (id: String, title: String, subtitle: String, url: Uri) -> Unit,
|
||||
showBottomSheet: (artistUrl: Uri, songUrl: Uri) -> Unit,
|
||||
) {
|
||||
if (results.isEmpty()) {
|
||||
Text(stringResource(id = R.string.no_results), style = MaterialTheme.typography.titleLarge)
|
||||
} else {
|
||||
Column(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.found_results, results.size),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
LazyColumn {
|
||||
items(results) {
|
||||
ResultListItem(
|
||||
it,
|
||||
onClick = {
|
||||
setLyricsTitle(it.id, it.title, it.artist, it.url)
|
||||
navigateToLyrics()
|
||||
},
|
||||
onLongClick = {
|
||||
showBottomSheet(it.artistUrl, it.url)
|
||||
}
|
||||
)
|
||||
|
||||
if (it != results.last())
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LongClickBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
sheetState: SheetState,
|
||||
artistUri: Uri,
|
||||
songUri: Uri,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
ModalBottomSheet(
|
||||
dragHandle = {},
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
windowInsets = WindowInsets(top = 0.dp),
|
||||
) {
|
||||
Column {
|
||||
if (artistUri != Uri.EMPTY) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { uriHandler.openUri(artistUri.toString()) }
|
||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Filled.LibraryMusic, contentDescription = "Music Library")
|
||||
Text(stringResource(id = R.string.open_artist_page), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
if (songUri != Uri.EMPTY) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { uriHandler.openUri(songUri.toString()) }
|
||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Filled.MusicNote, contentDescription = "Music Note")
|
||||
Text(stringResource(id = R.string.open_song_page), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ResultListItem(
|
||||
item: SearchResult,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
onLongClick()
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
},
|
||||
)
|
||||
) {
|
||||
val color = LocalContentColor.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
AsyncImage(
|
||||
item.thumbnail,
|
||||
error = forwardingPainter(
|
||||
rememberVectorPainter(image = Icons.Filled.BrokenImage),
|
||||
colorFilter = ColorFilter.tint(color),
|
||||
size = with(density) { DpSize(40.dp, 40.dp).toSize() },
|
||||
),
|
||||
fallback = rememberVectorPainter(Icons.Filled.Image),
|
||||
contentDescription = "Thumbnail for ${item.title}",
|
||||
modifier = Modifier.size(75.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
item.title,
|
||||
fontSize = TextUnit(18F, TextUnitType.Sp),
|
||||
fontWeight = FontWeight.Black,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
item.artist,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun ResultListItemPreview() {
|
||||
ResultListItem(
|
||||
item = SearchResult(
|
||||
"Test",
|
||||
"Artist",
|
||||
id = "1",
|
||||
url = Uri.EMPTY,
|
||||
artistUrl = Uri.EMPTY,
|
||||
thumbnail = Uri.EMPTY
|
||||
),
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.illusionman1212.lyricsgrabbr.R
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LyricsPage(
|
||||
goBack: () -> Unit,
|
||||
lyricsViewModel: LyricsViewModel = viewModel(
|
||||
factory = LyricsViewModel.Factory
|
||||
),
|
||||
) {
|
||||
val uiState = lyricsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||
|
||||
LaunchedEffect(uiState.song, uiState.artist, uiState.url) {
|
||||
if (uiState.useSpotifyApi) {
|
||||
lyricsViewModel.fetchLyricsFromSpotify()
|
||||
} else {
|
||||
lyricsViewModel.fetchLyricsFromGenius()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
uiState.song,
|
||||
fontWeight = FontWeight.Black,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
uiState.artist,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
LGIconButton(
|
||||
tooltip = stringResource(id = R.string.go_back),
|
||||
onClick = goBack
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(
|
||||
id = R.string.go_back
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.then(
|
||||
if (!uiState.isLoading && uiState.lyrics.isNotEmpty()) Modifier.verticalScroll(
|
||||
rememberScrollState()
|
||||
) else Modifier
|
||||
),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
if (uiState.lyrics.isEmpty()) {
|
||||
Instrumental()
|
||||
} else {
|
||||
Column(Modifier.padding(top = 12.dp, start = 12.dp, end = 12.dp, bottom = 32.dp)) {
|
||||
SelectionContainer {
|
||||
TextField(
|
||||
value = uiState.lyrics,
|
||||
onValueChange = {},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
readOnly = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun Instrumental() {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.MusicNote,
|
||||
contentDescription = "Music Note",
|
||||
modifier = Modifier.size(50.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(id = R.string.instrumental),
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.clickable
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.Palette
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.adamratzman.spotify.SpotifyClientApi
|
||||
import com.adamratzman.spotify.SpotifyException
|
||||
import com.adamratzman.spotify.auth.pkce.startSpotifyClientPkceLoginActivity
|
||||
import com.illusionman1212.lyricsgrabbr.LGApp
|
||||
import com.illusionman1212.lyricsgrabbr.R
|
||||
import com.illusionman1212.lyricsgrabbr.SpotifyPkceLoginActivityImpl
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGAlertDialog
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGRadioButton
|
||||
import com.illusionman1212.lyricsgrabbr.ui.theme.ColorSecondaryLight
|
||||
import com.illusionman1212.lyricsgrabbr.data.Theme
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
|
||||
import com.illusionman1212.lyricsgrabbr.utils.annotatedStringResource
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsState
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
private val Themes = hashMapOf(
|
||||
Theme.LIGHT.ordinal to R.string.theme_light,
|
||||
Theme.DARK.ordinal to R.string.theme_dark,
|
||||
Theme.SYSTEM.ordinal to R.string.theme_system
|
||||
)
|
||||
|
||||
// TODO: this is here as a reference because I think I will need a guard in the future
|
||||
fun <T> Activity.guardValidSpotifyApi(
|
||||
classBackTo: Class<out Activity>,
|
||||
alreadyTriedToReauthenticate: Boolean = false,
|
||||
block: suspend (api: SpotifyClientApi) -> T
|
||||
): T? {
|
||||
return runBlocking {
|
||||
val app = (application as LGApp)
|
||||
|
||||
try {
|
||||
val token = app.credentialStore.spotifyToken
|
||||
?: throw SpotifyException.ReAuthenticationNeededException()
|
||||
val usesPkceAuth = token.refreshToken != null
|
||||
val api = (if (usesPkceAuth) app.credentialStore.getSpotifyClientPkceApi()
|
||||
else app.credentialStore.getSpotifyImplicitGrantApi())
|
||||
?: throw SpotifyException.ReAuthenticationNeededException()
|
||||
|
||||
block(api)
|
||||
} catch (e: SpotifyException) {
|
||||
e.printStackTrace()
|
||||
val api = app.credentialStore.getSpotifyClientPkceApi()!!
|
||||
if (!alreadyTriedToReauthenticate) {
|
||||
try {
|
||||
api.refreshToken()
|
||||
app.credentialStore.spotifyToken = api.token
|
||||
block(api)
|
||||
} catch (e: SpotifyException.ReAuthenticationNeededException) {
|
||||
e.printStackTrace()
|
||||
return@runBlocking guardValidSpotifyApi(
|
||||
classBackTo = classBackTo,
|
||||
alreadyTriedToReauthenticate = true,
|
||||
block = block
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTrace()
|
||||
return@runBlocking guardValidSpotifyApi(
|
||||
classBackTo = classBackTo,
|
||||
alreadyTriedToReauthenticate = true,
|
||||
block = block
|
||||
)
|
||||
}
|
||||
} else {
|
||||
//pkceClassBackTo = classBackTo
|
||||
startSpotifyClientPkceLoginActivity(SpotifyPkceLoginActivityImpl::class.java)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsPage(
|
||||
goBack: () -> Unit,
|
||||
viewModel: SettingsViewModel,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
// TODO: on RTL languages all newer(only?) android systems change the direction of the status bar
|
||||
// including all its children, so we need to figure out how to position stuff to the right
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
) {
|
||||
LGIconButton(tooltip = stringResource(id = R.string.go_back), onClick = goBack) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(
|
||||
id = R.string.go_back
|
||||
) )
|
||||
}
|
||||
Text(text = stringResource(id = R.string.settings), style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Appearance(viewModel, uiState)
|
||||
Lyrics(viewModel, uiState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Appearance(viewModel: SettingsViewModel, uiState: SettingsState) {
|
||||
var themeOpenDialog by remember { mutableStateOf(false) }
|
||||
|
||||
SettingGroup(title = stringResource(id = R.string.appearance)) {
|
||||
DialogSetting(
|
||||
title = stringResource(id = R.string.theme),
|
||||
value = Themes[uiState.appTheme]?.let { stringResource(it) } ?: "None",
|
||||
icon = Icons.Outlined.Palette,
|
||||
onClick = { themeOpenDialog = true }
|
||||
)
|
||||
}
|
||||
if (themeOpenDialog) {
|
||||
ThemeDialog(
|
||||
uiState = uiState,
|
||||
onDismiss = { themeOpenDialog = false },
|
||||
onThemeChange = {
|
||||
viewModel.setTheme(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeDialog(
|
||||
uiState: SettingsState,
|
||||
onDismiss: () -> Unit,
|
||||
onThemeChange: (v: Theme) -> Unit
|
||||
) {
|
||||
LGAlertDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = stringResource(id = R.string.theme),
|
||||
buttons = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(id = R.string.cancel))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column {
|
||||
LGRadioButton(
|
||||
text = stringResource(id = R.string.theme_light),
|
||||
onClick = {
|
||||
onThemeChange(Theme.LIGHT)
|
||||
},
|
||||
selected = uiState.appTheme == Theme.LIGHT.ordinal,
|
||||
)
|
||||
LGRadioButton(
|
||||
text = stringResource(id = R.string.theme_dark),
|
||||
onClick = {
|
||||
onThemeChange(Theme.DARK)
|
||||
},
|
||||
selected = uiState.appTheme == Theme.DARK.ordinal,
|
||||
)
|
||||
LGRadioButton(
|
||||
text = stringResource(id = R.string.theme_system),
|
||||
onClick = {
|
||||
onThemeChange(Theme.SYSTEM)
|
||||
},
|
||||
selected = uiState.appTheme == Theme.SYSTEM.ordinal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Lyrics(viewModel: SettingsViewModel, uiState: SettingsState) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as Activity
|
||||
val application = activity.application as LGApp
|
||||
|
||||
var isDialogOpen by remember { mutableStateOf(false) }
|
||||
var spotifyIdValue by remember { mutableStateOf("") }
|
||||
|
||||
SettingGroup(stringResource(id = R.string.lyrics)) {
|
||||
ToggleSetting(
|
||||
title = stringResource(id = R.string.use_spotify_api),
|
||||
checked = uiState.isSpotifyAuthed,
|
||||
icon = painterResource(id = R.drawable.spotify),
|
||||
onClick = {
|
||||
if (uiState.isSpotifyAuthed) {
|
||||
application.credentialStore.clear()
|
||||
viewModel.updateSpotifyAuthStatus(false)
|
||||
} else {
|
||||
isDialogOpen = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isDialogOpen) {
|
||||
LGAlertDialog(
|
||||
onDismiss = { isDialogOpen = false },
|
||||
title = stringResource(id = R.string.spotify_client_id),
|
||||
buttons = {
|
||||
TextButton(onClick = { isDialogOpen = false }) {
|
||||
Text(stringResource(id = R.string.cancel))
|
||||
}
|
||||
TextButton(onClick = {
|
||||
SpotifyPkceLoginActivityImpl.CLIENT_ID = spotifyIdValue
|
||||
SpotifyPkceLoginActivityImpl.settingsViewModel = viewModel
|
||||
activity.startSpotifyClientPkceLoginActivity(SpotifyPkceLoginActivityImpl::class.java)
|
||||
isDialogOpen = false
|
||||
}) {
|
||||
Text(stringResource(id = R.string.confirm))
|
||||
}
|
||||
}
|
||||
) {
|
||||
val linkColor = MaterialTheme.colorScheme.surfaceTint
|
||||
val spotifyDesc = annotatedStringResource(R.string.spotify_client_id_desc) {
|
||||
when (it.key) {
|
||||
"href" -> SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val textColor = LocalContentColor.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
ClickableText(
|
||||
spotifyDesc,
|
||||
onClick = { offset ->
|
||||
spotifyDesc.getStringAnnotations(tag = "href", start = offset, end = offset)
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
uriHandler.openUri(it.item)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = spotifyIdValue,
|
||||
onValueChange = { spotifyIdValue = it },
|
||||
placeholder = {
|
||||
Text(stringResource(id = R.string.not_set))
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingGroup(title: String, content: @Composable () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Header(title)
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Header(title: String) {
|
||||
Row(Modifier.padding(start = 56.dp)) {
|
||||
Text(text = title, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogSetting(
|
||||
title: String,
|
||||
value: String,
|
||||
icon: ImageVector?,
|
||||
onClick: () -> Unit,
|
||||
singleLine : Boolean = false,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
.then(if (enabled) Modifier.clickable { onClick() } else Modifier)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.width(56.dp))
|
||||
} else {
|
||||
Box(modifier = Modifier.width(56.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = title, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(
|
||||
text = value,
|
||||
color = ColorSecondaryLight,
|
||||
fontSize = 14.sp,
|
||||
maxLines = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(end = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToggleSetting(
|
||||
title: String,
|
||||
icon: Painter? = null,
|
||||
checked: Boolean,
|
||||
onClick: (Boolean) -> Unit)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier.clickable onClick@{
|
||||
onClick(!checked)
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.padding(top = 8.dp, end = 24.dp, bottom = 8.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(painter = icon, contentDescription = null, Modifier.width(56.dp))
|
||||
} else {
|
||||
Box(modifier = Modifier.width(56.dp))
|
||||
}
|
||||
Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f))
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
thumbContent = if (checked) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
} else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
//val Purple80 = Color(0xFFD0BCFF)
|
||||
//val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
//val Pink80 = Color(0xFFEFB8C8)
|
||||
//
|
||||
//val Purple40 = Color(0xFF6650a4)
|
||||
//val PurpleGrey40 = Color(0xFF625b71)
|
||||
//val Pink40 = Color(0xFF7D5260)
|
||||
//
|
||||
//val ColorSecondaryLight = Color(0xFF999999)
|
||||
|
||||
val ColorPrimaryLight = Color(0xFFFFFFFF)
|
||||
val ColorPrimaryVariantLight = Color(0xFFEFEFEF)
|
||||
val ColorOnPrimaryLight = Color(0xFF000000)
|
||||
val ColorSecondaryLight = Color(0xFF999999)
|
||||
val ColorSecondaryVariantLight = Color(0xFF888888)
|
||||
val ColorOnSecondaryLight = Color(0xFF111111)
|
||||
val ColorAmoledLight = Color(0xFFFFFFFF)
|
||||
|
||||
val ColorPrimaryDark = Color(0xFF222222)
|
||||
val ColorPrimaryVariantDark = Color(0xFF202020)
|
||||
val ColorOnPrimaryDark = Color(0xFFFFFFFF)
|
||||
val ColorSecondaryDark = Color(0xFF696969)
|
||||
val ColorSecondaryVariantDark = Color(0xFF515151)
|
||||
val ColorOnSecondaryDark = Color(0xDFFFFFFF)
|
||||
val ColorAmoledDark = Color(0xFF000000)
|
||||
|
||||
val md_theme_light_primary = Color(0xFF7C44A8)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFFF2DAFF)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF2E004D)
|
||||
val md_theme_light_secondary = Color(0xFF675A6E)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFEEDDF5)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF221829)
|
||||
val md_theme_light_tertiary = Color(0xFF99405F)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFFFD9E1)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF3F001C)
|
||||
val md_theme_light_error = Color(0xFFBA1A1A)
|
||||
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||
val md_theme_light_background = Color(0xFFFFFBFF)
|
||||
val md_theme_light_onBackground = Color(0xFF1D1B1E)
|
||||
val md_theme_light_surface = Color(0xFFFFFBFF)
|
||||
val md_theme_light_onSurface = Color(0xFF1D1B1E)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFEADFEA)
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF4B454D)
|
||||
val md_theme_light_outline = Color(0xFF7C757E)
|
||||
val md_theme_light_inverseOnSurface = Color(0xFFF6EFF3)
|
||||
val md_theme_light_inverseSurface = Color(0xFF322F33)
|
||||
val md_theme_light_inversePrimary = Color(0xFFE1B6FF)
|
||||
val md_theme_light_shadow = Color(0xFF000000)
|
||||
val md_theme_light_surfaceTint = Color(0xFF7C44A8)
|
||||
val md_theme_light_outlineVariant = Color(0xFFCDC3CE)
|
||||
val md_theme_light_scrim = Color(0xFF000000)
|
||||
|
||||
val md_theme_dark_primary = Color(0xFFE1B6FF)
|
||||
val md_theme_dark_onPrimary = Color(0xFF4B0A76)
|
||||
val md_theme_dark_primaryContainer = Color(0xFF632A8E)
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFFF2DAFF)
|
||||
val md_theme_dark_secondary = Color(0xFFD2C1D9)
|
||||
val md_theme_dark_onSecondary = Color(0xFF372C3F)
|
||||
val md_theme_dark_secondaryContainer = Color(0xFF4E4256)
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFEEDDF5)
|
||||
val md_theme_dark_tertiary = Color(0xFFFFB1C6)
|
||||
val md_theme_dark_onTertiary = Color(0xFF5E1131)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF7B2947)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFFFFD9E1)
|
||||
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||
val md_theme_dark_onError = Color(0xFF690005)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_background = Color(0xFF1D1B1E)
|
||||
val md_theme_dark_onBackground = Color(0xFFE7E1E5)
|
||||
val md_theme_dark_surface = Color(0xFF1D1B1E)
|
||||
val md_theme_dark_onSurface = Color(0xFFE7E1E5)
|
||||
val md_theme_dark_surfaceVariant = Color(0xFF4B454D)
|
||||
val md_theme_dark_onSurfaceVariant = Color(0xFFCDC3CE)
|
||||
val md_theme_dark_outline = Color(0xFF968E98)
|
||||
val md_theme_dark_inverseOnSurface = Color(0xFF1D1B1E)
|
||||
val md_theme_dark_inverseSurface = Color(0xFFE7E1E5)
|
||||
val md_theme_dark_inversePrimary = Color(0xFF7C44A8)
|
||||
val md_theme_dark_shadow = Color(0xFF000000)
|
||||
val md_theme_dark_surfaceTint = Color(0xFFE1B6FF)
|
||||
val md_theme_dark_outlineVariant = Color(0xFF4B454D)
|
||||
val md_theme_dark_scrim = Color(0xFF000000)
|
||||
|
||||
val seed = Color(0xFF662D91)
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onError = md_theme_light_onError,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
surfaceTint = md_theme_light_surfaceTint,
|
||||
outlineVariant = md_theme_light_outlineVariant,
|
||||
scrim = md_theme_light_scrim,
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onError = md_theme_dark_onError,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
surfaceTint = md_theme_dark_surfaceTint,
|
||||
outlineVariant = md_theme_dark_outlineVariant,
|
||||
scrim = md_theme_dark_scrim,
|
||||
)
|
||||
|
||||
|
||||
@Composable
|
||||
fun LyricsGrabbrTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = Color.Transparent.toArgb()
|
||||
window.navigationBarColor = Color.Transparent.toArgb()
|
||||
// allows pages to extend to the status bar
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(darkTheme) {
|
||||
(context as ComponentActivity).enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.TRANSPARENT
|
||||
),
|
||||
navigationBarStyle = SystemBarStyle.auto(
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.TRANSPARENT
|
||||
)
|
||||
)
|
||||
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
Reference in New Issue
Block a user